203 lines
8.1 KiB
JavaScript
203 lines
8.1 KiB
JavaScript
/* Seed/live persistence.
|
|
* data/scene.json — committed seed (survives in git)
|
|
* data/live/scene.live.json — runtime edits, volume-mounted in Docker, survives redeploys
|
|
*/
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DATA = path.join(__dirname, '..', 'data');
|
|
const LIVE_DIR = process.env.LIVE_DIR || path.join(DATA, 'live');
|
|
const SEED = path.join(DATA, 'scene.json');
|
|
const LIVE = path.join(LIVE_DIR, 'scene.live.json');
|
|
const PLAYLIST_LIVE = path.join(LIVE_DIR, 'playlist.live.json');
|
|
const MODELS_LIVE = path.join(LIVE_DIR, 'models.live.json');
|
|
const MODELS_SEED = path.join(DATA, 'models.json');
|
|
const CHARS_LIVE = path.join(LIVE_DIR, 'characters.live.json');
|
|
const LANDING_LIVE = path.join(LIVE_DIR, 'landing.live.json');
|
|
|
|
/* Named layouts: each is a self-contained { name, scene, playlist } saved under
|
|
* layouts/<slug>.json. layouts.index.json tracks the list + which slug is active.
|
|
* The active layout's scene/playlist are mirrored into scene.live.json /
|
|
* playlist.live.json so viewers and the playlist engine need no changes. */
|
|
const LAYOUTS_DIR = path.join(LIVE_DIR, 'layouts');
|
|
const LAYOUTS_INDEX = path.join(LIVE_DIR, 'layouts.index.json');
|
|
|
|
fs.mkdirSync(LIVE_DIR, { recursive: true });
|
|
fs.mkdirSync(LAYOUTS_DIR, { recursive: true });
|
|
|
|
function slugify(name) {
|
|
const s = String(name || '').toLowerCase().trim()
|
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
|
return s || 'layout';
|
|
}
|
|
|
|
function readJSON(p, fallback) {
|
|
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
|
|
}
|
|
function writeJSON(p, obj) {
|
|
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
|
}
|
|
|
|
let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], paths: [], world: { units: 'cm' } });
|
|
let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []);
|
|
let gradients = readJSON(path.join(DATA, 'gradients.json'), {});
|
|
let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] });
|
|
/* characters: per-ghost visual overrides keyed by ghost id, plus defaults.
|
|
* { defaults: {...}, byId: { 'mad-fenton': { modelId, opacity, topColor, bottomColor,
|
|
* faceTextureUrl, torsoTextureUrl, heightCm, scale } } } */
|
|
let characters = readJSON(CHARS_LIVE, null) || { defaults: {}, byId: {} };
|
|
|
|
/* landing: end-user facing start screen + details page. All text is editable except
|
|
* that the disclaimer is ALWAYS rendered (wording editable, presence is not). */
|
|
const LANDING_DEFAULTS = {
|
|
title: 'NEWBURY NIGHTS',
|
|
subtitle: 'Point your phone at the Newbury Crests to reveal the hidden side of the town.',
|
|
logoUrl: '',
|
|
backgroundUrl: '',
|
|
startButton: 'Start Ghost Watching',
|
|
detailsButton: 'About this build',
|
|
detailsTitle: 'About Newbury Nights',
|
|
detailsMarkdown: 'Write about your build here.\n\nUse **bold**, *italic*, and blank lines for paragraphs.',
|
|
disclaimer: 'Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group. LEGO® and Hidden Side™ are trademarks of the LEGO Group.',
|
|
accent: '#51eaf1',
|
|
textColor: '#e8ecff',
|
|
overlay: 0.55,
|
|
};
|
|
let landing = Object.assign({}, LANDING_DEFAULTS, readJSON(LANDING_LIVE, null) || {});
|
|
|
|
const listeners = [];
|
|
|
|
/* ---------- named layouts ---------- */
|
|
function readLayoutIndex() {
|
|
const idx = readJSON(LAYOUTS_INDEX, null);
|
|
if (idx && Array.isArray(idx.layouts)) return idx;
|
|
return { layouts: [], activeSlug: null };
|
|
}
|
|
function writeLayoutIndex(idx) { writeJSON(LAYOUTS_INDEX, idx); }
|
|
function layoutPath(slug) { return path.join(LAYOUTS_DIR, `${slug}.json`); }
|
|
|
|
function listLayouts() {
|
|
const idx = readLayoutIndex();
|
|
return { layouts: idx.layouts, activeSlug: idx.activeSlug };
|
|
}
|
|
|
|
/* Snapshot the CURRENT live scene + playlist under a name. Overwrites if the
|
|
* slug already exists (same name). Returns the updated index. */
|
|
function saveLayout(name, playlistCfg) {
|
|
const slug = slugify(name);
|
|
const record = {
|
|
name: String(name || slug),
|
|
slug,
|
|
scene,
|
|
playlist: playlistCfg || readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), {}),
|
|
savedAt: Date.now(),
|
|
};
|
|
writeJSON(layoutPath(slug), record);
|
|
const idx = readLayoutIndex();
|
|
if (!idx.layouts.some(l => l.slug === slug)) idx.layouts.push({ slug, name: record.name });
|
|
else idx.layouts = idx.layouts.map(l => l.slug === slug ? { slug, name: record.name } : l);
|
|
idx.activeSlug = slug;
|
|
writeLayoutIndex(idx);
|
|
return listLayouts();
|
|
}
|
|
|
|
/* Make a saved layout active: mirror its scene into the live scene (so viewers
|
|
* update) and return its playlist so the caller can reload the engine. */
|
|
function loadLayout(slug) {
|
|
const rec = readJSON(layoutPath(slug), null);
|
|
if (!rec) throw new Error('layout not found: ' + slug);
|
|
scene = validateScene(rec.scene);
|
|
writeJSON(LIVE, scene);
|
|
if (rec.playlist) writeJSON(PLAYLIST_LIVE, rec.playlist);
|
|
const idx = readLayoutIndex();
|
|
idx.activeSlug = slug;
|
|
writeLayoutIndex(idx);
|
|
listeners.forEach(f => f());
|
|
return { scene, playlist: rec.playlist || null };
|
|
}
|
|
|
|
function deleteLayout(slug) {
|
|
try { fs.unlinkSync(layoutPath(slug)); } catch {}
|
|
const idx = readLayoutIndex();
|
|
idx.layouts = idx.layouts.filter(l => l.slug !== slug);
|
|
if (idx.activeSlug === slug) idx.activeSlug = null;
|
|
writeLayoutIndex(idx);
|
|
return listLayouts();
|
|
}
|
|
|
|
function validateScene(s) {
|
|
if (!s || typeof s !== 'object') throw new Error('scene must be object');
|
|
for (const k of ['anchors', 'buildings', 'spawns']) {
|
|
if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`);
|
|
}
|
|
s.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, s.table || {});
|
|
s.ghostHeightCm = Number(s.ghostHeightCm || 4); // minifigure-scale default
|
|
s.paths = Array.isArray(s.paths) ? s.paths : [];
|
|
for (const pth of s.paths) {
|
|
if (!pth.id) throw new Error('path.id required');
|
|
if (!Array.isArray(pth.points) || pth.points.length < 2) throw new Error(`path ${pth.id} needs >= 2 points`);
|
|
pth.mode = pth.mode === 'pingpong' ? 'pingpong' : 'loop';
|
|
pth.enabled = pth.enabled !== false;
|
|
}
|
|
for (const a of s.anchors) {
|
|
if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required');
|
|
if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required');
|
|
a.mount = a.mount || 'flat'; // 'flat' | 'wall' | 'custom'
|
|
a.yawDeg = Number(a.yawDeg || 0);
|
|
a.pitchDeg = Number(a.pitchDeg || 0);
|
|
a.rollDeg = Number(a.rollDeg || 0);
|
|
a.sizeMM = Number(a.sizeMM || 60); // printed marker size
|
|
a.enabled = a.enabled !== false;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
module.exports = {
|
|
getScene: () => scene,
|
|
putScene(s) {
|
|
scene = validateScene(s);
|
|
writeJSON(LIVE, scene);
|
|
listeners.forEach(f => f());
|
|
return scene;
|
|
},
|
|
resetToSeed() {
|
|
scene = readJSON(SEED, scene);
|
|
writeJSON(LIVE, scene);
|
|
listeners.forEach(f => f());
|
|
return scene;
|
|
},
|
|
getGhosts: () => ({ ghosts, gradients }),
|
|
getLanding: () => landing,
|
|
putLanding(l) {
|
|
if (!l || typeof l !== 'object') throw new Error('landing must be object');
|
|
const next = Object.assign({}, LANDING_DEFAULTS, l);
|
|
// disclaimer may be reworded but never emptied — it protects the project
|
|
if (!String(next.disclaimer || '').trim()) next.disclaimer = LANDING_DEFAULTS.disclaimer;
|
|
landing = next;
|
|
writeJSON(LANDING_LIVE, landing);
|
|
listeners.forEach(f => f());
|
|
return landing;
|
|
},
|
|
getCharacters: () => characters,
|
|
putCharacters(c) {
|
|
if (!c || typeof c !== 'object') throw new Error('characters must be object');
|
|
characters = { defaults: c.defaults || {}, byId: c.byId || {} };
|
|
writeJSON(CHARS_LIVE, characters);
|
|
listeners.forEach(f => f());
|
|
return characters;
|
|
},
|
|
liveDir: LIVE_DIR,
|
|
getModels: () => models,
|
|
putModels(m) {
|
|
if (!m || !Array.isArray(m.models)) throw new Error('models.models must be array');
|
|
models = m; writeJSON(MODELS_LIVE, models); return models;
|
|
},
|
|
getPlaylistConfig: (fallback) => readJSON(PLAYLIST_LIVE, null) || readJSON(path.join(DATA, 'playlist.json'), fallback),
|
|
putPlaylistConfig(cfg) { writeJSON(PLAYLIST_LIVE, cfg); return cfg; },
|
|
listLayouts,
|
|
saveLayout,
|
|
loadLayout,
|
|
deleteLayout,
|
|
onChange: (f) => listeners.push(f),
|
|
};
|