// 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 }; }