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::Floor => '.',
|
||||||
Tile::Door => '+',
|
Tile::Door => '+',
|
||||||
Tile::Pillar => 'o',
|
Tile::Pillar => 'o',
|
||||||
|
Tile::Water => '~',
|
||||||
|
Tile::Lava => '!',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ pub enum Tile {
|
||||||
/// decorator. Not walkable (treated like [`Tile::Wall`] for movement), but
|
/// decorator. Not walkable (treated like [`Tile::Wall`] for movement), but
|
||||||
/// semantically distinct so renderers can draw it as a column on lit floor.
|
/// semantically distinct so renderers can draw it as a column on lit floor.
|
||||||
Pillar,
|
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
|
/// The generator's output: what occupies each cell, the semantic regions, and
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,8 @@ pub use corridor::CorridorCarver;
|
||||||
pub mod door;
|
pub mod door;
|
||||||
pub use door::{DoorConfig, DoorPlacer};
|
pub use door::{DoorConfig, DoorPlacer};
|
||||||
|
|
||||||
|
pub mod pool;
|
||||||
|
pub use pool::{PoolConfig, PoolDecorator};
|
||||||
|
|
||||||
pub mod pillar;
|
pub mod pillar;
|
||||||
pub use pillar::{PillarConfig, PillarPlacer};
|
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:
|
//! order:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer → PillarPlacer
|
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer
|
||||||
|
//! → PoolDecorator → PillarPlacer
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
//! 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.
|
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
|
||||||
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
|
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
|
||||||
//! doors (purely cosmetic — connectivity is already established by the floor).
|
//! 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).
|
//! finishing decorator; isolated obstacles that never break connectivity).
|
||||||
//!
|
//!
|
||||||
//! ## Why validation matters (spec §8)
|
//! ## Why validation matters (spec §8)
|
||||||
|
|
@ -41,7 +44,7 @@ use serde::{Deserialize, Serialize};
|
||||||
use crate::pass::Pipeline;
|
use crate::pass::Pipeline;
|
||||||
use crate::passes::{
|
use crate::passes::{
|
||||||
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
|
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
|
||||||
PillarConfig, PillarPlacer, RoomCarver, RoomConfig, ShapeWeights,
|
PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Configuration for the [`dungeon`] recipe.
|
/// Configuration for the [`dungeon`] recipe.
|
||||||
|
|
@ -64,6 +67,8 @@ pub struct DungeonConfig {
|
||||||
pub connect: ConnectConfig,
|
pub connect: ConnectConfig,
|
||||||
/// How room↔corridor pierce points are marked as doors.
|
/// How room↔corridor pierce points are marked as doors.
|
||||||
pub doors: DoorConfig,
|
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).
|
/// How free-standing pillars are scattered into larger rooms (decorator).
|
||||||
pub pillars: PillarConfig,
|
pub pillars: PillarConfig,
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +105,7 @@ impl Default for DungeonConfig {
|
||||||
extra_edge_ratio: 0.30,
|
extra_edge_ratio: 0.30,
|
||||||
},
|
},
|
||||||
doors: DoorConfig::default(),
|
doors: DoorConfig::default(),
|
||||||
|
pools: PoolConfig::default(),
|
||||||
pillars: PillarConfig::default(),
|
pillars: PillarConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,6 +218,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
|
||||||
.then(MstConnect::new(cfg.connect))
|
.then(MstConnect::new(cfg.connect))
|
||||||
.then(CorridorCarver::new())
|
.then(CorridorCarver::new())
|
||||||
.then(DoorPlacer::new(cfg.doors))
|
.then(DoorPlacer::new(cfg.doors))
|
||||||
|
.then(PoolDecorator::new(cfg.pools))
|
||||||
.then(PillarPlacer::new(cfg.pillars));
|
.then(PillarPlacer::new(cfg.pillars));
|
||||||
|
|
||||||
Ok(pipeline)
|
Ok(pipeline)
|
||||||
|
|
@ -226,12 +233,13 @@ mod tests {
|
||||||
use std::collections::{BTreeSet, VecDeque};
|
use std::collections::{BTreeSet, VecDeque};
|
||||||
|
|
||||||
/// The pass names in pipeline order, used to check snapshot labels.
|
/// The pass names in pipeline order, used to check snapshot labels.
|
||||||
const PASS_NAMES: [&str; 6] = [
|
const PASS_NAMES: [&str; 7] = [
|
||||||
"bsp_partition",
|
"bsp_partition",
|
||||||
"room_carver",
|
"room_carver",
|
||||||
"mst_connect",
|
"mst_connect",
|
||||||
"corridor_carver",
|
"corridor_carver",
|
||||||
"door_placer",
|
"door_placer",
|
||||||
|
"pool_decorator",
|
||||||
"pillar_placer",
|
"pillar_placer",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -298,7 +306,7 @@ mod tests {
|
||||||
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
|
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
|
||||||
|
|
||||||
assert_eq!(plain, snapped, "snapshotting must not change the map");
|
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();
|
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
|
||||||
assert_eq!(
|
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::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
|
||||||
Tile::Door => GOLD,
|
Tile::Door => GOLD,
|
||||||
Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0),
|
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::Floor => 1,
|
||||||
Tile::Door => 2,
|
Tile::Door => 2,
|
||||||
Tile::Pillar => 3,
|
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.ellipse', '◯ ellipse', 0, 3, 0.1],
|
||||||
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
|
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
|
||||||
['pillars.room_chance', 'pillars', 0, 1, 0.05],
|
['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) {
|
function getPath(obj, path) {
|
||||||
|
|
@ -191,6 +193,8 @@ window.reikhelm = {
|
||||||
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
|
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,
|
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
|
||||||
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
|
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 }),
|
ok: !!state.env }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// same contract the Rust renderers honor. All art (color, light, depth) lives
|
// same contract the Rust renderers honor. All art (color, light, depth) lives
|
||||||
// here; the core stores none of it.
|
// 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.
|
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
|
||||||
const PAL = {
|
const PAL = {
|
||||||
|
|
@ -21,6 +21,10 @@ const PAL = {
|
||||||
doorDark: [120, 84, 32],
|
doorDark: [120, 84, 32],
|
||||||
pillarTop: [122, 112, 100], // lit top-left of a stone column
|
pillarTop: [122, 112, 100], // lit top-left of a stone column
|
||||||
pillarBody: [60, 54, 54], // shadowed lower-right
|
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)',
|
outlineRoom: 'rgba(122, 184, 240, 0.85)',
|
||||||
outlineCorr: 'rgba(150, 150, 170, 0.40)',
|
outlineCorr: 'rgba(150, 150, 170, 0.40)',
|
||||||
edge: 'rgba(232, 96, 84, 0.85)',
|
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
|
aoDepth: 2.6, // cells from wall at which AO is fully open
|
||||||
maxB: 1.2, // clamp so highlights don't blow out
|
maxB: 1.2, // clamp so highlights don't blow out
|
||||||
vignette: 0.30, // strength of the screen-edge darkening
|
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 -------------------------------------------------------
|
// --- small helpers -------------------------------------------------------
|
||||||
|
|
@ -110,6 +116,35 @@ function roomGlow(regions, w, h) {
|
||||||
return glow;
|
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 ----------------------------------------------------------
|
// --- main entry ----------------------------------------------------------
|
||||||
|
|
||||||
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
|
// 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 lit = opts.lighting !== false;
|
||||||
const dist = lit ? distanceToWall(tiles, w, h) : null;
|
const dist = lit ? distanceToWall(tiles, w, h) : null;
|
||||||
const glow = lit ? roomGlow(regions, 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 px = (x) => ox + x * cell;
|
||||||
const py = (y) => oy + y * cell;
|
const py = (y) => oy + y * cell;
|
||||||
const isFloor = (x, y) => x >= 0 && y >= 0 && x < w && y < h && tiles[y][x] !== WALL;
|
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;
|
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 y = 0; y < h; y++) {
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
const t = tiles[y][x];
|
const t = tiles[y][x];
|
||||||
if (t === WALL) continue;
|
if (t === WALL) continue;
|
||||||
const i = y * w + x;
|
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) {
|
if (lit) {
|
||||||
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
|
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
|
||||||
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
|
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;
|
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 {
|
} else {
|
||||||
b = 0.85;
|
b = 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calm stone: low-frequency patches mottle warm↔cool (sampled per ~4×4
|
// Water: a cool, reflective pool — darker than stone with a faint sheen.
|
||||||
// block, not per cell, so it reads as stone rather than static), with a
|
if (t === WATER) {
|
||||||
// faint per-cell grain on top. Torchlight (room glow) warms the lit core.
|
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 coarse = hash2((x >> 2) + 11, (y >> 2) + 7);
|
||||||
const grain = 0.93 + 0.09 * hash2(x, y);
|
const grain = 0.93 + 0.09 * hash2(x, y);
|
||||||
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
|
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
|
||||||
let col = scale3(stone, b * grain);
|
let col = scale3(stone, b * grain);
|
||||||
if (lit) {
|
if (lit) {
|
||||||
const warm = glow[i] * 58; // torchlight pools warm in room cores
|
const warm = g * 58 + lavaB * 95; // torch + lava both pool warm
|
||||||
col = [col[0] + warm, col[1] + warm * 0.56, col[2] + warm * 0.12];
|
col = [col[0] + warm, col[1] + warm * 0.5, col[2] + warm * 0.1];
|
||||||
}
|
}
|
||||||
ctx.fillStyle = rgb(col);
|
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.
|
// Shadow cast by a wall to the north, falling onto this floor cell.
|
||||||
if (lit && isWall(x, y - 1)) {
|
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(0, 'rgba(0,0,0,0.45)');
|
||||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
ctx.fillStyle = grd;
|
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