diff --git a/data/scene.json b/data/scene.json
index 4935c9f..6878ffc 100644
--- a/data/scene.json
+++ b/data/scene.json
@@ -4,24 +4,179 @@
"note": "origin = front-left corner of the table; +X right, +Z toward the back, +Y up. All positions/sizes in centimetres; marker print size stays in mm (sizeMM)."
},
"anchors": [
- { "markerId": 0, "position": [20.0, 0.0, 20.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
- { "markerId": 1, "position": [90.0, 0.0, 20.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
- { "markerId": 2, "position": [20.0, 0.0, 90.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
- { "markerId": 3, "position": [90.0, 0.0, 90.0], "mount": "flat", "yawDeg": 0, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true },
- { "markerId": 4, "position": [55.0, 12.0, 0.0], "mount": "wall", "yawDeg": 180, "pitchDeg": 0, "rollDeg": 0, "sizeMM": 60, "enabled": true }
+ {
+ "markerId": 0,
+ "position": [
+ 20.0,
+ 0.0,
+ 20.0
+ ],
+ "mount": "flat",
+ "yawDeg": 0,
+ "pitchDeg": 0,
+ "rollDeg": 0,
+ "sizeMM": 60,
+ "enabled": true
+ },
+ {
+ "markerId": 1,
+ "position": [
+ 90.0,
+ 0.0,
+ 20.0
+ ],
+ "mount": "flat",
+ "yawDeg": 0,
+ "pitchDeg": 0,
+ "rollDeg": 0,
+ "sizeMM": 60,
+ "enabled": true
+ },
+ {
+ "markerId": 2,
+ "position": [
+ 20.0,
+ 0.0,
+ 90.0
+ ],
+ "mount": "flat",
+ "yawDeg": 0,
+ "pitchDeg": 0,
+ "rollDeg": 0,
+ "sizeMM": 60,
+ "enabled": true
+ },
+ {
+ "markerId": 3,
+ "position": [
+ 90.0,
+ 0.0,
+ 90.0
+ ],
+ "mount": "flat",
+ "yawDeg": 0,
+ "pitchDeg": 0,
+ "rollDeg": 0,
+ "sizeMM": 60,
+ "enabled": true
+ },
+ {
+ "markerId": 4,
+ "position": [
+ 55.0,
+ 12.0,
+ 0.0
+ ],
+ "mount": "wall",
+ "yawDeg": 180,
+ "pitchDeg": 0,
+ "rollDeg": 0,
+ "sizeMM": 60,
+ "enabled": true
+ }
],
"buildings": [
- { "name": "Newbury High", "position": [55.0, 0.0, 55.0], "size": [35.0, 30.0, 25.0], "yawDeg": 0 }
+ {
+ "name": "Newbury High",
+ "position": [
+ 55.0,
+ 0.0,
+ 55.0
+ ],
+ "size": [
+ 35.0,
+ 30.0,
+ 25.0
+ ],
+ "yawDeg": 0
+ }
],
"spawns": [
- { "id": "spawn-1", "position": [35.0, 30.0, 40.0], "enabled": true },
- { "id": "spawn-2", "position": [75.0, 35.0, 40.0], "enabled": true },
- { "id": "spawn-3", "position": [55.0, 45.0, 70.0], "enabled": true },
- { "id": "spawn-4", "position": [25.0, 25.0, 75.0], "enabled": true },
- { "id": "spawn-5", "position": [85.0, 28.0, 75.0], "enabled": true }
+ {
+ "id": "spawn-1",
+ "position": [
+ 35.0,
+ 30.0,
+ 40.0
+ ],
+ "enabled": true
+ },
+ {
+ "id": "spawn-2",
+ "position": [
+ 75.0,
+ 35.0,
+ 40.0
+ ],
+ "enabled": true
+ },
+ {
+ "id": "spawn-3",
+ "position": [
+ 55.0,
+ 45.0,
+ 70.0
+ ],
+ "enabled": true
+ },
+ {
+ "id": "spawn-4",
+ "position": [
+ 25.0,
+ 25.0,
+ 75.0
+ ],
+ "enabled": true
+ },
+ {
+ "id": "spawn-5",
+ "position": [
+ 85.0,
+ 28.0,
+ 75.0
+ ],
+ "enabled": true
+ }
],
"paths": [
- { "id": "main-street", "mode": "loop", "enabled": true, "points": [[20, 20, 35], [90, 20, 35], [95, 25, 75], [55, 30, 95], [15, 25, 70]] }
+ {
+ "id": "main-street",
+ "mode": "loop",
+ "enabled": true,
+ "points": [
+ [
+ 20,
+ 20,
+ 35
+ ],
+ [
+ 90,
+ 20,
+ 35
+ ],
+ [
+ 95,
+ 25,
+ 75
+ ],
+ [
+ 55,
+ 30,
+ 95
+ ],
+ [
+ 15,
+ 25,
+ 70
+ ]
+ ]
+ }
],
- "table": { "width": 110, "depth": 110, "offsetX": 0, "offsetZ": 0, "show": true }
-}
+ "table": {
+ "width": 110,
+ "depth": 110,
+ "offsetX": 0,
+ "offsetZ": 0,
+ "show": true
+ }
+}
\ No newline at end of file
diff --git a/public/admin/admin.js b/public/admin/admin.js
index 34e2a36..4207204 100644
--- a/public/admin/admin.js
+++ b/public/admin/admin.js
@@ -39,7 +39,7 @@ export const styles = `
export function nav(active) {
return `
Newbury Exhibit — Admin
- ${['preview', 'layout', 'plan', 'playlist', 'print', 'calibrate', 'errors'].map(p =>
+ ${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p =>
`${p}`).join('')}
viewer ↗
`;
diff --git a/public/admin/characters.html b/public/admin/characters.html
new file mode 100644
index 0000000..78dd745
--- /dev/null
+++ b/public/admin/characters.html
@@ -0,0 +1,302 @@
+
+
+Characters — Newbury Exhibit
+
+
+
+
+
diff --git a/public/admin/preview.html b/public/admin/preview.html
index fd15c53..11a1372 100644
--- a/public/admin/preview.html
+++ b/public/admin/preview.html
@@ -50,6 +50,7 @@ app.innerHTML = `${nav('preview')}
const g = await (await fetch('/api/ghosts')).json();
const gradients = g.gradients.gradients || g.gradients;
const modelManifest = await (await fetch('/api/models')).json();
+let characters = await (await fetch('/api/characters')).json();
// ---------- three.js ----------
const cvs = document.getElementById('gl');
@@ -134,7 +135,7 @@ const clockSamples = []; let clockOffset = 0;
async function addGhost(rec) {
if (active.has(rec.uid)) return;
- const group = await buildGhost(rec, gradients, modelManifest);
+ const group = await buildGhost(rec, gradients, modelManifest, characters);
group.userData.setHeight(ghostHeight);
const label = textSprite(rec.name, COLORS[rec.color] || '#fff');
label.scale.set(20, 5, 1);
@@ -157,6 +158,12 @@ net.on('scene', m => {
ghostHeight = m.scene.ghostHeightCm || 4;
for (const e of active.values()) e.group.userData.setHeight(ghostHeight);
})
+ .on('characters', async m => {
+ characters = m.characters || characters;
+ const recs = [...active.values()].map(e => e.rec);
+ for (const uid of [...active.keys()]) removeGhost(uid);
+ for (const rec of recs) await addGhost(rec);
+ })
.on('active', async m => {
for (const uid of [...active.keys()]) removeGhost(uid);
for (const rec of m.ghosts) await addGhost(rec);
diff --git a/public/js/exhibit.js b/public/js/exhibit.js
index cd0e4b6..47cd63b 100644
--- a/public/js/exhibit.js
+++ b/public/js/exhibit.js
@@ -15,7 +15,7 @@ const $ = (s) => document.querySelector(s);
let info = { mode: 'dev' };
let scene3, camera3, renderer, video, canvas2d, ctx2d;
let detector, poseEst, fuser;
-let gradients = {}, modelManifest = null;
+let gradients = {}, modelManifest = null, characters = { defaults: {}, byId: {} };
let ghostHeight = 4; // cm, from scene.ghostHeightCm
const activeGhosts = new Map(); // uid -> { rec, group }
let tracking = { markers: 0, lastSeen: 0 };
@@ -29,6 +29,7 @@ async function boot() {
const g = await (await fetch('/api/ghosts')).json();
gradients = g.gradients.gradients || g.gradients;
modelManifest = await (await fetch('/api/models')).json();
+ characters = await (await fetch('/api/characters')).json();
$('#start').addEventListener('click', start, { once: true });
}
@@ -68,6 +69,12 @@ async function start() {
ghostHeight = m.scene.ghostHeightCm || 4;
for (const e of activeGhosts.values()) e.group.userData.setHeight(ghostHeight);
})
+ .on('characters', async (m) => {
+ characters = m.characters || characters;
+ const recs = [...activeGhosts.values()].map(e => e.rec);
+ for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
+ for (const rec of recs) await addGhost(rec); // rebuild with new visuals
+ })
.on('active', async (m) => {
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
for (const rec of m.ghosts) await addGhost(rec);
@@ -111,7 +118,7 @@ function buildOcclusion(scene) {
// ---------- ghosts ----------
async function addGhost(rec) {
if (activeGhosts.has(rec.uid)) return;
- const group = await buildGhost(rec, gradients, modelManifest);
+ const group = await buildGhost(rec, gradients, modelManifest, characters);
group.userData.setHeight(ghostHeight);
scene3.add(group);
activeGhosts.set(rec.uid, { rec, group });
diff --git a/public/js/ghosts/loader.js b/public/js/ghosts/loader.js
index 4fdca52..240a5d3 100644
--- a/public/js/ghosts/loader.js
+++ b/public/js/ghosts/loader.js
@@ -1,20 +1,36 @@
/* loader.js — ghost visuals.
- * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
- * every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
- * by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
- * is used so the exhibit always has something to show.
*
- * Model manifest (data/models.json, editable via /api/models):
- * { "models": [ { "id": "classic", "scale": 1.0,
- * "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
+ * Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
+ * rendered with the recovered Hidden Side gradient shader (top->bottom tint by GhostColor:
+ * Red / Yellow / Blue). Procedural wisp fallback until models are uploaded.
+ *
+ * Per-character overrides come from /api/characters (managed in /admin/characters.html):
+ * { modelId, opacity, topColor, bottomColor, heightCm, scale,
+ * faceTextureUrl, torsoTextureUrl }
+ *
+ * Textures: face and torso images are projected planar-front onto the mesh (UV-independent,
+ * so untextured OBJs still work). Each is placed by a normalized Y band with adjustable
+ * centre/size, blended over the gradient — a simple decal without needing UV-mapped models.
*/
import * as THREE from 'three';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
const objLoader = new OBJLoader();
const objCache = new Map();
+const texLoader = new THREE.TextureLoader();
+const texCache = new Map();
-function gradientMaterial(top, bottom, opacity = 0.92) {
+function loadTexture(url) {
+ if (!url) return null;
+ if (!texCache.has(url)) {
+ const t = texLoader.load(url);
+ t.colorSpace = THREE.SRGBColorSpace;
+ texCache.set(url, t);
+ }
+ return texCache.get(url);
+}
+
+function gradientMaterial(top, bottom, opacity, faceTex, torsoTex, bands) {
return new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
@@ -25,25 +41,60 @@ function gradientMaterial(top, bottom, opacity = 0.92) {
uMinY: { value: 0 },
uMaxY: { value: 1 },
uTime: { value: 0 },
+ faceMap: { value: faceTex || null },
+ torsoMap: { value: torsoTex || null },
+ hasFace: { value: faceTex ? 1 : 0 },
+ hasTorso: { value: torsoTex ? 1 : 0 },
+ // [centreY, halfHeight, halfWidth] in normalized model space
+ faceBand: { value: new THREE.Vector3(bands.faceY, bands.faceSize, bands.faceSize) },
+ torsoBand: { value: new THREE.Vector3(bands.torsoY, bands.torsoSize, bands.torsoSize) },
},
vertexShader: `
varying float vY;
varying vec3 vNormal;
+ varying vec3 vLocal;
uniform float uMinY, uMaxY;
void main() {
- vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
+ float h = max(uMaxY - uMinY, 0.001);
+ vY = clamp((position.y - uMinY) / h, 0.0, 1.0);
+ vLocal = vec3(position.x / h, vY, position.z / h);
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
varying float vY;
varying vec3 vNormal;
+ varying vec3 vLocal;
uniform vec3 topColor, bottomColor;
uniform float opacity, uTime;
+ uniform sampler2D faceMap, torsoMap;
+ uniform int hasFace, hasTorso;
+ uniform vec3 faceBand, torsoBand;
+
+ // planar-front decal: map local X/Y into the band's UV box
+ vec4 decal(sampler2D map, vec3 band) {
+ vec2 uv = vec2((vLocal.x / band.z) * 0.5 + 0.5,
+ ((vLocal.y - band.x) / band.y) * 0.5 + 0.5);
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return vec4(0.0);
+ return texture2D(map, uv);
+ }
+
void main() {
vec3 c = mix(bottomColor, topColor, vY);
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
c += rim * 0.35;
+
+ // only decal the front-facing side so images don't mirror onto the back
+ float front = smoothstep(0.0, 0.35, vNormal.z);
+ if (hasTorso == 1) {
+ vec4 t = decal(torsoMap, torsoBand);
+ c = mix(c, t.rgb, t.a * front);
+ }
+ if (hasFace == 1) {
+ vec4 f = decal(faceMap, faceBand);
+ c = mix(c, f.rgb, f.a * front);
+ }
+
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
gl_FragColor = vec4(c, opacity * pulse);
}`,
@@ -69,12 +120,37 @@ function proceduralWisp() {
return g;
}
-export async function buildGhost(ghost, gradients, manifest) {
+/* Resolve the override chain: character-specific -> defaults -> built-in. */
+function resolveOverrides(ghost, characters) {
+ const d = (characters && characters.defaults) || {};
+ const c = (characters && characters.byId && characters.byId[ghost.id]) || {};
+ return { ...d, ...c };
+}
+
+function pickModel(manifest, ov) {
+ const models = (manifest && manifest.models) || [];
+ if (!models.length) return null;
+ if (ov.modelId) return models.find(m => m.id === ov.modelId) || models[0];
+ return models[0];
+}
+
+export async function buildGhost(ghost, gradients, manifest, characters) {
+ const ov = resolveOverrides(ghost, characters);
const grad = gradients[ghost.color] || gradients.Blue;
- const mat = gradientMaterial(grad.top, grad.bottom);
+ const top = ov.topColor || grad.top;
+ const bottom = ov.bottomColor || grad.bottom;
+ const opacity = ov.opacity != null ? Number(ov.opacity) : 0.92;
+ const bands = {
+ faceY: ov.faceY != null ? Number(ov.faceY) : 0.82,
+ faceSize: ov.faceSize != null ? Number(ov.faceSize) : 0.16,
+ torsoY: ov.torsoY != null ? Number(ov.torsoY) : 0.52,
+ torsoSize: ov.torsoSize != null ? Number(ov.torsoSize) : 0.22,
+ };
+ const mat = gradientMaterial(top, bottom, opacity,
+ loadTexture(ov.faceTextureUrl), loadTexture(ov.torsoTextureUrl), bands);
let group;
- const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
+ const model = pickModel(manifest, ov);
if (model && model.parts) {
group = new THREE.Group();
try {
@@ -92,7 +168,7 @@ export async function buildGhost(ghost, gradients, manifest) {
group = proceduralWisp();
}
- // apply gradient material to every mesh; compute Y-range for gradient mapping
+ // gradient/decal mapping needs the model's Y range
const box = new THREE.Box3().setFromObject(group);
group.traverse(o => {
if (o.isMesh) {
@@ -102,20 +178,19 @@ export async function buildGhost(ghost, gradients, manifest) {
}
});
- // Normalize: wrap so feet sit at the group origin, then expose setHeight(cm) that
- // scales the whole ghost (any source units) to an exact world height. Minifigure ≈ 4 cm.
+ // Normalize: feet at origin, then setHeight(cm) scales any source units to world height.
const outer = new THREE.Group();
const inner = new THREE.Group();
- inner.position.y = -box.min.y; // feet -> origin
+ inner.position.y = -box.min.y;
inner.add(group);
outer.add(inner);
const rawH = Math.max(box.max.y - box.min.y, 1e-6);
- const modelScale = model?.scale || 1; // per-model fine-tune multiplier from the manifest
- outer.userData.setHeight = (cm) => outer.scale.setScalar((cm / rawH) * modelScale);
+ const modelScale = (ov.scale || model?.scale || 1);
+ outer.userData.setHeight = (cm) => outer.scale.setScalar(((ov.heightCm || cm) / rawH) * modelScale);
outer.userData.setHeight(4);
outer.userData.material = mat;
- outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
+ outer.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v * opacity; };
outer.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
return outer;
}
diff --git a/push-characters.bat b/push-characters.bat
new file mode 100644
index 0000000..5199eed
--- /dev/null
+++ b/push-characters.bat
@@ -0,0 +1,17 @@
+@echo off
+REM push-characters.bat - commit and push the character manager changes to Gitea.
+REM Run from anywhere; it always works on K:\newbury-exhibit-v2
+cd /d K:\newbury-exhibit-v2
+
+if not exist .git (
+ echo No git repo here - initialising and linking to Gitea...
+ git init -b main
+ git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/newbury-exhibit-v2.git
+ git fetch origin
+ git reset --soft origin/main
+)
+
+git add -A
+git commit -m "Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals"
+git push origin main
+pause
\ No newline at end of file
diff --git a/server/server.js b/server/server.js
index acba871..42cce51 100644
--- a/server/server.js
+++ b/server/server.js
@@ -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}`);
diff --git a/server/state.js b/server/state.js
index defc7b6..7cde8ee 100644
--- a/server/state.js
+++ b/server/state.js
@@ -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');