Update exhibit

This commit is contained in:
2026-07-06 19:25:57 +10:00
parent 5ffd1346d6
commit e92ad04b7e
8 changed files with 3757 additions and 195 deletions
+744
View File
@@ -0,0 +1,744 @@
/* Newbury Exhibit — scene workshop (admin editor)
*
* A 3D authoring GUI over data/scene.json. Edit the table, anchors (AR crests),
* ghost spawns + their motion (hover / patrol path), and building blockers
* (stackable cubes used later for occlusion). Save writes back to the server,
* which backs up the old file and hot-reloads so live viewers update.
*
* Coordinates: world millimetres, origin at the front-left table corner.
* X = length (1500), Z = depth (700), Y = height. Three.js uses metres (×0.001).
*/
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
const MM = 0.001;
const LURE = {
Red: { top: 0xf65151, bottom: 0xff2678 },
Yellow: { top: 0xb57f0b, bottom: 0xfff35d },
Blue: { top: 0x529eff, bottom: 0x51eaf1 },
};
const COL = { anchor: 0x00e5c0, blocker: 0xc98a4b, sel: 0xffd24a };
// ---- editable state (the scene we mutate, then POST) ----------------------
let scene = null;
let roster = []; // ghost roster for the picker (from /api/ghosts)
let rosterById = new Map();
let dirty = false;
let selected = null; // { kind:'anchor'|'spawn'|'blocker', id }
let tool = 'select';
let nextIdN = 1;
// ---- three.js -------------------------------------------------------------
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const view3d = new THREE.Scene();
view3d.background = new THREE.Color(0x070910);
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 200);
camera.position.set(0.9, 1.1, 1.7);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
view3d.add(new THREE.AmbientLight(0x8090b0, 0.9));
const key = new THREE.DirectionalLight(0xffffff, 1.0);
key.position.set(2, 3, 1.5); view3d.add(key);
// groups so we can rebuild each kind independently
const gTable = new THREE.Group(); view3d.add(gTable);
const gAnchors = new THREE.Group(); view3d.add(gAnchors);
const gSpawns = new THREE.Group(); view3d.add(gSpawns);
const gBlockers = new THREE.Group(); view3d.add(gBlockers);
const gPaths = new THREE.Group(); view3d.add(gPaths);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let tablePlane = null; // THREE.Plane at Y=0 for drag math
// map THREE object.uuid -> { kind, id } for picking
const pickMap = new Map();
// ---- helpers --------------------------------------------------------------
const $ = (id) => document.getElementById(id);
const mm2m = (v) => v * MM;
const v3 = (p) => new THREE.Vector3(p.x * MM, p.y * MM, p.z * MM);
function uid(prefix) { return `${prefix}-${Date.now().toString(36)}-${(nextIdN++).toString(36)}`; }
function markDirty() { dirty = true; $('dirty').classList.add('show'); $('saveBtn').disabled = false; }
function toast(msg, kind = '') {
const t = $('toast'); t.textContent = msg; t.className = 'show ' + kind;
clearTimeout(t._h); t._h = setTimeout(() => (t.className = ''), 2600);
}
// ===========================================================================
// LOAD
// ===========================================================================
async function load() {
const res = await fetch('/api/scene');
scene = await res.json();
// load roster once (optional; picker falls back to free text if missing)
if (!roster.length) {
try {
const rr = await fetch('/api/ghosts');
roster = await rr.json();
rosterById = new Map(roster.map((g) => [g.id, g]));
} catch (_) { roster = []; }
}
if (!scene.blockers) scene.blockers = [];
if (!scene.world.buildSize) scene.world.buildSize = { x: 1500, y: 600, z: 700 };
rebuildAll();
centerCamera();
renderPanels();
updateCounts();
$('worldMeta').textContent =
`${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`;
}
function centerCamera() {
const b = scene.world.buildSize;
controls.target.set(b.x * MM / 2, 0.1, b.z * MM / 2);
controls.update();
}
// ===========================================================================
// REBUILD 3D
// ===========================================================================
function rebuildAll() { rebuildTable(); rebuildAnchors(); rebuildSpawns(); rebuildBlockers(); }
function clearGroup(g) {
pickMapPrune(g);
while (g.children.length) {
const c = g.children.pop();
c.geometry?.dispose?.();
if (Array.isArray(c.material)) c.material.forEach((m) => m.dispose?.());
else c.material?.dispose?.();
}
}
function pickMapPrune(g) {
g.traverse((o) => pickMap.delete(o.uuid));
}
function rebuildTable() {
clearGroup(gTable);
const b = scene.world.buildSize;
const w = b.x * MM, d = b.z * MM;
tablePlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const top = new THREE.Mesh(
new THREE.PlaneGeometry(w, d),
new THREE.MeshStandardMaterial({ color: 0x10131f, roughness: 0.95, side: THREE.DoubleSide })
);
top.rotation.x = -Math.PI / 2;
top.position.set(w / 2, 0, d / 2);
gTable.add(top);
// grid every 100mm
const grid = new THREE.GridHelper(Math.max(w, d), Math.round(Math.max(b.x, b.z) / 100), 0x2a3450, 0x1a2238);
grid.position.set(w / 2, 0.0005, d / 2);
// clip grid to table by scaling (approx): leave as square underlay
gTable.add(grid);
// origin corner marker + axes (X red-ish along length, Z blue along depth)
const axisLen = 0.12;
gTable.add(axisLine(0, 0, 0, axisLen, 0, 0, 0xff6b6b)); // X
gTable.add(axisLine(0, 0, 0, 0, 0, axisLen, 0x6b8cff)); // Z
const corner = new THREE.Mesh(new THREE.SphereGeometry(0.01, 12, 12),
new THREE.MeshBasicMaterial({ color: 0xffffff }));
gTable.add(corner);
}
function axisLine(x1, y1, z1, x2, y2, z2, color) {
const g = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x2, y2, z2)]);
return new THREE.Line(g, new THREE.LineBasicMaterial({ color }));
}
function rebuildAnchors() {
clearGroup(gAnchors);
for (const a of scene.anchors) {
const s = (a.fiducialSizeMm || 40) * MM;
const isSel = selected?.kind === 'anchor' && selected.id === a.id;
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(s, s * 0.12, s),
new THREE.MeshStandardMaterial({
color: isSel ? COL.sel : COL.anchor, emissive: isSel ? COL.sel : COL.anchor,
emissiveIntensity: 0.45,
})
);
mesh.position.copy(v3(a.position));
mesh.rotation.y = THREE.MathUtils.degToRad(a.rotationDeg?.y || 0);
gAnchors.add(mesh);
pickMap.set(mesh.uuid, { kind: 'anchor', id: a.id });
// little post so it reads as a plaque on a stand
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.004, 0.004, a.position.y * MM, 6),
new THREE.MeshStandardMaterial({ color: 0x2a3450 }));
post.position.set(a.position.x * MM, (a.position.y * MM) / 2, a.position.z * MM);
gAnchors.add(post);
}
}
function rebuildSpawns() {
clearGroup(gSpawns); clearGroup(gPaths);
for (const sp of scene.spawns) {
const isSel = selected?.kind === 'spawn' && selected.id === sp.id;
const lure = LURE[sp.color] || LURE.Blue;
const mat = new THREE.MeshStandardMaterial({
color: isSel ? COL.sel : lure.bottom, emissive: lure.top, emissiveIntensity: 0.6,
transparent: true, opacity: 0.9, roughness: 0.4,
});
const mesh = new THREE.Mesh(new THREE.IcosahedronGeometry(0.045, 1), mat);
const home = sp.motion === 'patrol' ? (sp.path?.[0] || { x: 0, y: 0, z: 0 }) : sp.position;
mesh.position.copy(v3(home));
gSpawns.add(mesh);
pickMap.set(mesh.uuid, { kind: 'spawn', id: sp.id });
if (sp.motion === 'patrol' && sp.path?.length > 1) {
const pts = sp.path.map(v3); pts.push(pts[0].clone());
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0x51eaf1, transparent: true, opacity: 0.6 })
);
gPaths.add(line);
sp.path.forEach((p, i) => {
const node = new THREE.Mesh(new THREE.SphereGeometry(0.012, 10, 10),
new THREE.MeshBasicMaterial({ color: i === 0 ? 0xffffff : 0x51eaf1 }));
node.position.copy(v3(p));
gPaths.add(node);
pickMap.set(node.uuid, { kind: 'pathpt', id: sp.id, index: i });
});
} else if (sp.motion === 'hover' && sp.hoverRadiusMm) {
// hover ring
const r = sp.hoverRadiusMm * MM;
const ring = new THREE.Mesh(
new THREE.RingGeometry(r * 0.96, r, 32),
new THREE.MeshBasicMaterial({ color: 0x51eaf1, transparent: true, opacity: 0.3, side: THREE.DoubleSide })
);
ring.rotation.x = -Math.PI / 2;
ring.position.copy(v3(sp.position));
gPaths.add(ring);
}
}
}
function rebuildBlockers() {
clearGroup(gBlockers);
for (const bl of scene.blockers) {
const isSel = selected?.kind === 'blocker' && selected.id === bl.id;
const sz = bl.size;
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(sz.x * MM, sz.y * MM, sz.z * MM),
new THREE.MeshStandardMaterial({
color: isSel ? COL.sel : COL.blocker, transparent: true, opacity: isSel ? 0.55 : 0.4,
roughness: 0.8,
})
);
// position is the CENTRE of the box base footprint; Y is base height (usually 0)
mesh.position.set(
(bl.position.x + sz.x / 2) * MM,
(bl.position.y + sz.y / 2) * MM,
(bl.position.z + sz.z / 2) * MM
);
gBlockers.add(mesh);
pickMap.set(mesh.uuid, { kind: 'blocker', id: bl.id });
// wireframe edge for clarity
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(mesh.geometry),
new THREE.LineBasicMaterial({ color: isSel ? COL.sel : 0xe0a868, transparent: true, opacity: 0.7 })
);
edges.position.copy(mesh.position);
gBlockers.add(edges);
}
}
// ===========================================================================
// PICKING + DRAG
// ===========================================================================
let dragging = null; // { kind, id, index?, offset:Vector3 }
function setPointer(e) {
const r = canvas.getBoundingClientRect();
pointer.x = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.y = -((e.clientY - r.top) / r.height) * 2 + 1;
}
function intersectTable() {
raycaster.setFromCamera(pointer, camera);
const hit = new THREE.Vector3();
raycaster.ray.intersectPlane(tablePlane, hit);
return hit; // metres
}
function pickObject() {
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(
[...gAnchors.children, ...gSpawns.children, ...gBlockers.children, ...gPaths.children], false);
for (const h of hits) {
const ref = pickMap.get(h.object.uuid);
if (ref) return ref;
}
return null;
}
canvas.addEventListener('pointerdown', (e) => {
setPointer(e);
// tool-based add
if (tool !== 'select') { addAtCursor(); return; }
const ref = pickObject();
if (ref) {
if (ref.kind === 'pathpt') {
selectEntity('spawn', ref.id);
dragging = { kind: 'pathpt', id: ref.id, index: ref.index };
} else {
selectEntity(ref.kind, ref.id);
dragging = { kind: ref.kind, id: ref.id };
}
controls.enabled = false;
}
});
canvas.addEventListener('pointermove', (e) => {
setPointer(e);
const hit = intersectTable();
if (hit) $('cursorPos').textContent = `${Math.round(hit.x / MM)}, ${Math.round(hit.z / MM)} mm`;
if (!dragging || !hit) return;
const x = Math.round(hit.x / MM), z = Math.round(hit.z / MM);
applyDrag(dragging, x, z);
});
window.addEventListener('pointerup', () => {
if (dragging) { dragging = null; controls.enabled = true; renderPanels(); }
});
function applyDrag(d, x, z) {
if (d.kind === 'anchor') {
const a = scene.anchors.find((o) => o.id === d.id);
a.position.x = clampX(x); a.position.z = clampZ(z); rebuildAnchors();
} else if (d.kind === 'blocker') {
const bl = scene.blockers.find((o) => o.id === d.id);
bl.position.x = clampX(x - bl.size.x / 2); bl.position.z = clampZ(z - bl.size.z / 2); rebuildBlockers();
} else if (d.kind === 'spawn') {
const sp = scene.spawns.find((o) => o.id === d.id);
if (sp.motion === 'hover') { sp.position.x = x; sp.position.z = z; }
else if (sp.path?.length) { const dx = x - sp.path[0].x, dz = z - sp.path[0].z;
sp.path.forEach((p) => { p.x += dx; p.z += dz; }); }
rebuildSpawns();
} else if (d.kind === 'pathpt') {
const sp = scene.spawns.find((o) => o.id === d.id);
sp.path[d.index].x = x; sp.path[d.index].z = z; rebuildSpawns();
}
markDirty();
}
function clampX(x) { return Math.max(0, Math.min(scene.world.buildSize.x, x)); }
function clampZ(z) { return Math.max(0, Math.min(scene.world.buildSize.z, z)); }
function addAtCursor() {
const hit = intersectTable(); if (!hit) return;
const x = Math.round(clampX(hit.x / MM)), z = Math.round(clampZ(hit.z / MM));
if (tool === 'addSpawn') {
const sp = { id: uid('spawn'), ghostId: '', color: 'Blue', label: 'New ghost', motion: 'hover',
position: { x, y: 180, z }, hoverRadiusMm: 50, hoverPeriodS: 6, bobAmplitudeMm: 30, bobPeriodS: 3, yawDegPerS: 10 };
scene.spawns.push(sp); rebuildSpawns(); selectEntity('spawn', sp.id); setTool('select');
} else if (tool === 'addBlocker') {
const bl = { id: uid('blk'), label: 'Building', position: { x, y: 0, z }, size: { x: 120, y: 100, z: 120 } };
scene.blockers.push(bl); rebuildBlockers(); selectEntity('blocker', bl.id); setTool('select');
} else if (tool === 'addAnchor') {
const n = scene.anchors.length;
const a = { id: uid('anchor'), label: `Anchor ${n + 1}`, fiducialType: 'aruco', fiducialId: n,
fiducialSizeMm: 96, position: { x, y: 30, z }, rotationDeg: { x: 0, y: 0, z: 0 } };
scene.anchors.push(a); rebuildAnchors(); selectEntity('anchor', a.id); setTool('select');
}
markDirty(); updateCounts();
}
// ===========================================================================
// SELECTION + PANELS
// ===========================================================================
function selectEntity(kind, id) {
selected = { kind, id };
rebuildAnchors(); rebuildSpawns(); rebuildBlockers();
// switch to the relevant tab
switchTab(kind === 'anchor' ? 'anchors' : kind === 'spawn' ? 'spawns' : 'blockers');
renderPanels();
const obj = kind === 'anchor' ? scene.anchors.find((o) => o.id === id)
: kind === 'spawn' ? scene.spawns.find((o) => o.id === id)
: scene.blockers.find((o) => o.id === id);
$('selInfo').innerHTML = obj ? `${kind}: <b>${obj.label || obj.id}</b>` : 'nothing selected';
}
function updateCounts() {
$('nAnchors').textContent = scene.anchors.length;
$('nSpawns').textContent = scene.spawns.length;
$('nBlockers').textContent = scene.blockers.length;
}
// ---- panel rendering ------------------------------------------------------
function renderPanels() { renderWorld(); renderAnchors(); renderSpawns(); renderBlockers(); }
function numRow(label, value, oninput, opts = {}) {
const wrap = document.createElement('div'); wrap.className = 'row';
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
const i = document.createElement('input'); i.type = 'number'; i.value = value;
if (opts.step) i.step = opts.step; if (opts.min != null) i.min = opts.min;
i.addEventListener('input', () => oninput(parseFloat(i.value)));
wrap.appendChild(i); return wrap;
}
function xyzRow(label, obj, keys, onchange, opts = {}) {
const wrap = document.createElement('div'); wrap.className = 'row';
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
const box = document.createElement('div'); box.className = 'xyz';
for (const k of keys) {
const ax = document.createElement('div'); ax.className = 'axis'; ax.dataset.a = k;
const i = document.createElement('input'); i.type = 'number'; i.value = Math.round(obj[k]);
if (opts.step) i.step = opts.step;
i.addEventListener('input', () => { obj[k] = parseFloat(i.value) || 0; onchange(); });
ax.appendChild(i); box.appendChild(ax);
}
wrap.appendChild(box); return wrap;
}
function textRow(label, value, oninput) {
const wrap = document.createElement('div'); wrap.className = 'row';
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
const i = document.createElement('input'); i.type = 'text'; i.value = value || '';
i.addEventListener('input', () => oninput(i.value));
wrap.appendChild(i); return wrap;
}
function selectRow(label, value, options, oninput) {
const wrap = document.createElement('div'); wrap.className = 'row';
const l = document.createElement('label'); l.textContent = label; wrap.appendChild(l);
const s = document.createElement('select');
for (const o of options) { const op = document.createElement('option'); op.value = o; op.textContent = o; if (o === value) op.selected = true; s.appendChild(op); }
s.addEventListener('change', () => oninput(s.value));
wrap.appendChild(s); return wrap;
}
function renderWorld() {
const p = $('panel-world'); p.innerHTML = '';
const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Table dimensions (mm)'; p.appendChild(h);
p.appendChild(xyzRow('Build size', scene.world.buildSize, ['x', 'y', 'z'], () => { rebuildTable(); centerCamera(); markDirty(); updateMeta(); }));
const hint = document.createElement('div'); hint.className = 'hint';
hint.textContent = 'X = length, Z = depth, Y = max ghost height. Max display 700 × 1500 mm. Origin is the front-left corner; all positions measured from there.';
p.appendChild(hint);
const h2 = document.createElement('div'); h2.className = 'section-h'; h2.textContent = 'Origin'; p.appendChild(h2);
const o = document.createElement('div'); o.className = 'hint';
o.textContent = scene.world.origin || 'front-left corner of baseplate, table surface = Y0';
p.appendChild(o);
}
function updateMeta() {
$('worldMeta').textContent =
`${scene.world.buildSize.x}×${scene.world.buildSize.z}mm · ${scene.anchors.length} anchors · ${scene.spawns.length} ghosts · ${scene.blockers.length} buildings`;
}
function listItem({ dot, name, tag, sub, sel, onclick, ondel }) {
const el = document.createElement('div'); el.className = 'list-item' + (sel ? ' sel' : '');
const top = document.createElement('div'); top.className = 'li-top';
if (dot) { const d = document.createElement('span'); d.className = 'li-dot'; d.style.background = dot; top.appendChild(d); }
const nm = document.createElement('span'); nm.className = 'li-name'; nm.textContent = name; top.appendChild(nm);
if (tag) { const t = document.createElement('span'); t.className = 'li-tag'; t.textContent = tag; top.appendChild(t); }
el.appendChild(top);
if (sub) { const s = document.createElement('div'); s.className = 'li-sub'; s.textContent = sub; el.appendChild(s); }
el.addEventListener('click', onclick);
return el;
}
function hexCss(n) { return '#' + n.toString(16).padStart(6, '0'); }
function renderAnchors() {
const p = $('panel-anchors'); p.innerHTML = '';
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add anchor (or click ◎ then the table)';
add.addEventListener('click', () => { setTool('addAnchor'); toast('Click on the table to place the anchor'); });
p.appendChild(add);
if (!scene.anchors.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No anchors yet.'; p.appendChild(e); return; }
for (const a of scene.anchors) {
const sel = selected?.kind === 'anchor' && selected.id === a.id;
p.appendChild(listItem({
dot: hexCss(COL.anchor), name: a.label || a.id, tag: `id ${a.fiducialId}`,
sub: `${Math.round(a.position.x)}, ${Math.round(a.position.z)} mm · ${a.fiducialSizeMm}mm`,
sel, onclick: () => selectEntity('anchor', a.id),
}));
if (sel) p.appendChild(anchorEditor(a));
}
}
function anchorEditor(a) {
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
box.appendChild(textRow('Label', a.label, (v) => { a.label = v; markDirty(); renderAnchorsSoft(); }));
box.appendChild(selectRow('Marker', a.fiducialType || 'aruco', ['aruco', 'apriltag'], (v) => { a.fiducialType = v; markDirty(); }));
box.appendChild(numRow('Marker id', a.fiducialId, (v) => { a.fiducialId = v | 0; markDirty(); renderAnchorsSoft(); }, { step: 1, min: 0 }));
box.appendChild(numRow('Size (mm)', a.fiducialSizeMm, (v) => { a.fiducialSizeMm = v; rebuildAnchors(); markDirty(); }, { step: 1 }));
box.appendChild(xyzRow('Pos (mm)', a.position, ['x', 'y', 'z'], () => { rebuildAnchors(); markDirty(); }));
box.appendChild(numRow('Yaw (°)', a.rotationDeg?.y || 0, (v) => { a.rotationDeg = a.rotationDeg || { x: 0, y: 0, z: 0 }; a.rotationDeg.y = v; rebuildAnchors(); markDirty(); }, { step: 1 }));
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete anchor';
del.style.marginTop = '6px';
del.addEventListener('click', () => { scene.anchors = scene.anchors.filter((o) => o.id !== a.id); selected = null; rebuildAnchors(); renderAnchors(); updateCounts(); markDirty(); });
box.appendChild(del);
return box;
}
function renderAnchorsSoft() { updateCounts(); updateMeta(); }
function renderSpawns() {
const p = $('panel-spawns'); p.innerHTML = '';
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add ghost (or click 👻 then the table)';
add.addEventListener('click', () => { setTool('addSpawn'); toast('Click on the table to place the ghost'); });
p.appendChild(add);
if (!scene.spawns.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No ghosts yet.'; p.appendChild(e); return; }
for (const sp of scene.spawns) {
const sel = selected?.kind === 'spawn' && selected.id === sp.id;
const lure = LURE[sp.color] || LURE.Blue;
p.appendChild(listItem({
dot: hexCss(lure.bottom), name: sp.label || sp.id, tag: sp.motion,
sub: sp.ghostId ? sp.ghostId : '(no ghost id)',
sel, onclick: () => selectEntity('spawn', sp.id),
}));
if (sel) p.appendChild(spawnEditor(sp));
}
}
// Searchable ghost picker: type to filter 111 ghosts, pick one -> auto-fills
// ghostId + lure colour + boss flag. Falls back to free text if no roster.
function ghostPicker(sp, onPick) {
const wrap = document.createElement('div'); wrap.className = 'row'; wrap.style.position = 'relative';
const l = document.createElement('label'); l.textContent = 'Ghost'; wrap.appendChild(l);
const field = document.createElement('div'); field.style.cssText = 'flex:1;min-width:0;position:relative;';
const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'search ghosts…';
const cur = rosterById.get(sp.ghostId);
input.value = cur ? cur.name : (sp.ghostId || '');
input.style.cssText = 'width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;font-family:var(--mono);';
field.appendChild(input);
if (!roster.length) {
// no roster available — behave as plain free-text id
input.addEventListener('input', () => { sp.ghostId = input.value; markDirty(); });
wrap.appendChild(field); return wrap;
}
const menu = document.createElement('div');
menu.style.cssText = 'position:absolute;left:0;right:0;top:calc(100% + 3px);z-index:20;max-height:230px;overflow-y:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 12px 30px rgba(0,0,0,0.5);display:none;';
field.appendChild(menu);
const rarityDot = { Common: '#7f8aa3', Rare: '#5ad1ff', Epic: '#c07bff', Legendary: '#ffb038' };
function render(filter) {
const q = filter.trim().toLowerCase();
const matches = roster.filter((g) => !q || g.name.toLowerCase().includes(q) || g.id.includes(q) || g.color.toLowerCase().startsWith(q)).slice(0, 40);
menu.innerHTML = '';
if (!matches.length) { menu.style.display = 'none'; return; }
for (const g of matches) {
const it = document.createElement('div');
it.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 9px;cursor:pointer;font-size:12px;border-bottom:1px solid rgba(255,255,255,0.03);';
it.onmouseenter = () => (it.style.background = 'rgba(255,255,255,0.05)');
it.onmouseleave = () => (it.style.background = 'transparent');
const cdot = document.createElement('span'); cdot.style.cssText = `width:9px;height:9px;border-radius:50%;flex-shrink:0;background:${hexCss(LURE[g.color].bottom)};`;
const nm = document.createElement('span'); nm.textContent = g.name; nm.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
const meta = document.createElement('span'); meta.style.cssText = `font-family:var(--mono);font-size:10px;color:${rarityDot[g.rarity] || 'var(--dim)'};`;
meta.textContent = (g.isBoss ? '★ ' : '') + g.rarity;
it.append(cdot, nm, meta);
it.addEventListener('mousedown', (e) => { // mousedown so it fires before blur
e.preventDefault();
sp.ghostId = g.id; sp.color = g.color;
input.value = g.name; menu.style.display = 'none';
onPick(g); markDirty();
});
menu.appendChild(it);
}
menu.style.display = 'block';
}
input.addEventListener('focus', () => render(''));
input.addEventListener('input', () => { sp.ghostId = input.value; render(input.value); });
input.addEventListener('blur', () => setTimeout(() => (menu.style.display = 'none'), 120));
wrap.appendChild(field); return wrap;
}
function spawnEditor(sp) {
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
box.appendChild(textRow('Label', sp.label, (v) => { sp.label = v; markDirty(); }));
box.appendChild(ghostPicker(sp, (g) => {
// auto-fill: colour follows ghost; label defaults to ghost name if blank/placeholder
if (!sp.label || sp.label === 'New ghost') sp.label = g.name;
rebuildSpawns(); renderSpawns();
}));
// colour swatches
const cr = document.createElement('div'); cr.className = 'row';
const cl = document.createElement('label'); cl.textContent = 'Lure'; cr.appendChild(cl);
const sws = document.createElement('div'); sws.className = 'swatches';
for (const key of ['Red', 'Yellow', 'Blue']) {
const s = document.createElement('div'); s.className = 'sw' + (sp.color === key ? ' sel' : '');
s.style.background = hexCss(LURE[key].bottom);
s.addEventListener('click', () => { sp.color = key; rebuildSpawns(); renderSpawns(); markDirty(); });
sws.appendChild(s);
}
cr.appendChild(sws); box.appendChild(cr);
box.appendChild(selectRow('Motion', sp.motion, ['hover', 'patrol'], (v) => {
sp.motion = v;
if (v === 'patrol' && !sp.path) {
const base = sp.position || { x: 200, y: 200, z: 200 };
sp.path = [{ ...base }, { x: base.x + 200, y: base.y, z: base.z }, { x: base.x + 200, y: base.y, z: base.z + 150 }];
sp.patrolPeriodS = sp.patrolPeriodS || 16; sp.faceTravel = sp.faceTravel ?? true;
}
if (v === 'hover' && !sp.position) {
sp.position = sp.path?.[0] ? { ...sp.path[0] } : { x: 200, y: 200, z: 200 };
sp.hoverRadiusMm = sp.hoverRadiusMm ?? 50; sp.hoverPeriodS = sp.hoverPeriodS ?? 6;
}
rebuildSpawns(); renderSpawns(); markDirty();
}));
if (sp.motion === 'hover') {
box.appendChild(xyzRow('Pos (mm)', sp.position, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); }));
box.appendChild(numRow('Hover r (mm)', sp.hoverRadiusMm ?? 0, (v) => { sp.hoverRadiusMm = v; rebuildSpawns(); markDirty(); }));
box.appendChild(numRow('Hover period s', sp.hoverPeriodS ?? 6, (v) => { sp.hoverPeriodS = v; markDirty(); }, { step: 0.5 }));
box.appendChild(numRow('Yaw °/s', sp.yawDegPerS ?? 0, (v) => { sp.yawDegPerS = v; markDirty(); }, { step: 1 }));
} else {
const h = document.createElement('div'); h.className = 'section-h'; h.textContent = 'Patrol path (mm)'; box.appendChild(h);
sp.path.forEach((pt, i) => {
const row = document.createElement('div'); row.className = 'pathpt';
const idx = document.createElement('span'); idx.className = 'idx'; idx.textContent = i; row.appendChild(idx);
const grp = xyzRow('', pt, ['x', 'y', 'z'], () => { rebuildSpawns(); markDirty(); });
grp.querySelector('label').remove(); grp.style.flex = '1'; grp.style.margin = '0';
row.appendChild(grp);
if (sp.path.length > 2) {
const rm = document.createElement('button'); rm.className = 'btn-sm'; rm.textContent = '×';
rm.addEventListener('click', () => { sp.path.splice(i, 1); rebuildSpawns(); renderSpawns(); markDirty(); });
row.appendChild(rm);
}
box.appendChild(row);
});
const addPt = document.createElement('button'); addPt.className = 'btn-sm'; addPt.textContent = '+ point';
addPt.addEventListener('click', () => { const last = sp.path[sp.path.length - 1]; sp.path.push({ x: last.x + 100, y: last.y, z: last.z }); rebuildSpawns(); renderSpawns(); markDirty(); });
box.appendChild(addPt);
box.appendChild(numRow('Loop period s', sp.patrolPeriodS ?? 16, (v) => { sp.patrolPeriodS = v; markDirty(); }, { step: 0.5 }));
box.appendChild(selectRow('Face travel', String(sp.faceTravel ?? true), ['true', 'false'], (v) => { sp.faceTravel = v === 'true'; markDirty(); }));
}
box.appendChild(numRow('Bob amp (mm)', sp.bobAmplitudeMm ?? 0, (v) => { sp.bobAmplitudeMm = v; markDirty(); }));
box.appendChild(numRow('Bob period s', sp.bobPeriodS ?? 3, (v) => { sp.bobPeriodS = v; markDirty(); }, { step: 0.5 }));
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete ghost'; del.style.marginTop = '6px';
del.addEventListener('click', () => { scene.spawns = scene.spawns.filter((o) => o.id !== sp.id); selected = null; rebuildSpawns(); renderSpawns(); updateCounts(); markDirty(); });
box.appendChild(del);
return box;
}
function renderBlockers() {
const p = $('panel-blockers'); p.innerHTML = '';
const add = document.createElement('button'); add.className = 'btn-add'; add.textContent = '+ Add building (or click ▦ then the table)';
add.addEventListener('click', () => { setTool('addBlocker'); toast('Click on the table to place the building'); });
p.appendChild(add);
const hint = document.createElement('div'); hint.className = 'hint';
hint.textContent = 'Buildings are simple boxes used to hide ghosts behind them (occlusion). Stack or place several to approximate a structure. Position is the footprint corner; size is W×H×D.';
p.appendChild(hint);
if (!scene.blockers.length) { const e = document.createElement('div'); e.className = 'empty'; e.textContent = 'No buildings yet.'; p.appendChild(e); return; }
for (const bl of scene.blockers) {
const sel = selected?.kind === 'blocker' && selected.id === bl.id;
p.appendChild(listItem({
dot: hexCss(COL.blocker), name: bl.label || bl.id, tag: `${bl.size.x}×${bl.size.y}×${bl.size.z}`,
sub: `at ${Math.round(bl.position.x)}, ${Math.round(bl.position.z)} mm`,
sel, onclick: () => selectEntity('blocker', bl.id),
}));
if (sel) p.appendChild(blockerEditor(bl));
}
}
function blockerEditor(bl) {
const box = document.createElement('div'); box.style.cssText = 'padding:4px 2px 12px';
box.appendChild(textRow('Label', bl.label, (v) => { bl.label = v; markDirty(); }));
box.appendChild(xyzRow('Corner (mm)', bl.position, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); }));
box.appendChild(xyzRow('Size (mm)', bl.size, ['x', 'y', 'z'], () => { rebuildBlockers(); markDirty(); }));
const dup = document.createElement('button'); dup.className = 'btn-sm'; dup.textContent = 'Duplicate (stack +Y)';
dup.addEventListener('click', () => {
const copy = JSON.parse(JSON.stringify(bl)); copy.id = uid('blk');
copy.position.y = bl.position.y + bl.size.y; // stack on top
copy.label = (bl.label || 'Building') + ' (stack)';
scene.blockers.push(copy); rebuildBlockers(); selectEntity('blocker', copy.id); renderBlockers(); updateCounts(); markDirty();
});
box.appendChild(dup);
const del = document.createElement('button'); del.className = 'btn-del'; del.textContent = 'Delete building'; del.style.marginLeft = '6px';
del.addEventListener('click', () => { scene.blockers = scene.blockers.filter((o) => o.id !== bl.id); selected = null; rebuildBlockers(); renderBlockers(); updateCounts(); markDirty(); });
box.appendChild(del);
return box;
}
// ===========================================================================
// TABS + TOOLS
// ===========================================================================
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
document.querySelectorAll('.panel').forEach((pl) => pl.classList.toggle('active', pl.id === 'panel-' + name));
}
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
function setTool(name) {
tool = name;
document.querySelectorAll('#toolbar button').forEach((b) => b.classList.toggle('active', b.dataset.tool === name));
canvas.style.cursor = name === 'select' ? 'default' : 'crosshair';
}
document.querySelectorAll('#toolbar button').forEach((b) => b.addEventListener('click', () => setTool(b.dataset.tool)));
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'v') setTool('select');
if (e.key === 'g') setTool('addSpawn');
if (e.key === 'b') setTool('addBlocker');
if (e.key === 'a') setTool('addAnchor');
if (e.key === 'Escape') { selected = null; rebuildAll(); renderPanels(); $('selInfo').textContent = 'nothing selected'; }
if ((e.key === 'Delete' || e.key === 'Backspace') && selected) {
const k = selected.kind;
if (k === 'spawn') scene.spawns = scene.spawns.filter((o) => o.id !== selected.id);
if (k === 'blocker') scene.blockers = scene.blockers.filter((o) => o.id !== selected.id);
if (k === 'anchor') scene.anchors = scene.anchors.filter((o) => o.id !== selected.id);
selected = null; rebuildAll(); renderPanels(); updateCounts(); markDirty();
}
});
// ===========================================================================
// SAVE / EXPORT / RELOAD
// ===========================================================================
$('saveBtn').addEventListener('click', async () => {
$('saveBtn').disabled = true;
try {
const res = await fetch('/api/scene', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scene),
});
const j = await res.json();
if (j.ok) {
dirty = false; $('dirty').classList.remove('show');
toast(`Saved · ${j.spawns} ghosts, ${j.anchors} anchors, ${j.blockers} buildings`, 'ok');
} else { toast('Save failed: ' + j.error, 'err'); $('saveBtn').disabled = false; }
} catch (e) { toast('Save error: ' + e.message, 'err'); $('saveBtn').disabled = false; }
});
$('exportBtn').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(scene, null, 2)], { type: 'application/json' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'scene.json';
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
$('reloadBtn').addEventListener('click', async () => {
if (dirty && !confirm('Discard unsaved changes and reload from server?')) return;
selected = null; dirty = false; $('dirty').classList.remove('show'); $('saveBtn').disabled = true;
await load();
toast('Reloaded from server');
});
window.addEventListener('beforeunload', (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
// ===========================================================================
// RENDER LOOP + RESIZE
// ===========================================================================
function resize() {
const r = canvas.getBoundingClientRect();
renderer.setSize(r.width, r.height, false);
camera.aspect = r.width / r.height; camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(view3d, camera);
}
// boot
resize();
load().then(() => { setTool('select'); animate(); });