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
+176 -83
View File
@@ -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() {