The roadmap capstone: the dungeon is now populated.
- New entity layer: src/entity.rs (Entity { kind, at }, EntityKind {Entrance,
Exit, Treasure, Monster}); Map gains an `entities: Vec<Entity>` overlay field
(serde round-tripped). Entities aren't terrain, so no new Tile and the frozen
viz is untouched.
- EntityPlacer/EntityConfig: places one Entrance (random room), one Exit (the
room farthest from it — a traversal goal), per-room Treasure (treasure_chance)
and Monsters (monster_chance, 1..=max, never in the entrance room). All on
plain Floor only, never two per cell, fully deterministic.
- Handoff via the Blackboard (the designed side channel for non-tile/region/edge
data) under entity::BLACKBOARD_KEY, harvested by Pipeline into Map.entities —
so GenContext's 25 literals stay untouched.
- Renderer draws iconic markers on the final stage: green beacon (entrance),
cyan portal (exit), gold diamond (treasure), crimson eyed-blob (monster), each
with a soft glow. New entities toggle + treasure/monster sliders + readout.
Core: 135 tests green (6 new: one entrance/one exit on distinct floor, entrance
room monster-free, determinism, empty-map, recipe populated-check). clippy
clean. Pipeline is now 9 passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
461 lines
19 KiB
JavaScript
461 lines
19 KiB
JavaScript
// 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)',
|
||
};
|
||
|
||
// 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.
|
||
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;
|
||
}
|
||
|
||
// 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 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.
|
||
const coarse = hash2((x >> 2) + 11, (y >> 2) + 7);
|
||
const grain = 0.93 + 0.09 * hash2(x, y);
|
||
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
|
||
let col = scale3(stone, b * grain);
|
||
if (lit) {
|
||
const warm = g * 58 + 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).
|
||
ctx.fillStyle = rgb(scale3(PAL.wallRim, 0.6 * grain));
|
||
if (isFloor(x, y - 1)) ctx.fillRect(X, Y, cell, rim);
|
||
if (isFloor(x, y + 1)) ctx.fillRect(X, Y + cell - rim, cell, rim);
|
||
if (isFloor(x - 1, y)) ctx.fillRect(X, Y, rim, cell);
|
||
if (isFloor(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();
|
||
}
|