reikhelm/reikhelm-web/explore.js
Parley Hatch ed00e31009 feat(render): real-geometry depth export + first-person explorable atlas
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>
2026-06-02 00:02:14 -06:00

127 lines
5.3 KiB
JavaScript

// explore.js — turn a dungeon envelope into a navigable room graph for the
// pre-baked first-person explorer.
//
// The player occupies a ROOM and a cardinal FACING. Moving forward (W) steps to
// the neighbouring room in the faced direction; A/D rotate the facing. To support
// that we need, per room: a vantage cell (where the camera stands), and which
// room lies to its N/E/S/W. Rooms connect through corridors, so we contract the
// region graph (rooms = nodes, corridors = edges to traverse through) and bin
// each room-neighbour into the cardinal of its bearing.
//
// Renderer-side, like depth.js/fpv.js: derived from regions + edges, nothing
// stored in the core. Room ids here are the region ARRAY INDEX (== the wire id;
// edges already reference regions by that index), used as the atlas frame key.
import { getStage, distanceToWall } from './render.js';
import { mostOpenCell, centroidOf } from './fpv.js';
const WALL = 0;
const DIRV = [[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']]; // y grows downward = south
// Build the room navigation graph from the ACTUAL tile openings (not centroid
// bearings, which disagree with what the views render). For each room we scan its
// boundary for real gaps, bin each gap by the cardinal it sits on, and flood the
// corridor behind it to find the room it truly leads to. nav + open then share a
// single source of truth with the rendered passages.
//
// Returns { seed, width, height, startRoom, rooms }; each room =
// { id, theme, vantage:[x,y], centroid:[x,y], bounds, nav:{N,E,S,W}, open:{N,E,S,W} }
// (nav = neighbour room id or null; open = a real gap exists that way).
export function dungeonGraph(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const dist = distanceToWall(tiles, w, h);
const isRoom = (i) => regions[i] && regions[i].kind === 'Room' && regions[i].cells && regions[i].cells.length > 0;
const inBounds = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
const open = (x, y) => inBounds(x, y) && tiles[y][x] !== WALL; // floor/door/pillar/water/lava all pass
// cell → owning room id (region index), -1 for corridors/walls.
const cellRoom = new Int32Array(w * h).fill(-1);
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
for (const [x, y] of regions[i].cells) if (inBounds(x, y)) cellRoom[y * w + x] = i;
}
// From a gap cell just outside `home`, flood through non-room corridor cells
// until we reach a different room; return its id (or null for a dead end/loop).
const destFrom = (sx, sy, home) => {
const seen = new Set([sx + ',' + sy]);
const q = [[sx, sy]];
while (q.length) {
const [x, y] = q.shift();
const rid = cellRoom[y * w + x];
if (rid !== -1 && rid !== home) return rid; // arrived at another room
for (const [dx, dy] of DIRV) {
const nx = x + dx, ny = y + dy, k = nx + ',' + ny;
if (seen.has(k) || !open(nx, ny)) continue;
if (cellRoom[ny * w + nx] === home) continue; // never wander back into home
seen.add(k); q.push([nx, ny]);
}
}
return null;
};
const rooms = [];
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
const r = regions[i];
const centroid = centroidOf(r.cells);
const van = mostOpenCell(dist, w, r.cells);
const cellSet = new Set(r.cells.map(([x, y]) => x + ',' + y));
// Collect boundary gaps per cardinal: a room cell whose neighbour that way is
// outside the room and not a wall.
const gaps = { N: [], E: [], S: [], W: [] };
for (const [x, y] of r.cells) {
for (const [dx, dy, dir] of DIRV) {
const nx = x + dx, ny = y + dy;
if (!inBounds(nx, ny) || cellSet.has(nx + ',' + ny) || tiles[ny][nx] === WALL) continue;
gaps[dir].push([nx, ny]);
}
}
const nav = { N: null, E: null, S: null, W: null };
const openDir = { N: false, E: false, S: false, W: false };
for (const dir of ['N', 'E', 'S', 'W']) {
if (gaps[dir].length === 0) continue;
openDir[dir] = true;
for (const [gx, gy] of gaps[dir]) { // first gap that reaches a room wins
const d = destFrom(gx, gy, i);
if (d != null) { nav[dir] = d; break; }
}
}
rooms.push({
id: i,
theme: r.theme || null,
vantage: van,
centroid: [Math.round(centroid[0]), Math.round(centroid[1])],
bounds: r.bounds,
nav,
open: openDir,
});
}
// Start room: the one holding the Entrance entity, else the largest.
let startRoom = rooms.length ? rooms[0].id : null;
const ent = (env.entities || []).find((e) => e.kind === 'Entrance');
if (ent) {
const hit = rooms.find((rm) => regions[rm.id].cells.some(([x, y]) => x === ent.at[0] && y === ent.at[1]));
if (hit) startRoom = hit.id;
} else {
let bestArea = 0;
for (const rm of rooms) {
const a = regions[rm.id].cells.length;
if (a > bestArea) { bestArea = a; startRoom = rm.id; }
}
}
return { seed: env.seed, width: w, height: h, startRoom, rooms };
}
// Full manifest = the nav graph + the tile grid (for the viewer's minimap).
export function dungeonManifest(env, opts = {}) {
const g = dungeonGraph(env, opts);
const { tiles } = getStage(env, opts.stage);
return { ...g, tiles };
}