reikhelm/reikhelm-web/render.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

506 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// render.js — atmospheric Canvas 2D renderer for reikhelm map envelopes.
//
// This module is a *renderer*: it reads the JSON envelope the WASM bridge
// produces (tiles as integer codes, regions, edges) and paints it. It holds NO
// generation logic and knows nothing about how a dungeon is built — exactly the
// same contract the Rust renderers honor. All art (color, light, depth) lives
// here; the core stores none of it.
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
const PAL = {
bg: '#07070a',
stoneWarm: [158, 144, 118],
stoneCool: [118, 122, 136],
wallBody: [40, 37, 47], // visible dark slate (distinct from the void bg)
wallTop: [92, 84, 100], // lit top edge of a raised wall block
wallFace: [58, 53, 64], // lit vertical face at the base of a wall
wallRim: [120, 110, 128], // rim where stone meets carved floor
doorWarm: [212, 165, 74],
doorDark: [120, 84, 32],
pillarTop: [122, 112, 100], // lit top-left of a stone column
pillarBody: [60, 54, 54], // shadowed lower-right
waterDeep: [30, 70, 104], // deep pool
waterShallow: [66, 120, 152], // sheen
lavaCrust: [120, 32, 10], // cooled crust
lavaHot: [255, 152, 48], // molten core
entrance: [96, 214, 124], // green beacon
exit: [96, 200, 232], // cyan portal
treasure: [242, 202, 92], // gold
monster: [226, 84, 72], // crimson
outlineRoom: 'rgba(122, 184, 240, 0.85)',
outlineCorr: 'rgba(150, 150, 170, 0.40)',
edge: 'rgba(232, 96, 84, 0.85)',
grid: 'rgba(255,255,255,0.045)',
};
// Room themes — renderer-owned palette keyed by the wire theme id. Each theme
// retints a room's floor (`floor`) and wall rim (`accent`) and biases its torch
// warmth (`warm`, where 0.5 reproduces the untinted baseline). All floor values
// sit in the ~90..170/channel band so the lighting model reads unchanged —
// themes shift hue/temperature, never brightness. `glyph`/`label` feed the page
// legend. Tuned by eye against the warm-stone base.
export const THEMES = {
forge: { floor: [150, 98, 70], accent: [228, 118, 50], warm: 1.0, glyph: '🔥', label: 'Forge' },
cistern: { floor: [98, 116, 134], accent: [84, 148, 178], warm: 0.12, glyph: '💧', label: 'Cistern' },
hall: { floor: [144, 136, 118], accent: [190, 162, 104], warm: 0.6, glyph: '🏛', label: 'Hall' },
throne: { floor: [120, 100, 140], accent: [202, 142, 200], warm: 0.5, glyph: '👑', label: 'Throne' },
threshold: { floor: [126, 138, 128], accent: [116, 192, 148], warm: 0.42, glyph: '🚪', label: 'Threshold' },
vault: { floor: [142, 132, 102], accent: [216, 184, 92], warm: 0.6, glyph: '💰', label: 'Vault' },
library: { floor: [138, 120, 94], accent: [120, 90, 60], warm: 0.68, glyph: '📜', label: 'Library' },
den: { floor: [120, 96, 90], accent: [186, 92, 80], warm: 0.4, glyph: '⚔', label: 'Den' },
crypt: { floor: [106, 112, 130], accent: [126, 138, 168], warm: 0.08, glyph: '💀', label: 'Crypt' },
stone: { floor: [138, 133, 127], accent: [120, 110, 128], warm: 0.5, glyph: '⛏', label: 'Stone' },
};
// Lighting tunables (tweaked by eye via screenshots).
const LIGHT = {
ambient: 0.44, // floor brightness with no room glow
corridorFloor: 0.36, // ambient for cells in no room (corridors)
glow: 0.78, // peak extra brightness at a room's lit core
aoFloor: 0.64, // brightness multiplier right next to a wall
aoDepth: 2.6, // cells from wall at which AO is fully open
maxB: 1.2, // clamp so highlights don't blow out
vignette: 0.30, // strength of the screen-edge darkening
lavaGlow: 0.75, // brightness lava adds to nearby floor
lavaRadius: 6, // how far (cells) lava light reaches
};
// --- small helpers -------------------------------------------------------
function lerp(a, b, t) { return a + (b - a) * t; }
function lerp3(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
function scale3(c, s) { return [c[0] * s, c[1] * s, c[2] * s]; }
function rgb(c) { return `rgb(${c[0] | 0},${c[1] | 0},${c[2] | 0})`; }
// Deterministic per-cell value noise in [0,1) — stable floor texture.
function hash2(x, y) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)) >>> 0;
h = (Math.imul(h ^ (h >>> 13), 1274126177)) >>> 0;
return (h >>> 0) % 1000 / 1000;
}
// Resolve the stage to draw: snapshots[stage], defaulting to the final stage.
export function getStage(env, stage) {
const snaps = env.snapshots || [];
if (snaps.length === 0) {
return { tiles: env.tiles, regions: env.regions, edges: env.edges, label: 'final' };
}
const i = Math.max(0, Math.min(snaps.length - 1, stage ?? snaps.length - 1));
return snaps[i];
}
// --- precomputed fields --------------------------------------------------
// Multi-source BFS distance (in cells) from every floor cell to the nearest
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion
// here, and crevice AO in the depth-map exporter (depth.js) — hence exported.
export function distanceToWall(tiles, w, h) {
const dist = new Float32Array(w * h).fill(Infinity);
const q = new Int32Array(w * h);
let tail = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] === WALL) { dist[y * w + x] = 0; q[tail++] = y * w + x; }
}
}
let head = 0;
while (head < tail) {
const idx = q[head++];
const d = dist[idx];
const x = idx % w, y = (idx / w) | 0;
if (x > 0) { const n = idx - 1; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (x < w - 1) { const n = idx + 1; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (y > 0) { const n = idx - w; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (y < h - 1) { const n = idx + w; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
}
return dist;
}
// Per-cell room glow in [0,1]: bright at each room's core, fading to its edge.
// Applied per-region over that region's own cells, so light never bleeds
// through a wall into the next room — cheap, and it makes rooms pool light.
function roomGlow(regions, w, h) {
const glow = new Float32Array(w * h).fill(0);
for (const r of regions) {
if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue;
let cx = 0, cy = 0;
for (const [x, y] of r.cells) { cx += x; cy += y; }
cx /= r.cells.length; cy /= r.cells.length;
const radius = Math.max(2.2, Math.sqrt(r.cells.length) * 0.78);
for (const [x, y] of r.cells) {
const dx = x - cx, dy = y - cy;
const dd = Math.sqrt(dx * dx + dy * dy);
const g = Math.max(0, 1 - dd / (radius * 1.7));
const i = y * w + x;
if (g > glow[i]) glow[i] = g;
}
}
return glow;
}
// Per-cell theme lookup: map every cell of a themed Room region to its theme
// object (from THEMES), null elsewhere (corridors, unthemed rooms, pre-themer
// snapshot stages). Rooms don't overlap, so the assignment is unambiguous.
function themeField(regions, w, h) {
const field = new Array(w * h).fill(null);
for (const r of regions) {
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
const th = THEMES[r.theme];
if (!th) continue;
for (const [x, y] of r.cells) {
if (x >= 0 && y >= 0 && x < w && y < h) field[y * w + x] = th;
}
}
return field;
}
// Multi-source BFS distance (in cells) from lava to each open cell — lava light
// pools out through open space and is blocked by walls. Returns null if there's
// no lava (so the caller can skip the warm-light contribution entirely).
function lavaLightField(tiles, w, h) {
const dist = new Float32Array(w * h).fill(Infinity);
const q = new Int32Array(w * h);
let tail = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] === LAVA) { dist[y * w + x] = 0; q[tail++] = y * w + x; }
}
}
if (tail === 0) return null;
let head = 0;
while (head < tail) {
const idx = q[head++];
const d = dist[idx];
if (d >= LIGHT.lavaRadius) continue; // cap how far the glow travels
const x = idx % w, y = (idx / w) | 0;
const step = (nx, ny) => {
if (nx < 0 || ny < 0 || nx >= w || ny >= h || tiles[ny][nx] === WALL) return;
const n = ny * w + nx;
if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; }
};
step(x + 1, y); step(x - 1, y); step(x, y + 1); step(x, y - 1);
}
return dist;
}
// --- main entry ----------------------------------------------------------
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
export function renderMap(ctx, vw, vh, env, opts = {}) {
const { tiles, regions, edges } = getStage(env, opts.stage);
const w = env.width, h = env.height;
ctx.fillStyle = PAL.bg;
ctx.fillRect(0, 0, vw, vh);
if (!tiles || w === 0 || h === 0) return null;
// Layout: integer cell size, centered (letterboxed).
const pad = 18;
const cell = Math.max(2, Math.floor(Math.min((vw - pad * 2) / w, (vh - pad * 2) / h)));
const gw = cell * w, gh = cell * h;
const ox = Math.floor((vw - gw) / 2);
const oy = Math.floor((vh - gh) / 2);
const layout = { cell, ox, oy, w, h };
const lit = opts.lighting !== false;
const dist = lit ? distanceToWall(tiles, w, h) : null;
const glow = lit ? roomGlow(regions, w, h) : null;
const lavaField = lit ? lavaLightField(tiles, w, h) : null;
const tfield = opts.themes !== false ? themeField(regions, w, h) : null;
const px = (x) => ox + x * cell;
const py = (y) => oy + y * cell;
const isFloor = (x, y) => x >= 0 && y >= 0 && x < w && y < h && tiles[y][x] !== WALL;
const isWall = (x, y) => x < 0 || y < 0 || x >= w || y >= h || tiles[y][x] === WALL;
// 1) Terrain: lit stone for floor/door/pillar-underlay, plus water and lava
// pools. Lava is self-lit and also throws warm light onto nearby floor.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const t = tiles[y][x];
if (t === WALL) continue;
const i = y * w + x;
const X = px(x), Y = py(y);
// Lava: molten rock, drawn bright regardless of ambient light.
if (t === LAVA) {
const f = hash2(x * 7 + 2, y * 5 + 9);
ctx.fillStyle = rgb(lerp3(PAL.lavaHot, PAL.lavaCrust, 0.2 + 0.6 * f));
ctx.fillRect(X, Y, cell, cell);
continue;
}
let b, g = 0, lavaB = 0;
if (lit) {
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
g = glow[i];
if (lavaField) lavaB = Math.max(0, 1 - lavaField[i] / LIGHT.lavaRadius);
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g + LIGHT.lavaGlow * lavaB) * aoMul);
} else {
b = 0.85;
}
// Water: a cool, reflective pool — darker than stone with a faint sheen.
if (t === WATER) {
const f = hash2(x * 5 + 1, y * 9 + 4);
const base = lerp3(PAL.waterDeep, PAL.waterShallow, 0.35 + 0.4 * f);
ctx.fillStyle = rgb(scale3(base, 0.7 + 0.5 * b));
ctx.fillRect(X, Y, cell, cell);
continue;
}
// Floor / door / pillar underlay: lit stone, warmed by torch + lava light.
// A themed room substitutes its own floor hue (and scales the torch warmth
// by its `warm`); unthemed cells keep the cool↔warm stone lerp.
const th = tfield ? tfield[i] : null;
const coarse = hash2((x >> 2) + 11, (y >> 2) + 7);
const grain = 0.93 + 0.09 * hash2(x, y);
const stone = th ? th.floor : lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
let col = scale3(stone, b * grain);
if (lit) {
const warmScale = th ? 0.5 + th.warm : 1; // warm=0.5 ⇒ unchanged baseline
const warm = g * 58 * warmScale + lavaB * 95; // torch + lava both pool warm
col = [col[0] + warm, col[1] + warm * 0.5, col[2] + warm * 0.1];
}
ctx.fillStyle = rgb(col);
ctx.fillRect(X, Y, cell, cell);
// Shadow cast by a wall to the north, falling onto this floor cell.
if (lit && isWall(x, y - 1)) {
const grd = ctx.createLinearGradient(0, Y, 0, Y + cell * 0.7);
grd.addColorStop(0, 'rgba(0,0,0,0.45)');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(X, Y, cell, Math.ceil(cell * 0.7));
}
}
}
// 2) Walls that border open space, drawn as raised stone blocks for depth.
const rim = Math.max(1, Math.round(cell * 0.12));
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== WALL) continue;
// Only paint walls adjacent to floor (interior rock stays background).
let visible = false;
for (let dy = -1; dy <= 1 && !visible; dy++)
for (let dx = -1; dx <= 1; dx++)
if ((dx || dy) && isFloor(x + dx, y + dy)) { visible = true; break; }
if (!visible) continue;
const X = px(x), Y = py(y);
const grain = 0.82 + 0.34 * hash2(x * 5 + 3, y * 9 + 1);
if (isFloor(x, y + 1)) {
// Floor to the south: this is a room's north wall, presenting a face to
// the lit room. Light the top (the block's top surface), shadow the base.
const grd = ctx.createLinearGradient(0, Y, 0, Y + cell);
grd.addColorStop(0, rgb(scale3(PAL.wallTop, grain)));
grd.addColorStop(0.45, rgb(scale3(PAL.wallBody, grain)));
grd.addColorStop(1, rgb(scale3(PAL.wallBody, grain * 0.78)));
ctx.fillStyle = grd;
} else {
ctx.fillStyle = rgb(scale3(PAL.wallBody, grain));
}
ctx.fillRect(X, Y, cell, cell);
// Rim-light every edge where this stone meets carved floor, so the room
// boundary reads crisply on all sides (catches the floor's glow). Each rim
// segment takes the accent of the themed room it borders (else plain rim).
const rimColor = (nx, ny) => {
const th = tfield ? tfield[ny * w + nx] : null;
return rgb(scale3(th ? th.accent : PAL.wallRim, 0.6 * grain));
};
if (isFloor(x, y - 1)) { ctx.fillStyle = rimColor(x, y - 1); ctx.fillRect(X, Y, cell, rim); }
if (isFloor(x, y + 1)) { ctx.fillStyle = rimColor(x, y + 1); ctx.fillRect(X, Y + cell - rim, cell, rim); }
if (isFloor(x - 1, y)) { ctx.fillStyle = rimColor(x - 1, y); ctx.fillRect(X, Y, rim, cell); }
if (isFloor(x + 1, y)) { ctx.fillStyle = rimColor(x + 1, y); ctx.fillRect(X + cell - rim, Y, rim, cell); }
}
}
// 3) Doors — amber threshold slabs with jamb posts; archways stay plain floor.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== DOOR) continue;
const horizontal = isWall(x, y - 1) && isWall(x, y + 1); // jambs N/S → door faces E/W
const cx = px(x), cy = py(y);
const grd = ctx.createLinearGradient(cx, cy, cx + cell, cy + cell);
grd.addColorStop(0, rgb(PAL.doorWarm));
grd.addColorStop(1, rgb(PAL.doorDark));
ctx.fillStyle = grd;
const inset = Math.max(1, cell * 0.12);
ctx.fillRect(cx + inset, cy + inset, cell - inset * 2, cell - inset * 2);
// Jamb posts on the wall sides.
ctx.fillStyle = rgb(scale3(PAL.doorWarm, 0.5));
const t = Math.max(1, cell * 0.16);
if (horizontal) {
ctx.fillRect(cx, cy, cell, t);
ctx.fillRect(cx, cy + cell - t, cell, t);
} else {
ctx.fillRect(cx, cy, t, cell);
ctx.fillRect(cx + cell - t, cy, t, cell);
}
}
}
// 3.5) Pillars — free-standing stone columns standing on the lit floor (the
// floor under them was already painted in pass 1).
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== PILLAR) continue;
const cxp = px(x) + cell / 2, cyp = py(y) + cell / 2;
const r = cell * 0.36;
// Contact shadow pooled at the base.
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath();
ctx.ellipse(cxp, cyp + cell * 0.18, r * 1.05, r * 0.62, 0, 0, Math.PI * 2);
ctx.fill();
// Column body, lit from the top-left.
const grd = ctx.createRadialGradient(cxp - r * 0.4, cyp - r * 0.45, r * 0.1, cxp, cyp, r * 1.1);
grd.addColorStop(0, rgb(PAL.pillarTop));
grd.addColorStop(1, rgb(PAL.pillarBody));
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cxp, cyp, r, 0, Math.PI * 2);
ctx.fill();
}
}
// 4) Vignette over the whole grid for mood.
if (lit && LIGHT.vignette > 0) {
const cxp = ox + gw / 2, cyp = oy + gh / 2;
const grd = ctx.createRadialGradient(cxp, cyp, Math.min(gw, gh) * 0.30, cxp, cyp, Math.max(gw, gh) * 0.72);
grd.addColorStop(0, 'rgba(0,0,0,0)');
grd.addColorStop(1, `rgba(0,0,0,${LIGHT.vignette})`);
ctx.fillStyle = grd;
ctx.fillRect(ox, oy, gw, gh);
}
// 5) Optional semantic overlays.
if (opts.outlines) drawOutlines(ctx, regions, layout);
if (opts.graph) drawGraph(ctx, regions, edges, layout);
if (opts.grid) drawGrid(ctx, layout);
// 6) Entities (entrance/exit/treasure/monsters) — only exist on the final
// stage, drawn on top of everything as the dungeon's inhabitants.
const lastStage = (env.snapshots?.length ?? 1) - 1;
const isFinal = (opts.stage ?? lastStage) >= lastStage;
if (isFinal && opts.entities !== false && env.entities) {
drawEntities(ctx, env.entities, layout);
}
return layout;
}
// Draw each entity as an iconic marker centered in its cell, with a soft glow so
// it reads over the lit floor.
function drawEntities(ctx, entities, { cell, ox, oy }) {
const r = Math.max(2.5, cell * 0.3);
const center = (e) => [ox + e.at[0] * cell + cell / 2, oy + e.at[1] * cell + cell / 2];
const glow = (cx, cy, col, rad) => {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
g.addColorStop(0, `rgba(${col[0]},${col[1]},${col[2]},0.55)`);
g.addColorStop(1, `rgba(${col[0]},${col[1]},${col[2]},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
ctx.fill();
};
for (const e of entities) {
const [cx, cy] = center(e);
switch (e.kind) {
case 'Entrance': {
glow(cx, cy, PAL.entrance, r * 2.4);
ctx.fillStyle = rgb(PAL.entrance);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath(); ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2); ctx.fill();
break;
}
case 'Exit': {
glow(cx, cy, PAL.exit, r * 2.6);
ctx.strokeStyle = rgb(PAL.exit);
ctx.lineWidth = Math.max(1.5, r * 0.34);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r * 0.45, 0, Math.PI * 2); ctx.stroke();
break;
}
case 'Treasure': {
glow(cx, cy, PAL.treasure, r * 2.0);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.PI / 4); // diamond
ctx.fillStyle = rgb(PAL.treasure);
const s = r * 1.25;
ctx.fillRect(-s / 2, -s / 2, s, s);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillRect(-s / 2, -s / 2, s * 0.32, s * 0.32);
ctx.restore();
break;
}
case 'Monster': {
glow(cx, cy, PAL.monster, r * 1.9);
ctx.fillStyle = rgb(PAL.monster);
ctx.beginPath(); ctx.arc(cx, cy, r * 0.92, 0, Math.PI * 2); ctx.fill();
// two dark "eyes"
ctx.fillStyle = 'rgba(20,8,8,0.92)';
const eye = Math.max(0.7, r * 0.2);
ctx.beginPath(); ctx.arc(cx - r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
break;
}
}
}
}
// Trace each region's true cell-set boundary (shows polygonal room shapes).
function drawOutlines(ctx, regions, { cell, ox, oy }) {
for (const r of regions) {
if (!r.cells || r.cells.length === 0) continue;
const inRegion = new Set(r.cells.map(([x, y]) => x + ',' + y));
ctx.strokeStyle = r.kind === 'Room' ? PAL.outlineRoom : PAL.outlineCorr;
ctx.lineWidth = Math.max(1, cell * 0.1);
ctx.beginPath();
for (const [x, y] of r.cells) {
const X = ox + x * cell, Y = oy + y * cell;
if (!inRegion.has(x + ',' + (y - 1))) { ctx.moveTo(X, Y); ctx.lineTo(X + cell, Y); }
if (!inRegion.has(x + ',' + (y + 1))) { ctx.moveTo(X, Y + cell); ctx.lineTo(X + cell, Y + cell); }
if (!inRegion.has((x - 1) + ',' + y)) { ctx.moveTo(X, Y); ctx.lineTo(X, Y + cell); }
if (!inRegion.has((x + 1) + ',' + y)) { ctx.moveTo(X + cell, Y); ctx.lineTo(X + cell, Y + cell); }
}
ctx.stroke();
}
}
// Draw the connectivity graph: a line between each edge's region centers.
function drawGraph(ctx, regions, edges, { cell, ox, oy }) {
if (!edges) return;
const center = (r) => [ox + (r.bounds.x + r.bounds.w / 2) * cell, oy + (r.bounds.y + r.bounds.h / 2) * cell];
ctx.strokeStyle = PAL.edge;
ctx.lineWidth = Math.max(1.5, cell * 0.14);
ctx.fillStyle = PAL.edge;
for (const e of edges) {
const ra = regions[e.a], rb = regions[e.b];
if (!ra || !rb) continue;
const [ax, ay] = center(ra), [bx, by] = center(rb);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
for (const r of regions) {
const [x, y] = center(r);
ctx.beginPath(); ctx.arc(x, y, Math.max(2, cell * 0.18), 0, Math.PI * 2); ctx.fill();
}
}
function drawGrid(ctx, { cell, ox, oy, w, h }) {
ctx.strokeStyle = PAL.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= w; x++) { ctx.moveTo(ox + x * cell, oy); ctx.lineTo(ox + x * cell, oy + h * cell); }
for (let y = 0; y <= h; y++) { ctx.moveTo(ox, oy + y * cell); ctx.lineTo(ox + w * cell, oy + y * cell); }
ctx.stroke();
}