Files
newbury-exhibit-v3/public/js/ghosts/behavior.js
T

114 lines
5.1 KiB
JavaScript

/* 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';
/* Idle float amplitudes, in cm. The display is a ~25cm table with ~4cm ghosts, so
* these are deliberately tiny — a few millimetres reads as "hovering" at this
* scale, while anything larger looks like the ghost is bouncing. Per-ghost
* overrides: rec.behavior.bobAmp / bobHz. */
const BOB_AMP_CM = 0.3; // 3mm
const BOB_HZ = 0.4;
const SHIMMER_CM = 0.3; // secondary float used by wander + path modes
/* Fallbacks for records that arrive without explicit behaviour params. The
* playlist normally supplies these (wanderRadius / wanderVertical / pathSpeed),
* so these only fire for hand-made or legacy records — but they still need to
* suit a ~25cm table, or an unconfigured ghost drifts clean off the display. */
const WANDER_RADIUS_CM = 2.5;
const WANDER_VERTICAL_CM = 0.4;
const PATH_SPEED_CMS = 1.5;
const PATH_LOOKAHEAD_CM = 1.5; // distance sampled ahead to face travel direction
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 === 'path') {
pathTransform(b, t, out);
} else if (b.type === 'wander') {
const s = b.seed || 1;
const R = b.radius ?? WANDER_RADIUS_CM;
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 + (b.vertical ?? WANDER_VERTICAL_CM) * Math.sin(t * v * 1.3 + hashNoise(s, 3) * 6.28)
+ SHIMMER_CM * Math.sin(t * 1.7 + hashNoise(s, 6) * 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 ?? BOB_AMP_CM;
const hz = b.bobHz ?? BOB_HZ;
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; permanent residents (until == null) never fade out
const fade = (rec.crossfade || 3) * 1000;
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
const outA = rec.until == null ? 1 : Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
out.opacity = Math.min(inA, outA);
return out;
}
/* Walk a waypoint path at constant speed (cm/s), turning to face travel direction.
* Deterministic: position is a pure function of elapsed time, so all viewers agree.
* b: { points:[[x,y,z],...], mode:'loop'|'pingpong', speed, phase, seed } */
function pathTransform(b, t, out) {
const raw = b.points || [];
if (raw.length < 2) { out.position.set(...(raw[0] || [0, 0, 0])); out.rotationY = 0; return; }
const pts = b.mode === 'loop' ? [...raw, raw[0]] : raw;
// cumulative segment lengths
const cum = [0];
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1], dz = pts[i][2] - pts[i - 1][2];
cum.push(cum[i - 1] + Math.hypot(dx, dy, dz));
}
const total = cum[cum.length - 1] || 1;
const wrap = (d) => {
if (b.mode === 'pingpong') { const m = ((d % (2 * total)) + 2 * total) % (2 * total); return m < total ? m : 2 * total - m; }
return ((d % total) + total) % total;
};
const sample = (d, v) => {
let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
const f = (d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6);
v.set(
pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * f,
pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * f,
pts[i - 1][2] + (pts[i][2] - pts[i - 1][2]) * f);
return v;
};
const d = wrap(t * (b.speed ?? PATH_SPEED_CMS) + (b.phase || 0));
sample(d, out.position);
// face where we're heading: sample a short distance ahead so corners turn smoothly
const ahead = sample(wrap(t * (b.speed ?? PATH_SPEED_CMS) + (b.phase || 0) + PATH_LOOKAHEAD_CM), _look);
const dx = ahead.x - out.position.x, dz = ahead.z - out.position.z;
if (dx * dx + dz * dz > 1e-4) out.rotationY = Math.atan2(dx, dz);
// gentle float so walking still reads ghostly
out.position.y += SHIMMER_CM * Math.sin(t * 1.9 + (b.seed || 0));
}
const _look = new THREE.Vector3();