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>
157 lines
7.3 KiB
JavaScript
157 lines
7.3 KiB
JavaScript
// fpv.js — first-person (Eye-of-the-Beholder / Daggerfall style) depth exporter.
|
||
//
|
||
// A renderer concern, like depth.js: it reads the same envelope and produces an
|
||
// 8-bit depth map for the AI-render pipeline — but viewed from *inside* the
|
||
// dungeon rather than top-down. A grid dungeon is a 2D map extruded: walls are
|
||
// full-height blocks, floor and ceiling are flat planes, the eye sits at mid
|
||
// height, and you face one of four cardinal directions. So we don't need any 3D
|
||
// data from the core — a classic Wolfenstein-style raycaster turns the grid into
|
||
// a perspective depth map directly.
|
||
//
|
||
// Per screen column we cast one ray, march the grid (DDA) to the first blocking
|
||
// cell, and fill the column: a wall slice at that distance, floor ramping toward
|
||
// the camera below it, ceiling above. Distance → gray with NEAR=white/FAR=black
|
||
// (the same convention comfy.py expects). Doorways are non-blocking, so they
|
||
// read as dark recesses leading deeper — which is exactly what we want.
|
||
|
||
import { getStage, distanceToWall } from './render.js';
|
||
import { depthToCanvas } from './depth.js';
|
||
|
||
const WALL = 0, PILLAR = 3;
|
||
|
||
// Cardinal facings as (dx, dy) on the grid (y grows downward / "south").
|
||
export const DIRS = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
|
||
|
||
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
|
||
const blocks = (t) => t === WALL || t === PILLAR; // line-of-sight blockers
|
||
|
||
// Centroid of a cell list as [cx, cy].
|
||
export function centroidOf(cells) {
|
||
let cx = 0, cy = 0;
|
||
for (const [x, y] of cells) { cx += x; cy += y; }
|
||
return [cx / cells.length, cy / cells.length];
|
||
}
|
||
|
||
// The *most open* cell (max distance-to-wall) among `cells`, so a camera stands
|
||
// in open space rather than against a wall (a room centroid can land near a wall
|
||
// when the room is non-convex). Ties break toward the cell nearest the centroid,
|
||
// keeping it visually centred. `dist` is a precomputed distanceToWall field.
|
||
export function mostOpenCell(dist, w, cells) {
|
||
const [cx, cy] = centroidOf(cells);
|
||
let pick = cells[0], pmax = -1, pcen = Infinity;
|
||
for (const [x, y] of cells) {
|
||
const d = dist[y * w + x];
|
||
const cen = (x - cx) ** 2 + (y - cy) ** 2;
|
||
if (d > pmax || (d === pmax && cen < pcen)) { pmax = d; pcen = cen; pick = [x, y]; }
|
||
}
|
||
return pick;
|
||
}
|
||
|
||
// Vantage cell for one room region (its most-open cell).
|
||
export function roomVantage(env, region, opts = {}) {
|
||
const { tiles } = getStage(env, opts.stage);
|
||
return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells);
|
||
}
|
||
|
||
// Default demo vantage: the most-open cell of the largest room (or, failing that,
|
||
// the most open floor cell anywhere).
|
||
export function vantage(env, opts = {}) {
|
||
const { tiles, regions } = getStage(env, opts.stage);
|
||
const w = env.width, h = env.height;
|
||
const dist = distanceToWall(tiles, w, h);
|
||
let best = null, bestArea = 0;
|
||
for (const r of regions) {
|
||
if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue;
|
||
if (r.cells.length > bestArea) { bestArea = r.cells.length; best = r; }
|
||
}
|
||
if (best) return mostOpenCell(dist, w, best.cells);
|
||
const all = [];
|
||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!blocks(tiles[y][x])) all.push([x, y]);
|
||
return all.length ? mostOpenCell(dist, w, all) : [w >> 1, h >> 1];
|
||
}
|
||
|
||
// Build a first-person depth field. Returns { width, height, gray, at, dir }.
|
||
// opts: { stage?, at?[x,y], dir?'N'|'E'|'S'|'W', width?, height?, fov?deg, maxView?, gamma? }
|
||
export function computeFPVDepth(env, opts = {}) {
|
||
const { tiles } = getStage(env, opts.stage);
|
||
const w = env.width, h = env.height;
|
||
const W = opts.width ?? 1280, H = opts.height ?? 720; // 16:9 — integer-scales to 1080p/1440p
|
||
const fov = (opts.fov ?? 80) * Math.PI / 180; // wider horizontal FOV to fill the widescreen frame
|
||
const planeLen = Math.tan(fov / 2);
|
||
const maxView = opts.maxView ?? 12; // how far the eye sees (cells) before it's pure dark
|
||
const gamma = opts.gamma ?? 2.0; // >1 deepens the falloff to black (matches the proven corridor look)
|
||
|
||
const at = opts.at ?? vantage(env, opts);
|
||
const dirName = opts.dir ?? 'N';
|
||
const [dirX, dirY] = DIRS[dirName];
|
||
const posX = at[0] + 0.5, posY = at[1] + 0.5;
|
||
// Camera plane ⟂ to the view direction (rotate dir 90°), scaled to the FOV.
|
||
const planeX = -dirY * planeLen, planeY = dirX * planeLen;
|
||
|
||
const gray = new Uint8ClampedArray(W * H);
|
||
const half = H / 2;
|
||
const centerX = W >> 1;
|
||
let ahead = maxView; // straight-ahead wall distance (cells) — big = an opening leads onward
|
||
|
||
for (let x = 0; x < W; x++) {
|
||
const camX = 2 * x / W - 1; // −1 … 1 across the screen
|
||
const rayX = dirX + planeX * camX;
|
||
const rayY = dirY + planeY * camX;
|
||
|
||
// DDA grid march.
|
||
let mapX = posX | 0, mapY = posY | 0;
|
||
const deltaX = Math.abs(1 / rayX), deltaY = Math.abs(1 / rayY); // Infinity when axis-aligned — fine
|
||
let stepX, stepY, sideX, sideY;
|
||
if (rayX < 0) { stepX = -1; sideX = (posX - mapX) * deltaX; } else { stepX = 1; sideX = (mapX + 1 - posX) * deltaX; }
|
||
if (rayY < 0) { stepY = -1; sideY = (posY - mapY) * deltaY; } else { stepY = 1; sideY = (mapY + 1 - posY) * deltaY; }
|
||
|
||
let hit = false, side = 0;
|
||
const maxSteps = maxView * 2 + 4;
|
||
for (let s = 0; s < maxSteps; s++) {
|
||
if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
|
||
else { sideY += deltaY; mapY += stepY; side = 1; }
|
||
if (mapX < 0 || mapY < 0 || mapX >= w || mapY >= h) break; // off-map → open/far
|
||
if (blocks(tiles[mapY][mapX])) { hit = true; break; }
|
||
}
|
||
const perp = side === 0 ? sideX - deltaX : sideY - deltaY;
|
||
const dist = hit ? clamp(perp, 0.02, maxView) : maxView;
|
||
if (x === centerX) ahead = dist;
|
||
|
||
// Project the 1-cell-tall wall: lineH = H/dist (fills the screen at dist 1).
|
||
const lineH = H / dist;
|
||
const ds = clamp(Math.floor(half - lineH / 2), 0, H);
|
||
const de = clamp(Math.floor(half + lineH / 2), 0, H);
|
||
|
||
for (let y = 0; y < H; y++) {
|
||
let dpix;
|
||
if (y >= ds && y < de) {
|
||
dpix = dist; // wall slice
|
||
} else {
|
||
// Floor (below) / ceiling (above). (0.5·H)/p is continuous with the
|
||
// wall distance exactly at the slice edge, so the column has no seam.
|
||
const p = y < half ? (half - y) : (y - half + 1);
|
||
dpix = Math.min((0.5 * H) / p, maxView);
|
||
}
|
||
const t = clamp(dpix / maxView, 0, 1);
|
||
gray[y * W + x] = Math.round(255 * Math.pow(1 - t, gamma));
|
||
}
|
||
}
|
||
|
||
return { width: W, height: H, gray, at, dir: dirName, ahead };
|
||
}
|
||
|
||
// --- export glue (mirrors depth.js) --------------------------------------
|
||
|
||
export function exportFPVDataURL(env, opts = {}) {
|
||
const field = computeFPVDepth(env, opts);
|
||
return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
|
||
}
|
||
|
||
export async function exportFPVToServer(env, name, opts = {}) {
|
||
const field = computeFPVDepth(env, opts);
|
||
const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png'));
|
||
const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob });
|
||
if (!resp.ok) throw new Error('save failed: ' + resp.status);
|
||
const info = await resp.json();
|
||
return { ...info, width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
|
||
}
|