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:
parent
128dce6310
commit
e7e8bb9187
9 changed files with 410 additions and 18 deletions
|
|
@ -25,6 +25,8 @@ const fn glyph(tile: Tile) -> char {
|
|||
Tile::Floor => '.',
|
||||
Tile::Door => '+',
|
||||
Tile::Pillar => 'o',
|
||||
Tile::Water => '~',
|
||||
Tile::Lava => '!',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
312
reikhelm-core/src/passes/pool.rs
Normal file
312
reikhelm-core/src/passes/pool.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue