update Tue 07/14/2026 16:45:47.62

This commit is contained in:
2026-07-14 16:45:49 +10:00
parent ac1bb7121a
commit 1814125243
9 changed files with 432 additions and 99 deletions
+78 -7
View File
@@ -38,6 +38,7 @@ app.innerHTML = `${nav('layout')}
<button data-add="anchor">+ Anchor</button>
<button data-add="building">+ Building</button>
<button data-add="spawn">+ Spawn</button>
<button data-add="path">+ Path</button>
<button id="topView">Top view</button>
<span style="flex:1"></span>
<button id="reset" class="danger">Reset to seed</button>
@@ -48,13 +49,14 @@ app.innerHTML = `${nav('layout')}
<div id="view">
<canvas id="gl"></canvas>
<div id="legend"><b>N = +Z (back of table)</b> · grid 25 cm · drag gizmo to move (0.5 cm snap)<br>
flat crest arrow = top edge of print · wall crest arrow = direction the print faces</div>
flat crest arrow = top edge of print · wall crest arrow = direction the print faces<br>
purple = ghost paths: drag the spheres, cone shows walk direction</div>
</div>
<div id="side"></div>
</div>`;
let scene = await api('/api/scene');
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
// ---------- three.js setup ----------
const cvs = document.getElementById('gl');
@@ -102,6 +104,8 @@ const matAnchorFlat = new THREE.MeshStandardMaterial({ color: 0x3bc9a7, side: TH
const matAnchorWall = new THREE.MeshStandardMaterial({ color: 0xf65151, side: THREE.DoubleSide });
const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 });
const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b });
const matPath = new THREE.LineBasicMaterial({ color: 0xc77dff });
const matPathPt = new THREE.MeshStandardMaterial({ color: 0xc77dff });
function buildAll() {
gizmo.detach();
@@ -141,14 +145,43 @@ function buildAll() {
line.computeLineDistances(); m.add(line);
objRoot.add(m);
});
scene.paths.forEach((pth, i) => {
const pts = (pth.points || []).map(q => new THREE.Vector3(...q));
if (pts.length >= 2) {
const lp = pth.mode === 'loop' ? [...pts, pts[0]] : pts;
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(lp), matPath.clone());
if (pth.enabled === false) { line.material.transparent = true; line.material.opacity = 0.3; }
objRoot.add(line);
const dir = lp[1].clone().sub(lp[0]);
if (dir.lengthSq() > 1e-4) {
const cone = new THREE.Mesh(new THREE.ConeGeometry(2.2, 6, 10), matPathPt);
cone.position.copy(lp[0]).addScaledVector(dir, 0.5);
cone.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
objRoot.add(cone);
}
}
pts.forEach((q, wi) => {
const h = new THREE.Mesh(new THREE.SphereGeometry(3, 14, 10), matPathPt.clone());
h.position.copy(q);
h.userData = { kind: 'pathpoint', index: i, sub: wi };
const drop = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, -q.y, 0)]),
new THREE.LineDashedMaterial({ color: 0xc77dff, dashSize: 2, gapSize: 2 }));
drop.computeLineDistances(); h.add(drop);
objRoot.add(h);
});
});
reselect();
}
function dataOf(s) { return ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index]; }
function dataOf(s) {
if (s.kind === 'pathpoint') return scene.paths[s.index];
return ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
}
function reselect() {
if (!sel) return;
const o = objRoot.children.find(c => c.userData.kind === sel.kind && c.userData.index === sel.index);
const o = objRoot.children.find(c => c.userData.kind === sel.kind && c.userData.index === sel.index && (sel.sub === undefined || c.userData.sub === sel.sub));
if (o) { sel.obj3d = o; gizmo.attach(o); } else { sel = null; gizmo.detach(); }
}
@@ -166,7 +199,7 @@ cvs.addEventListener('pointerup', e => {
let top = hits.find(h => { let o = h.object; while (o && !o.userData.kind) o = o.parent; return o && o.userData.kind; });
if (top) {
let o = top.object; while (!o.userData.kind) o = o.parent;
sel = { kind: o.userData.kind, index: o.userData.index, obj3d: o };
sel = { kind: o.userData.kind, index: o.userData.index, sub: o.userData.sub, obj3d: o };
gizmo.attach(o);
} else { sel = null; gizmo.detach(); }
panel();
@@ -176,10 +209,13 @@ function onGizmoMove() {
if (!sel) return;
const d = dataOf(sel);
const p = sel.obj3d.position;
if (sel.kind === 'building') d.position = [+p.x.toFixed(1), +(p.y - d.size[1] / 2).toFixed(1), +p.z.toFixed(1)];
if (sel.kind === 'pathpoint') d.points[sel.sub] = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
else if (sel.kind === 'building') d.position = [+p.x.toFixed(1), +(p.y - d.size[1] / 2).toFixed(1), +p.z.toFixed(1)];
else d.position = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
panel(false);
}
// path lines follow their points only after the drag ends (cheap + smooth)
gizmo.addEventListener('mouseUp', () => { if (sel && sel.kind === 'pathpoint') buildAll(); });
// ---------- add / delete ----------
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
@@ -192,6 +228,10 @@ document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
} else if (b.dataset.add === 'building') {
scene.buildings.push({ name: 'building', position: [t.x, 0, t.z], size: [40, 30, 30], yawDeg: 0 });
sel = { kind: 'building', index: scene.buildings.length - 1 };
} else if (b.dataset.add === 'path') {
scene.paths.push({ id: 'path-' + (scene.paths.length + 1), mode: 'loop', enabled: true,
points: [[t.x - 25, 25, t.z], [t.x + 25, 25, t.z], [t.x, 25, t.z + 30]] });
sel = { kind: 'pathpoint', index: scene.paths.length - 1, sub: 0 };
} else {
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [t.x, 25, t.z], enabled: true });
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
@@ -246,6 +286,19 @@ function panel(rebuild = true) {
h += field('height', o.size[1], v => o.size[1] = v);
h += field('depth', o.size[2], v => o.size[2] = v);
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
} else if (sel.kind === 'pathpoint') {
h = `<h2>path ${sel.index} · point ${sel.sub + 1}/${o.points.length}</h2>`;
h += textField('path id', o.id, v => o.id = v);
h += field('mode', o.mode || 'loop', v => o.mode = v, { select: ['loop', 'pingpong'] });
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
const pt = o.points[sel.sub];
h += field('x (cm)', pt[0], v => pt[0] = v);
h += field('y (cm)', pt[1], v => pt[1] = v);
h += field('z (cm)', pt[2], v => pt[2] = v);
h += `<div class="row"><button id="addPt">+ point after</button><button id="delPt" class="danger">✕ point</button></div>`;
h += `<p class="badge">Ghosts walk point→point at path speed, turning to face each new direction.
loop = circles forever · pingpong = there and back. Tap other spheres to edit them;
the cone marks walk direction. Delete below removes the whole path.</p>`;
} else {
h += textField('id', o.id, v => o.id = v);
h += field('x (cm)', o.position[0], v => o.position[0] = v);
@@ -257,9 +310,27 @@ function panel(rebuild = true) {
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
side.innerHTML = h;
document.getElementById('del').onclick = () => {
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
if (sel.kind === 'pathpoint') scene.paths.splice(sel.index, 1);
else ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
sel = null; gizmo.detach(); buildAll(); panel();
};
wirePathExtras();
}
function wirePathExtras() {
const addBtn = document.getElementById('addPt'), delBtn = document.getElementById('delPt');
if (addBtn) addBtn.onclick = () => {
const pth = dataOf(sel);
const a = pth.points[sel.sub], b = pth.points[(sel.sub + 1) % pth.points.length];
pth.points.splice(sel.sub + 1, 0, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]);
sel.sub += 1; buildAll(); panel();
};
if (delBtn) delBtn.onclick = () => {
const pth = dataOf(sel);
if (pth.points.length <= 2) return alert('A path needs at least 2 points — delete the whole path instead.');
pth.points.splice(sel.sub, 1);
sel.sub = Math.max(0, sel.sub - 1); buildAll(); panel();
};
}
// ---------- toolbar ----------
+61 -1
View File
@@ -20,6 +20,7 @@ await requireAuth();
const app = document.getElementById('app');
let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts');
const sceneData = await api('/api/scene');
const colors = ['Red', 'Yellow', 'Blue'];
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
@@ -35,6 +36,19 @@ function rosterCount() {
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
}
function residentRow(r, i) {
const gOpts = ghosts.map(g => `<option value="${g.id}" ${g.id === r.id ? 'selected' : ''}>${g.name} (${g.rarity} ${g.color})</option>`).join('');
const locOpts = ['<option value="">auto</option>']
.concat((sceneData.spawns || []).map(sp => `<option value="s:${sp.id}" ${r.spawnId === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
.concat((sceneData.paths || []).map(pt => `<option value="p:${pt.id}" ${r.pathId === pt.id ? 'selected' : ''}>path ${pt.id}</option>`)).join('');
const behOpts = ['static', 'wander', 'path'].map(b => `<option ${(r.behavior || 'static') === b ? 'selected' : ''}>${b}</option>`).join('');
return `<div class="row" data-res="${i}">
<select data-rk="id" style="flex:2;min-width:0">${gOpts}</select>
<select data-rk="loc" style="flex:1;min-width:0">${locOpts}</select>
<select data-rk="behavior" style="width:86px">${behOpts}</select>
<button data-resdel="${i}" class="danger">✕</button></div>`;
}
function render() {
app.innerHTML = `${nav('playlist')}<main>
<div class="card">
@@ -43,7 +57,8 @@ function render() {
<div class="row"><label>Dwell time (seconds)</label><input id="dwell" type="number" min="10" value="${cfg.dwellSeconds}"></div>
<div class="row"><label>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
<div class="row"><label>Order</label>
<select id="order"><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
<select id="order"><option ${cfg.order === 'weighted' ? 'selected' : ''}>weighted</option><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
<p style="opacity:.6;font-size:.78rem;margin:4px 0 0">weighted = rarity odds below decide who shows up · shuffle/roster = everyone equally, in random/fixed order</p>
</div>
<div class="card">
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
@@ -56,6 +71,27 @@ function render() {
<div class="row"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
<div class="row"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
<div class="row"><label>Path walk speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
<div class="row"><label>Path chance (01)</label><input id="pathChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.pathChance ?? 0.4}"></div>
<p style="opacity:.6;font-size:.78rem;margin:4px 0 0">Path chance = odds a new ghost takes a free path instead of a spawn point.</p>
</div>
<div class="card">
<h2 style="margin-top:0">Rarity: appearances &amp; movement</h2>
<table>
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
${rarities.map(r => `<tr>
<td style="padding:4px 8px 4px 0">${r}</td>
<td><input data-rw="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.weights?.[r] ?? 1}"></td>
<td><input data-rm="${r}" type="number" step="0.05" min="0" style="width:90px" value="${cfg.rarity?.movementScale?.[r] ?? 1}"></td>
</tr>`).join('')}
</table>
<p style="opacity:.6;font-size:.78rem">Weight: how often they appear in <b>weighted</b> order (Legendary 0.1 = a tenth as often as Common 1.0).
Movement scale shrinks wander radius/speed and path speed — 0.4 means rare ghosts drift only a little.</p>
</div>
<div class="card">
<h2 style="margin-top:0">Residents <span style="opacity:.6;font-size:.8rem">(permanently in their place — never rotate out)</span></h2>
<div id="residents">${(cfg.residents || []).map((r, i) => residentRow(r, i)).join('') || '<em style="opacity:.6">No residents yet</em>'}</div>
<button id="addRes" style="margin-top:8px">+ Add resident</button>
</div>
<div class="card">
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
@@ -81,6 +117,28 @@ function render() {
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.del, 1); render(); });
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
document.getElementById('addRes').onclick = () => {
(cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' });
render();
};
document.querySelectorAll('[data-resdel]').forEach(b => b.onclick = () => { cfg.residents.splice(+b.dataset.resdel, 1); render(); });
document.querySelectorAll('#residents [data-rk]').forEach(el => el.onchange = () => {
const i = +el.closest('[data-res]').dataset.res, r = cfg.residents[i];
if (el.dataset.rk === 'id') r.id = el.value;
else if (el.dataset.rk === 'behavior') r.behavior = el.value;
else {
delete r.spawnId; delete r.pathId;
if (el.value.startsWith('s:')) r.spawnId = el.value.slice(2);
if (el.value.startsWith('p:')) { r.pathId = el.value.slice(2); r.behavior = 'path'; }
render(); return;
}
});
document.querySelectorAll('[data-rw],[data-rm]').forEach(el => el.onchange = () => {
cfg.rarity ||= { weights: {}, movementScale: {} };
if (el.dataset.rw) (cfg.rarity.weights ||= {})[el.dataset.rw] = +el.value;
if (el.dataset.rm) (cfg.rarity.movementScale ||= {})[el.dataset.rm] = +el.value;
});
document.getElementById('save').onclick = async () => {
cfg.slots = +document.getElementById('slots').value;
cfg.dwellSeconds = +document.getElementById('dwell').value;
@@ -91,6 +149,8 @@ function render() {
wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value,
wanderVertical: +document.getElementById('wanderVertical').value,
pathSpeed: +document.getElementById('pathSpeed').value,
pathChance: +document.getElementById('pathChance').value,
};
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }