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
+7
View File
@@ -31,6 +31,13 @@ positions and sizes, wander radius, calibration readouts. Marker print size stay
- **Playlist engine** (server-driven, all viewers in sync): N concurrent ghosts, - **Playlist engine** (server-driven, all viewers in sync): N concurrent ghosts,
per-ghost dwell time then rotate through the whole roster, shuffle/roster order, per-ghost dwell time then rotate through the whole roster, shuffle/roster order,
color/rarity filters, optional time-of-day windows, crossfade in/out. color/rarity filters, optional time-of-day windows, crossfade in/out.
- **Paths**: waypoint routes (loop or pingpong) drawn in the 3D editor — ghosts walk them
at constant speed and turn to face each new direction, like they live there. Waypoints are
full 3D so paths can climb. Path motion is deterministic (pure function of time), same as wander.
- **Rarity system**: `weighted` order makes Commons appear often and Legendaries rarely
(per-rarity appearance weights), and `movementScale` shrinks how much rarer ghosts move.
- **Residents**: pin any ghost permanently to a spawn or path (`residents` in the playlist);
they never rotate out and their spot is excluded from rotation.
- **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift — - **Behaviors**: static float (bob + idle sway) or wander (deterministic orbit-drift —
a pure function of server spawn record + wall clock, so every phone computes the a pure function of server spawn record + wall clock, so every phone computes the
same motion with zero position streaming). Wander is 3D: horizontal radius plus a same motion with zero position streaming). Wander is 3D: horizontal radius plus a
+20 -3
View File
@@ -2,7 +2,7 @@
"slots": 5, "slots": 5,
"dwellSeconds": 120, "dwellSeconds": 120,
"crossfadeSeconds": 3, "crossfadeSeconds": 3,
"order": "shuffle", "order": "weighted",
"include": { "include": {
"colors": [], "colors": [],
"rarities": [], "rarities": [],
@@ -12,7 +12,24 @@
"staticChance": 0.5, "staticChance": 0.5,
"wanderRadius": 60, "wanderRadius": 60,
"wanderSpeed": 0.15, "wanderSpeed": 0.15,
"wanderVertical": 10 "wanderVertical": 10,
"pathSpeed": 8,
"pathChance": 0.4
}, },
"timeWindows": [] "timeWindows": [],
"rarity": {
"weights": {
"Common": 1.0,
"Rare": 0.5,
"Epic": 0.25,
"Legendary": 0.1
},
"movementScale": {
"Common": 1.0,
"Rare": 1.0,
"Epic": 0.7,
"Legendary": 0.4
}
},
"residents": []
} }
+34
View File
@@ -137,5 +137,39 @@
], ],
"enabled": true "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
]
]
}
] ]
} }
+78 -7
View File
@@ -38,6 +38,7 @@ app.innerHTML = `${nav('layout')}
<button data-add="anchor">+ Anchor</button> <button data-add="anchor">+ Anchor</button>
<button data-add="building">+ Building</button> <button data-add="building">+ Building</button>
<button data-add="spawn">+ Spawn</button> <button data-add="spawn">+ Spawn</button>
<button data-add="path">+ Path</button>
<button id="topView">Top view</button> <button id="topView">Top view</button>
<span style="flex:1"></span> <span style="flex:1"></span>
<button id="reset" class="danger">Reset to seed</button> <button id="reset" class="danger">Reset to seed</button>
@@ -48,13 +49,14 @@ app.innerHTML = `${nav('layout')}
<div id="view"> <div id="view">
<canvas id="gl"></canvas> <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> <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>
<div id="side"></div> <div id="side"></div>
</div>`; </div>`;
let scene = await api('/api/scene'); let scene = await api('/api/scene');
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= [];
// ---------- three.js setup ---------- // ---------- three.js setup ----------
const cvs = document.getElementById('gl'); 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 matAnchorWall = new THREE.MeshStandardMaterial({ color: 0xf65151, side: THREE.DoubleSide });
const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 }); const matBuilding = new THREE.MeshStandardMaterial({ color: 0x529eff, transparent: true, opacity: 0.35 });
const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b }); const matSpawn = new THREE.MeshStandardMaterial({ color: 0xb57f0b });
const matPath = new THREE.LineBasicMaterial({ color: 0xc77dff });
const matPathPt = new THREE.MeshStandardMaterial({ color: 0xc77dff });
function buildAll() { function buildAll() {
gizmo.detach(); gizmo.detach();
@@ -141,14 +145,43 @@ function buildAll() {
line.computeLineDistances(); m.add(line); line.computeLineDistances(); m.add(line);
objRoot.add(m); 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(); 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() { function reselect() {
if (!sel) return; 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(); } 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; }); let top = hits.find(h => { let o = h.object; while (o && !o.userData.kind) o = o.parent; return o && o.userData.kind; });
if (top) { if (top) {
let o = top.object; while (!o.userData.kind) o = o.parent; 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); gizmo.attach(o);
} else { sel = null; gizmo.detach(); } } else { sel = null; gizmo.detach(); }
panel(); panel();
@@ -176,10 +209,13 @@ function onGizmoMove() {
if (!sel) return; if (!sel) return;
const d = dataOf(sel); const d = dataOf(sel);
const p = sel.obj3d.position; 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)]; else d.position = [+p.x.toFixed(1), +p.y.toFixed(1), +p.z.toFixed(1)];
panel(false); 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 ---------- // ---------- add / delete ----------
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => { 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') { } else if (b.dataset.add === 'building') {
scene.buildings.push({ name: 'building', position: [t.x, 0, t.z], size: [40, 30, 30], yawDeg: 0 }); 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 }; 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 { } else {
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [t.x, 25, t.z], enabled: true }); 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 }; 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('height', o.size[1], v => o.size[1] = v);
h += field('depth', o.size[2], v => o.size[2] = v); h += field('depth', o.size[2], v => o.size[2] = v);
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 }); 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 { } else {
h += textField('id', o.id, v => o.id = v); h += textField('id', o.id, v => o.id = v);
h += field('x (cm)', o.position[0], v => o.position[0] = 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>`; h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
side.innerHTML = h; side.innerHTML = h;
document.getElementById('del').onclick = () => { 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(); 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 ---------- // ---------- toolbar ----------
+61 -1
View File
@@ -20,6 +20,7 @@ await requireAuth();
const app = document.getElementById('app'); const app = document.getElementById('app');
let cfg = await api('/api/playlist'); let cfg = await api('/api/playlist');
const { ghosts } = await api('/api/ghosts'); const { ghosts } = await api('/api/ghosts');
const sceneData = await api('/api/scene');
const colors = ['Red', 'Yellow', 'Blue']; const colors = ['Red', 'Yellow', 'Blue'];
const rarities = ['Common', 'Rare', 'Epic', 'Legendary']; const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
@@ -35,6 +36,19 @@ function rosterCount() {
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length; (!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() { function render() {
app.innerHTML = `${nav('playlist')}<main> app.innerHTML = `${nav('playlist')}<main>
<div class="card"> <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>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>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
<div class="row"><label>Order</label> <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>
<div class="card"> <div class="card">
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2> <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 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>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>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>
<div class="card"> <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> <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('[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.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 () => { document.getElementById('save').onclick = async () => {
cfg.slots = +document.getElementById('slots').value; cfg.slots = +document.getElementById('slots').value;
cfg.dwellSeconds = +document.getElementById('dwell').value; cfg.dwellSeconds = +document.getElementById('dwell').value;
@@ -91,6 +149,8 @@ function render() {
wanderRadius: +document.getElementById('wanderRadius').value, wanderRadius: +document.getElementById('wanderRadius').value,
wanderSpeed: +document.getElementById('wanderSpeed').value, wanderSpeed: +document.getElementById('wanderSpeed').value,
wanderVertical: +document.getElementById('wanderVertical').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(); } 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; } catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
+2 -1
View File
@@ -121,7 +121,8 @@ function scheduleRemove(uid) {
const e = activeGhosts.get(uid); const e = activeGhosts.get(uid);
if (!e) return removeGhost(uid); if (!e) return removeGhost(uid);
// let the client-side crossfade (driven by rec.until) finish, then remove // let the client-side crossfade (driven by rec.until) finish, then remove
const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000; const until = e.rec.until ?? (Date.now() + clockOffset); // residents removed by config change: fade now
const wait = Math.max(0, until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
setTimeout(() => removeGhost(uid), Math.min(wait, 8000)); setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
} }
+46 -3
View File
@@ -15,7 +15,9 @@ export function ghostTransform(rec, nowMs, out) {
const base = new THREE.Vector3(...rec.pos); const base = new THREE.Vector3(...rec.pos);
const b = rec.behavior || { type: 'static' }; const b = rec.behavior || { type: 'static' };
if (b.type === 'wander') { if (b.type === 'path') {
pathTransform(b, t, out);
} else if (b.type === 'wander') {
const s = b.seed || 1; const s = b.seed || 1;
const R = b.radius ?? 60; // cm const R = b.radius ?? 60; // cm
const v = b.speed ?? 0.15; const v = b.speed ?? 0.15;
@@ -44,10 +46,51 @@ export function ghostTransform(rec, nowMs, out) {
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
} }
// crossfade opacity: fade in on spawn, fade out approaching `until` // crossfade opacity: fade in on spawn; permanent residents (until == null) never fade out
const fade = (rec.crossfade || 3) * 1000; const fade = (rec.crossfade || 3) * 1000;
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade); const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade)); const outA = rec.until == null ? 1 : Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
out.opacity = Math.min(inA, outA); out.opacity = Math.min(inA, outA);
return out; return out;
} }
/* Walk a waypoint path at constant speed (cm/s), turning to face travel direction.
* Deterministic: position is a pure function of elapsed time, so all viewers agree.
* b: { points:[[x,y,z],...], mode:'loop'|'pingpong', speed, phase, seed } */
function pathTransform(b, t, out) {
const raw = b.points || [];
if (raw.length < 2) { out.position.set(...(raw[0] || [0, 0, 0])); out.rotationY = 0; return; }
const pts = b.mode === 'loop' ? [...raw, raw[0]] : raw;
// cumulative segment lengths
const cum = [0];
for (let i = 1; i < pts.length; i++) {
const dx = pts[i][0] - pts[i - 1][0], dy = pts[i][1] - pts[i - 1][1], dz = pts[i][2] - pts[i - 1][2];
cum.push(cum[i - 1] + Math.hypot(dx, dy, dz));
}
const total = cum[cum.length - 1] || 1;
const wrap = (d) => {
if (b.mode === 'pingpong') { const m = ((d % (2 * total)) + 2 * total) % (2 * total); return m < total ? m : 2 * total - m; }
return ((d % total) + total) % total;
};
const sample = (d, v) => {
let i = 1; while (i < cum.length - 1 && cum[i] < d) i++;
const f = (d - cum[i - 1]) / Math.max(cum[i] - cum[i - 1], 1e-6);
v.set(
pts[i - 1][0] + (pts[i][0] - pts[i - 1][0]) * f,
pts[i - 1][1] + (pts[i][1] - pts[i - 1][1]) * f,
pts[i - 1][2] + (pts[i][2] - pts[i - 1][2]) * f);
return v;
};
const d = wrap(t * (b.speed ?? 8) + (b.phase || 0));
sample(d, out.position);
// face where we're heading: sample a few cm ahead so corners turn smoothly
const ahead = sample(wrap(t * (b.speed ?? 8) + (b.phase || 0) + 6), _look);
const dx = ahead.x - out.position.x, dz = ahead.z - out.position.z;
if (dx * dx + dz * dz > 1e-4) out.rotationY = Math.atan2(dx, dz);
// gentle float so walking still reads ghostly
out.position.y += 2 * Math.sin(t * 1.9 + (b.seed || 0));
}
const _look = new THREE.Vector3();
+174 -81
View File
@@ -2,72 +2,201 @@
* *
* Config: * Config:
* { * {
* slots: 5, // concurrent visible ghosts * slots: 5, // concurrent ROTATING ghosts (residents are extra)
* dwellSeconds: 120, // how long each ghost stays before rotating out * dwellSeconds: 120,
* crossfadeSeconds: 3, // fade out/in overlap hint for clients * crossfadeSeconds: 3,
* order: 'shuffle' | 'roster', // rotation order through the roster * order: 'weighted' | 'shuffle' | 'roster',
* include: { colors:[], rarities:[], ids:[] }, // empty = all * include: { colors:[], rarities:[], ids:[] }, // empty = all
* behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 }, * behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15,
* timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on * wanderVertical: 10, pathSpeed: 8, pathChance: 0.4 },
* rarity: {
* weights: { Common: 1.0, Rare: 0.5, Epic: 0.25, Legendary: 0.1 }, // appearance odds
* movementScale: { Common: 1.0, Rare: 1.0, Epic: 0.7, Legendary: 0.4 } // how much they move
* },
* residents: [ { id, spawnId?, pathId?, behavior: 'static'|'wander'|'path' } ], // permanent
* timeWindows: [] // [{start:"09:00",end:"17:00"}] — empty = always on
* } * }
* *
* Broadcasts: * Broadcasts: spawn / despawn / active (snapshot). Permanent residents have until: null.
* { type:'spawn', ghost:{ uid, id, name, color, spawnId, pos, behavior, until } }
* { type:'despawn', uid }
* { type:'active', ghosts:[...] } (full snapshot, sent on join + config change)
*/ */
class Playlist { class Playlist {
constructor(state, broadcast) { constructor(state, broadcast) {
this.state = state; this.state = state;
this.broadcast = broadcast; this.broadcast = broadcast;
this.cfg = state.getPlaylistConfig({ this.cfg = state.getPlaylistConfig(Playlist.defaults());
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle', this.cfg = { ...Playlist.defaults(), ...this.cfg };
include: { colors: [], rarities: [], ids: [] }, this.active = new Map();
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 },
timeWindows: [],
});
this.active = new Map(); // uid -> ghost record
this.queue = []; this.queue = [];
this.uidCounter = 1; this.uidCounter = 1;
this.timer = null; this.timer = null;
} }
static defaults() {
return {
slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'weighted',
include: { colors: [], rarities: [], ids: [] },
behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10, pathSpeed: 8, pathChance: 0.4 },
rarity: {
weights: { Common: 1.0, Rare: 0.5, Epic: 0.25, Legendary: 0.1 },
movementScale: { Common: 1.0, Rare: 1.0, Epic: 0.7, Legendary: 0.4 },
},
residents: [],
timeWindows: [],
};
}
getConfig() { return this.cfg; } getConfig() { return this.cfg; }
setConfig(cfg) { setConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('bad config'); if (!cfg || typeof cfg !== 'object') throw new Error('bad config');
this.cfg = { ...this.cfg, ...cfg }; this.cfg = { ...Playlist.defaults(), ...this.cfg, ...cfg };
this.state.putPlaylistConfig(this.cfg); this.state.putPlaylistConfig(this.cfg);
// rebuild world: clear and respawn under new rules for (const uid of [...this.active.keys()]) this.despawn(uid, true);
for (const uid of [...this.active.keys()]) this.despawn(uid);
this.queue = []; this.queue = [];
this.tick(); this.tick();
this.broadcast({ type: 'active', ghosts: this.activeSnapshot() }); this.broadcast({ type: 'active', ghosts: this.activeSnapshot() });
return this.cfg; return this.cfg;
} }
allGhosts() { return this.state.getGhosts().ghosts; }
roster() { roster() {
const { ghosts } = this.state.getGhosts();
const inc = this.cfg.include || {}; const inc = this.cfg.include || {};
return ghosts.filter(g => const residentIds = new Set((this.cfg.residents || []).map(r => r.id));
return this.allGhosts().filter(g =>
!residentIds.has(g.id) &&
(!inc.colors?.length || inc.colors.includes(g.color)) && (!inc.colors?.length || inc.colors.includes(g.color)) &&
(!inc.rarities?.length || inc.rarities.includes(g.rarity)) && (!inc.rarities?.length || inc.rarities.includes(g.rarity)) &&
(!inc.ids?.length || inc.ids.includes(g.id))); (!inc.ids?.length || inc.ids.includes(g.id)));
} }
refillQueue() { scale(g) { return this.cfg.rarity?.movementScale?.[g.rarity] ?? 1; }
const r = this.roster().map(g => g.id);
if (!r.length) return; // ---------- ghost selection ----------
if (this.cfg.order === 'shuffle') { pickGhost() {
for (let i = r.length - 1; i > 0; i--) { const activeIds = new Set([...this.active.values()].map(x => x.id));
const j = Math.floor(Math.random() * (i + 1)); const pool = this.roster().filter(g => !activeIds.has(g.id));
[r[i], r[j]] = [r[j], r[i]]; if (!pool.length) return null;
if (this.cfg.order === 'weighted') {
const w = this.cfg.rarity?.weights || {};
const weights = pool.map(g => Math.max(0, w[g.rarity] ?? 1));
const sum = weights.reduce((a, b) => a + b, 0);
if (sum <= 0) return pool[Math.floor(Math.random() * pool.length)];
let r = Math.random() * sum;
for (let i = 0; i < pool.length; i++) { r -= weights[i]; if (r <= 0) return pool[i]; }
return pool[pool.length - 1];
} }
// shuffle / roster: queue-based full rotation
if (!this.queue.length) {
const ids = this.roster().map(g => g.id);
if (this.cfg.order === 'shuffle')
for (let i = ids.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [ids[i], ids[j]] = [ids[j], ids[i]]; }
this.queue = ids;
} }
// avoid immediate repeat of currently-active ghosts at queue head while (this.queue.length) {
const activeIds = new Set([...this.active.values()].map(g => g.id)); const id = this.queue.shift();
this.queue.push(...r.filter(id => !activeIds.has(id)), ...r.filter(id => activeIds.has(id))); const g = pool.find(x => x.id === id);
if (g) return g;
} }
return null;
}
// ---------- locations ----------
occupied() {
const spawns = new Set(), paths = new Set();
for (const g of this.active.values()) { if (g.spawnId) spawns.add(g.spawnId); if (g.pathId) paths.add(g.pathId); }
return { spawns, paths };
}
freeSpawn(occ) {
const s = (this.state.getScene().spawns || []).filter(x => x.enabled !== false && !occ.spawns.has(x.id));
return s.length ? s[Math.floor(Math.random() * s.length)] : null;
}
freePath(occ) {
const p = (this.state.getScene().paths || []).filter(x => x.enabled !== false && (x.points || []).length >= 2 && !occ.paths.has(x.id));
return p.length ? p[Math.floor(Math.random() * p.length)] : null;
}
// ---------- behavior builders (movement scale applied here) ----------
makeBehavior(kind, g, path) {
const b = this.cfg.behaviors || {};
const k = this.scale(g);
if (kind === 'path' && path) {
return { type: 'path', points: path.points.map(p => [...p]), mode: path.mode || 'loop',
speed: (b.pathSpeed ?? 8) * k, phase: Math.random() * 1000, seed: Math.floor(Math.random() * 1e6) };
}
if (kind === 'wander') {
return { type: 'wander', radius: (b.wanderRadius ?? 60) * k, speed: (b.wanderSpeed ?? 0.15) * k,
vertical: (b.wanderVertical ?? 10) * k, seed: Math.floor(Math.random() * 1e6) };
}
return { type: 'static', bobAmp: (5 + Math.random() * 5) * k, bobHz: 0.3 + Math.random() * 0.3 };
}
makeRecord(g, { spawn, path }, behavior, permanent = false) {
return {
uid: permanent ? 'res-' + g.id : 'g' + (this.uidCounter++),
id: g.id, name: g.name, color: g.color, rarity: g.rarity,
spawnId: spawn ? spawn.id : null,
pathId: path ? path.id : null,
pos: spawn ? [...spawn.position] : [...path.points[0]],
behavior,
spawnedAt: Date.now(),
until: permanent ? null : Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
crossfade: this.cfg.crossfadeSeconds || 3,
permanent,
};
}
// ---------- residents: permanently in their place ----------
ensureResidents() {
for (const r of this.cfg.residents || []) {
const uid = 'res-' + r.id;
if (this.active.has(uid)) continue;
const g = this.allGhosts().find(x => x.id === r.id);
if (!g) continue;
const scene = this.state.getScene();
const occ = this.occupied();
let spawn = null, path = null;
if (r.pathId) path = (scene.paths || []).find(p => p.id === r.pathId && (p.points || []).length >= 2);
if (!path && r.spawnId) spawn = (scene.spawns || []).find(s => s.id === r.spawnId);
if (!path && !spawn) { spawn = this.freeSpawn(occ); if (!spawn) path = this.freePath(occ); }
if (!path && !spawn) continue;
const kind = r.behavior || (path ? 'path' : 'static');
const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), true);
this.active.set(uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
}
// remove residents that were deleted from config
const wanted = new Set((this.cfg.residents || []).map(r => 'res-' + r.id));
for (const uid of [...this.active.keys()])
if (uid.startsWith('res-') && !wanted.has(uid)) this.despawn(uid);
}
// ---------- rotation ----------
spawnOne() {
const g = this.pickGhost();
if (!g) return false;
const occ = this.occupied();
const b = this.cfg.behaviors || {};
let path = null, spawn = null;
if (Math.random() < (b.pathChance ?? 0.4)) path = this.freePath(occ);
if (!path) spawn = this.freeSpawn(occ);
if (!path && !spawn) return false;
const kind = path ? 'path' : (Math.random() < (b.staticChance ?? 0.5) ? 'static' : 'wander');
const rec = this.makeRecord(g, { spawn, path }, this.makeBehavior(kind, g, path), false);
this.active.set(rec.uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
return true;
}
despawn(uid, silentOk) {
if (!this.active.has(uid)) return;
this.active.delete(uid);
this.broadcast({ type: 'despawn', uid });
}
activeSnapshot() { return [...this.active.values()]; }
inTimeWindow() { inTimeWindow() {
const w = this.cfg.timeWindows || []; const w = this.cfg.timeWindows || [];
@@ -78,63 +207,27 @@ class Playlist {
const [sh, sm] = String(start).split(':').map(Number); const [sh, sm] = String(start).split(':').map(Number);
const [eh, em] = String(end).split(':').map(Number); const [eh, em] = String(end).split(':').map(Number);
const s = sh * 60 + (sm || 0), e = eh * 60 + (em || 0); const s = sh * 60 + (sm || 0), e = eh * 60 + (em || 0);
return s <= e ? (mins >= s && mins < e) : (mins >= s || mins < e); // handles overnight return s <= e ? (mins >= s && mins < e) : (mins >= s || mins < e);
}); });
} }
freeSpawn() {
const scene = this.state.getScene();
const used = new Set([...this.active.values()].map(g => g.spawnId));
const free = (scene.spawns || []).filter(s => s.enabled !== false && !used.has(s.id));
if (!free.length) return null;
return free[Math.floor(Math.random() * free.length)];
}
spawnOne() {
if (!this.queue.length) this.refillQueue();
const id = this.queue.shift();
if (!id) return;
const g = this.roster().find(x => x.id === id);
const spawn = this.freeSpawn();
if (!g || !spawn) return;
const b = this.cfg.behaviors || {};
const isStatic = Math.random() < (b.staticChance ?? 0.5);
const uid = 'g' + (this.uidCounter++);
const rec = {
uid, id: g.id, name: g.name, color: g.color, rarity: g.rarity,
spawnId: spawn.id, pos: spawn.position,
behavior: isStatic
? { type: 'static', bobAmp: 5 + Math.random() * 5, bobHz: 0.3 + Math.random() * 0.3 }
: { type: 'wander', radius: b.wanderRadius ?? 60, speed: b.wanderSpeed ?? 0.15, vertical: b.wanderVertical ?? 10, seed: Math.floor(Math.random() * 1e6) },
spawnedAt: Date.now(),
until: Date.now() + (this.cfg.dwellSeconds || 120) * 1000,
crossfade: this.cfg.crossfadeSeconds || 3,
};
this.active.set(uid, rec);
this.broadcast({ type: 'spawn', ghost: rec });
}
despawn(uid) {
if (!this.active.has(uid)) return;
this.active.delete(uid);
this.broadcast({ type: 'despawn', uid });
}
activeSnapshot() { return [...this.active.values()]; }
tick() { tick() {
const now = Date.now(); const now = Date.now();
if (!this.inTimeWindow()) { if (!this.inTimeWindow()) {
for (const uid of [...this.active.keys()]) this.despawn(uid); for (const uid of [...this.active.keys()]) this.despawn(uid); // residents sleep outside hours too
return; return;
} }
for (const [uid, g] of this.active) if (now >= g.until) this.despawn(uid); this.ensureResidents();
const want = Math.min(this.cfg.slots || 5, (this.state.getScene().spawns || []).filter(s => s.enabled !== false).length); for (const [uid, g] of this.active) if (!g.permanent && g.until != null && now >= g.until) this.despawn(uid);
while (this.active.size < want) {
const before = this.active.size; const scene = this.state.getScene();
this.spawnOne(); const capacity = (scene.spawns || []).filter(s => s.enabled !== false).length +
if (this.active.size === before) break; // no roster/spawns available (scene.paths || []).filter(p => p.enabled !== false && (p.points || []).length >= 2).length;
} const rotatingNow = [...this.active.values()].filter(g => !g.permanent).length;
const residents = this.active.size - rotatingNow;
const want = Math.min(this.cfg.slots || 5, Math.max(0, capacity - residents));
let n = rotatingNow;
while (n < want && this.spawnOne()) n++;
} }
start() { start() {
+8 -1
View File
@@ -22,7 +22,7 @@ function writeJSON(p, obj) {
fs.writeFileSync(p, JSON.stringify(obj, null, 2)); fs.writeFileSync(p, JSON.stringify(obj, null, 2));
} }
let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], world: { units: 'cm' } }); let scene = readJSON(LIVE, null) || readJSON(SEED, { anchors: [], buildings: [], spawns: [], paths: [], world: { units: 'cm' } });
let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []); let ghosts = readJSON(path.join(DATA, 'ghosts.json'), []);
let gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let gradients = readJSON(path.join(DATA, 'gradients.json'), {});
let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] });
@@ -34,6 +34,13 @@ function validateScene(s) {
for (const k of ['anchors', 'buildings', 'spawns']) { for (const k of ['anchors', 'buildings', 'spawns']) {
if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`); if (!Array.isArray(s[k])) throw new Error(`scene.${k} must be array`);
} }
s.paths = Array.isArray(s.paths) ? s.paths : [];
for (const pth of s.paths) {
if (!pth.id) throw new Error('path.id required');
if (!Array.isArray(pth.points) || pth.points.length < 2) throw new Error(`path ${pth.id} needs >= 2 points`);
pth.mode = pth.mode === 'pingpong' ? 'pingpong' : 'loop';
pth.enabled = pth.enabled !== false;
}
for (const a of s.anchors) { for (const a of s.anchors) {
if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required'); if (typeof a.markerId !== 'number') throw new Error('anchor.markerId required');
if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required'); if (!Array.isArray(a.position) || a.position.length !== 3) throw new Error('anchor.position [x,y,z] required');