feat(core): cellular-automata caverns (CaveShaper) + robust corridor anchoring

Eighth pass (after RoomCarver, before MstConnect): re-sculpts some rooms into
organic caves.

- CaveShaper/CaveConfig (cave_chance, min_room, fill, steps): fills the room
  rect with noise, runs the classic 4-5 cellular automaton `steps` times, keeps
  the LARGEST 4-connected floor component, and re-carves the room to that blob
  (cells + bounds updated; the rest of the footprint reverts to Wall). Too-small
  results leave the room clean. Caves stay Room regions — their organic outline
  reads as a cavern, so the renderer needs no change.
- CorridorCarver now anchors to the room cell NEAREST bounds.center() instead of
  the center point itself. Output-preserving for convex/centered shapes (the
  center cell is already floor), but robust for irregular cave blobs whose AABB
  center may fall in rock — so a cavernized room is always reachable.
- DungeonConfig gains `caves`; recipe inserts CaveShaper after RoomCarver.

Core: 129 tests green (4 new: cave is connected floor within room, small/zero
skip, determinism); connectivity holds with caves in the default recipe. clippy
clean. Pipeline is now 8 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 00:46:33 -06:00
parent e7e8bb9187
commit 381a52c310
4 changed files with 380 additions and 13 deletions

View file

@ -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<Vec<Point>> {
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<Point> = 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<Vec<usize>> {
let mut seen = vec![false; w * h];
let mut best: Option<Vec<usize>> = 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<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: 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);
}
}

View file

@ -121,15 +121,31 @@ impl Pass for CorridorCarver {
} }
} }
/// Returns the center of the `Room` region with id `index`, or [`None`] if the /// Returns a corridor anchor for the `Room` region with id `index`, or [`None`]
/// id is out of range or names a non-Room region. /// 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 anchor is the room's carved cell **nearest its bounding-box center**. For
/// the assembled recipe this always resolves; the guard simply keeps the pass /// a convex, centered room (rectangle, octagon, ellipse, plus) the center cell is
/// total against a hand-built or malformed graph. /// 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<Point> { fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option<Point> {
let region = ctx.regions.get(index.0)?; 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) /// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid)

View file

@ -29,6 +29,9 @@ pub use bsp::{BspConfig, BspPartition};
pub mod room; pub mod room;
pub use room::{RoomCarver, RoomConfig, ShapeWeights}; pub use room::{RoomCarver, RoomConfig, ShapeWeights};
pub mod cave;
pub use cave::{CaveConfig, CaveShaper};
pub mod connect; pub mod connect;
pub use connect::{ConnectConfig, MstConnect}; pub use connect::{ConnectConfig, MstConnect};

View file

@ -6,13 +6,15 @@
//! order: //! order:
//! //!
//! ```text //! ```text
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer //! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
//! → PoolDecorator → PillarPlacer //! → DoorPlacer → PoolDecorator → PillarPlacer
//! ``` //! ```
//! //!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
//! Room region per leaf). //! 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 //! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus
//! optional loop edges). //! optional loop edges).
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge. //! 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::pass::Pipeline;
use crate::passes::{ use crate::passes::{
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect, BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights, DoorPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver,
RoomConfig, ShapeWeights,
}; };
/// Configuration for the [`dungeon`] recipe. /// Configuration for the [`dungeon`] recipe.
@ -63,6 +66,8 @@ pub struct DungeonConfig {
pub bsp: BspConfig, pub bsp: BspConfig,
/// How a room is carved inside each leaf. /// How a room is carved inside each leaf.
pub rooms: RoomConfig, 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. /// How rooms are linked into a connectivity graph.
pub connect: ConnectConfig, pub connect: ConnectConfig,
/// How room↔corridor pierce points are marked as doors. /// How room↔corridor pierce points are marked as doors.
@ -101,6 +106,7 @@ impl Default for DungeonConfig {
margin: 1, margin: 1,
shapes: ShapeWeights::varied(), shapes: ShapeWeights::varied(),
}, },
caves: CaveConfig::default(),
connect: ConnectConfig { connect: ConnectConfig {
extra_edge_ratio: 0.30, extra_edge_ratio: 0.30,
}, },
@ -215,6 +221,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
let pipeline = Pipeline::new(cfg.width, cfg.height) let pipeline = Pipeline::new(cfg.width, cfg.height)
.then(BspPartition::new(cfg.bsp)) .then(BspPartition::new(cfg.bsp))
.then(RoomCarver::new(cfg.rooms)) .then(RoomCarver::new(cfg.rooms))
.then(CaveShaper::new(cfg.caves))
.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))
@ -233,9 +240,10 @@ 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; 7] = [ const PASS_NAMES: [&str; 8] = [
"bsp_partition", "bsp_partition",
"room_carver", "room_carver",
"cave_shaper",
"mst_connect", "mst_connect",
"corridor_carver", "corridor_carver",
"door_placer", "door_placer",
@ -306,7 +314,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(), 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(); let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
assert_eq!( assert_eq!(