diff --git a/reikhelm-core/src/ascii.rs b/reikhelm-core/src/ascii.rs index 72bc999..6f5c954 100644 --- a/reikhelm-core/src/ascii.rs +++ b/reikhelm-core/src/ascii.rs @@ -25,6 +25,8 @@ const fn glyph(tile: Tile) -> char { Tile::Floor => '.', Tile::Door => '+', Tile::Pillar => 'o', + Tile::Water => '~', + Tile::Lava => '!', } } diff --git a/reikhelm-core/src/map.rs b/reikhelm-core/src/map.rs index e3f9b01..91f0da7 100644 --- a/reikhelm-core/src/map.rs +++ b/reikhelm-core/src/map.rs @@ -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 diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 032da34..cf3afbf 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -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}; diff --git a/reikhelm-core/src/passes/pool.rs b/reikhelm-core/src/passes/pool.rs new file mode 100644 index 0000000..085127d --- /dev/null +++ b/reikhelm-core/src/passes/pool.rs @@ -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)> = 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 = 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 = 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 = 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); + } +} diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index 187baba..f7221b2 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -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 { .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!( diff --git a/reikhelm-viz/src/main.rs b/reikhelm-viz/src/main.rs index ec4e1d3..365916c 100644 --- a/reikhelm-viz/src/main.rs +++ b/reikhelm-viz/src/main.rs @@ -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), } } diff --git a/reikhelm-wasm/src/lib.rs b/reikhelm-wasm/src/lib.rs index a1a24bd..8b7c8f3 100644 --- a/reikhelm-wasm/src/lib.rs +++ b/reikhelm-wasm/src/lib.rs @@ -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, } } diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index d185310..4abffc6 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -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 }), }; diff --git a/reikhelm-web/render.js b/reikhelm-web/render.js index 120faf0..6b49a73 100644 --- a/reikhelm-web/render.js +++ b/reikhelm-web/render.js @@ -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)); } } }