Update exhibit
This commit is contained in:
+81
-13
@@ -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()));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user