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