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>
127 lines
5.3 KiB
JavaScript
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 };
|
|
}
|