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() {