Dungeon venue: grid-first traversable dungeon with cast, loot and encounters

New VenueGenerator 'dungeon' archetype. The floor plan is generated first
on a 44x44 grid (rejection-sampled rooms, Kruskal MST + loop edges,
L-shaped corridors, BFS-verified connectivity); walls are merged runs
built exactly where open meets solid, so the shell has no holes, and
the walk route is a closed travelling tour through every room.

The camera either walks first-person (hero hidden to avoid clipping) or
plants on a per-room watch cell with line-of-sight to the route and
tracks the actor. Enemies (walker/structure/swarm ActorSpecs from the
track's own summary) lurk, stand sentry, dart and drift; loot caches
pause the analytic odometer, which can hold or advance but never retreat.

Includes dungeon-debug.html (2D map + timeline stills), 14 map tests
plus facade camera tests, and the portcullis-holds-for-the-whole-party
regression test.
This commit is contained in:
Dejvino
2026-10-04 22:13:25 +02:00
parent 3df4b3545b
commit c96e810d44
13 changed files with 3309 additions and 20 deletions
+225
View File
@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · dungeon debug</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, monospace; }
h1 { font-size:15px; color:#7d8698; margin:0 0 4px; }
h2 { font-size:12px; color:#6b7280; margin:22px 0 8px; text-transform:uppercase; letter-spacing:.1em; }
#status { color:#8b94a7; margin: 8px 0 16px; }
#status b { color:#e6eaf2; }
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
#map { background:#05070a; border:1px solid #1c2030; image-rendering:pixelated; }
#mapmeta { max-width:340px; }
#mapmeta .kv { display:flex; justify-content:space-between; gap:12px; padding:1px 0; }
#mapmeta .kv span { color:#8b94a7; }
.legend { margin-top:10px; font-size:12px; color:#8b94a7; }
.legend i { display:inline-block; width:10px; height:10px; margin-right:5px; vertical-align:baseline; }
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:10px; }
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
.card h3 .mode-walk { color:#7dd3fc; } .card h3 .mode-room { color:#a3e635; } .card h3 .mode-portal { color:#f0abfc; }
canvas.still { width:100%; display:block; background:#05070a; aspect-ratio:16/9; }
a { color:#7dd3fc; }
</style>
</head>
<body>
<h1>flow-state · dungeon debug</h1>
<div id="status">starting…</div>
<h2>map — <span id="maptitle"></span></h2>
<div class="wrap">
<canvas id="map" width="440" height="440"></canvas>
<div id="mapmeta"></div>
</div>
<h2>stills — song timeline, both camera modes</h2>
<div id="out" class="grid"></div>
<script type="module">
import { Engine } from '/src/engine/Engine.js';
import { createLayer } from '/src/engine/Layer.js';
import { song } from '/src/audio/songbank.js';
import { generateLook } from '/src/look/LookGenerator.js';
import { venues } from '/src/venues/registry.js';
import { dungeon } from '/src/venues/dungeon.js';
import { genDungeon } from '/src/venues/dungeon/map.js';
import { hashString } from '/src/engine/rng.js';
import { blit } from '/src/checks/blit.js';
import * as THREE from 'three';
const out = document.getElementById('out');
const status = document.getElementById('status');
const params = new URLSearchParams(location.search);
const songName = params.get('song') || 'ember';
const mapSeedParam = params.get('mapseed');
const TIMES = (params.get('times') || '5,25,45,65,85,105').split(',').map(Number);
const { track } = song(songName);
const look = generateLook(track, { seed: hashString(songName), venueEnabled: true });
look.venue.module = dungeon;
look.venue.name = dungeon.name;
look.venue.archetype = dungeon.archetype;
const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7);
const map = genDungeon({ seed: mapSeed });
look.venue.dungeon = { map };
// ---------------------------------------------------------- 2D map
const ZC = { hall: '#232b3d', room: '#2b3450', forest: '#14301c', wallwalk: '#33302a' };
const mc = document.getElementById('map');
const mg = mc.getContext('2d');
const S = mc.width / map.w;
document.getElementById('maptitle').textContent = `${songName} · map seed ${mapSeed} · ${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u`;
for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) {
if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; }
else {
const r = map.rooms.find((rm) => x >= rm.x && x < rm.x + rm.w && y >= rm.y && y < rm.y + rm.h);
mg.fillStyle = r ? (ZC[r.zone] || '#222') : '#1a2030';
}
mg.fillRect(x * S, y * S, S, S);
}
}
// Route.
mg.strokeStyle = '#f5b942'; mg.lineWidth = 2; mg.beginPath();
map.route.pts.forEach((p, i) => {
const cx = (p.x / 2 + map.w / 2) * S, cy = (p.z / 2 + map.h / 2) * S;
if (i === 0) mg.moveTo(cx, cy); else mg.lineTo(cx, cy);
});
mg.closePath(); mg.stroke();
const dot = (x, z, color, r = 3) => {
mg.fillStyle = color; mg.beginPath();
mg.arc((x / 2 + map.w / 2) * S, (z / 2 + map.h / 2) * S, r, 0, 7); mg.fill();
};
for (const p of map.items.props) dot(p.x, p.z, '#8b94a7', 2);
for (const p of map.items.puddles) dot(p.x, p.z, '#38bdf8', 3);
for (const p of map.items.torches) dot(p.x, p.z, '#fb923c', 2);
for (const p of map.items.windows) dot(p.x, p.z, '#22d3ee', 2);
for (const p of map.items.gates) dot(p.x, p.z, '#ef4444', 3);
for (const v of map.items.vegetation || []) {
dot(v.x, v.z, v.kind === 'boulder' ? '#6b7280' : '#166534', 2);
}
const ECOL = { lurker: '#f87171', sentry: '#fb923c', critter: '#facc15', wisp: '#a5f3fc' };
for (const e of map.items.entities || []) {
dot(e.x, e.z, ECOL[e.kind] || '#f87171', 3);
mg.fillStyle = '#0b0d12';
mg.fillText(e.kind[0].toUpperCase(), (e.x / 2 + map.w / 2) * S - 3, (e.z / 2 + map.h / 2) * S + 3);
}
for (const l of map.items.loot || []) {
dot(l.x, l.z, '#fde047', 3);
mg.strokeStyle = '#fde047'; mg.lineWidth = 1; mg.beginPath();
mg.arc((l.x / 2 + map.w / 2) * S, (l.z / 2 + map.h / 2) * S, 5, 0, 7); mg.stroke();
}
if (map.portal) {
dot(map.portal.x, map.portal.z, '#f0abfc', 5);
mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath();
mg.arc((map.portal.x / 2 + map.w / 2) * S, (map.portal.z / 2 + map.h / 2) * S, 7, 0, 7); mg.stroke();
}
mg.fillStyle = '#a3e635';
for (const w of Object.values(map.watch)) {
const cx = (w.x / 2 + map.w / 2) * S, cy = (w.z / 2 + map.h / 2) * S;
mg.fillRect(cx - 3, cy - 3, 6, 6);
}
mg.fillStyle = '#fff';
map.rooms.forEach((rm) => {
mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S);
});
document.getElementById('mapmeta').innerHTML =
map.rooms.map((rm) =>
`<div class="kv"><span>room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}</span><b>${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}</b></div>`).join('') +
`<div class="legend">
<div><i style="background:#f5b942"></i>route (closed tour)</div>
<div><i style="background:#a3e635"></i>watch cell (room camera plant)</div>
<div><i style="background:#f0abfc"></i>portal · <i style="background:#ef4444"></i>gate ·
<i style="background:#fb923c"></i>torch · <i style="background:#22d3ee"></i>window ·
<i style="background:#38bdf8"></i>puddle · <i style="background:#8b94a7"></i>prop ·
<i style="background:#166534"></i>veg · <i style="background:#6b7280"></i>boulder ·
<i style="background:#f87171"></i>lurker · <i style="background:#fb923c"></i>sentry ·
<i style="background:#facc15"></i>critter · <i style="background:#a5f3fc"></i>wisp ·
<i style="background:#fde047"></i>loot (halt)</div></div>`;
// ---------------------------------------------------------- 3D stills
// Fake viz feed so windows/portal/puddles show their IMAGE (the compositor
// would bind the real viz texture; the lab stands in a test pattern).
function fakeViz() {
const c = document.createElement('canvas');
c.width = 256; c.height = 144;
const g = c.getContext('2d');
const grad = g.createLinearGradient(0, 0, 256, 144);
grad.addColorStop(0, '#0ff'); grad.addColorStop(0.5, '#80f'); grad.addColorStop(1, '#f0f');
g.fillStyle = grad; g.fillRect(0, 0, 256, 144);
g.fillStyle = '#fff';
for (let i = 0; i < 24; i++) g.fillRect((i * 53) % 256, (i * 97) % 144, 20, 8);
return c;
}
const vizCanvas = fakeViz();
const engine = new Engine({ width: 480, height: 270 });
const layer = createLayer(dungeon, {
params: look.venue.params || {},
seed: look.venue.seed ?? 7,
opacity: 1, blend: 'normal',
actorSpec: look.actors?.monolith || null,
venueSpec: look.venue,
});
layer.setPalette(look.palette);
layer.setPersonality(look.personality);
if (layer.setActor && look.actors?.monolith) layer.setActor(look.actors.monolith);
if (layer.setVenue) layer.setVenue(look.venue);
engine.compositor.setLayers([layer]);
function bindFakeViz() {
const tex = new THREE.CanvasTexture(vizCanvas);
dungeon.bindVizTexture(layer.instance, tex);
}
bindFakeViz();
const fps = track.fps || 60;
const modes = [
{ target: 'walk', label: 'walk · first-person (actor hidden)' },
{ target: 'room', label: 'room · plant watches actor' },
];
for (const t of TIMES) {
const frame = Math.round(t * fps);
const feat = track.at ? track.at(frame) : {};
const tl = { frame, time: frame / fps, progress: frame / (track.frameCount || 1) };
for (const m of modes) {
layer.setFraming({
scale: 1, shift: [0, 0],
venueShot: { target: m.target, pos: [0, 1, 2], look: [0, 0.4, -5], size: 'normal', dolly: 1.0 },
});
for (let k = 0; k < 4; k++) {
layer.render(engine.renderer, engine.compositor.layerTarget, {
timeline: { ...tl, frame: frame - (3 - k), time: (frame - (3 - k)) / fps },
features: feat, prevTexture: null,
});
}
layer.render(engine.renderer, engine.compositor.layerTarget, {
timeline: tl, features: feat, prevTexture: null,
});
const pix = Uint8Array.from(engine.readPixels(engine.compositor.layerTarget));
const card = document.createElement('div');
card.className = 'card';
// Where the actor actually is: nearest route sample to the built hero.
const hp = layer.instance.hero.position;
let bi = 0, bd = Infinity;
map.route.pts.forEach((p, i) => {
const d = Math.hypot(p.x - hp.x, p.z - hp.z);
if (d < bd) { bd = d; bi = i; }
});
const rid = map.route.roomIds[bi];
const zn = map.route.zones[bi];
card.innerHTML = `<h3>t=${t}s · <span class="mode-${m.target}">${m.target}</span> · ` +
(rid >= 0 ? `room ${rid} (${zn})` : `corridor (${zn})`) + `</h3>`;
const cv = document.createElement('canvas');
cv.width = 480; cv.height = 270; cv.className = 'still';
card.appendChild(cv); out.appendChild(card);
blit(cv, pix, 480, 270);
}
}
status.innerHTML = `done — <b>${songName}</b> · map seed <b>${mapSeed}</b> · ` +
`${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u · ` +
`${TIMES.length * modes.length} stills`;
window.__DUNGEON_DEBUG_DONE__ = true;
</script>
</body>
</html>