// 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. 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(); }