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:
parent
e7e8bb9187
commit
381a52c310
4 changed files with 380 additions and 13 deletions
340
reikhelm-core/src/passes/cave.rs
Normal file
340
reikhelm-core/src/passes/cave.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue