Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals

This commit is contained in:
2026-07-24 14:47:36 +10:00
parent b6822af047
commit 5357c8464d
9 changed files with 642 additions and 39 deletions
+28 -2
View File
@@ -11,18 +11,40 @@ const { WebSocketServer } = require('ws');
const state = require('./state');
const auth = require('./auth');
const Playlist = require('./playlist');
const { Assets } = require('./uploads');
const PORT = process.env.PORT || 33044;
const MODE = process.env.EXHIBIT_MODE || 'dev';
const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.json({ limit: '20mb' })); // base64 asset uploads
// ---------- public static ----------
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/data/ghosts.json', express.static(path.join(__dirname, '..', 'data', 'ghosts.json')));
app.use('/data/gradients.json', express.static(path.join(__dirname, '..', 'data', 'gradients.json')));
// ---------- uploaded assets (models + textures) ----------
const assets = new Assets(state.liveDir);
app.use('/assets', express.static(path.join(state.liveDir, 'assets'), { maxAge: '1h' }));
app.get('/api/assets', (req, res) => res.json(assets.list()));
app.post('/api/assets', guard, (req, res) => {
try { res.json(assets.save(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
app.delete('/api/assets/:id', guard, (req, res) => {
try { res.json(assets.remove(req.params.id)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- character overrides ----------
app.get('/api/characters', (req, res) => res.json(state.getCharacters()));
app.put('/api/characters', guard, (req, res) => {
try { res.json(state.putCharacters(req.body)); }
catch (e) { res.status(400).json({ error: String(e.message || e) }); }
});
// ---------- runtime info ----------
app.get('/api/info', (req, res) => {
res.json({ mode: MODE, authRequired: MODE === 'final', version: 2 });
@@ -91,6 +113,7 @@ playlist.start();
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'scene', scene: state.getScene() }));
ws.send(JSON.stringify({ type: 'characters', characters: state.getCharacters() }));
ws.send(JSON.stringify({ type: 'active', ghosts: playlist.activeSnapshot() }));
ws.on('message', (raw) => {
let m; try { m = JSON.parse(raw); } catch { return; }
@@ -99,7 +122,10 @@ wss.on('connection', (ws) => {
});
// push scene updates to viewers when admin saves
state.onChange(() => broadcast({ type: 'scene', scene: state.getScene() }));
state.onChange(() => {
broadcast({ type: 'scene', scene: state.getScene() });
broadcast({ type: 'characters', characters: state.getCharacters() });
});
server.listen(PORT, () => {
console.log(`Newbury Exhibit v2 [${MODE}] on :${PORT}`);
+14
View File
@@ -12,6 +12,7 @@ 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');
fs.mkdirSync(LIVE_DIR, { recursive: true });
@@ -26,6 +27,10 @@ let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [],
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: {} };
const listeners = [];
@@ -71,6 +76,15 @@ module.exports = {
return scene;
},
getGhosts: () => ({ ghosts, gradients }),
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');