Phase 0+1 of the browser viz effort: - core: drop unused `rand` dep (removes getrandom/libc → clean wasm32 build); derive serde on DungeonConfig + 5 sub-configs so the bridge can (de)serialize - reikhelm-wasm: thin wasm-bindgen cdylib exposing generate(seed, config_json) -> JSON envelope (tiles as int codes, regions, edges, per-pass snapshots) and default_config_json(); holds no generation logic - workspace: add reikhelm-wasm member; default-members keeps host cargo test/clippy on core+viz only - reikhelm-web: vanilla-JS + Canvas 2D renderer running the real core live in the browser. Atmospheric look — lit stone floor, raised stone walls with rim-lit carved edges + top bevel, room light-pooling, amber door thresholds, vignette. Controls: seed/reroll, 9 config sliders, stage scrubber, semantic overlays (region outlines / graph / grid). window.reikhelm hooks for Playwright-driven iteration. Core stays 115 tests green. Driven + screenshotted via Playwright MCP. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
353 lines
13 KiB
Rust
353 lines
13 KiB
Rust
//! The [`BspPartition`] partitioner pass (spec §4.7).
|
||
//!
|
||
//! Binary space partitioning recursively cuts the whole-map rectangle into
|
||
//! smaller, non-overlapping leaf rectangles. Each cut chooses a split axis and a
|
||
//! split position at random (from the pass's dedicated [`Rng`] sub-stream), and
|
||
//! recursion stops at a leaf once it is too small to split without violating the
|
||
//! minimum leaf size, or once the configured maximum recursion depth is reached.
|
||
//!
|
||
//! Per the inter-pass data contract (see [`crate::passes`]), this pass is the
|
||
//! first in the v1 dungeon recipe: it reads an empty grid and **writes** one
|
||
//! placeholder [`Region`](crate::region::Region) of kind
|
||
//! [`Room`](crate::region::RegionKind::Room) per leaf — `bounds` set to the leaf
|
||
//! rect and `cells` empty. It carves **no** tiles; the grid stays all
|
||
//! [`Wall`](crate::map::Tile::Wall). A later `RoomCarver` pass consumes these
|
||
//! placeholders, shrinks each to an actual room, and carves floor.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::geometry::Rect;
|
||
use crate::pass::{GenContext, Pass};
|
||
use crate::region::RegionKind;
|
||
use crate::rng::Rng;
|
||
|
||
/// Configuration for a [`BspPartition`] pass.
|
||
///
|
||
/// `min_leaf` is the smallest allowed extent (in cells) for any dimension of a
|
||
/// leaf rectangle: a cut is only made when both resulting halves keep every
|
||
/// dimension at or above this size. `max_depth` caps how many times the
|
||
/// recursion may split; `max_depth: 0` produces a single leaf — the whole map.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct BspConfig {
|
||
/// The minimum allowed extent of any leaf dimension, in cells.
|
||
pub min_leaf: i32,
|
||
/// The maximum recursion depth. `0` yields a single whole-map leaf.
|
||
pub max_depth: u32,
|
||
}
|
||
|
||
impl Default for BspConfig {
|
||
/// A sensible default for the v1 dungeon: leaves no smaller than 6 cells on
|
||
/// a side, recursing up to 4 levels deep (up to 16 leaves).
|
||
fn default() -> Self {
|
||
BspConfig {
|
||
min_leaf: 6,
|
||
max_depth: 4,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A binary-space-partition partitioner pass (spec §4.7).
|
||
///
|
||
/// Splits the map into leaf rectangles and records one placeholder
|
||
/// [`Room`](crate::region::RegionKind::Room) region per leaf. Construct one with
|
||
/// [`BspPartition::new`]; it implements [`Pass`] with the stable name
|
||
/// `"bsp_partition"`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct BspPartition {
|
||
/// The split limits this pass partitions under.
|
||
cfg: BspConfig,
|
||
}
|
||
|
||
impl BspPartition {
|
||
/// Creates a partitioner with the given configuration.
|
||
pub fn new(cfg: BspConfig) -> Self {
|
||
BspPartition { cfg }
|
||
}
|
||
|
||
/// Recursively partitions `rect`, appending each final leaf to `leaves`.
|
||
///
|
||
/// `depth` counts down from the configured maximum: recursion stops when it
|
||
/// reaches `0` or when `rect` is too small to split (neither axis has room
|
||
/// for two halves each at least `min_leaf` wide). Randomness — the axis
|
||
/// choice and the split offset — is drawn from `rng` in a fixed order, so the
|
||
/// resulting leaf set is fully determined by the seed.
|
||
fn split(&self, rect: Rect, depth: u32, rng: &mut Rng, leaves: &mut Vec<Rect>) {
|
||
let min = self.cfg.min_leaf;
|
||
|
||
// A leaf must be at least `2 * min` along an axis to be cut into two
|
||
// halves that each keep `min`. With `min` non-positive every rect is
|
||
// "splittable", so guard against it to keep the recursion well-founded.
|
||
let can_split_w = min > 0 && rect.w >= 2 * min;
|
||
let can_split_h = min > 0 && rect.h >= 2 * min;
|
||
|
||
if depth == 0 || (!can_split_w && !can_split_h) {
|
||
leaves.push(rect);
|
||
return;
|
||
}
|
||
|
||
// Choose the split axis. `true` = vertical cut (split the width into a
|
||
// left/right pair); `false` = horizontal cut (top/bottom pair). When only
|
||
// one axis is splittable we must take it; when both are, pick at random.
|
||
let split_vertical = match (can_split_w, can_split_h) {
|
||
(true, false) => true,
|
||
(false, true) => false,
|
||
_ => rng.chance(0.5),
|
||
};
|
||
|
||
let (first, second) = if split_vertical {
|
||
// Offset is the left half's width: in `[min, w - min]` inclusive, so
|
||
// both halves are at least `min` wide. `range` is half-open, hence
|
||
// the `+ 1` on the upper bound.
|
||
let offset = rng.range(min, rect.w - min + 1);
|
||
(
|
||
Rect::new(rect.x, rect.y, offset, rect.h),
|
||
Rect::new(rect.x + offset, rect.y, rect.w - offset, rect.h),
|
||
)
|
||
} else {
|
||
let offset = rng.range(min, rect.h - min + 1);
|
||
(
|
||
Rect::new(rect.x, rect.y, rect.w, offset),
|
||
Rect::new(rect.x, rect.y + offset, rect.w, rect.h - offset),
|
||
)
|
||
};
|
||
|
||
self.split(first, depth - 1, rng, leaves);
|
||
self.split(second, depth - 1, rng, leaves);
|
||
}
|
||
}
|
||
|
||
impl Pass for BspPartition {
|
||
fn name(&self) -> &str {
|
||
"bsp_partition"
|
||
}
|
||
|
||
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
|
||
// The whole canvas is the root rectangle to partition.
|
||
let map_rect = Rect::new(0, 0, ctx.tiles.width() as i32, ctx.tiles.height() as i32);
|
||
|
||
let mut leaves = Vec::new();
|
||
self.split(map_rect, self.cfg.max_depth, rng, &mut leaves);
|
||
|
||
// One placeholder Room per leaf: bounds = leaf rect, no cells, no tiles
|
||
// carved. `RoomCarver` refines these later.
|
||
for leaf in leaves {
|
||
ctx.add_region(RegionKind::Room, leaf, Vec::new());
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::blackboard::Blackboard;
|
||
use crate::geometry::Point;
|
||
use crate::grid::Grid;
|
||
use crate::map::Tile;
|
||
use crate::region::ConnGraph;
|
||
|
||
/// Builds the precondition this pass requires: a fresh `GenContext` whose
|
||
/// grid is all `Wall` and whose regions/graph/blackboard are empty — exactly
|
||
/// the state `Pipeline::run` would hand the first pass. Constructed by hand
|
||
/// so the test depends on no other pass.
|
||
fn empty_ctx(width: u32, height: u32) -> GenContext {
|
||
GenContext {
|
||
tiles: Grid::new(width, height, Tile::Wall),
|
||
regions: Vec::new(),
|
||
graph: ConnGraph::new(),
|
||
blackboard: Blackboard::new(),
|
||
}
|
||
}
|
||
|
||
/// Runs `BspPartition` with the given config/seed on a fresh `width`×`height`
|
||
/// context and returns the resulting context for inspection.
|
||
fn run(width: u32, height: u32, cfg: BspConfig, seed: u64) -> GenContext {
|
||
let mut ctx = empty_ctx(width, height);
|
||
// Mirror the pipeline's keying so we exercise the real sub-stream path.
|
||
let mut rng = Rng::from_seed(seed).fork("bsp_partition#0");
|
||
BspPartition::new(cfg).apply(&mut ctx, &mut rng);
|
||
ctx
|
||
}
|
||
|
||
/// The pass identifies itself with the exact contract name.
|
||
#[test]
|
||
fn name_is_bsp_partition() {
|
||
assert_eq!(BspPartition::new(BspConfig::default()).name(), "bsp_partition");
|
||
}
|
||
|
||
/// After running on an empty context, `regions` is non-empty and every
|
||
/// region is a `Room` placeholder (kind = Room, empty `cells`).
|
||
#[test]
|
||
fn produces_nonempty_room_placeholders() {
|
||
let ctx = run(64, 48, BspConfig::default(), 0xABCD);
|
||
assert!(!ctx.regions.is_empty(), "partition must yield at least one leaf");
|
||
assert!(
|
||
ctx.regions.iter().all(|r| r.kind == RegionKind::Room),
|
||
"every leaf region must be a Room"
|
||
);
|
||
assert!(
|
||
ctx.regions.iter().all(|r| r.cells.is_empty()),
|
||
"placeholders carry no cells"
|
||
);
|
||
}
|
||
|
||
/// The pass carves no tiles: the grid stays entirely `Wall`.
|
||
#[test]
|
||
fn carves_no_tiles() {
|
||
let mut ctx = empty_ctx(64, 48);
|
||
let mut rng = Rng::from_seed(0xABCD).fork("bsp_partition#0");
|
||
BspPartition::new(BspConfig::default()).apply(&mut ctx, &mut rng);
|
||
assert!(
|
||
ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall),
|
||
"partitioner must not carve any tile"
|
||
);
|
||
}
|
||
|
||
/// Every leaf lies fully within the map and respects `min_leaf` on both
|
||
/// dimensions.
|
||
#[test]
|
||
fn leaves_in_bounds_and_respect_min_leaf() {
|
||
let cfg = BspConfig {
|
||
min_leaf: 5,
|
||
max_depth: 5,
|
||
};
|
||
let (w, h) = (80, 50);
|
||
let map = Rect::new(0, 0, w as i32, h as i32);
|
||
let ctx = run(w, h, cfg, 0x1234_5678);
|
||
|
||
for r in &ctx.regions {
|
||
let b = r.bounds;
|
||
// Within bounds.
|
||
assert!(
|
||
b.x >= 0 && b.y >= 0 && b.right() <= map.right() && b.bottom() <= map.bottom(),
|
||
"leaf {b:?} escapes the map {map:?}"
|
||
);
|
||
// Respects the minimum on both axes.
|
||
assert!(
|
||
b.w >= cfg.min_leaf && b.h >= cfg.min_leaf,
|
||
"leaf {b:?} violates min_leaf {}",
|
||
cfg.min_leaf
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Leaves do not overlap and their areas tile the whole map exactly (no gap,
|
||
/// no overlap): the sum of leaf areas equals the map area, and no pair of
|
||
/// leaves intersects.
|
||
#[test]
|
||
fn leaves_do_not_overlap_and_cover_the_map() {
|
||
let cfg = BspConfig {
|
||
min_leaf: 4,
|
||
max_depth: 6,
|
||
};
|
||
let (w, h) = (70, 56);
|
||
let ctx = run(w, h, cfg, 0xDEAD_BEEF);
|
||
let leaves: Vec<Rect> = ctx.regions.iter().map(|r| r.bounds).collect();
|
||
|
||
// No two distinct leaves intersect.
|
||
for (i, a) in leaves.iter().enumerate() {
|
||
for b in &leaves[i + 1..] {
|
||
assert!(!a.intersects(b), "leaves overlap: {a:?} and {b:?}");
|
||
}
|
||
}
|
||
|
||
// Areas sum to the whole map: a partition leaves no gaps.
|
||
let total: i64 = leaves.iter().map(|r| r.w as i64 * r.h as i64).sum();
|
||
assert_eq!(
|
||
total,
|
||
w as i64 * h as i64,
|
||
"leaf areas must tile the map exactly"
|
||
);
|
||
|
||
// Stronger check: every map cell is covered by exactly one leaf.
|
||
for y in 0..h as i32 {
|
||
for x in 0..w as i32 {
|
||
let p = Point::new(x, y);
|
||
let covering = leaves.iter().filter(|r| r.contains(p)).count();
|
||
assert_eq!(covering, 1, "cell {p:?} covered by {covering} leaves");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Determinism: the same seed (same sub-stream) yields an identical leaf set
|
||
/// (compare region bounds in order).
|
||
#[test]
|
||
fn same_seed_yields_identical_leaves() {
|
||
let cfg = BspConfig::default();
|
||
let a = run(96, 64, cfg, 0x5EED);
|
||
let b = run(96, 64, 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 leaves");
|
||
}
|
||
|
||
/// Different seeds (overwhelmingly) yield a different partition.
|
||
#[test]
|
||
fn distinct_seeds_diverge() {
|
||
let cfg = BspConfig::default();
|
||
let a = run(96, 64, cfg, 1);
|
||
let b = run(96, 64, 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);
|
||
}
|
||
|
||
/// `max_depth: 0` yields a single leaf: the whole map.
|
||
#[test]
|
||
fn max_depth_zero_is_single_whole_map_leaf() {
|
||
let cfg = BspConfig {
|
||
min_leaf: 4,
|
||
max_depth: 0,
|
||
};
|
||
let (w, h) = (40, 30);
|
||
let ctx = run(w, h, cfg, 999);
|
||
|
||
assert_eq!(ctx.regions.len(), 1, "depth 0 must produce exactly one leaf");
|
||
assert_eq!(ctx.regions[0].kind, RegionKind::Room);
|
||
assert_eq!(
|
||
ctx.regions[0].bounds,
|
||
Rect::new(0, 0, w as i32, h as i32),
|
||
"the single leaf must be the whole map"
|
||
);
|
||
}
|
||
|
||
/// A map too small to split (every extent below `2 * min_leaf`) yields the
|
||
/// whole map as one leaf, even with depth budget to spare. Bounds-safety:
|
||
/// no panic, no underflow.
|
||
#[test]
|
||
fn unsplittable_map_yields_single_leaf() {
|
||
let cfg = BspConfig {
|
||
min_leaf: 10,
|
||
max_depth: 8,
|
||
};
|
||
// 12×12 < 2*10 on both axes, so no cut is possible.
|
||
let ctx = run(12, 12, cfg, 42);
|
||
assert_eq!(ctx.regions.len(), 1);
|
||
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 12, 12));
|
||
}
|
||
|
||
/// Bounds-safety on degenerate inputs: a zero-sized map and a degenerate
|
||
/// `min_leaf` must not panic and must still leave the grid all `Wall`.
|
||
#[test]
|
||
fn degenerate_inputs_do_not_panic() {
|
||
// Zero-area map.
|
||
let ctx = run(0, 0, BspConfig::default(), 7);
|
||
assert_eq!(ctx.regions.len(), 1);
|
||
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 0, 0));
|
||
|
||
// min_leaf == 0: must not enable infinite recursion; treated as
|
||
// unsplittable so a single leaf results.
|
||
let ctx = run(
|
||
32,
|
||
32,
|
||
BspConfig {
|
||
min_leaf: 0,
|
||
max_depth: 4,
|
||
},
|
||
7,
|
||
);
|
||
assert_eq!(ctx.regions.len(), 1);
|
||
assert_eq!(ctx.regions[0].bounds, Rect::new(0, 0, 32, 32));
|
||
assert!(ctx.tiles.iter().all(|(_, &t)| t == Tile::Wall));
|
||
}
|
||
}
|