/* 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; }