feat(core): RoomCarver pass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f95a562927
commit
d2d5fa1a8b
2 changed files with 430 additions and 0 deletions
|
|
@ -25,3 +25,6 @@
|
|||
|
||||
pub mod bsp;
|
||||
pub use bsp::{BspConfig, BspPartition};
|
||||
|
||||
pub mod room;
|
||||
pub use room::{RoomCarver, RoomConfig};
|
||||
|
|
|
|||
427
reikhelm-core/src/passes/room.rs
Normal file
427
reikhelm-core/src/passes/room.rs
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
//! The [`RoomCarver`] shaper pass (spec §4.7).
|
||||
//!
|
||||
//! A *shaper* carves open space within the regions a partitioner produced. This
|
||||
//! pass consumes the placeholder [`Room`](crate::region::RegionKind::Room)
|
||||
//! regions that `BspPartition` left behind — one per BSP leaf, each with its
|
||||
//! `bounds` set to the leaf rect and empty `cells` — and turns each into an
|
||||
//! actual room.
|
||||
//!
|
||||
//! Per the inter-pass data contract (see [`crate::passes`]), for every existing
|
||||
//! `Room` region it:
|
||||
//!
|
||||
//! 1. Picks a room rectangle that fits inside the leaf, leaving a [`margin`] of
|
||||
//! cells from the leaf edge so corridors and walls have somewhere to live.
|
||||
//! The room's width and height are random within
|
||||
//! [`min_size`](RoomConfig::min_size)..=[`max_size`](RoomConfig::max_size),
|
||||
//! capped to what the margin-shrunk leaf can hold.
|
||||
//! 2. Carves [`Floor`](crate::map::Tile::Floor) over that rectangle in
|
||||
//! `ctx.tiles`.
|
||||
//! 3. Shrinks the region's `bounds` to the actual room rect and fills `cells`
|
||||
//! with the carved floor points (row-major order).
|
||||
//!
|
||||
//! A leaf too small to hold a `min_size` room — even before the margin — is
|
||||
//! **skipped gracefully**: its region is left in place with empty `cells`
|
||||
//! (never removed, which would break the `RegionId`-as-index invariant of
|
||||
//! [`RegionId`](crate::region::RegionId)). A `Room` region with empty `cells`
|
||||
//! denotes "not actually a room"; downstream room consumers (`MstConnect`,
|
||||
//! `CorridorCarver`) treat only `Room` regions with non-empty `cells` as real
|
||||
//! rooms, and `DoorPlacer` relies on `cells` being exactly the room interior
|
||||
//! floor so it can tell room-floor from corridor-floor. In the assembled
|
||||
//! dungeon recipe this skip never fires (Task 12 validates that every leaf fits
|
||||
//! a room), but the pass must handle it without panicking (spec §8).
|
||||
//!
|
||||
//! This pass reads/writes `ctx.regions` and `ctx.tiles` only and does **not**
|
||||
//! depend on `BspPartition`'s code — only on the documented "placeholder Room
|
||||
//! regions" precondition, which its tests construct directly.
|
||||
|
||||
use crate::geometry::Rect;
|
||||
use crate::map::Tile;
|
||||
use crate::pass::{GenContext, Pass};
|
||||
use crate::region::RegionKind;
|
||||
use crate::rng::Rng;
|
||||
|
||||
/// Configuration for a [`RoomCarver`] pass.
|
||||
///
|
||||
/// A room's width and height are each drawn from the inclusive range
|
||||
/// `min_size..=max_size`, then capped to whatever the leaf can hold after a
|
||||
/// `margin` of cells is reserved on every side. The margin keeps room floors off
|
||||
/// the leaf boundary so the surrounding wall — and the corridors that pierce it
|
||||
/// — have room to exist.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RoomConfig {
|
||||
/// The smallest allowed room extent (in cells) on either axis. A leaf that
|
||||
/// cannot hold a `min_size`-square room (after the margin) is skipped.
|
||||
pub min_size: i32,
|
||||
/// The largest desired room extent (in cells) on either axis. The actual
|
||||
/// extent is capped to what the margin-shrunk leaf can fit.
|
||||
pub max_size: i32,
|
||||
/// Cells reserved on every side between the room and its leaf edge.
|
||||
pub margin: i32,
|
||||
}
|
||||
|
||||
impl Default for RoomConfig {
|
||||
/// A sensible default for the v1 dungeon: rooms between 4 and 10 cells on a
|
||||
/// side, set in one cell from each leaf edge.
|
||||
fn default() -> Self {
|
||||
RoomConfig {
|
||||
min_size: 4,
|
||||
max_size: 10,
|
||||
margin: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A room-carving shaper pass (spec §4.7).
|
||||
///
|
||||
/// Turns each placeholder [`Room`](crate::region::RegionKind::Room) region into
|
||||
/// an actual carved room. Construct one with [`RoomCarver::new`]; it implements
|
||||
/// [`Pass`] with the stable name `"room_carver"`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RoomCarver {
|
||||
/// The size/margin limits rooms are carved under.
|
||||
cfg: RoomConfig,
|
||||
}
|
||||
|
||||
impl RoomCarver {
|
||||
/// Creates a room carver with the given configuration.
|
||||
pub fn new(cfg: RoomConfig) -> Self {
|
||||
RoomCarver { cfg }
|
||||
}
|
||||
|
||||
/// Chooses an actual room rectangle inside `leaf`, or [`None`] if `leaf` is
|
||||
/// too small to hold a `min_size` room after reserving the margin.
|
||||
///
|
||||
/// Randomness is drawn from `rng` in a fixed order — width, height, x-offset,
|
||||
/// y-offset — so the chosen rect is fully determined by the seed. Returning
|
||||
/// `None` draws nothing, keeping the per-region stream position predictable
|
||||
/// (a skipped leaf consumes no randomness).
|
||||
fn pick_room(&self, leaf: Rect, rng: &mut Rng) -> Option<Rect> {
|
||||
let cfg = self.cfg;
|
||||
|
||||
// The placeable area: the leaf shrunk by `margin` on every side. A
|
||||
// negative or zero result means there is no interior to place a room in.
|
||||
let inner = leaf.inflate(-cfg.margin);
|
||||
if inner.w < cfg.min_size || inner.h < cfg.min_size {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Cap the desired max to what the inner area can actually hold, then draw
|
||||
// a size in `[min_size, cap]` inclusive (`range` is half-open, hence + 1).
|
||||
let max_w = cfg.max_size.min(inner.w);
|
||||
let max_h = cfg.max_size.min(inner.h);
|
||||
let w = rng.range(cfg.min_size, max_w + 1);
|
||||
let h = rng.range(cfg.min_size, max_h + 1);
|
||||
|
||||
// Slide the room to a random position within the inner area. The offset
|
||||
// ranges keep the whole room inside `inner` (and therefore inside the
|
||||
// leaf with the margin intact).
|
||||
let x = rng.range(inner.x, inner.x + (inner.w - w) + 1);
|
||||
let y = rng.range(inner.y, inner.y + (inner.h - h) + 1);
|
||||
|
||||
Some(Rect::new(x, y, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
impl Pass for RoomCarver {
|
||||
fn name(&self) -> &str {
|
||||
"room_carver"
|
||||
}
|
||||
|
||||
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
|
||||
// Visit regions in index (id) order so the draw sequence is deterministic
|
||||
// and the id-as-index invariant is untouched (we mutate in place, never
|
||||
// add or remove). Only existing Room placeholders are shaped.
|
||||
for idx in 0..ctx.regions.len() {
|
||||
if ctx.regions[idx].kind != RegionKind::Room {
|
||||
continue;
|
||||
}
|
||||
let leaf = ctx.regions[idx].bounds;
|
||||
|
||||
let Some(room) = self.pick_room(leaf, rng) else {
|
||||
// Leaf too small: leave the placeholder in place with empty
|
||||
// cells. Never removed — that would break RegionId-as-index.
|
||||
continue;
|
||||
};
|
||||
|
||||
// Carve Floor over the room rect. Writes route through the bounds-safe
|
||||
// accessor, so any cell outside the grid is silently skipped.
|
||||
for p in room.iter() {
|
||||
ctx.tiles.set(p, Tile::Floor);
|
||||
}
|
||||
|
||||
// Finalize the region: bounds become the actual room rect, and cells
|
||||
// become exactly the room interior floor cells (row-major order).
|
||||
// DoorPlacer relies on `cells` being the room interior only.
|
||||
let region = &mut ctx.regions[idx];
|
||||
region.bounds = room;
|
||||
region.cells = room.iter().collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::blackboard::Blackboard;
|
||||
use crate::geometry::Point;
|
||||
use crate::grid::Grid;
|
||||
use crate::region::{ConnGraph, Region, RegionId};
|
||||
|
||||
/// Builds a `GenContext` in this pass's required precondition: an all-`Wall`
|
||||
/// grid plus a set of placeholder `Room` regions (bounds = leaf rect, empty
|
||||
/// cells), exactly the state `BspPartition` would leave. Constructed by hand
|
||||
/// so the test depends on no other pass.
|
||||
fn ctx_with_leaves(width: u32, height: u32, leaves: &[Rect]) -> GenContext {
|
||||
let mut ctx = GenContext {
|
||||
tiles: Grid::new(width, height, Tile::Wall),
|
||||
regions: Vec::new(),
|
||||
graph: ConnGraph::new(),
|
||||
blackboard: Blackboard::new(),
|
||||
};
|
||||
for &leaf in leaves {
|
||||
ctx.add_region(RegionKind::Room, leaf, Vec::new());
|
||||
}
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Runs `RoomCarver` with the given config/seed over `ctx`, keyed exactly as
|
||||
/// the pipeline would (`"room_carver#0"` sub-stream).
|
||||
fn run(ctx: &mut GenContext, cfg: RoomConfig, seed: u64) {
|
||||
let mut rng = Rng::from_seed(seed).fork("room_carver#0");
|
||||
RoomCarver::new(cfg).apply(ctx, &mut rng);
|
||||
}
|
||||
|
||||
/// The pass identifies itself with the exact contract name.
|
||||
#[test]
|
||||
fn name_is_room_carver() {
|
||||
assert_eq!(RoomCarver::new(RoomConfig::default()).name(), "room_carver");
|
||||
}
|
||||
|
||||
/// Each carved room's bounds lie within its originating leaf and within the
|
||||
/// map, and the floor cell count equals the bounds area.
|
||||
#[test]
|
||||
fn carved_rooms_fit_leaves_and_match_floor_count() {
|
||||
let cfg = RoomConfig::default();
|
||||
let leaves = [Rect::new(0, 0, 20, 16), Rect::new(20, 0, 20, 16)];
|
||||
let mut ctx = ctx_with_leaves(40, 16, &leaves);
|
||||
run(&mut ctx, cfg, 0xABCD);
|
||||
|
||||
let map = Rect::new(0, 0, 40, 16);
|
||||
for (region, &leaf) in ctx.regions.iter().zip(leaves.iter()) {
|
||||
let b = region.bounds;
|
||||
// Non-empty: with these generous leaves no room is skipped.
|
||||
assert!(!region.cells.is_empty(), "expected a carved room for {leaf:?}");
|
||||
|
||||
// Room rect sits inside its leaf.
|
||||
assert!(
|
||||
b.x >= leaf.x && b.y >= leaf.y && b.right() <= leaf.right() && b.bottom() <= leaf.bottom(),
|
||||
"room {b:?} escapes its leaf {leaf:?}"
|
||||
);
|
||||
// ...and inside the map.
|
||||
assert!(
|
||||
b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(),
|
||||
"room {b:?} escapes the map {map:?}"
|
||||
);
|
||||
|
||||
// The cell set is exactly the bounds area, all distinct, all carved.
|
||||
assert_eq!(region.cells.len() as i32, b.w * b.h, "cell count must equal area");
|
||||
let mut unique = region.cells.clone();
|
||||
unique.sort_by_key(|p| (p.y, p.x));
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), region.cells.len(), "cells must be distinct");
|
||||
for &p in ®ion.cells {
|
||||
assert!(b.contains(p), "cell {p:?} outside bounds {b:?}");
|
||||
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cell {p:?} must be Floor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The carved size and margin are respected: each room dimension stays within
|
||||
/// `[min_size, max_size]` and the room keeps `margin` cells from every leaf
|
||||
/// edge (so no floor extends into the margin or outside the leaf).
|
||||
#[test]
|
||||
fn size_and_margin_are_respected() {
|
||||
let cfg = RoomConfig {
|
||||
min_size: 3,
|
||||
max_size: 6,
|
||||
margin: 2,
|
||||
};
|
||||
let leaves = [Rect::new(2, 2, 18, 18)];
|
||||
let mut ctx = ctx_with_leaves(24, 24, &leaves);
|
||||
run(&mut ctx, cfg, 0x1234_5678);
|
||||
|
||||
let leaf = leaves[0];
|
||||
let b = ctx.regions[0].bounds;
|
||||
|
||||
// Size within configured limits.
|
||||
assert!(b.w >= cfg.min_size && b.w <= cfg.max_size, "width {} out of range", b.w);
|
||||
assert!(b.h >= cfg.min_size && b.h <= cfg.max_size, "height {} out of range", b.h);
|
||||
|
||||
// Margin honored on every side: the room sits inside the deflated leaf.
|
||||
let inner = leaf.inflate(-cfg.margin);
|
||||
assert!(
|
||||
b.x >= inner.x && b.y >= inner.y && b.right() <= inner.right() && b.bottom() <= inner.bottom(),
|
||||
"room {b:?} violates margin (inner area {inner:?})"
|
||||
);
|
||||
|
||||
// No Floor tile lies outside the leaf (margin keeps the carve interior).
|
||||
for (p, &t) in ctx.tiles.iter() {
|
||||
if t == Tile::Floor {
|
||||
assert!(leaf.contains(p), "floor at {p:?} escaped leaf {leaf:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf smaller than `min_size` (after the margin) is skipped gracefully:
|
||||
/// its region stays in place with empty cells, no tile is carved, and the
|
||||
/// id-as-index invariant is preserved. No panic.
|
||||
#[test]
|
||||
fn undersized_leaf_is_skipped_without_panic() {
|
||||
let cfg = RoomConfig {
|
||||
min_size: 6,
|
||||
max_size: 10,
|
||||
margin: 1,
|
||||
};
|
||||
// 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6.
|
||||
let leaves = [Rect::new(1, 1, 4, 4)];
|
||||
let mut ctx = ctx_with_leaves(8, 8, &leaves);
|
||||
run(&mut ctx, cfg, 7);
|
||||
|
||||
// Region preserved, untouched, still a placeholder.
|
||||
assert_eq!(ctx.regions.len(), 1, "region must not be removed");
|
||||
assert_eq!(ctx.regions[0].id, RegionId(0), "id-as-index preserved");
|
||||
assert_eq!(ctx.regions[0].kind, RegionKind::Room);
|
||||
assert_eq!(ctx.regions[0].bounds, leaves[0], "skipped leaf keeps its bounds");
|
||||
assert!(ctx.regions[0].cells.is_empty(), "skipped leaf carries no cells");
|
||||
|
||||
// Nothing was carved.
|
||||
assert!(
|
||||
ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall),
|
||||
"an undersized leaf must carve no floor"
|
||||
);
|
||||
}
|
||||
|
||||
/// A mix of carvable and undersized leaves: the carvable ones become rooms,
|
||||
/// the undersized one is skipped, and every region is preserved at its index.
|
||||
#[test]
|
||||
fn mixed_leaves_carve_some_skip_others() {
|
||||
let cfg = RoomConfig {
|
||||
min_size: 4,
|
||||
max_size: 8,
|
||||
margin: 1,
|
||||
};
|
||||
let leaves = [
|
||||
Rect::new(0, 0, 16, 16), // big: carved
|
||||
Rect::new(16, 0, 4, 4), // tiny: skipped
|
||||
Rect::new(0, 16, 16, 16), // big: carved
|
||||
];
|
||||
let mut ctx = ctx_with_leaves(32, 32, &leaves);
|
||||
run(&mut ctx, cfg, 0xFEED);
|
||||
|
||||
assert_eq!(ctx.regions.len(), 3, "all regions preserved");
|
||||
assert!(!ctx.regions[0].cells.is_empty(), "leaf 0 should be carved");
|
||||
assert!(ctx.regions[1].cells.is_empty(), "leaf 1 (tiny) should be skipped");
|
||||
assert!(!ctx.regions[2].cells.is_empty(), "leaf 2 should be carved");
|
||||
for (i, r) in ctx.regions.iter().enumerate() {
|
||||
assert_eq!(r.id, RegionId(i), "id must equal index");
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism: the same seed/sub-stream reproduces identical carved rooms —
|
||||
/// same bounds, same cells, same tile grid.
|
||||
#[test]
|
||||
fn same_seed_yields_identical_rooms() {
|
||||
let cfg = RoomConfig::default();
|
||||
let leaves = [
|
||||
Rect::new(0, 0, 20, 20),
|
||||
Rect::new(20, 0, 20, 20),
|
||||
Rect::new(0, 20, 20, 20),
|
||||
];
|
||||
|
||||
let mut a = ctx_with_leaves(40, 40, &leaves);
|
||||
let mut b = ctx_with_leaves(40, 40, &leaves);
|
||||
run(&mut a, cfg, 0x5EED);
|
||||
run(&mut b, cfg, 0x5EED);
|
||||
|
||||
let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
|
||||
let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
|
||||
assert_eq!(bounds_a, bounds_b, "same seed must reproduce the same room rects");
|
||||
|
||||
let cells_a: Vec<&Vec<Point>> = a.regions.iter().map(|r| &r.cells).collect();
|
||||
let cells_b: Vec<&Vec<Point>> = b.regions.iter().map(|r| &r.cells).collect();
|
||||
assert_eq!(cells_a, cells_b, "same seed must reproduce the same cells");
|
||||
|
||||
assert_eq!(a.tiles, b.tiles, "same seed must reproduce the same tile grid");
|
||||
}
|
||||
|
||||
/// Different seeds (overwhelmingly) carve different rooms.
|
||||
#[test]
|
||||
fn distinct_seeds_diverge() {
|
||||
let cfg = RoomConfig::default();
|
||||
let leaves = [Rect::new(0, 0, 24, 24), Rect::new(24, 0, 24, 24)];
|
||||
|
||||
let mut a = ctx_with_leaves(48, 24, &leaves);
|
||||
let mut b = ctx_with_leaves(48, 24, &leaves);
|
||||
run(&mut a, cfg, 1);
|
||||
run(&mut b, cfg, 2);
|
||||
|
||||
let bounds_a: Vec<Rect> = a.regions.iter().map(|r| r.bounds).collect();
|
||||
let bounds_b: Vec<Rect> = b.regions.iter().map(|r| r.bounds).collect();
|
||||
assert_ne!(bounds_a, bounds_b);
|
||||
}
|
||||
|
||||
/// Bounds-safety: a leaf whose rect extends past the grid edge must not panic.
|
||||
/// Only the in-grid portion is carved; cells are still exactly the room rect.
|
||||
#[test]
|
||||
fn leaf_partly_off_grid_does_not_panic() {
|
||||
let cfg = RoomConfig {
|
||||
min_size: 4,
|
||||
max_size: 8,
|
||||
margin: 1,
|
||||
};
|
||||
// Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall
|
||||
// outside the grid. The carve must clip silently.
|
||||
let leaves = [Rect::new(4, 0, 12, 8)];
|
||||
let mut ctx = ctx_with_leaves(8, 8, &leaves);
|
||||
run(&mut ctx, cfg, 0xDEAD);
|
||||
|
||||
let r = &ctx.regions[0];
|
||||
// The room rect is recorded faithfully even where it spills off-grid.
|
||||
assert_eq!(r.cells.len() as i32, r.bounds.w * r.bounds.h);
|
||||
// Every in-grid cell of the room rect is Floor; off-grid writes were
|
||||
// no-ops, so no panic and no spurious Floor outside the grid.
|
||||
for &p in &r.cells {
|
||||
if ctx.tiles.in_bounds(p) {
|
||||
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-Room regions are ignored entirely (this pass only shapes Rooms).
|
||||
#[test]
|
||||
fn non_room_regions_are_left_untouched() {
|
||||
let mut ctx = GenContext {
|
||||
tiles: Grid::new(24, 24, Tile::Wall),
|
||||
regions: Vec::new(),
|
||||
graph: ConnGraph::new(),
|
||||
blackboard: Blackboard::new(),
|
||||
};
|
||||
ctx.add_region(RegionKind::Room, Rect::new(0, 0, 16, 16), Vec::new());
|
||||
let corridor_bounds = Rect::new(16, 0, 8, 2);
|
||||
ctx.regions.push(Region {
|
||||
id: RegionId(1),
|
||||
kind: RegionKind::Corridor,
|
||||
bounds: corridor_bounds,
|
||||
cells: vec![Point::new(16, 0)],
|
||||
});
|
||||
|
||||
run(&mut ctx, RoomConfig::default(), 99);
|
||||
|
||||
// The corridor region is unchanged.
|
||||
assert_eq!(ctx.regions[1].kind, RegionKind::Corridor);
|
||||
assert_eq!(ctx.regions[1].bounds, corridor_bounds);
|
||||
assert_eq!(ctx.regions[1].cells, vec![Point::new(16, 0)]);
|
||||
// The room region was carved.
|
||||
assert!(!ctx.regions[0].cells.is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue