update Tue 07/14/2026 11:38:16.92

This commit is contained in:
2026-07-14 11:38:17 +10:00
commit b2b555ef16
38 changed files with 7348 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
* randomness, so phones stay in sync without streaming positions.
*/
import * as THREE from 'three';
function hashNoise(seed, k) {
// cheap deterministic pseudo-noise in [-1, 1]
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
return (x - Math.floor(x)) * 2 - 1;
}
export function ghostTransform(rec, nowMs, out) {
const t = (nowMs - rec.spawnedAt) / 1000;
const base = new THREE.Vector3(...rec.pos);
const b = rec.behavior || { type: 'static' };
if (b.type === 'wander') {
const s = b.seed || 1;
const R = b.radius ?? 0.6;
const v = b.speed ?? 0.15;
// smooth pseudo-random orbit-drift: two incommensurate sines per axis
const ph = t * v;
out.position.set(
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
base.y + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28),
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
);
// face travel direction (finite difference)
const eps = 0.05;
const ahead = (t2) => new THREE.Vector3(
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
0,
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
const dir = ahead(t + eps).sub(ahead(t));
out.rotationY = Math.atan2(dir.x, dir.z);
} else {
const amp = b.bobAmp ?? 0.06;
const hz = b.bobHz ?? 0.4;
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z);
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
}
// crossfade opacity: fade in on spawn, fade out approaching `until`
const fade = (rec.crossfade || 3) * 1000;
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
out.opacity = Math.min(inA, outA);
return out;
}
+110
View File
@@ -0,0 +1,110 @@
/* loader.js — ghost visuals.
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
* every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
* by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
* is used so the exhibit always has something to show.
*
* Model manifest (data/models.json, editable via /api/models):
* { "models": [ { "id": "classic", "scale": 1.0,
* "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
*/
import * as THREE from 'three';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
const objLoader = new OBJLoader();
const objCache = new Map();
function gradientMaterial(top, bottom, opacity = 0.92) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
uniforms: {
topColor: { value: new THREE.Color(top) },
bottomColor: { value: new THREE.Color(bottom) },
opacity: { value: opacity },
uMinY: { value: 0 },
uMaxY: { value: 1 },
uTime: { value: 0 },
},
vertexShader: `
varying float vY;
varying vec3 vNormal;
uniform float uMinY, uMaxY;
void main() {
vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying float vY;
varying vec3 vNormal;
uniform vec3 topColor, bottomColor;
uniform float opacity, uTime;
void main() {
vec3 c = mix(bottomColor, topColor, vY);
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
c += rim * 0.35;
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
gl_FragColor = vec4(c, opacity * pulse);
}`,
});
}
async function loadOBJ(url) {
if (objCache.has(url)) return objCache.get(url).clone();
const obj = await objLoader.loadAsync(url);
objCache.set(url, obj);
return obj.clone();
}
function proceduralWisp() {
const g = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16));
body.scale.set(1, 1.35, 1);
body.position.y = 0.14;
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16));
tail.rotation.x = Math.PI;
tail.position.y = 0.0;
g.add(body, tail);
return g;
}
export async function buildGhost(ghost, gradients, manifest) {
const grad = gradients[ghost.color] || gradients.Blue;
const mat = gradientMaterial(grad.top, grad.bottom);
let group;
const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
if (model && model.parts) {
group = new THREE.Group();
try {
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
const url = model.parts[key];
if (!url) continue;
group.add(await loadOBJ(url));
}
if (model.scale) group.scale.setScalar(model.scale);
if (!group.children.length) group = proceduralWisp();
} catch (e) {
console.warn('ghost model load failed, using wisp fallback', e);
group = proceduralWisp();
}
} else {
group = proceduralWisp();
}
// apply gradient material to every mesh; compute Y-range for gradient mapping
const box = new THREE.Box3().setFromObject(group);
group.traverse(o => {
if (o.isMesh) {
o.material = mat;
o.material.uniforms.uMinY.value = box.min.y;
o.material.uniforms.uMaxY.value = box.max.y;
}
});
group.userData.material = mat;
group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
group.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
return group;
}