diff --git a/README.md b/README.md index d9686e5..724b1f9 100644 --- a/README.md +++ b/README.md @@ -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, per-ghost dwell time then rotate through the whole roster, shuffle/roster order, 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 — 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 diff --git a/data/playlist.json b/data/playlist.json index b35fc94..89e74e2 100644 --- a/data/playlist.json +++ b/data/playlist.json @@ -2,7 +2,7 @@ "slots": 5, "dwellSeconds": 120, "crossfadeSeconds": 3, - "order": "shuffle", + "order": "weighted", "include": { "colors": [], "rarities": [], @@ -12,7 +12,24 @@ "staticChance": 0.5, "wanderRadius": 60, "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": [] } \ No newline at end of file diff --git a/data/scene.json b/data/scene.json index 32a6d82..3fa28eb 100644 --- a/data/scene.json +++ b/data/scene.json @@ -137,5 +137,39 @@ ], "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 + ] + ] + } ] } \ No newline at end of file diff --git a/public/admin/layout.html b/public/admin/layout.html index f4cfee5..99dbf60 100644 --- a/public/admin/layout.html +++ b/public/admin/layout.html @@ -38,6 +38,7 @@ app.innerHTML = `${nav('layout')} + @@ -48,13 +49,14 @@ app.innerHTML = `${nav('layout')}
N = +Z (back of table) · grid 25 cm · drag gizmo to move (0.5 cm snap)
- flat crest arrow = top edge of print · wall crest arrow = direction the print faces
+ flat crest arrow = top edge of print · wall crest arrow = direction the print faces
+ purple = ghost paths: drag the spheres, cone shows walk direction
`; 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 = `

path ${sel.index} · point ${sel.sub + 1}/${o.points.length}

`; + 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 += `
`; + h += `

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.

