303 lines
15 KiB
HTML
303 lines
15 KiB
HTML
<!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>
|