update Tue 07/14/2026 16:45:47.62
This commit is contained in:
@@ -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
@@ -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": []
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 ----------
|
||||||
|
|||||||
@@ -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 (0–1)</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 & 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; }
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user