219 lines
7.9 KiB
JavaScript
219 lines
7.9 KiB
JavaScript
import 'dotenv/config';
|
|
import express from 'express';
|
|
import { createServer } from 'http';
|
|
import { WebSocketServer } from 'ws';
|
|
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } 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 ----------------------------------------------------------
|
|
// Two files:
|
|
// data/scene.json -> committed "seed" (tracked in git). Default/first-run.
|
|
// data/scene.live.json -> authored scene written by the admin (git-IGNORED),
|
|
// so `git reset --hard` on deploy never clobbers it.
|
|
// We read live if it exists, else fall back to the seed. Saves always go to live.
|
|
const SEED_PATH = join(ROOT, 'data', 'scene.json');
|
|
const LIVE_PATH = join(ROOT, 'data', 'scene.live.json');
|
|
|
|
function loadScene() {
|
|
const path = existsSync(LIVE_PATH) ? LIVE_PATH : SEED_PATH;
|
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
}
|
|
let scene = loadScene();
|
|
|
|
// ---- Ghost roster (for the admin picker) ---------------------------------
|
|
// Full enriched roster lives in data/ghosts.json. We serve a trimmed view with
|
|
// just what the scene editor needs to pick a ghost and auto-fill its colour.
|
|
let roster = [];
|
|
try {
|
|
const rraw = readFileSync(join(ROOT, 'data', 'ghosts.json'), 'utf-8');
|
|
roster = JSON.parse(rraw).map((g) => ({
|
|
id: g.id, name: g.name, color: g.color, rarity: g.rarity,
|
|
rarityTier: g.rarityTier, isBoss: !!g.isBoss, ability: g.abilityId,
|
|
}));
|
|
} catch (_) {
|
|
roster = []; // roster optional; editor falls back to free-text id
|
|
}
|
|
|
|
// ---- 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.json({ limit: '4mb' })); // for the admin save endpoint
|
|
app.use(express.static(join(ROOT, 'public')));
|
|
|
|
// Full scene for the admin editor (everything) and clients (anchors+spawns).
|
|
app.get('/api/scene', (_req, res) => {
|
|
res.json(scene);
|
|
});
|
|
|
|
// Ghost roster for the admin picker (trimmed).
|
|
app.get('/api/ghosts', (_req, res) => {
|
|
res.json(roster);
|
|
});
|
|
|
|
// Admin save: validate, back up the old file, write, hot-reload.
|
|
app.post('/api/scene', (req, res) => {
|
|
const incoming = req.body;
|
|
const err = validateScene(incoming);
|
|
if (err) return res.status(400).json({ ok: false, error: err });
|
|
try {
|
|
const dataDir = join(ROOT, 'data');
|
|
const target = LIVE_PATH; // authored scene -> live file (survives deploys)
|
|
// timestamped backup so a bad save is always recoverable
|
|
const backupDir = join(dataDir, 'backups');
|
|
if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
|
|
if (existsSync(target)) {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
copyFileSync(target, join(backupDir, `scene-${stamp}.json`));
|
|
}
|
|
writeFileSync(target, JSON.stringify(incoming, null, 2), 'utf-8');
|
|
scene = incoming; // hot-reload: live ghosts update immediately
|
|
broadcastHello(); // push new anchors/world to connected viewers
|
|
res.json({ ok: true, spawns: scene.spawns.length, anchors: scene.anchors.length, blockers: (scene.blockers || []).length });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
}
|
|
});
|
|
|
|
// Minimal schema guard so a malformed save can't crash the motion loop.
|
|
function validateScene(s) {
|
|
if (!s || typeof s !== 'object') return 'scene must be an object';
|
|
if (!s.world || !s.world.buildSize) return 'missing world.buildSize';
|
|
if (!Array.isArray(s.anchors)) return 'anchors must be an array';
|
|
if (!Array.isArray(s.spawns)) return 'spawns must be an array';
|
|
if (s.blockers && !Array.isArray(s.blockers)) return 'blockers must be an array';
|
|
for (const sp of s.spawns) {
|
|
if (!sp.id || !sp.motion) return `spawn missing id/motion: ${JSON.stringify(sp).slice(0, 80)}`;
|
|
if (sp.motion === 'patrol' && (!Array.isArray(sp.path) || sp.path.length < 2)) {
|
|
return `patrol spawn "${sp.id}" needs a path of >=2 points`;
|
|
}
|
|
if (sp.motion === 'hover' && !sp.position) return `hover spawn "${sp.id}" needs a position`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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' });
|
|
|
|
function helloPayload() {
|
|
return JSON.stringify({
|
|
type: 'hello',
|
|
world: scene.world,
|
|
anchors: scene.anchors,
|
|
blockers: scene.blockers || [],
|
|
});
|
|
}
|
|
|
|
function broadcastHello() {
|
|
const payload = helloPayload();
|
|
for (const client of wss.clients) {
|
|
if (client.readyState === 1) client.send(payload);
|
|
}
|
|
}
|
|
|
|
wss.on('connection', (socket) => {
|
|
// Send scene + immediate snapshot on connect so a new phone is instantly aligned.
|
|
socket.send(helloPayload());
|
|
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` +
|
|
` (${existsSync(LIVE_PATH) ? 'live: scene.live.json' : 'seed: scene.json'})`);
|
|
});
|