Render actual generated dungeons through the AI pipeline — both top-down and first-person (Eye-of-the-Beholder style) — instead of synthetic test depth. All renderer-side: derived from the envelope's tiles+regions+theme, the core stores none of it. - depth.js: top-down height-field depth exporter (walls raised, pools recessed, pillars as bumps, subtle per-theme relief), calibrated to the proven room_depth levels so one tall room can't crush every floor dark. - fpv.js: first-person depth via a Wolfenstein-style grid raycaster (16:9), reports straight-ahead distance for opening detection. - explore.js: room->room nav graph derived from REAL tile openings (scan boundary gaps, bin by cardinal, flood the corridor to the destination room) — nav + open share one source of truth with the rendered passages. - bake_atlas.py: bakes per-room x4-facing pixel-art frames; per-theme + per-facing (wall vs passage) prompts, fixed diffusion seed for cross-frame style coherence, per-run unique prefixes so re-bakes don't silently skip. - explore.html + explore-viewer.js: WASD first-person dungeon explorer (room-to-room movement, turning, minimap), integer-pixel fullscreen. - serve.py: stdlib dev server — static web root + /out tree + POST /save, no-store. - main.js/render.js/style.css: export hooks, the depth button, distanceToWall export. - tools/README.md: Stage 0/1/2/3 docs; .dev/2026-06-01-fpv-prompts.md: prompt research (cfg-1 negatives are inert; trigger words summon hands; empty-ruin reframe; fixed seed = consistency). Proven end-to-end on seed 7 (17 rooms). Outputs organized under git-ignored tools/out/. Control strength: 0.80-0.85 top-down, 0.85 first-person. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
145 lines
5.3 KiB
JavaScript
145 lines
5.3 KiB
JavaScript
// explore-viewer.js — walk a pre-baked first-person dungeon.
|
|
//
|
|
// Loads an atlas manifest (room graph + tiles) and the baked frames from
|
|
// /out/atlas/<seed>/, and lets you move through it Eye-of-the-Beholder style:
|
|
// you occupy a room and a cardinal facing; W/S step forward/back to the room in
|
|
// that direction, A/D turn. Each frame is the pre-rendered pixel-art view for the
|
|
// current (room, facing). Movement is just swapping the image — the dungeon was
|
|
// "burned off" ahead of time by tools/bake_atlas.py.
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
const SEED = params.get('seed') || '7';
|
|
const BASE = `/out/atlas/${SEED}`;
|
|
const DIRS = ['N', 'E', 'S', 'W'];
|
|
const VEC = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
|
|
|
|
const img = document.getElementById('frame');
|
|
const hud = document.getElementById('hud');
|
|
const miss = document.getElementById('miss');
|
|
const missDetail = document.getElementById('miss-detail');
|
|
const mini = document.getElementById('minimap');
|
|
const mctx = mini.getContext('2d');
|
|
|
|
let manifest, byId;
|
|
const state = { room: null, facing: 'N' };
|
|
|
|
// One cache-bust token per page load: re-bakes show fresh art on reload, but
|
|
// turning/stepping within a session still hits the browser cache (snappy).
|
|
const BUST = `?t=${Date.now()}`;
|
|
const frameURL = (id, dir) => `${BASE}/r${id}_${dir}.png${BUST}`;
|
|
const room = () => byId.get(state.room);
|
|
const opposite = (d) => DIRS[(DIRS.indexOf(d) + 2) % 4];
|
|
|
|
async function boot() {
|
|
try {
|
|
manifest = await (await fetch(`${BASE}/manifest.json`)).json();
|
|
} catch {
|
|
hud.innerHTML = `no atlas for seed <b>${SEED}</b> — bake one with <span class="dim">tools/bake_atlas.py --seed ${SEED}</span>`;
|
|
return;
|
|
}
|
|
byId = new Map(manifest.rooms.map((r) => [r.id, r]));
|
|
// ?room=&facing= jump straight to a room (demo/debug); else start at the entrance.
|
|
const wantRoom = Number(params.get('room'));
|
|
state.room = (params.has('room') && byId.has(wantRoom)) ? wantRoom : manifest.startRoom;
|
|
const wantFacing = (params.get('facing') || '').toUpperCase();
|
|
state.facing = DIRS.includes(wantFacing) ? wantFacing : (firstOpenFacing(state.room) || 'N');
|
|
show();
|
|
preloadAround();
|
|
}
|
|
|
|
function firstOpenFacing(id) {
|
|
const nav = byId.get(id).nav;
|
|
return DIRS.find((d) => nav[d] != null);
|
|
}
|
|
|
|
function show() {
|
|
miss.classList.remove('show');
|
|
img.src = frameURL(state.room, state.facing);
|
|
updateHud();
|
|
drawMinimap();
|
|
}
|
|
|
|
function updateHud() {
|
|
const r = room();
|
|
const exits = DIRS.filter((d) => r.nav[d] != null);
|
|
const ahead = r.nav[state.facing] != null ? `→ room ${r.nav[state.facing]}` : 'wall';
|
|
hud.innerHTML =
|
|
`seed <b>${manifest.seed}</b> · room <b>${r.id}</b>${r.theme ? ` · ${r.theme}` : ''} · ` +
|
|
`facing <b>${state.facing}</b> <span class="dim">(${ahead})</span><br>` +
|
|
`<span class="dim">exits</span> ${exits.join(' ') || '—'}`;
|
|
}
|
|
|
|
function turn(delta) {
|
|
state.facing = DIRS[(DIRS.indexOf(state.facing) + delta + 4) % 4];
|
|
show();
|
|
}
|
|
|
|
function step(forward) {
|
|
const dir = forward ? state.facing : opposite(state.facing);
|
|
const target = room().nav[dir];
|
|
if (target != null) {
|
|
state.room = target;
|
|
show();
|
|
preloadAround();
|
|
} else {
|
|
img.animate(
|
|
[{ filter: 'brightness(1)' }, { filter: 'brightness(0.55)' }, { filter: 'brightness(1)' }],
|
|
{ duration: 150 },
|
|
);
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', (e) => {
|
|
const k = e.key.toLowerCase();
|
|
if (k === 'w' || k === 'arrowup') step(true);
|
|
else if (k === 's' || k === 'arrowdown') step(false);
|
|
else if (k === 'a' || k === 'arrowleft') turn(-1);
|
|
else if (k === 'd' || k === 'arrowright') turn(1);
|
|
else return;
|
|
e.preventDefault();
|
|
});
|
|
|
|
// A frame that 404s just hasn't been baked yet — show a hint instead of a broken image.
|
|
img.addEventListener('error', () => {
|
|
if (!img.getAttribute('src')) return;
|
|
miss.classList.add('show');
|
|
missDetail.textContent = `room ${state.room}, facing ${state.facing}`;
|
|
});
|
|
|
|
// Warm the browser cache for instant turning/stepping.
|
|
function preloadAround() {
|
|
const seen = new Set();
|
|
const pre = (id) => DIRS.forEach((d) => {
|
|
const u = frameURL(id, d);
|
|
if (!seen.has(u)) { seen.add(u); new Image().src = u; }
|
|
});
|
|
pre(state.room);
|
|
const nav = room().nav;
|
|
DIRS.forEach((d) => { if (nav[d] != null) pre(nav[d]); });
|
|
}
|
|
|
|
function drawMinimap() {
|
|
const { tiles, width, height } = manifest;
|
|
const s = Math.max(2, Math.floor(170 / Math.max(width, height)));
|
|
mini.width = width * s; mini.height = height * s;
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const t = tiles[y][x];
|
|
mctx.fillStyle = t === 0 ? '#15151c' : t === 2 ? '#caa24a' : '#34343f';
|
|
mctx.fillRect(x * s, y * s, s, s);
|
|
}
|
|
}
|
|
for (const r of manifest.rooms) { // every room as a faint node
|
|
mctx.fillStyle = 'rgba(122,160,220,0.45)';
|
|
mctx.fillRect(r.vantage[0] * s, r.vantage[1] * s, s, s);
|
|
}
|
|
const [vx, vy] = room().vantage; // the player + facing arrow
|
|
const cx = vx * s + s / 2, cy = vy * s + s / 2;
|
|
mctx.fillStyle = '#e8dcc8';
|
|
mctx.beginPath(); mctx.arc(cx, cy, Math.max(2, s * 0.7), 0, Math.PI * 2); mctx.fill();
|
|
const [dx, dy] = VEC[state.facing];
|
|
mctx.strokeStyle = '#d4a54a'; mctx.lineWidth = Math.max(1.5, s * 0.5);
|
|
mctx.beginPath(); mctx.moveTo(cx, cy); mctx.lineTo(cx + dx * s * 2.4, cy + dy * s * 2.4); mctx.stroke();
|
|
}
|
|
|
|
boot();
|