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>
This commit is contained in:
Parley Hatch 2026-05-31 00:41:48 -06:00
parent 128dce6310
commit e7e8bb9187
9 changed files with 410 additions and 18 deletions

View file

@ -25,6 +25,8 @@ const fn glyph(tile: Tile) -> char {
Tile::Floor => '.',
Tile::Door => '+',
Tile::Pillar => 'o',
Tile::Water => '~',
Tile::Lava => '!',
}
}

View file

@ -33,6 +33,11 @@ pub enum Tile {
/// decorator. Not walkable (treated like [`Tile::Wall`] for movement), but
/// semantically distinct so renderers can draw it as a column on lit floor.
Pillar,
/// A pool of water: an impassable hazard you route around. Not walkable.
Water,
/// A pool of lava: an impassable hazard. Not walkable; renderers may treat it
/// as a warm light source.
Lava,
}
/// The generator's output: what occupies each cell, the semantic regions, and

View file

@ -38,5 +38,8 @@ pub use corridor::CorridorCarver;
pub mod door;
pub use door::{DoorConfig, DoorPlacer};
pub mod pool;
pub use pool::{PoolConfig, PoolDecorator};
pub mod pillar;
pub use pillar::{PillarConfig, PillarPlacer};

View file

@ -0,0 +1,312 @@
//! The [`PoolDecorator`] pass: water and lava pools.
//!
//! A *decorator* finishing pass that floods an organic blob of
//! [`Water`](crate::map::Tile::Water) or [`Lava`](crate::map::Tile::Lava) into
//! the interior of some larger rooms — a hazard you route around, and (for lava)
//! a light source a renderer can play with.
//!
//! ## Connectivity is guarded, not assumed
//!
//! Water and lava are **not walkable**, so a careless pool could wall a room in
//! half or cover the cell a corridor connects to. This pass is conservative:
//!
//! - A pool is a single contiguous blob grown only over **interior** room floor
//! — at least two cells from the room's bounding edge (so the perimeter ring a
//! corridor pierces stays floor) and never on the room center (the corridor's
//! target).
//! - After growing a candidate blob, the pass **verifies** that the room's
//! remaining floor is still 4-connected and still contains the center; only
//! then does it commit. Otherwise the pool is dropped for that room.
//!
//! Because every room's center stays floor and connected to its perimeter, and
//! corridors are unchanged, the whole dungeon stays connected — a property the
//! recipe's connectivity test exercises with pools enabled.
//!
//! Pools land only on cells that are currently [`Floor`](crate::map::Tile::Floor),
//! so they respect non-rectangular room shapes and never overwrite a door,
//! corridor, or pillar.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// Configuration for a [`PoolDecorator`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PoolConfig {
/// Probability that a qualifying room is flooded with a **water** pool.
pub water_chance: f64,
/// Probability that a qualifying room (that did not get water) is flooded
/// with a **lava** pool. Evaluated after the water roll, so a room holds at
/// most one pool.
pub lava_chance: f64,
/// Minimum room extent (smaller of width/height) for a room to qualify.
pub min_room: i32,
}
impl Default for PoolConfig {
/// A sensible default: water is an occasional feature of larger rooms, lava
/// rarer still.
fn default() -> Self {
PoolConfig {
water_chance: 0.20,
lava_chance: 0.10,
min_room: 7,
}
}
}
/// A water/lava pool decorator pass.
///
/// Construct one with [`PoolDecorator::new`]; it implements [`Pass`] with the
/// stable name `"pool_decorator"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PoolDecorator {
cfg: PoolConfig,
}
impl PoolDecorator {
/// Creates a pool decorator with the given configuration.
pub fn new(cfg: PoolConfig) -> Self {
PoolDecorator { cfg }
}
}
impl Pass for PoolDecorator {
fn name(&self) -> &str {
"pool_decorator"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
let rooms: Vec<(crate::geometry::Rect, Vec<Point>)> = ctx
.regions
.iter()
.filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|r| (r.bounds, r.cells.clone()))
.collect();
for (b, cells) in rooms {
if b.w.min(b.h) < self.cfg.min_room {
continue;
}
// One roll per qualifying room selects water, lava, or nothing.
let roll = rng.chance(self.cfg.water_chance);
let tile = if roll {
Tile::Water
} else if rng.chance(self.cfg.lava_chance) {
Tile::Lava
} else {
continue;
};
let center = b.center();
// Candidate interior floor: >=2 from the bounds edge, not the center,
// currently Floor (so we respect the room's shape and skip pillars).
let interior: Vec<Point> = cells
.iter()
.copied()
.filter(|&p| {
let (lx, ly) = (p.x - b.x, p.y - b.y);
lx >= 2
&& ly >= 2
&& lx <= b.w - 3
&& ly <= b.h - 3
&& p != center
&& ctx.tiles.get(p) == Some(&Tile::Floor)
})
.collect();
if interior.is_empty() {
continue;
}
let interior_set: BTreeSet<(i32, i32)> = interior.iter().map(|p| (p.x, p.y)).collect();
// Grow an organic blob from a random interior seed via randomized BFS.
let area = (b.w * b.h) as usize;
let target = (area / 6).clamp(3, 26) + rng.range(0, 5) as usize;
let seed = *rng.choose(&interior).expect("interior is non-empty");
let blob = grow_blob(seed, &interior_set, target, rng);
// Commit only if the room's remaining floor stays connected with the
// center intact — otherwise the pool would orphan part of the room.
if !floor_stays_connected(&cells, &blob, center) {
continue;
}
for &(x, y) in &blob {
ctx.tiles.set(Point::new(x, y), tile);
}
}
}
}
/// Grow a contiguous blob from `seed` over `interior` cells via randomized
/// breadth-first expansion, up to `target` cells. The randomized frontier order
/// gives an organic (non-circular) outline. Draws from `rng`.
fn grow_blob(
seed: Point,
interior: &BTreeSet<(i32, i32)>,
target: usize,
rng: &mut Rng,
) -> BTreeSet<(i32, i32)> {
let mut blob = BTreeSet::new();
blob.insert((seed.x, seed.y));
let mut frontier: Vec<(i32, i32)> = neighbors4(seed.x, seed.y)
.into_iter()
.filter(|c| interior.contains(c))
.collect();
while blob.len() < target && !frontier.is_empty() {
// Pop a random frontier cell so the blob grows unevenly.
let i = rng.range(0, frontier.len() as i32) as usize;
let cell = frontier.swap_remove(i);
if !blob.insert(cell) {
continue;
}
for n in neighbors4(cell.0, cell.1) {
if interior.contains(&n) && !blob.contains(&n) {
frontier.push(n);
}
}
}
blob
}
/// Returns true if the room's floor — its `cells` minus the pool — is non-empty,
/// still contains `center`, and is 4-connected (so the pool splits nothing off).
fn floor_stays_connected(cells: &[Point], pool: &BTreeSet<(i32, i32)>, center: Point) -> bool {
let floor: BTreeSet<(i32, i32)> = cells
.iter()
.map(|p| (p.x, p.y))
.filter(|c| !pool.contains(c))
.collect();
if floor.is_empty() || !floor.contains(&(center.x, center.y)) {
return false;
}
let mut seen = BTreeSet::new();
let mut stack = vec![(center.x, center.y)];
while let Some(c) = stack.pop() {
if !floor.contains(&c) || !seen.insert(c) {
continue;
}
for n in neighbors4(c.0, c.1) {
stack.push(n);
}
}
seen.len() == floor.len()
}
fn neighbors4(x: i32, y: i32) -> [(i32, i32); 4] {
[(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::{ConnGraph, RegionId};
fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
let cells: Vec<Point> = room.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, room, cells);
ctx
}
fn run(ctx: &mut GenContext, cfg: PoolConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("pool_decorator#0");
PoolDecorator::new(cfg).apply(ctx, &mut rng);
}
fn count(ctx: &GenContext, tile: Tile) -> usize {
ctx.tiles.iter().filter(|&(_, &t)| t == tile).count()
}
#[test]
fn name_is_pool_decorator() {
assert_eq!(PoolDecorator::new(PoolConfig::default()).name(), "pool_decorator");
}
/// A guaranteed water pool lands only on interior floor and keeps the room's
/// remaining floor 4-connected (water never orphans part of a room).
#[test]
fn water_pool_is_interior_and_keeps_floor_connected() {
let room = Rect::new(0, 0, 18, 14);
let mut ctx = ctx_with_room(18, 14, room);
run(&mut ctx, PoolConfig { water_chance: 1.0, lava_chance: 0.0, min_room: 7 }, 5);
let water: Vec<Point> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Water)
.map(|(p, _)| p)
.collect();
assert!(!water.is_empty(), "a large room should get a water pool");
let center = room.center();
for &p in &water {
let (lx, ly) = (p.x - room.x, p.y - room.y);
assert!(lx >= 2 && ly >= 2 && lx <= room.w - 3 && ly <= room.h - 3, "pool {p:?} not interior");
assert_ne!(p, center, "pool must not cover the room center");
}
// Remaining floor (Floor only) is 4-connected.
let floor: BTreeSet<(i32, i32)> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Floor)
.map(|(p, _)| (p.x, p.y))
.collect();
let start = *floor.iter().next().unwrap();
let mut seen = BTreeSet::new();
let mut stack = vec![start];
while let Some(c) = stack.pop() {
if !floor.contains(&c) || !seen.insert(c) {
continue;
}
for n in neighbors4(c.0, c.1) {
stack.push(n);
}
}
assert_eq!(seen.len(), floor.len(), "pool must leave the floor 4-connected");
}
/// Zero chances place nothing; a small room never qualifies.
#[test]
fn zero_chance_and_small_rooms_get_no_pools() {
let room = Rect::new(0, 0, 18, 14);
let mut ctx = ctx_with_room(18, 14, room);
run(&mut ctx, PoolConfig { water_chance: 0.0, lava_chance: 0.0, min_room: 7 }, 9);
assert_eq!(count(&ctx, Tile::Water) + count(&ctx, Tile::Lava), 0);
let small = Rect::new(0, 0, 6, 6);
let mut ctx2 = ctx_with_room(6, 6, small);
run(&mut ctx2, PoolConfig { water_chance: 1.0, lava_chance: 1.0, min_room: 7 }, 9);
assert_eq!(count(&ctx2, Tile::Water) + count(&ctx2, Tile::Lava), 0);
}
/// Same seed reproduces the same pool.
#[test]
fn pools_are_deterministic() {
let room = Rect::new(0, 0, 18, 14);
let cfg = PoolConfig { water_chance: 1.0, lava_chance: 0.0, min_room: 7 };
let mut a = ctx_with_room(18, 14, room);
let mut b = ctx_with_room(18, 14, room);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(a.tiles, b.tiles);
}
}

