reikhelm/reikhelm-web/render.js
Parley Hatch b1449248f8 feat(core): pillars — PillarPlacer decorator + Tile::Pillar
A finishing decorator that scatters free-standing pillar columns into larger
rooms on a spaced interior grid. Sixth pass in the dungeon recipe.

- New Tile::Pillar (impassable like Wall, drawn distinctly). Exhaustive matches
  updated: ascii ('o'), frozen macroquad viz (still compiles), wasm code 3.
- PillarPlacer/PillarConfig (room_chance, min_room, spacing): places isolated
  single-cell pillars only on interior room floor (>= 2 from the edge, never the
  center), on a grid with spacing >= 2 — so the floor stays 4-connected by
  construction and a room is never orphaned. Respects non-rect room shapes
  (pillars land only on actual carved floor).
- DungeonConfig gains `pillars`; recipe appends PillarPlacer after DoorPlacer.
- Renderer draws pillars as lit stone columns with a contact shadow; new
  "pillars" density slider.

Core: 121 tests green (5 new: interior-only placement, floor stays connected,
zero-chance/small-room skip, determinism). clippy clean (core + viz).

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

333 lines
14 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;
// 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
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
};
// --- 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;
}
// --- 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 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) Floor + doors, lit.
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;
let b;
if (lit) {
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
const g = glow[i];
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g) * aoMul);
} else {
b = 0.85;
}
// Calm stone: low-frequency patches mottle warm↔cool (sampled per ~4×4
// block, not per cell, so it reads as stone rather than static), with a
// faint per-cell grain on top. Torchlight (room glow) warms the lit core.
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 = glow[i] * 58; // torchlight pools warm in room cores
col = [col[0] + warm, col[1] + warm * 0.56, col[2] + warm * 0.12];
}
ctx.fillStyle = rgb(col);
ctx.fillRect(px(x), py(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, py(y), 0, py(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(px(x), py(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();
}