Files
music-video-gen/flow-state/dungeon-debug.html
T
Dejvino c96e810d44 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.
2026-10-04 22:13:25 +02:00

226 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>