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.
`;
+}
+
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
+
+
appearance weight
movement scale
+ ${rarities.map(r => `
+
${r}
+
+
+
`).join('')}
+
+
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)