From d34b850f1bdf4bd7140c54c4908629d2d1ba5852 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 3 Aug 2026 15:08:20 +1000 Subject: [PATCH] Add layout-prefixed spawn IDs, safe per-spawn rename, and renumber-all (playlist refs kept in sync) --- public/admin/layout.html | 91 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/public/admin/layout.html b/public/admin/layout.html index ca20efb..e735066 100644 --- a/public/admin/layout.html +++ b/public/admin/layout.html @@ -100,6 +100,29 @@ let scene = await api('/api/scene'); scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= []; scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {}); +// Active layout slug drives spawn/path ID prefixes (e.g. small-demo -> "sd-sp1"). +// Fetched here and refreshed whenever a layout is saved or loaded. +let activeLayoutSlug = null; +try { activeLayoutSlug = (await api('/api/layouts')).activeSlug || null; } catch { activeLayoutSlug = null; } + +// Short prefix from a slug: initials of its words. "small-demo" -> "sd", +// "full-build" -> "fb". Null slug (unsaved scene) -> null (use legacy names). +function layoutPrefix() { + if (!activeLayoutSlug) return null; + const initials = activeLayoutSlug.split('-').filter(Boolean).map(w => w[0]).join(''); + return initials || activeLayoutSlug.slice(0, 3); +} + +// Next free "-sp" (or "spawn-" when no layout is active), +// scanning existing spawn IDs so numbers don't collide after deletes. +function nextSpawnId() { + const p = layoutPrefix(); + const base = p ? `${p}-sp` : 'spawn-'; + const used = new Set(scene.spawns.map(s => s.id)); + let n = 1; while (used.has(base + n)) n++; + return base + n; +} + // ---------- three.js setup ---------- const cvs = document.getElementById('gl'); const renderer = new THREE.WebGLRenderer({ canvas: cvs, antialias: true }); @@ -307,7 +330,7 @@ document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => { 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 }); + scene.spawns.push({ id: nextSpawnId(), position: [t.x, 25, t.z], enabled: true }); sel = { kind: 'spawn', index: scene.spawns.length - 1 }; } buildAll(); panel(); @@ -353,6 +376,7 @@ async function refreshLayouts() { scene = r.scene; scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= []; scene.paths ||= []; scene.table = Object.assign({ width: 120, depth: 100, offsetX: 0, offsetZ: 0, show: true }, scene.table || {}); + activeLayoutSlug = (r.layouts && r.layouts.activeSlug) || slug; sel = null; gizmo.detach(); buildAll(); panel(); status.textContent = 'Loaded layout'; refreshLayouts(); @@ -374,13 +398,53 @@ document.getElementById('layoutSave').onclick = async () => { try { // persist current edits into the live scene, then snapshot as a layout scene = await api('/api/scene', 'PUT', scene); - await api('/api/layouts', 'POST', { name }); + const saved = await api('/api/layouts', 'POST', { name }); + activeLayoutSlug = saved.activeSlug || activeLayoutSlug; document.getElementById('layoutName').value = ''; status.textContent = 'Saved layout "' + name + '"'; refreshLayouts(); } catch (e) { alert('Save failed: ' + e.message); } }; +// ---------- spawn ID rename (keeps playlist references in sync) ---------- +// Given a { oldId: newId } map, rewrite scene.spawns, then load the playlist +// config, remap track.spawnPoint (bare spawn refs only) and resident.spawnId, +// and save both. Persists immediately so live viewers stay consistent. +async function applySpawnRename(map) { + const changed = Object.keys(map).filter(k => map[k] && map[k] !== k); + if (!changed.length) return; + for (const s of scene.spawns) if (map[s.id]) s.id = map[s.id]; + scene = await api('/api/scene', 'PUT', scene); + try { + const cfg = await api('/api/playlist'); + let touched = false; + for (const t of cfg.tracks || []) { + // spawnPoint is 'random', a bare spawn id, or 'path:' — only remap bare spawn ids + if (t.spawnPoint && !String(t.spawnPoint).startsWith('path:') && map[t.spawnPoint]) { + t.spawnPoint = map[t.spawnPoint]; touched = true; + } + } + for (const r of cfg.residents || []) { + if (r.spawnId && map[r.spawnId]) { r.spawnId = map[r.spawnId]; touched = true; } + } + if (touched) await api('/api/playlist', 'PUT', cfg); + } catch (e) { console.warn('playlist remap skipped:', e.message); } + buildAll(); +} + +// Renumber every spawn to the active layout's prefix: sp-prefix-1, -2, … in +// current array order. Rewrites playlist refs via applySpawnRename. +async function renumberSpawns() { + if (!scene.spawns.length) return alert('No spawns to renumber.'); + const p = layoutPrefix(); + const base = p ? `${p}-sp` : 'spawn-'; + const preview = scene.spawns.map((s, i) => `${s.id} → ${base}${i + 1}`).join('\n'); + if (!confirm(`Renumber ${scene.spawns.length} spawn(s) to "${base}N"?\n\n${preview}\n\nPlaylist references will be updated too.`)) return; + const map = {}; scene.spawns.forEach((s, i) => { map[s.id] = `${base}${i + 1}`; }); + try { await applySpawnRename(map); status.textContent = 'Renumbered spawns'; panel(); } + catch (e) { alert('Renumber failed: ' + e.message); } +} + // ---------- property panel ---------- const side = document.getElementById('side'); function field(label, val, oncommit, opts) { @@ -463,12 +527,16 @@ function panel(rebuild = true) { 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 += `
`; h += field('x (cm)', o.position[0], v => o.position[0] = v); h += field('y (cm)', o.position[1], v => o.position[1] = v); h += field('z (cm)', o.position[2], v => o.position[2] = v); h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] }); - h += `

Drag the green (Y) arrow to set spawn height — ghosts wander around this point in 3D.

`; + h += `
`; + h += `

Editing the id is a failsafe — hit Rename to change it safely; any playlist + references (track spawn points, pinned residents) are updated to match. Renumber all spawns retags + every spawn to this layout's prefix (${layoutPrefix() ? layoutPrefix() + '-sp1, -sp2…' : 'spawn-1, -2…'}). + Drag the green (Y) arrow to set spawn height — ghosts wander around this point in 3D.

`; } h += `
`; side.innerHTML = h; @@ -478,6 +546,21 @@ function panel(rebuild = true) { sel = null; gizmo.detach(); buildAll(); panel(); }; wirePathExtras(); + wireSpawnExtras(); +} + +function wireSpawnExtras() { + const renameBtn = document.getElementById('spawnRename'); + const renumBtn = document.getElementById('spawnRenumber'); + if (renameBtn) renameBtn.onclick = async () => { + const o = dataOf(sel); + const newId = document.getElementById('spawnId').value.trim(); + if (!newId || newId === o.id) return; + if (scene.spawns.some(s => s !== o && s.id === newId)) return alert('That id is already used by another spawn.'); + try { await applySpawnRename({ [o.id]: newId }); status.textContent = 'Renamed spawn'; panel(); } + catch (e) { alert('Rename failed: ' + e.message); } + }; + if (renumBtn) renumBtn.onclick = renumberSpawns; } function wirePathExtras() {