Initial Comming
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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`);
|
||||
});
|
||||
Reference in New Issue
Block a user