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