142 lines
4.5 KiB
JavaScript
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`);
|
|
});
|