import 'dotenv/config'; import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const PORT = process.env.PORT || 34034; const TICK_HZ = Number(process.env.TICK_HZ || 20); // server broadcast rate // ---- Load scene ---------------------------------------------------------- function loadScene() { const raw = readFileSync(join(ROOT, 'data', 'scene.json'), 'utf-8'); return JSON.parse(raw); } let scene = loadScene(); // ---- Motion solvers ------------------------------------------------------ // Every solver takes (spawn, tSeconds) and returns { x,y,z, yawDeg }. // Positions are in world millimetres, matching scene.json. function solveHover(spawn, t) { const p = spawn.position; const r = spawn.hoverRadiusMm ?? 0; const hoverW = (2 * Math.PI) / (spawn.hoverPeriodS ?? 6); const bobA = spawn.bobAmplitudeMm ?? 0; const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); const yaw = ((spawn.yawDegPerS ?? 0) * t) % 360; return { x: p.x + Math.cos(hoverW * t) * r, y: p.y + Math.sin(bobW * t) * bobA, z: p.z + Math.sin(hoverW * t) * r, yawDeg: yaw, }; } function solvePatrol(spawn, t) { const path = spawn.path || []; if (path.length === 0) return { x: 0, y: 0, z: 0, yawDeg: 0 }; if (path.length === 1) return { ...path[0], yawDeg: 0 }; const period = spawn.patrolPeriodS ?? 12; const segCount = path.length; // looped path const phase = ((t % period) / period) * segCount; // 0..segCount const seg = Math.floor(phase) % segCount; const frac = phase - Math.floor(phase); const a = path[seg]; const b = path[(seg + 1) % segCount]; const x = a.x + (b.x - a.x) * frac; const y = a.y + (b.y - a.y) * frac; const z = a.z + (b.z - a.z) * frac; const bobA = spawn.bobAmplitudeMm ?? 0; const bobW = (2 * Math.PI) / (spawn.bobPeriodS ?? 3); let yawDeg = 0; if (spawn.faceTravel) { yawDeg = (Math.atan2(b.x - a.x, b.z - a.z) * 180) / Math.PI; } return { x, y: y + Math.sin(bobW * t) * bobA, z, yawDeg }; } const SOLVERS = { hover: solveHover, patrol: solvePatrol }; function solveSpawn(spawn, t) { const solver = SOLVERS[spawn.motion] || solveHover; return solver(spawn, t); } // ---- Authoritative state ------------------------------------------------- const startTime = Date.now(); function nowSeconds() { return (Date.now() - startTime) / 1000; } function buildSnapshot() { const t = nowSeconds(); const ghosts = scene.spawns.map((spawn) => { const pose = solveSpawn(spawn, t); return { spawnId: spawn.id, ghostId: spawn.ghostId, label: spawn.label, pos: { x: round(pose.x), y: round(pose.y), z: round(pose.z) }, yawDeg: round(pose.yawDeg), }; }); return { type: 'state', serverTime: t, ghosts }; } function round(n) { return Math.round(n * 10) / 10; } // ---- HTTP + static ------------------------------------------------------- const app = express(); app.use(express.static(join(ROOT, 'public'))); // Scene metadata (anchors + spawn definitions) for clients to align/render. app.get('/api/scene', (_req, res) => { res.json({ world: scene.world, anchors: scene.anchors, spawns: scene.spawns.map((s) => ({ id: s.id, ghostId: s.ghostId, label: s.label, motion: s.motion, })), }); }); app.get('/api/health', (_req, res) => { res.json({ ok: true, viewers: wss ? wss.clients.size : 0, tickHz: TICK_HZ }); }); const httpServer = createServer(app); // ---- WebSocket broadcast ------------------------------------------------- const wss = new WebSocketServer({ server: httpServer, path: '/sync' }); wss.on('connection', (socket) => { // Send scene + immediate snapshot on connect so a new phone is instantly aligned. socket.send(JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors })); socket.send(JSON.stringify(buildSnapshot())); }); setInterval(() => { if (wss.clients.size === 0) return; const payload = JSON.stringify(buildSnapshot()); for (const client of wss.clients) { if (client.readyState === 1) client.send(payload); } }, 1000 / TICK_HZ); httpServer.listen(PORT, () => { console.log(`Newbury exhibit sync server on :${PORT} (broadcast ${TICK_HZ}Hz)`); console.log(` Viewer: http://localhost:${PORT}/`); console.log(` Scene: ${scene.spawns.length} spawns, ${scene.anchors.length} anchors`); });