One album must not render the same dungeon twice. dungeonTheme() scores the measured summary per theme — pace terms gate on tempoConfidence so doubled detectors never read fast, crypt is penalised for blazing masters — plus small seeded jitter so ambiguous songs vary across seeds while decided races stay decided. The plan carries the theme name; dressing mix, planting/timber/puddle densities follow it, and the facade builds all materials (stone/floor multipliers, torch + light color, wood, fog tint) from the same record. Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners stable across seeds; plan reshaping, facade wiring, and murk-vs-clear gates covered by tests.
232 lines
11 KiB
HTML
232 lines
11 KiB
HTML
<!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, summary: track.summary });
|
||
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`;
|
||
const ch = map.character || {};
|
||
const chLine = `<div class="kv"><span>character</span><b>seal ${(ch.indoorBias ?? 0).toFixed(2)} · murk ${(ch.murk ?? 0).toFixed(2)} · size ×${(ch.sizeMul ?? 1).toFixed(2)}</b></div>` +
|
||
`<div class="kv"><span>theme</span><b>${map.theme ?? 'crypt'}</b></div>`;
|
||
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();
|
||
}
|
||
for (const t of map.items.timbers || []) {
|
||
dot(t.x, t.z, '#92400e', 2);
|
||
}
|
||
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 = chLine +
|
||
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) · <i style="background:#92400e"></i>timber</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>
|