Character manager: asset uploads, per-ghost model/colour/opacity overrides, face+torso texture decals
This commit is contained in:
+169
-14
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const styles = `
|
||||
export function nav(active) {
|
||||
return `<header>
|
||||
<h1>Newbury Exhibit — Admin</h1>
|
||||
${['preview', 'layout', 'plan', 'playlist', 'print', 'calibrate', 'errors'].map(p =>
|
||||
${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p =>
|
||||
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
|
||||
<a href="/">viewer ↗</a>
|
||||
</header>`;
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Characters — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="importmap">
|
||||
{ "imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||||
} }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { buildGhost } from '/js/ghosts/loader.js';
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 260px 1fr 300px; height:calc(100vh - 44px); }
|
||||
#list { background:#0d1226; overflow:auto; padding:8px; }
|
||||
#list .gh { padding:6px 9px; border-radius:6px; cursor:pointer; font-size:.82rem; display:flex; gap:7px; align-items:center; }
|
||||
#list .gh:hover { background:#1a2244; }
|
||||
#list .gh.sel { background:#2a3a6e; }
|
||||
#list .gh .dot { width:9px; height:9px; border-radius:50%; flex:none; }
|
||||
#list .gh .cust { margin-left:auto; font-size:.65rem; color:#51eaf1; }
|
||||
#list input.search { width:100%; box-sizing:border-box; margin-bottom:8px; }
|
||||
#stage { position:relative; background:#0a0e1c; }
|
||||
#gl { width:100%; height:100%; display:block; }
|
||||
#side { background:#111730; padding:12px; overflow:auto; }
|
||||
#side h2 { font-size:.9rem; margin:12px 0 6px; color:#8fb8ff; }
|
||||
#side h2:first-child { margin-top:0; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:5px 0; }
|
||||
.row label { width:96px; font-size:.75rem; }
|
||||
.row input, .row select { flex:1; min-width:0; }
|
||||
.row input[type=color] { padding:1px; height:26px; }
|
||||
.row input[type=range] { padding:0; }
|
||||
.assets { display:flex; flex-wrap:wrap; gap:6px; margin:6px 0; }
|
||||
.asset { background:#0d1226; border-radius:6px; padding:5px 8px; font-size:.72rem; display:flex; gap:6px; align-items:center; }
|
||||
.asset img { width:26px; height:26px; object-fit:cover; border-radius:3px; }
|
||||
.asset button { padding:1px 6px; font-size:.7rem; }
|
||||
.badge { font-size:.7rem; opacity:.62; line-height:1.45; }
|
||||
.drop { border:1px dashed #2a3a6e; border-radius:8px; padding:10px; text-align:center; font-size:.75rem; opacity:.8; }
|
||||
.drop.over { border-color:#51eaf1; background:#141c3a; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const COLORS = { Red: '#ff2678', Yellow: '#fff35d', Blue: '#51eaf1' };
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `${nav('characters')}
|
||||
<div id="wrap">
|
||||
<div id="list"><input class="search" id="search" placeholder="Search ghosts…"><div id="ghosts"></div></div>
|
||||
<div id="stage"><canvas id="gl"></canvas>
|
||||
<div style="position:absolute;left:10px;bottom:10px;font-size:.72rem;background:rgba(6,8,15,.6);padding:6px 10px;border-radius:8px">
|
||||
Live preview · drag to orbit · changes apply to all viewers on Save</div></div>
|
||||
<div id="side"></div>
|
||||
</div>`;
|
||||
|
||||
// ---------- data ----------
|
||||
const gdata = await api('/api/ghosts');
|
||||
const ghosts = gdata.ghosts;
|
||||
const gradients = gdata.gradients.gradients || gdata.gradients;
|
||||
let manifest = await api('/api/models');
|
||||
let assets = await api('/api/assets');
|
||||
let chars = await api('/api/characters');
|
||||
chars.byId ||= {}; chars.defaults ||= {};
|
||||
const scene = await api('/api/scene');
|
||||
const baseHeight = scene.ghostHeightCm ?? 4;
|
||||
|
||||
let selId = ghosts[0]?.id;
|
||||
let filter = '';
|
||||
|
||||
// ---------- preview ----------
|
||||
const cvs = document.getElementById('gl');
|
||||
const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
const scn = new THREE.Scene();
|
||||
scn.background = new THREE.Color(0x0a0e1c);
|
||||
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 500);
|
||||
cam.position.set(0, 4, 12);
|
||||
scn.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.7); dl.position.set(4, 10, 8); scn.add(dl);
|
||||
const orbit = new OrbitControls(cam, cvs);
|
||||
orbit.target.set(0, 2.2, 0);
|
||||
const grid = new THREE.GridHelper(20, 20, 0x2a3a6e, 0x151b36); scn.add(grid);
|
||||
|
||||
let previewObj = null;
|
||||
async function refreshPreview() {
|
||||
if (previewObj) { scn.remove(previewObj); previewObj = null; }
|
||||
const g = ghosts.find(x => x.id === selId);
|
||||
if (!g) return;
|
||||
previewObj = await buildGhost(g, gradients, manifest, chars);
|
||||
previewObj.userData.setHeight(baseHeight);
|
||||
scn.add(previewObj);
|
||||
const h = chars.byId[selId]?.heightCm || baseHeight;
|
||||
orbit.target.set(0, h / 2, 0);
|
||||
cam.position.set(0, h * 0.75, h * 3);
|
||||
}
|
||||
|
||||
function fit() {
|
||||
renderer.setSize(cvs.clientWidth, cvs.clientHeight, false);
|
||||
cam.aspect = cvs.clientWidth / cvs.clientHeight; cam.updateProjectionMatrix();
|
||||
}
|
||||
new ResizeObserver(fit).observe(cvs);
|
||||
(function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
if (previewObj) { previewObj.userData.tick(t / 1000); previewObj.rotation.y += 0.004; }
|
||||
orbit.update(); renderer.render(scn, cam);
|
||||
})(0);
|
||||
|
||||
// ---------- ghost list ----------
|
||||
function renderList() {
|
||||
const q = filter.toLowerCase();
|
||||
document.getElementById('ghosts').innerHTML = ghosts
|
||||
.filter(g => !q || g.name.toLowerCase().includes(q))
|
||||
.map(g => `<div class="gh ${g.id === selId ? 'sel' : ''}" data-id="${g.id}">
|
||||
<span class="dot" style="background:${COLORS[g.color]}"></span>${g.name}
|
||||
${chars.byId[g.id] ? '<span class="cust">●</span>' : ''}</div>`).join('');
|
||||
document.querySelectorAll('.gh').forEach(el => el.onclick = async () => {
|
||||
selId = el.dataset.id; renderList(); renderSide(); await refreshPreview();
|
||||
});
|
||||
}
|
||||
document.getElementById('search').oninput = e => { filter = e.target.value; renderList(); };
|
||||
|
||||
// ---------- side panel ----------
|
||||
const side = document.getElementById('side');
|
||||
const ov = () => (chars.byId[selId] ||= {});
|
||||
const cur = (k, dflt) => { const v = chars.byId[selId]?.[k]; return v != null ? v : (chars.defaults[k] != null ? chars.defaults[k] : dflt); };
|
||||
|
||||
function renderSide() {
|
||||
const g = ghosts.find(x => x.id === selId);
|
||||
if (!g) { side.innerHTML = ''; return; }
|
||||
const grad = gradients[g.color] || gradients.Blue;
|
||||
const models = (manifest.models || []);
|
||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||
|
||||
side.innerHTML = `
|
||||
<h2>${g.name}</h2>
|
||||
<div class="badge">${g.rarity} · ${g.color} · id <code>${g.id}</code></div>
|
||||
|
||||
<h2>Model</h2>
|
||||
<div class="row"><label>model</label>
|
||||
<select id="modelId">
|
||||
<option value="">— default (${models[0]?.id || 'procedural wisp'}) —</option>
|
||||
${models.map(m => `<option value="${m.id}" ${cur('modelId', '') === m.id ? 'selected' : ''}>${m.id}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>height (cm)</label>
|
||||
<input type="number" step="0.5" min="0.5" id="heightCm" value="${cur('heightCm', baseHeight)}"></div>
|
||||
<div class="row"><label>extra scale</label>
|
||||
<input type="number" step="0.05" min="0.05" id="scale" value="${cur('scale', 1)}"></div>
|
||||
|
||||
<h2>Appearance</h2>
|
||||
<div class="row"><label>opacity</label>
|
||||
<input type="range" min="0.1" max="1" step="0.02" id="opacity" value="${cur('opacity', 0.92)}">
|
||||
<span id="opv" style="width:32px;font-size:.72rem">${(+cur('opacity', 0.92)).toFixed(2)}</span></div>
|
||||
<div class="row"><label>top colour</label>
|
||||
<input type="color" id="topColor" value="${cur('topColor', grad.top)}">
|
||||
<button id="resetTop">reset</button></div>
|
||||
<div class="row"><label>bottom</label>
|
||||
<input type="color" id="bottomColor" value="${cur('bottomColor', grad.bottom)}">
|
||||
<button id="resetBottom">reset</button></div>
|
||||
|
||||
<h2>Textures</h2>
|
||||
<div class="badge">Front-projected decals — no UV mapping needed. Use PNGs with transparency.</div>
|
||||
<div class="row"><label>face</label>
|
||||
<select id="faceTextureUrl"><option value="">— none —</option>
|
||||
${textures.map(a => `<option value="${a.url}" ${cur('faceTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>face Y</label><input type="range" min="0" max="1" step="0.01" id="faceY" value="${cur('faceY', 0.82)}"></div>
|
||||
<div class="row"><label>face size</label><input type="range" min="0.04" max="0.5" step="0.01" id="faceSize" value="${cur('faceSize', 0.16)}"></div>
|
||||
<div class="row"><label>torso</label>
|
||||
<select id="torsoTextureUrl"><option value="">— none —</option>
|
||||
${textures.map(a => `<option value="${a.url}" ${cur('torsoTextureUrl', '') === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div>
|
||||
<div class="row"><label>torso Y</label><input type="range" min="0" max="1" step="0.01" id="torsoY" value="${cur('torsoY', 0.52)}"></div>
|
||||
<div class="row"><label>torso size</label><input type="range" min="0.04" max="0.6" step="0.01" id="torsoSize" value="${cur('torsoSize', 0.22)}"></div>
|
||||
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button id="clearChar" class="danger">Reset this ghost</button>
|
||||
<button id="applyAll">Apply to all</button>
|
||||
</div>
|
||||
<button id="save" class="primary" style="width:100%;padding:10px;margin-top:8px">Save characters</button>
|
||||
<div id="status" class="badge" style="margin-top:6px"></div>`;
|
||||
|
||||
const bind = (id, key, transform = v => v) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.oninput = el.onchange = async () => {
|
||||
const v = transform(el.value);
|
||||
if (v === '' || v == null) delete ov()[key]; else ov()[key] = v;
|
||||
if (id === 'opacity') document.getElementById('opv').textContent = (+v).toFixed(2);
|
||||
if (!Object.keys(chars.byId[selId] || {}).length) delete chars.byId[selId];
|
||||
await refreshPreview(); renderList();
|
||||
};
|
||||
};
|
||||
bind('modelId', 'modelId');
|
||||
bind('heightCm', 'heightCm', v => parseFloat(v) || baseHeight);
|
||||
bind('scale', 'scale', v => parseFloat(v) || 1);
|
||||
bind('opacity', 'opacity', v => parseFloat(v));
|
||||
bind('topColor', 'topColor');
|
||||
bind('bottomColor', 'bottomColor');
|
||||
bind('faceTextureUrl', 'faceTextureUrl');
|
||||
bind('torsoTextureUrl', 'torsoTextureUrl');
|
||||
for (const k of ['faceY', 'faceSize', 'torsoY', 'torsoSize']) bind(k, k, v => parseFloat(v));
|
||||
|
||||
document.getElementById('resetTop').onclick = async () => { delete ov().topColor; await refreshPreview(); renderSide(); };
|
||||
document.getElementById('resetBottom').onclick = async () => { delete ov().bottomColor; await refreshPreview(); renderSide(); };
|
||||
document.getElementById('clearChar').onclick = async () => {
|
||||
delete chars.byId[selId]; await refreshPreview(); renderList(); renderSide();
|
||||
};
|
||||
document.getElementById('applyAll').onclick = async () => {
|
||||
const src = { ...(chars.byId[selId] || {}) };
|
||||
delete src.faceTextureUrl; // per-ghost faces stay per-ghost
|
||||
if (!confirm('Apply this ghost\'s model/appearance settings as the default for every ghost?')) return;
|
||||
chars.defaults = { ...chars.defaults, ...src };
|
||||
renderList(); renderSide();
|
||||
};
|
||||
document.getElementById('save').onclick = async () => {
|
||||
const st = document.getElementById('status');
|
||||
try { chars = await api('/api/characters', 'PUT', chars); chars.byId ||= {}; chars.defaults ||= {};
|
||||
st.textContent = 'Saved ' + new Date().toLocaleTimeString(); renderList();
|
||||
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- asset manager (bottom of list column) ----------
|
||||
function renderAssets() {
|
||||
const models = assets.assets.filter(a => a.kind === 'model');
|
||||
const textures = assets.assets.filter(a => a.kind === 'texture');
|
||||
let box = document.getElementById('assetBox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'assetBox';
|
||||
box.style.cssText = 'margin-top:12px;border-top:1px solid #1a2244;padding-top:10px';
|
||||
document.getElementById('list').appendChild(box);
|
||||
}
|
||||
box.innerHTML = `
|
||||
<h2 style="font-size:.85rem;color:#8fb8ff;margin:0 0 6px">Assets</h2>
|
||||
<div class="drop" id="drop">Drop .obj / .png here<br><span style="opacity:.6">or</span>
|
||||
<input type="file" id="file" multiple accept=".obj,.mtl,.png,.jpg,.jpeg,.webp" style="width:100%;margin-top:5px"></div>
|
||||
<div class="badge" style="margin-top:6px">Models (${models.length})</div>
|
||||
<div class="assets">${models.map(a => `<span class="asset">${a.name}
|
||||
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
|
||||
<div class="badge">Textures (${textures.length})</div>
|
||||
<div class="assets">${textures.map(a => `<span class="asset"><img src="${a.url}">${a.name}
|
||||
<button data-delasset="${a.id}" class="danger">✕</button></span>`).join('') || '<span class="badge">none</span>'}</div>
|
||||
${models.length ? `<button id="buildModel" style="width:100%;margin-top:6px">Build model from parts…</button>` : ''}`;
|
||||
|
||||
const drop = document.getElementById('drop');
|
||||
drop.ondragover = e => { e.preventDefault(); drop.classList.add('over'); };
|
||||
drop.ondragleave = () => drop.classList.remove('over');
|
||||
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); upload([...e.dataTransfer.files]); };
|
||||
document.getElementById('file').onchange = e => upload([...e.target.files]);
|
||||
document.querySelectorAll('[data-delasset]').forEach(b => b.onclick = async () => {
|
||||
if (!confirm('Delete this asset? Any character using it falls back to default.')) return;
|
||||
await api('/api/assets/' + b.dataset.delasset, 'DELETE');
|
||||
assets = await api('/api/assets'); renderAssets(); renderSide(); await refreshPreview();
|
||||
});
|
||||
const bm = document.getElementById('buildModel');
|
||||
if (bm) bm.onclick = buildModelDialog;
|
||||
}
|
||||
|
||||
async function upload(files) {
|
||||
for (const f of files) {
|
||||
const ext = f.name.toLowerCase().split('.').pop();
|
||||
const kind = ['obj', 'mtl'].includes(ext) ? 'model' : 'texture';
|
||||
const dataBase64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(String(r.result).split(',')[1]);
|
||||
r.onerror = () => rej(new Error('read failed'));
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
try { await api('/api/assets', 'POST', { name: f.name, kind, dataBase64 }); }
|
||||
catch (e) { alert(`${f.name}: ${e.message}`); }
|
||||
}
|
||||
assets = await api('/api/assets');
|
||||
renderAssets(); renderSide();
|
||||
}
|
||||
|
||||
/* Assemble uploaded OBJ parts into a named model in the manifest. */
|
||||
async function buildModelDialog() {
|
||||
const objs = assets.assets.filter(a => a.kind === 'model' && a.name.toLowerCase().endsWith('.obj'));
|
||||
if (!objs.length) return alert('Upload some .obj parts first.');
|
||||
const id = prompt('Model name (e.g. minifig):', 'minifig');
|
||||
if (!id) return;
|
||||
const parts = {};
|
||||
for (const key of ['legs', 'torso', 'head', 'headpiece']) {
|
||||
const list = objs.map((o, i) => `${i}: ${o.name}`).join('\n');
|
||||
const pick = prompt(`Which file is the ${key}? (number, or blank to skip)\n\n${list}`, '');
|
||||
if (pick === null) return;
|
||||
if (pick.trim() !== '' && objs[+pick]) parts[key] = objs[+pick].url;
|
||||
}
|
||||
if (!Object.keys(parts).length) return alert('No parts chosen.');
|
||||
manifest.models = (manifest.models || []).filter(m => m.id !== id);
|
||||
manifest.models.push({ id, scale: 1, parts });
|
||||
manifest = await api('/api/models', 'PUT', manifest);
|
||||
renderSide(); await refreshPreview();
|
||||
alert(`Model "${id}" created — pick it in the Model dropdown.`);
|
||||
}
|
||||
|
||||
renderList(); renderSide(); renderAssets(); fit(); await refreshPreview();
|
||||
</script>
|
||||
</body></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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+94
-19
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+28
-2
@@ -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}`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user