reikhelm/reikhelm-web/render.js
Parley Hatch e7e8bb9187 feat(core): water & lava pools (PoolDecorator) + lava lighting
Seventh pass (before pillars): floods an organic blob of Water or Lava into
some larger rooms via randomized BFS growth.

- New Tile::Water / Tile::Lava (impassable hazards). Exhaustive matches updated
  (ascii '~'/'!', frozen viz colors, wasm codes 4/5).
- PoolDecorator/PoolConfig (water_chance, lava_chance, min_room): blob is
  interior-only (>= 2 from edge, never the center), and a connectivity guard
  verifies the room's remaining floor stays 4-connected with the center intact
  before committing — so a pool never orphans part of a room.
- DungeonConfig gains `pools`; recipe inserts PoolDecorator after DoorPlacer.
- Renderer: water as cool reflective pools; lava as molten rock that ALSO
  throws warm light onto nearby floor (BFS light field, wall-blocked) — lava
  chambers visibly glow. New water/lava sliders.

Core: 125 tests green (4 new: interior placement, floor stays connected,
zero/small-room skip, determinism); connectivity holds with pools in the
default recipe. clippy clean (core + viz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:41:48 -06:00

387 lines
16 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
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);
return layout;
}
// 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();
}