Update exhibit

This commit is contained in:
2026-07-06 19:25:57 +10:00
parent 5ffd1346d6
commit e92ad04b7e
8 changed files with 3757 additions and 195 deletions
+81 -13
View File
@@ -2,7 +2,7 @@ import 'dotenv/config';
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { readFileSync } from 'fs';
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
@@ -19,6 +19,20 @@ function loadScene() {
}
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.
@@ -95,22 +109,60 @@ function round(n) {
// ---- HTTP + static -------------------------------------------------------
const app = express();
app.use(express.json({ limit: '4mb' })); // for the admin save endpoint
app.use(express.static(join(ROOT, 'public')));
// Scene metadata (anchors + spawn definitions) for clients to align/render.
// Full scene for the admin editor (everything) and clients (anchors+spawns).
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,
})),
});
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 = join(dataDir, 'scene.json');
// 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 });
});
@@ -120,9 +172,25 @@ 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(JSON.stringify({ type: 'hello', world: scene.world, anchors: scene.anchors }));
socket.send(helloPayload());
socket.send(JSON.stringify(buildSnapshot()));
});