Track-based playlist GUI with live ApproxRuntime, ghost-set picker, and residents
This commit is contained in:
+207
-79
@@ -6,121 +6,244 @@
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
main { max-width:640px; margin:20px auto; padding:0 16px; }
|
||||
.card { background:#111730; border-radius:10px; padding:16px; margin-bottom:16px; }
|
||||
main { max-width:1080px; margin:18px auto; padding:0 16px; }
|
||||
.card { background:#111730; border-radius:10px; padding:14px 16px; margin-bottom:14px; }
|
||||
.card h2 { margin:0 0 10px; font-size:.95rem; }
|
||||
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
|
||||
.row label { width:170px; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.chip { padding:4px 12px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.85rem; user-select:none; }
|
||||
.trk { background:#0d1226; border-radius:10px; padding:12px 14px; margin-bottom:10px; border-left:3px solid #2a3a6e; }
|
||||
.trk.sched { border-left-color:#51eaf1; }
|
||||
.trk.off { opacity:.45; }
|
||||
.thead { display:flex; gap:10px; align-items:center; margin-bottom:8px; }
|
||||
.thead input.nm { flex:1; font-weight:600; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px 12px; }
|
||||
.f label { display:block; font-size:.72rem; opacity:.7; margin-bottom:2px; }
|
||||
.f input, .f select { width:100%; box-sizing:border-box; }
|
||||
.rt { margin-top:8px; font-size:.8rem; color:#51eaf1; }
|
||||
.rt .warn { color:#ffd166; }
|
||||
.setline { font-size:.75rem; opacity:.7; margin-top:6px; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:5px; margin-top:6px; max-height:150px; overflow:auto; }
|
||||
.chip { padding:3px 9px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.75rem; }
|
||||
.chip.on { background:#2a6ea0; }
|
||||
table { width:100%; font-size:.85rem; }
|
||||
table { width:100%; font-size:.85rem; border-collapse:collapse; }
|
||||
td { padding:3px 8px 3px 0; }
|
||||
</style>`);
|
||||
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 scene = await api('/api/scene');
|
||||
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
|
||||
const colors = ['Red', 'Yellow', 'Blue'];
|
||||
let expanded = null; // trackId whose ghost-picker is open
|
||||
|
||||
function chips(list, selected, onToggle) {
|
||||
return list.map(v => `<span class="chip ${selected.includes(v) ? 'on' : ''}" data-v="${v}">${v}</span>`).join('');
|
||||
const fmt = (s) => s >= 3600 ? `${(s / 3600).toFixed(1)} hr` : s >= 60 ? `~${Math.round(s / 60)} min` : `${Math.round(s)} s`;
|
||||
const locOptions = (sel) => ['random']
|
||||
.concat((scene.spawns || []).map(s => s.id))
|
||||
.concat((scene.paths || []).map(p => 'path:' + p.id))
|
||||
.map(v => `<option value="${v}" ${v === sel ? 'selected' : ''}>${v}</option>`).join('');
|
||||
|
||||
function setSize(t) {
|
||||
const resIds = new Set((cfg.residents || []).map(r => r.id));
|
||||
const all = ghosts.filter(g => !resIds.has(g.id));
|
||||
if (!t.set || t.set === 'all') return all.length;
|
||||
if (t.set.ids?.length) return t.set.ids.length;
|
||||
return all.filter(g => (!t.set.colors?.length || t.set.colors.includes(g.color)) &&
|
||||
(!t.set.rarities?.length || t.set.rarities.includes(g.rarity))).length;
|
||||
}
|
||||
function approx(t) {
|
||||
const n = setSize(t);
|
||||
return (t.spawnTime === 'random' || t.spawnTime == null)
|
||||
? n * (t.runEach || 30)
|
||||
: (Number(t.spawnTime) || 0) + (t.runEach || 30);
|
||||
}
|
||||
function setLabel(t) {
|
||||
if (!t.set || t.set === 'all') return 'All ghosts';
|
||||
if (t.set.ids?.length) {
|
||||
const names = t.set.ids.map(id => ghosts.find(g => g.id === id)?.name || id);
|
||||
return names.length <= 3 ? names.join(' and ') : `${names.length} ghosts`;
|
||||
}
|
||||
const bits = [...(t.set.colors || []), ...(t.set.rarities || [])];
|
||||
return bits.length ? bits.join(' / ') : 'All ghosts';
|
||||
}
|
||||
|
||||
function rosterCount() {
|
||||
const inc = cfg.include || {};
|
||||
return ghosts.filter(g =>
|
||||
(!inc.colors?.length || inc.colors.includes(g.color)) &&
|
||||
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
|
||||
function trackCard(t, i) {
|
||||
const timed = !(t.spawnTime === 'random' || t.spawnTime == null);
|
||||
const n = setSize(t);
|
||||
const rt = approx(t);
|
||||
const tooMany = t.concurrent > n;
|
||||
return `<div class="trk ${timed ? 'sched' : ''} ${t.enabled === false ? 'off' : ''}" data-i="${i}">
|
||||
<div class="thead">
|
||||
<input class="nm" data-k="name" value="${t.name || t.id}">
|
||||
<label><input type="checkbox" data-k="enabled" ${t.enabled !== false ? 'checked' : ''}> enabled</label>
|
||||
<button data-up="${i}" ${i === 0 ? 'disabled' : ''}>↑</button>
|
||||
<button data-down="${i}">↓</button>
|
||||
<button data-del="${i}" class="danger">✕</button>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="f"><label>Ghosts</label>
|
||||
<button data-pick="${t.id}" style="width:100%;text-align:left">${setLabel(t)} (${n})</button></div>
|
||||
<div class="f"><label>SpawnTime</label>
|
||||
<select data-k="spawnMode">
|
||||
<option value="random" ${!timed ? 'selected' : ''}>random (continuous)</option>
|
||||
<option value="timed" ${timed ? 'selected' : ''}>every…</option>
|
||||
</select></div>
|
||||
<div class="f"><label>${timed ? 'interval (minutes)' : '—'}</label>
|
||||
<input type="number" step="0.5" min="0" data-k="spawnTime" value="${timed ? (Number(t.spawnTime) / 60) : ''}" ${timed ? '' : 'disabled'}></div>
|
||||
<div class="f"><label>SpawnPoint</label>
|
||||
<select data-k="spawnPoint">${locOptions(t.spawnPoint || 'random')}</select></div>
|
||||
<div class="f"><label>RunEach (seconds)</label>
|
||||
<input type="number" step="1" min="1" data-k="runEach" value="${t.runEach || 30}"></div>
|
||||
<div class="f"><label>ConcurrentSpawns</label>
|
||||
<input type="number" step="1" min="1" data-k="concurrent" value="${t.concurrent || 1}"></div>
|
||||
<div class="f"><label>Priority</label>
|
||||
<input type="number" step="1" data-k="priority" value="${t.priority ?? (timed ? 10 : 0)}"></div>
|
||||
</div>
|
||||
<div class="rt"><b>ApproxRuntime: ${fmt(rt)}</b>
|
||||
<span style="opacity:.7">— ${timed
|
||||
? `one cycle: waits ${fmt(Number(t.spawnTime) || 0)}, then runs ${fmt(t.runEach)}`
|
||||
: `one full pass through ${n} ghost${n === 1 ? '' : 's'} at ${t.runEach}s each`}</span>
|
||||
${tooMany ? `<div class="warn">⚠ ConcurrentSpawns (${t.concurrent}) exceeds the ${n}-ghost set — only ${n} can show at once.</div>` : ''}
|
||||
${timed && t.runEach > Number(t.spawnTime) ? `<div class="warn">⚠ RunEach is longer than the interval — this track is effectively always on screen.</div>` : ''}
|
||||
</div>
|
||||
${expanded === t.id ? ghostPicker(t) : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function ghostPicker(t) {
|
||||
const ids = (t.set && t.set.ids) ? t.set.ids : [];
|
||||
const cols = (t.set && t.set.colors) ? t.set.colors : [];
|
||||
const rars = (t.set && t.set.rarities) ? t.set.rarities : [];
|
||||
return `<div class="setline" style="margin-top:10px">
|
||||
<b>Set:</b> pick individual ghosts, or filter by colour/rarity. Nothing selected = all ghosts.
|
||||
<button data-clear="${t.id}" style="margin-left:8px">clear</button>
|
||||
<div style="margin-top:6px">Colours: ${colors.map(c =>
|
||||
`<span class="chip ${cols.includes(c) ? 'on' : ''}" data-col="${t.id}|${c}">${c}</span>`).join(' ')}
|
||||
Rarities: ${rarities.map(r =>
|
||||
`<span class="chip ${rars.includes(r) ? 'on' : ''}" data-rar="${t.id}|${r}">${r}</span>`).join(' ')}</div>
|
||||
<div class="chips">${ghosts.map(g =>
|
||||
`<span class="chip ${ids.includes(g.id) ? 'on' : ''}" data-gid="${t.id}|${g.id}">${g.name}</span>`).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 locSel = r.pathId ? 'path:' + r.pathId : (r.spawnId || '');
|
||||
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('');
|
||||
.concat((scene.spawns || []).map(sp => `<option value="${sp.id}" ${locSel === sp.id ? 'selected' : ''}>spawn ${sp.id}</option>`))
|
||||
.concat((scene.paths || []).map(pt => `<option value="path:${pt.id}" ${locSel === 'path:' + 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>
|
||||
<select data-rk="behavior" style="width:90px">${behOpts}</select>
|
||||
<button data-resdel="${i}" class="danger">✕</button></div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tracks = cfg.tracks || [];
|
||||
const longest = tracks.filter(t => t.enabled !== false).map(approx);
|
||||
app.innerHTML = `${nav('playlist')}<main>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Rotation</h2>
|
||||
<div class="row"><label>Concurrent ghosts (slots)</label><input id="slots" type="number" min="1" max="20" value="${cfg.slots}"></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>Order</label>
|
||||
<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>
|
||||
<h2>Playlist tracks <span style="opacity:.6;font-weight:400;font-size:.8rem">— run simultaneously; scheduled tracks (cyan) outrank ambient ones and will take a spawn point from them</span></h2>
|
||||
<div id="tracks">${tracks.map(trackCard).join('') || '<em style="opacity:.6">No tracks yet</em>'}</div>
|
||||
<button id="addTrack" style="margin-top:6px">+ Add track</button>
|
||||
${longest.length ? `<div class="rt" style="margin-top:10px">Full show loops roughly every <b>${fmt(Math.max(...longest))}</b>
|
||||
(longest enabled track) · ${tracks.filter(t => t.enabled !== false).length} track(s) active</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
|
||||
<div class="row"><label>Colors</label><div class="chips" id="colors">${chips(colors, cfg.include?.colors || [])}</div></div>
|
||||
<div class="row"><label>Rarities</label><div class="chips" id="rarities">${chips(rarities, cfg.include?.rarities || [])}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Behavior</h2>
|
||||
<div class="row"><label>Static chance (0–1)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></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>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>
|
||||
<h2>Residents <span style="opacity:.6;font-weight:400;font-size:.8rem">— permanent, never rotate out, outrank every track</span></h2>
|
||||
<div id="residents">${(cfg.residents || []).map(residentRow).join('') || '<em style="opacity:.6">No residents</em>'}</div>
|
||||
<button id="addRes" style="margin-top:8px">+ Add resident</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
|
||||
<h2>Movement & rarity</h2>
|
||||
<div class="grid">
|
||||
<div class="f"><label>Static chance (0–1)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
|
||||
<div class="f"><label>Wander radius (cm)</label><input id="wanderRadius" type="number" step="1" value="${cfg.behaviors.wanderRadius}"></div>
|
||||
<div class="f"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
||||
<div class="f"><label>Vertical wander (cm)</label><input id="wanderVertical" type="number" step="1" value="${cfg.behaviors.wanderVertical ?? 10}"></div>
|
||||
<div class="f"><label>Path speed (cm/s)</label><input id="pathSpeed" type="number" step="0.5" value="${cfg.behaviors.pathSpeed ?? 8}"></div>
|
||||
<div class="f"><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>
|
||||
<div class="f"><label>Crossfade (seconds)</label><input id="fade" type="number" step="0.5" min="0" value="${cfg.crossfadeSeconds}"></div>
|
||||
</div>
|
||||
<table style="margin-top:10px">
|
||||
<tr style="opacity:.6"><td></td><td>appearance weight</td><td>movement scale</td></tr>
|
||||
${rarities.map(r => `<tr><td>${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">Weights bias which ghost an <b>ambient</b> track picks next; movement scale shrinks wander/path speed for rarer ghosts.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Time windows <span style="opacity:.6;font-weight:400;font-size:.8rem">— whole exhibit sleeps outside these; empty = always on</span></h2>
|
||||
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
|
||||
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
|
||||
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
|
||||
<button data-del="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
|
||||
<button data-wdel="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
|
||||
<button id="addWin" style="margin-top:8px">+ Add window</button>
|
||||
</div>
|
||||
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts rotation)</button>
|
||||
<div id="status" style="margin:10px 0;opacity:.7;font-size:.85rem"></div>
|
||||
</main>`;
|
||||
|
||||
for (const grp of ['colors', 'rarities']) {
|
||||
document.getElementById(grp).querySelectorAll('.chip').forEach(c => c.onclick = () => {
|
||||
const arr = cfg.include[grp] = cfg.include[grp] || [];
|
||||
const i = arr.indexOf(c.dataset.v);
|
||||
i >= 0 ? arr.splice(i, 1) : arr.push(c.dataset.v);
|
||||
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts all tracks)</button>
|
||||
<div id="status" style="margin:10px 0;opacity:.75;font-size:.85rem"></div>
|
||||
</main>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function wire() {
|
||||
// per-track fields
|
||||
document.querySelectorAll('#tracks .trk').forEach(el => {
|
||||
const i = +el.dataset.i, t = cfg.tracks[i];
|
||||
el.querySelectorAll('[data-k]').forEach(inp => inp.onchange = () => {
|
||||
const k = inp.dataset.k;
|
||||
if (k === 'enabled') t.enabled = inp.checked;
|
||||
else if (k === 'name') t.name = inp.value;
|
||||
else if (k === 'spawnMode') t.spawnTime = inp.value === 'random' ? 'random' : 60;
|
||||
else if (k === 'spawnTime') t.spawnTime = Math.max(0, (parseFloat(inp.value) || 0) * 60);
|
||||
else if (k === 'spawnPoint') t.spawnPoint = inp.value;
|
||||
else t[k] = parseFloat(inp.value) || 0;
|
||||
render();
|
||||
});
|
||||
}
|
||||
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); 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('[data-pick]').forEach(b => b.onclick = () => {
|
||||
expanded = expanded === b.dataset.pick ? null : b.dataset.pick; render();
|
||||
});
|
||||
const trackById = id => cfg.tracks.find(t => t.id === id);
|
||||
const ensureSet = t => { if (!t.set || t.set === 'all') t.set = { ids: [], colors: [], rarities: [] }; return t.set; };
|
||||
document.querySelectorAll('[data-gid]').forEach(c => c.onclick = () => {
|
||||
const [tid, gid] = c.dataset.gid.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.ids ||= []; const k = s.ids.indexOf(gid); k >= 0 ? s.ids.splice(k, 1) : s.ids.push(gid); render();
|
||||
});
|
||||
document.querySelectorAll('[data-col]').forEach(c => c.onclick = () => {
|
||||
const [tid, v] = c.dataset.col.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.colors ||= []; const k = s.colors.indexOf(v); k >= 0 ? s.colors.splice(k, 1) : s.colors.push(v); render();
|
||||
});
|
||||
document.querySelectorAll('[data-rar]').forEach(c => c.onclick = () => {
|
||||
const [tid, v] = c.dataset.rar.split('|'); const s = ensureSet(trackById(tid));
|
||||
s.rarities ||= []; const k = s.rarities.indexOf(v); k >= 0 ? s.rarities.splice(k, 1) : s.rarities.push(v); render();
|
||||
});
|
||||
document.querySelectorAll('[data-clear]').forEach(b => b.onclick = () => { trackById(b.dataset.clear).set = 'all'; render(); });
|
||||
|
||||
document.getElementById('addRes').onclick = () => {
|
||||
(cfg.residents ||= []).push({ id: ghosts[0].id, behavior: 'static' });
|
||||
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.tracks.splice(+b.dataset.del, 1); render(); });
|
||||
document.querySelectorAll('[data-up]').forEach(b => b.onclick = () => {
|
||||
const i = +b.dataset.up; [cfg.tracks[i - 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i - 1]]; render();
|
||||
});
|
||||
document.querySelectorAll('[data-down]').forEach(b => b.onclick = () => {
|
||||
const i = +b.dataset.down; if (i + 1 >= cfg.tracks.length) return;
|
||||
[cfg.tracks[i + 1], cfg.tracks[i]] = [cfg.tracks[i], cfg.tracks[i + 1]]; render();
|
||||
});
|
||||
document.getElementById('addTrack').onclick = () => {
|
||||
(cfg.tracks ||= []).push({ id: 'track-' + Date.now().toString(36), name: 'New track', set: 'all',
|
||||
spawnTime: 600, spawnPoint: 'random', runEach: 120, concurrent: 1, priority: 10, enabled: true });
|
||||
render();
|
||||
};
|
||||
|
||||
// residents
|
||||
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];
|
||||
@@ -128,11 +251,18 @@ function render() {
|
||||
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;
|
||||
if (el.value.startsWith('path:')) { r.pathId = el.value.slice(5); r.behavior = 'path'; }
|
||||
else if (el.value) r.spawnId = el.value;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// windows
|
||||
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
|
||||
document.querySelectorAll('[data-wdel]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.wdel, 1); render(); });
|
||||
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
|
||||
|
||||
// rarity table
|
||||
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;
|
||||
@@ -140,10 +270,7 @@ function render() {
|
||||
});
|
||||
|
||||
document.getElementById('save').onclick = async () => {
|
||||
cfg.slots = +document.getElementById('slots').value;
|
||||
cfg.dwellSeconds = +document.getElementById('dwell').value;
|
||||
cfg.crossfadeSeconds = +document.getElementById('fade').value;
|
||||
cfg.order = document.getElementById('order').value;
|
||||
cfg.behaviors = {
|
||||
staticChance: +document.getElementById('staticChance').value,
|
||||
wanderRadius: +document.getElementById('wanderRadius').value,
|
||||
@@ -152,8 +279,9 @@ function render() {
|
||||
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; }
|
||||
try { cfg = await api('/api/playlist', 'PUT', cfg); render();
|
||||
document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString();
|
||||
} catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user