View file

@ -6,7 +6,8 @@
//! order:
//!
//! ```text
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer → PillarPlacer
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer
//! → PoolDecorator → PillarPlacer
//! ```
//!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
@ -17,7 +18,9 @@
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
//! doors (purely cosmetic — connectivity is already established by the floor).
//! 6. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
//! 6. [`PoolDecorator`] floods organic water/lava pools into some larger rooms
//! (a connectivity-guarded decorator — never orphans part of a room).
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
//! finishing decorator; isolated obstacles that never break connectivity).
//!
//! ## Why validation matters (spec §8)
@ -41,7 +44,7 @@ use serde::{Deserialize, Serialize};
use crate::pass::Pipeline;
use crate::passes::{
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
PillarConfig, PillarPlacer, RoomCarver, RoomConfig, ShapeWeights,
PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
};
/// Configuration for the [`dungeon`] recipe.
@ -64,6 +67,8 @@ pub struct DungeonConfig {
pub connect: ConnectConfig,
/// How room↔corridor pierce points are marked as doors.
pub doors: DoorConfig,
/// How water/lava pools are flooded into larger rooms (decorator).
pub pools: PoolConfig,
/// How free-standing pillars are scattered into larger rooms (decorator).
pub pillars: PillarConfig,
}
@ -100,6 +105,7 @@ impl Default for DungeonConfig {
extra_edge_ratio: 0.30,
},
doors: DoorConfig::default(),
pools: PoolConfig::default(),
pillars: PillarConfig::default(),
}
}
@ -212,6 +218,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
.then(MstConnect::new(cfg.connect))
.then(CorridorCarver::new())
.then(DoorPlacer::new(cfg.doors))
.then(PoolDecorator::new(cfg.pools))
.then(PillarPlacer::new(cfg.pillars));
Ok(pipeline)
@ -226,12 +233,13 @@ mod tests {
use std::collections::{BTreeSet, VecDeque};
/// The pass names in pipeline order, used to check snapshot labels.
const PASS_NAMES: [&str; 6] = [
const PASS_NAMES: [&str; 7] = [
"bsp_partition",
"room_carver",
"mst_connect",
"corridor_carver",
"door_placer",
"pool_decorator",
"pillar_placer",
];
@ -298,7 +306,7 @@ mod tests {
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
assert_eq!(plain, snapped, "snapshotting must not change the map");
assert_eq!(snapshots.len(), 6, "one snapshot per pass (six passes)");
assert_eq!(snapshots.len(), 7, "one snapshot per pass (seven passes)");
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
assert_eq!(

View file

@ -42,6 +42,8 @@ fn color_of(tile: Tile) -> Color {
Tile::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
Tile::Door => GOLD,
Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0),
Tile::Water => Color::new(0.16, 0.34, 0.52, 1.0),
Tile::Lava => Color::new(0.85, 0.32, 0.10, 1.0),
}
}

View file

@ -37,6 +37,8 @@ fn tile_code(tile: Tile) -> u8 {
Tile::Floor => 1,
Tile::Door => 2,
Tile::Pillar => 3,
Tile::Water => 4,
Tile::Lava => 5,
}
}

View file

@ -41,6 +41,8 @@ const SLIDERS = [
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
['pillars.room_chance', 'pillars', 0, 1, 0.05],
['pools.water_chance', '≈ water', 0, 1, 0.05],
['pools.lava_chance', '♨ lava', 0, 1, 0.05],
];
function getPath(obj, path) {
@ -191,6 +193,8 @@ window.reikhelm = {
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
water: state.env ? countTiles(state.env, 4) : 0,
lava: state.env ? countTiles(state.env, 5) : 0,
ok: !!state.env }),
};

View file

@ -6,7 +6,7 @@
// 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;
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 = {
@ -21,6 +21,10 @@ const PAL = {
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)',
@ -36,6 +40,8 @@ const LIGHT = {
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 -------------------------------------------------------
@ -110,6 +116,35 @@ function roomGlow(regions, w, h) {
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).
@ -132,51 +167,70 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
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) Floor + doors, lit.
// 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);
let b;
// 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);
const g = glow[i];
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) * aoMul);
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g + LIGHT.lavaGlow * lavaB) * 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.
// 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 = glow[i] * 58; // torchlight pools warm in room cores
col = [col[0] + warm, col[1] + warm * 0.56, col[2] + warm * 0.12];
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(px(x), py(y), cell, cell);
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, py(y), 0, py(y) + cell * 0.7);
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(px(x), py(y), cell, Math.ceil(cell * 0.7));
ctx.fillRect(X, Y, cell, Math.ceil(cell * 0.7));
}
}
}