diff --git a/reikhelm-core/src/passes/cave.rs b/reikhelm-core/src/passes/cave.rs new file mode 100644 index 0000000..7d189ec --- /dev/null +++ b/reikhelm-core/src/passes/cave.rs @@ -0,0 +1,340 @@ +//! The [`CaveShaper`] shaper pass: organic cellular-automata caverns. +//! +//! A *shaper* that re-sculpts some already-carved rooms into natural-looking +//! caves. It runs **after** [`RoomCarver`](crate::passes::room::RoomCarver) and +//! **before** [`MstConnect`](crate::passes::connect::MstConnect), so connectors +//! see the final cave footprint when they wire and carve corridors. +//! +//! For each qualifying room (large enough, chosen by chance) it: +//! +//! 1. Fills the room's bounding rect with random noise (interior cells start as +//! wall with probability `fill`; a one-cell border starts as wall so the cave +//! pulls inward), then runs `steps` rounds of the classic **4-5 cellular +//! automaton**: a cell becomes wall when 5+ of its 8 neighbors are wall (cells +//! off the local grid count as wall), which relaxes noise into blobby caverns. +//! 2. Keeps the **largest 4-connected floor component** — so the resulting cave +//! is a single connected blob, never scattered pockets. +//! 3. Re-carves the room: cells in the cave become [`Floor`](crate::map::Tile::Floor), +//! the rest of the room's old footprint reverts to [`Wall`](crate::map::Tile::Wall); +//! the region's `cells` become the cave blob and `bounds` its bounding box. +//! +//! If the cave would be too small (or empty), the room is left as it was. Because +//! the cave is one connected component and [`CorridorCarver`](crate::passes::corridor::CorridorCarver) +//! anchors to the room cell nearest the bounds center (a cave cell), a +//! cavernized room is always reachable — connectivity is preserved. +//! +//! Caves stay [`Room`](crate::region::RegionKind::Room) regions: their organic +//! outline is what reads as a cavern, so renderers need no special case. + +use serde::{Deserialize, Serialize}; + +use crate::geometry::{Point, Rect}; +use crate::map::Tile; +use crate::pass::{GenContext, Pass}; +use crate::region::RegionKind; +use crate::rng::Rng; + +/// Configuration for a [`CaveShaper`] pass. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct CaveConfig { + /// Probability a qualifying room is turned into a cave. + pub cave_chance: f64, + /// Minimum room extent (smaller of width/height) to qualify. Caves need room + /// to breathe; small rooms stay clean. + pub min_room: i32, + /// Initial wall probability for interior cells before smoothing (~0.45 gives + /// good caves). Clamped to `[0.0, 1.0]`. + pub fill: f64, + /// Number of cellular-automaton smoothing rounds. + pub steps: u32, +} + +impl Default for CaveConfig { + /// A sensible default: about a third of larger rooms (min extent >= 7) become + /// caves, from 45%-wall noise smoothed four times. + fn default() -> Self { + CaveConfig { + cave_chance: 0.30, + min_room: 7, + fill: 0.45, + steps: 4, + } + } +} + +/// Smallest cave (in cells) worth keeping; below this the room is left clean. +const MIN_CAVE_CELLS: usize = 8; + +/// A cellular-automata cave shaper pass. +/// +/// Construct one with [`CaveShaper::new`]; it implements [`Pass`] with the stable +/// name `"cave_shaper"`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaveShaper { + cfg: CaveConfig, +} + +impl CaveShaper { + /// Creates a cave shaper with the given configuration. + pub fn new(cfg: CaveConfig) -> Self { + CaveShaper { cfg } + } + + /// Builds a cave blob (as global [`Point`]s) within `bounds`, or [`None`] if + /// the smoothed automaton leaves nothing big enough to keep. Draws all + /// randomness from `rng`. + fn carve_cave(&self, bounds: Rect, rng: &mut Rng) -> Option> { + let w = bounds.w.max(0) as usize; + let h = bounds.h.max(0) as usize; + if w < 3 || h < 3 { + return None; + } + let idx = |x: usize, y: usize| y * w + x; + let fill = self.cfg.fill.clamp(0.0, 1.0); + + // Initialize: border is wall, interior is wall with probability `fill`. + let mut wall = vec![true; w * h]; + for y in 1..h - 1 { + for x in 1..w - 1 { + wall[idx(x, y)] = rng.chance(fill); + } + } + + // Smooth with the 4-5 rule. Cells off the grid count as wall. + for _ in 0..self.cfg.steps { + let mut next = wall.clone(); + for y in 0..h { + for x in 0..w { + let mut walls = 0; + for dy in -1i32..=1 { + for dx in -1i32..=1 { + if dx == 0 && dy == 0 { + continue; + } + let nx = x as i32 + dx; + let ny = y as i32 + dy; + if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { + walls += 1; // out of bounds counts as wall + } else if wall[idx(nx as usize, ny as usize)] { + walls += 1; + } + } + } + next[idx(x, y)] = walls >= 5; + } + } + wall = next; + } + + // Keep the largest 4-connected floor component. + let component = largest_floor_component(&wall, w, h)?; + if component.len() < MIN_CAVE_CELLS { + return None; + } + + // Map local cells to global points, in row-major order. + let mut cells: Vec = component + .into_iter() + .map(|i| Point::new(bounds.x + (i % w) as i32, bounds.y + (i / w) as i32)) + .collect(); + cells.sort_by_key(|p| (p.y, p.x)); + Some(cells) + } +} + +impl Pass for CaveShaper { + fn name(&self) -> &str { + "cave_shaper" + } + + fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) { + for idx in 0..ctx.regions.len() { + let region = &ctx.regions[idx]; + if region.kind != RegionKind::Room || region.cells.is_empty() { + continue; + } + let bounds = region.bounds; + if bounds.w.min(bounds.h) < self.cfg.min_room { + continue; + } + if !rng.chance(self.cfg.cave_chance) { + continue; + } + + let Some(cave) = self.carve_cave(bounds, rng) else { + continue; + }; + + // Re-carve: clear the room's old footprint, then carve the cave. The + // old cells are this room's only carved area at this stage (corridors + // run later), so clearing them touches nothing else. + let old_cells = ctx.regions[idx].cells.clone(); + for &p in &old_cells { + ctx.tiles.set(p, Tile::Wall); + } + for &p in &cave { + ctx.tiles.set(p, Tile::Floor); + } + + // Tighten bounds to the cave and replace the region's cells. + let region = &mut ctx.regions[idx]; + region.bounds = bounding_rect(&cave); + region.cells = cave; + } + } +} + +/// Finds the largest 4-connected component of floor cells (`!wall`) in a `w`×`h` +/// grid, returned as the set of flat indices, or [`None`] if there is no floor. +fn largest_floor_component(wall: &[bool], w: usize, h: usize) -> Option> { + let mut seen = vec![false; w * h]; + let mut best: Option> = None; + for start in 0..w * h { + if wall[start] || seen[start] { + continue; + } + // BFS this component. + let mut comp = Vec::new(); + let mut stack = vec![start]; + seen[start] = true; + while let Some(i) = stack.pop() { + comp.push(i); + let (x, y) = (i % w, i / w); + let mut push = |nx: usize, ny: usize| { + let n = ny * w + nx; + if !wall[n] && !seen[n] { + seen[n] = true; + stack.push(n); + } + }; + if x + 1 < w { + push(x + 1, y); + } + if x > 0 { + push(x - 1, y); + } + if y + 1 < h { + push(x, y + 1); + } + if y > 0 { + push(x, y - 1); + } + } + if best.as_ref().map(|b| comp.len() > b.len()).unwrap_or(true) { + best = Some(comp); + } + } + best +} + +/// The axis-aligned bounding box enclosing every point in `cells` (empty rect for +/// an empty slice). +fn bounding_rect(cells: &[Point]) -> Rect { + let Some(&first) = cells.first() else { + return Rect::new(0, 0, 0, 0); + }; + let (mut min_x, mut min_y, mut max_x, mut max_y) = (first.x, first.y, first.x, first.y); + for &p in &cells[1..] { + min_x = min_x.min(p.x); + min_y = min_y.min(p.y); + max_x = max_x.max(p.x); + max_y = max_y.max(p.y); + } + Rect::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blackboard::Blackboard; + use crate::grid::Grid; + use crate::region::{ConnGraph, RegionId}; + use std::collections::BTreeSet; + + 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: CaveConfig, seed: u64) { + let mut rng = Rng::from_seed(seed).fork("cave_shaper#0"); + CaveShaper::new(cfg).apply(ctx, &mut rng); + } + + #[test] + fn name_is_cave_shaper() { + assert_eq!(CaveShaper::new(CaveConfig::default()).name(), "cave_shaper"); + } + + /// A guaranteed cave in a large room: it is non-empty, smaller than the full + /// rect (organic), 4-connected, every cell is Floor, every cell stays inside + /// the original room rect, and the region's cells/bounds match the cave. + #[test] + fn cave_is_connected_floor_within_room() { + let room = Rect::new(0, 0, 22, 18); + let mut ctx = ctx_with_room(22, 18, room); + run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 0x1234); + + let r = &ctx.regions[0]; + assert!(!r.cells.is_empty(), "cave should carve cells"); + assert!((r.cells.len() as i32) < room.w * room.h, "a cave is not the full rect"); + + let set: BTreeSet<(i32, i32)> = r.cells.iter().map(|p| (p.x, p.y)).collect(); + for &p in &r.cells { + assert!(room.contains(p), "cave cell {p:?} escaped the room rect"); + assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cave cell {p:?} not Floor"); + assert!(r.bounds.contains(p), "cave cell {p:?} escaped bounds {:?}", r.bounds); + } + + // 4-connected: flood from the first cell reaches all of them. + let mut seen = BTreeSet::new(); + let mut stack = vec![(r.cells[0].x, r.cells[0].y)]; + while let Some((x, y)) = stack.pop() { + if !set.contains(&(x, y)) || !seen.insert((x, y)) { + continue; + } + stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]); + } + assert_eq!(seen.len(), r.cells.len(), "cave must be one 4-connected blob"); + } + + /// A small room never becomes a cave; `cave_chance` 0.0 never caves anything. + #[test] + fn small_rooms_and_zero_chance_stay_clean() { + let small = Rect::new(0, 0, 5, 5); + let mut ctx = ctx_with_room(5, 5, small); + let before = ctx.regions[0].cells.clone(); + run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 1); + assert_eq!(ctx.regions[0].cells, before, "small room must stay a clean rect"); + + let big = Rect::new(0, 0, 20, 16); + let mut ctx2 = ctx_with_room(20, 16, big); + let before2 = ctx2.regions[0].cells.clone(); + run(&mut ctx2, CaveConfig { cave_chance: 0.0, min_room: 7, fill: 0.45, steps: 4 }, 1); + assert_eq!(ctx2.regions[0].cells, before2, "cave_chance 0.0 changes nothing"); + } + + /// Same seed reproduces an identical cave (tiles + region). + #[test] + fn caves_are_deterministic() { + let room = Rect::new(0, 0, 20, 16); + let cfg = CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }; + let mut a = ctx_with_room(20, 16, room); + let mut b = ctx_with_room(20, 16, room); + run(&mut a, cfg, 0x5EED); + run(&mut b, cfg, 0x5EED); + assert_eq!(a.tiles, b.tiles); + assert_eq!(a.regions[0].cells, b.regions[0].cells); + assert_eq!(a.regions[0].bounds, b.regions[0].bounds); + } +} diff --git a/reikhelm-core/src/passes/corridor.rs b/reikhelm-core/src/passes/corridor.rs index 26e0de7..0093839 100644 --- a/reikhelm-core/src/passes/corridor.rs +++ b/reikhelm-core/src/passes/corridor.rs @@ -121,15 +121,31 @@ impl Pass for CorridorCarver { } } -/// Returns the center of the `Room` region with id `index`, or [`None`] if the -/// id is out of range or names a non-Room region. +/// Returns a corridor anchor for the `Room` region with id `index`, or [`None`] +/// if the id is out of range or names a non-Room region. /// -/// Endpoint ids come from `MstConnect`, which only ever links real rooms, so in -/// the assembled recipe this always resolves; the guard simply keeps the pass -/// total against a hand-built or malformed graph. +/// The anchor is the room's carved cell **nearest its bounding-box center**. For +/// a convex, centered room (rectangle, octagon, ellipse, plus) the center cell is +/// itself carved, so this is exactly `bounds.center()` — i.e. output-preserving. +/// For an irregular room whose AABB center may fall in rock (a cave blob), it +/// snaps to the nearest actual floor cell, so the corridor always meets carved +/// floor and the room cannot be left unreachable. +/// +/// Endpoint ids come from `MstConnect`, which only ever links real rooms (with +/// non-empty `cells`), so in the assembled recipe this always resolves to a real +/// cell; the guards keep the pass total against a hand-built or malformed graph. fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option { let region = ctx.regions.get(index.0)?; - (region.kind == RegionKind::Room).then(|| region.bounds.center()) + if region.kind != RegionKind::Room { + return None; + } + let target = region.bounds.center(); + region + .cells + .iter() + .copied() + .min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2)) + .or(Some(target)) } /// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid) diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index cf3afbf..2ac1b1f 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -29,6 +29,9 @@ pub use bsp::{BspConfig, BspPartition}; pub mod room; pub use room::{RoomCarver, RoomConfig, ShapeWeights}; +pub mod cave; +pub use cave::{CaveConfig, CaveShaper}; + pub mod connect; pub use connect::{ConnectConfig, MstConnect}; diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index f7221b2..def32c7 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -6,13 +6,15 @@ //! order: //! //! ```text -//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer -//! → PoolDecorator → PillarPlacer +//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver +//! → DoorPlacer → PoolDecorator → PillarPlacer //! ``` //! //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! Room region per leaf). -//! 2. [`RoomCarver`] carves an actual room inside each leaf. +//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which +//! [`CaveShaper`] re-sculpts some rooms into organic cellular-automata caves +//! (each a single connected blob; the rest stay clean polygons). //! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus //! optional loop edges). //! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge. @@ -43,8 +45,9 @@ use serde::{Deserialize, Serialize}; use crate::pass::Pipeline; use crate::passes::{ - BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect, - PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights, + BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig, + DoorPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, + RoomConfig, ShapeWeights, }; /// Configuration for the [`dungeon`] recipe. @@ -63,6 +66,8 @@ pub struct DungeonConfig { pub bsp: BspConfig, /// How a room is carved inside each leaf. pub rooms: RoomConfig, + /// How some rooms are re-sculpted into organic cellular-automata caves. + pub caves: CaveConfig, /// How rooms are linked into a connectivity graph. pub connect: ConnectConfig, /// How room↔corridor pierce points are marked as doors. @@ -101,6 +106,7 @@ impl Default for DungeonConfig { margin: 1, shapes: ShapeWeights::varied(), }, + caves: CaveConfig::default(), connect: ConnectConfig { extra_edge_ratio: 0.30, }, @@ -215,6 +221,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result { let pipeline = Pipeline::new(cfg.width, cfg.height) .then(BspPartition::new(cfg.bsp)) .then(RoomCarver::new(cfg.rooms)) + .then(CaveShaper::new(cfg.caves)) .then(MstConnect::new(cfg.connect)) .then(CorridorCarver::new()) .then(DoorPlacer::new(cfg.doors)) @@ -233,9 +240,10 @@ mod tests { use std::collections::{BTreeSet, VecDeque}; /// The pass names in pipeline order, used to check snapshot labels. - const PASS_NAMES: [&str; 7] = [ + const PASS_NAMES: [&str; 8] = [ "bsp_partition", "room_carver", + "cave_shaper", "mst_connect", "corridor_carver", "door_placer", @@ -306,7 +314,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(), 7, "one snapshot per pass (seven passes)"); + assert_eq!(snapshots.len(), 8, "one snapshot per pass (eight passes)"); let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect(); assert_eq!(