This commit is contained in:
2026-06-28 12:29:26 +10:00
parent 0d1d66a85d
commit ea92821ea0
7 changed files with 1231 additions and 1 deletions
+192
View File
@@ -0,0 +1,192 @@
/* 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;
}