Files
newbury-exhibit/server/sync-server.js
T
2026-06-26 13:58:39 +10:00

142 lines
4.5 KiB
JavaScript

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`);
});