193 lines
6.5 KiB
JavaScript
193 lines
6.5 KiB
JavaScript
/* Newbury Exhibit — LEGO-head ghost (3D)
|
|
*
|
|
* A classic minifig-head SILHOUETTE (cylinder body, domed top, stud) given a
|
|
* translucent ghost treatment in a Hidden Side gradient. Built from primitives
|
|
* so there's no external model file to load. Original eyes + wispy tail — we
|
|
* deliberately do NOT reproduce any printed LEGO face or licensed character.
|
|
*
|
|
* Exports buildLegoHeadGhost(THREE, colorKey) -> THREE.Group (units: mm).
|
|
* The group's local origin is at the centre of the head so it sits nicely on a
|
|
* marker anchor. Call group.userData.update(tSeconds) each frame for the idle
|
|
* bob/sway/blink animation.
|
|
*/
|
|
|
|
// Hidden Side ghost-lure gradients (recovered GhostType data).
|
|
export const GHOST_GRADIENTS = {
|
|
Red: { top: 0xf65151, bottom: 0xff2678 },
|
|
Yellow: { top: 0xb57f0b, bottom: 0xfff35d },
|
|
Blue: { top: 0x529eff, bottom: 0x51eaf1 },
|
|
};
|
|
|
|
// Vertical gradient + soft rim glow, applied to the head body.
|
|
const GHOST_VERT = `
|
|
varying vec3 vNormalW;
|
|
varying vec3 vViewDir;
|
|
varying float vY;
|
|
uniform float uMinY;
|
|
uniform float uMaxY;
|
|
void main() {
|
|
vec4 wp = modelMatrix * vec4(position, 1.0);
|
|
vNormalW = normalize(mat3(modelMatrix) * normal);
|
|
vViewDir = normalize(cameraPosition - wp.xyz);
|
|
vY = clamp((position.y - uMinY) / (uMaxY - uMinY), 0.0, 1.0);
|
|
gl_Position = projectionMatrix * viewMatrix * wp;
|
|
}
|
|
`;
|
|
|
|
const GHOST_FRAG = `
|
|
varying vec3 vNormalW;
|
|
varying vec3 vViewDir;
|
|
varying float vY;
|
|
uniform vec3 uTop;
|
|
uniform vec3 uBottom;
|
|
uniform float uOpacity;
|
|
uniform float uTime;
|
|
void main() {
|
|
// body gradient (top lighter -> bottom saturated)
|
|
vec3 base = mix(uBottom, uTop, vY);
|
|
// fresnel rim so the silhouette edge glows like a spectre
|
|
float fres = pow(1.0 - max(dot(normalize(vNormalW), normalize(vViewDir)), 0.0), 2.2);
|
|
vec3 col = base + fres * vec3(0.55, 0.85, 0.95);
|
|
// gentle internal shimmer
|
|
float shimmer = 0.06 * sin(vY * 18.0 - uTime * 2.2);
|
|
col += shimmer;
|
|
float alpha = uOpacity * (0.55 + 0.45 * fres);
|
|
gl_FragColor = vec4(col, alpha);
|
|
}
|
|
`;
|
|
|
|
function ghostBodyMaterial(THREE, colorKey, minY, maxY) {
|
|
const g = GHOST_GRADIENTS[colorKey] || GHOST_GRADIENTS.Blue;
|
|
return new THREE.ShaderMaterial({
|
|
uniforms: {
|
|
uTop: { value: new THREE.Color(g.bottom) }, // brighter accent at top
|
|
uBottom: { value: new THREE.Color(g.top) }, // deeper hue at base
|
|
uOpacity: { value: 0.82 },
|
|
uMinY: { value: minY },
|
|
uMaxY: { value: maxY },
|
|
uTime: { value: 0 },
|
|
},
|
|
vertexShader: GHOST_VERT,
|
|
fragmentShader: GHOST_FRAG,
|
|
transparent: true,
|
|
depthWrite: false, // translucent: don't occlude its own backfaces oddly
|
|
side: THREE.DoubleSide,
|
|
blending: THREE.NormalBlending,
|
|
});
|
|
}
|
|
|
|
export function buildLegoHeadGhost(THREE, colorKey = 'Blue') {
|
|
const group = new THREE.Group();
|
|
|
|
// --- dimensions in mm (classic minifig head proportions, ghost-scaled) ---
|
|
const R = 22; // head radius
|
|
const BODY_H = 46; // cylinder height
|
|
const DOME_H = 16; // top dome rise
|
|
const STUD_R = 9;
|
|
const STUD_H = 9;
|
|
|
|
const minY = -BODY_H / 2 - 26; // include tail reach for gradient base
|
|
const maxY = BODY_H / 2 + DOME_H + STUD_H;
|
|
const bodyMat = ghostBodyMaterial(THREE, colorKey, minY, maxY);
|
|
|
|
const mats = [bodyMat]; // track shader mats for time uniform
|
|
|
|
// cylinder body
|
|
const body = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(R, R, BODY_H, 40, 1, true),
|
|
bodyMat
|
|
);
|
|
group.add(body);
|
|
|
|
// domed top — half-sphere squashed to DOME_H
|
|
const dome = new THREE.Mesh(
|
|
new THREE.SphereGeometry(R, 40, 20, 0, Math.PI * 2, 0, Math.PI / 2),
|
|
bodyMat
|
|
);
|
|
dome.scale.set(1, DOME_H / R, 1);
|
|
dome.position.y = BODY_H / 2;
|
|
group.add(dome);
|
|
|
|
// bottom cap (rounded) so the base reads solid
|
|
const cap = new THREE.Mesh(
|
|
new THREE.SphereGeometry(R, 40, 16, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2),
|
|
bodyMat
|
|
);
|
|
cap.scale.set(1, 0.35, 1);
|
|
cap.position.y = -BODY_H / 2;
|
|
group.add(cap);
|
|
|
|
// the stud on top — the unmistakable LEGO tell
|
|
const stud = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(STUD_R, STUD_R, STUD_H, 28),
|
|
bodyMat
|
|
);
|
|
stud.position.y = BODY_H / 2 + DOME_H + STUD_H / 2 - 2;
|
|
group.add(stud);
|
|
|
|
// --- eyes (original, simple) — solid dark ovals on the front (+Z) face ---
|
|
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x081026 });
|
|
const glintMat = new THREE.MeshBasicMaterial({ color: 0xcfeeff });
|
|
function eye(sign) {
|
|
const e = new THREE.Group();
|
|
const ball = new THREE.Mesh(new THREE.SphereGeometry(5.2, 20, 16), eyeMat);
|
|
ball.scale.set(0.8, 1.15, 0.5);
|
|
e.add(ball);
|
|
const glint = new THREE.Mesh(new THREE.SphereGeometry(1.5, 10, 8), glintMat);
|
|
glint.position.set(sign * -0.8, 2.0, 4.6);
|
|
e.add(glint);
|
|
e.position.set(sign * 9, 2, R - 2);
|
|
return e;
|
|
}
|
|
const eyes = new THREE.Group();
|
|
eyes.add(eye(-1), eye(1));
|
|
group.add(eyes);
|
|
|
|
// --- wispy tail: a few translucent "drips" below the body ---
|
|
const tail = new THREE.Group();
|
|
const tailMat = bodyMat;
|
|
const drips = 4;
|
|
for (let i = 0; i < drips; i++) {
|
|
const dr = R * (0.5 - i * 0.06);
|
|
const drip = new THREE.Mesh(
|
|
new THREE.ConeGeometry(dr * 0.6, 22 + i * 2, 18, 1, true),
|
|
tailMat
|
|
);
|
|
const ang = (i / drips) * Math.PI * 2;
|
|
drip.position.set(Math.cos(ang) * R * 0.5, -BODY_H / 2 - 8, Math.sin(ang) * R * 0.5);
|
|
drip.userData.phase = ang;
|
|
tail.add(drip);
|
|
}
|
|
group.add(tail);
|
|
|
|
// --- idle animation ---
|
|
let baseY = 0;
|
|
group.userData.colorKey = colorKey;
|
|
group.userData.materials = mats;
|
|
group.userData.update = function (t) {
|
|
for (const m of mats) m.uniforms.uTime.value = t;
|
|
// bob + sway
|
|
group.position.y = baseY + Math.sin(t * 1.8) * 6;
|
|
eyes.rotation.y = Math.sin(t * 0.6) * 0.15;
|
|
// tail drips waggle
|
|
tail.children.forEach((d, i) => {
|
|
d.position.y = -BODY_H / 2 - 8 + Math.sin(t * 3 + d.userData.phase) * 3;
|
|
d.rotation.z = Math.sin(t * 2 + i) * 0.12;
|
|
});
|
|
// occasional blink (scale eyes flat briefly)
|
|
const blink = (Math.sin(t * 0.9) > 0.985) ? 0.1 : 1.0;
|
|
eyes.scale.y = eyes.scale.y + (blink - eyes.scale.y) * 0.4;
|
|
};
|
|
group.userData.setBaseY = (y) => { baseY = y; };
|
|
|
|
// recolour at runtime (e.g. when server assigns a ghost's lure colour)
|
|
group.userData.setColor = function (key) {
|
|
const g = GHOST_GRADIENTS[key] || GHOST_GRADIENTS.Blue;
|
|
bodyMat.uniforms.uTop.value.set(g.bottom);
|
|
bodyMat.uniforms.uBottom.value.set(g.top);
|
|
group.userData.colorKey = key;
|
|
};
|
|
|
|
return group;
|
|
}
|