`; } 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 += `
`; 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 ---------- diff --git a/public/admin/playlist.html b/public/admin/playlist.html index 4141ada..4a61065 100644 --- a/public/admin/playlist.html +++ b/public/admin/playlist.html @@ -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 => ``).join(''); + const locOpts = [''] + .concat((sceneData.spawns || []).map(sp => ``)) + .concat((sceneData.paths || []).map(pt => ``)).join(''); + const behOpts = ['static', 'wander', 'path'].map(b => ``).join(''); + return `
+ + + +
`; +} + function render() { app.innerHTML = `${nav('playlist')}
@@ -43,7 +57,8 @@ function render() {
-
+
+

weighted = rarity odds below decide who shows up · shuffle/roster = everyone equally, in random/fixed order

Roster filter (${rosterCount()} ghosts match — empty = all)

@@ -56,6 +71,27 @@ function render() {
+
+
+

Path chance = odds a new ghost takes a free path instead of a spawn point.

+
+
+

Rarity: appearances & movement

+ + + ${rarities.map(r => ` + + + + `).join('')} +
appearance weightmovement scale
${r}
+

Weight: how often they appear in weighted 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.

+
+
+

Residents (permanently in their place — never rotate out)

+
${(cfg.residents || []).map((r, i) => residentRow(r, i)).join('') || 'No residents yet'}
+

Time windows (ghosts only appear inside these; empty = always on)

@@ -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; } diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 3b21535..cd4245e 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -121,7 +121,8 @@ function scheduleRemove(uid) { const e = activeGhosts.get(uid); if (!e) return removeGhost(uid); // 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)); } diff --git a/public/js/ghosts/behavior.js b/public/js/ghosts/behavior.js index ce2bbea..6adcf49 100644 --- a/public/js/ghosts/behavior.js +++ b/public/js/ghosts/behavior.js @@ -15,7 +15,9 @@ export function ghostTransform(rec, nowMs, out) { const base = new THREE.Vector3(...rec.pos); 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 R = b.radius ?? 60; // cm 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 } - // 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 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); 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(); diff --git a/server/playlist.js b/server/playlist.js index 4e61f81..f96d105 100644 --- a/server/playlist.js +++ b/server/playlist.js @@ -2,73 +2,202 @@ * * Config: * { - * slots: 5, // concurrent visible ghosts - * dwellSeconds: 120, // how long each ghost stays before rotating out - * crossfadeSeconds: 3, // fade out/in overlap hint for clients - * order: 'shuffle' | 'roster', // rotation order through the roster - * include: { colors:[], rarities:[], ids:[] }, // empty = all - * behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 }, - * timeWindows: [] // e.g. [{ start:"09:00", end:"17:00" }] — empty = always on + * slots: 5, // concurrent ROTATING ghosts (residents are extra) + * dwellSeconds: 120, + * crossfadeSeconds: 3, + * order: 'weighted' | 'shuffle' | 'roster', + * include: { colors:[], rarities:[], ids:[] }, // empty = all + * 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 }, // 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: - * { type:'spawn', ghost:{ uid, id, name, color, spawnId, pos, behavior, until } } - * { type:'despawn', uid } - * { type:'active', ghosts:[...] } (full snapshot, sent on join + config change) + * Broadcasts: spawn / despawn / active (snapshot). Permanent residents have until: null. */ class Playlist { constructor(state, broadcast) { this.state = state; this.broadcast = broadcast; - this.cfg = state.getPlaylistConfig({ - slots: 5, dwellSeconds: 120, crossfadeSeconds: 3, order: 'shuffle', - include: { colors: [], rarities: [], ids: [] }, - behaviors: { staticChance: 0.5, wanderRadius: 60, wanderSpeed: 0.15, wanderVertical: 10 }, - timeWindows: [], - }); - this.active = new Map(); // uid -> ghost record + this.cfg = state.getPlaylistConfig(Playlist.defaults()); + this.cfg = { ...Playlist.defaults(), ...this.cfg }; + this.active = new Map(); this.queue = []; this.uidCounter = 1; 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; } setConfig(cfg) { 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); - // rebuild world: clear and respawn under new rules - for (const uid of [...this.active.keys()]) this.despawn(uid); + for (const uid of [...this.active.keys()]) this.despawn(uid, true); this.queue = []; this.tick(); this.broadcast({ type: 'active', ghosts: this.activeSnapshot() }); return this.cfg; } + allGhosts() { return this.state.getGhosts().ghosts; } + roster() { - const { ghosts } = this.state.getGhosts(); 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.rarities?.length || inc.rarities.includes(g.rarity)) && (!inc.ids?.length || inc.ids.includes(g.id))); } - refillQueue() { - const r = this.roster().map(g => g.id); - if (!r.length) return; - if (this.cfg.order === 'shuffle') { - for (let i = r.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [r[i], r[j]] = [r[j], r[i]]; - } + scale(g) { return this.cfg.rarity?.movementScale?.[g.rarity] ?? 1; } + + // ---------- ghost selection ---------- + pickGhost() { + const activeIds = new Set([...this.active.values()].map(x => x.id)); + const pool = this.roster().filter(g => !activeIds.has(g.id)); + 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]; } - // avoid immediate repeat of currently-active ghosts at queue head - const activeIds = new Set([...this.active.values()].map(g => g.id)); - this.queue.push(...r.filter(id => !activeIds.has(id)), ...r.filter(id => activeIds.has(id))); + + // 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; + } + while (this.queue.length) { + const id = this.queue.shift(); + 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() { const w = this.cfg.timeWindows || []; if (!w.length) return true; @@ -78,63 +207,27 @@ class Playlist { const [sh, sm] = String(start).split(':').map(Number); const [eh, em] = String(end).split(':').map(Number); 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() { const now = Date.now(); 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; } - for (const [uid, g] of this.active) if (now >= g.until) this.despawn(uid); - const want = Math.min(this.cfg.slots || 5, (this.state.getScene().spawns || []).filter(s => s.enabled !== false).length); - while (this.active.size < want) { - const before = this.active.size; - this.spawnOne(); - if (this.active.size === before) break; // no roster/spawns available - } + this.ensureResidents(); + for (const [uid, g] of this.active) if (!g.permanent && g.until != null && now >= g.until) this.despawn(uid); + + const scene = this.state.getScene(); + const capacity = (scene.spawns || []).filter(s => s.enabled !== false).length + + (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() { diff --git a/server/state.js b/server/state.js index 820a07b..0054359 100644 --- a/server/state.js +++ b/server/state.js @@ -22,7 +22,7 @@ function writeJSON(p, obj) { 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 gradients = readJSON(path.join(DATA, 'gradients.json'), {}); let models = readJSON(MODELS_LIVE, null) || readJSON(MODELS_SEED, { models: [] }); @@ -34,6 +34,13 @@ function validateScene(s) { for (const k of ['anchors', 'buildings', 'spawns']) { 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) { 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');