feat(core): DoorPlacer pass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42fda84eaa
commit
61aedfa0c7
2 changed files with 483 additions and 0 deletions
480
reikhelm-core/src/passes/door.rs
Normal file
480
reikhelm-core/src/passes/door.rs
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
//! The [`DoorPlacer`] placer pass (spec §4.7).
|
||||||
|
//!
|
||||||
|
//! A *placer* drops semantic markers onto an already-carved map. This is the
|
||||||
|
//! **final** pass of the v1 dungeon recipe: it reads the finished
|
||||||
|
//! [`Tile`](crate::map::Tile) grid, the [`Region`](crate::region::Region) set,
|
||||||
|
//! and the connectivity [`ConnGraph`](crate::region::ConnGraph), then converts
|
||||||
|
//! the [`Wall`](crate::map::Tile::Wall) cells that sit on a room↔corridor
|
||||||
|
//! threshold into [`Door`](crate::map::Tile::Door) tiles and records a
|
||||||
|
//! representative door location into each connected edge's `at`.
|
||||||
|
//!
|
||||||
|
//! Per the inter-pass data contract (see [`crate::passes`]), this pass:
|
||||||
|
//!
|
||||||
|
//! 1. Builds a membership lookup from the regions: which cells are *room floor*
|
||||||
|
//! (a cell in some [`Room`](crate::region::RegionKind::Room) region's `cells`)
|
||||||
|
//! and which are *corridor floor* (a cell in some
|
||||||
|
//! [`Corridor`](crate::region::RegionKind::Corridor) region's `cells`).
|
||||||
|
//! 2. Scans every cell. A `Wall` cell becomes a `Door` when one of its two
|
||||||
|
//! *opposite* orthogonal neighbor pairs — `(N, S)` or `(W, E)` — has one side
|
||||||
|
//! that is a clean room floor and the opposite side that is corridor floor.
|
||||||
|
//! Requiring the two floors to face each other across the wall is what keeps
|
||||||
|
//! doors strictly on the threshold and never mid-room or mid-corridor: a wall
|
||||||
|
//! in open ground, or one with floor on only one side, fails the test.
|
||||||
|
//! 3. For every edge in `ctx.graph`, records a representative door cell into
|
||||||
|
//! `edge.at` — the first placed door (in row-major scan order) whose room
|
||||||
|
//! side belongs to one of the edge's two endpoint rooms.
|
||||||
|
//!
|
||||||
|
//! All tile writes go through the bounds-safe
|
||||||
|
//! [`Grid::set`](crate::grid::Grid::set); all neighbor probing through
|
||||||
|
//! [`Grid`](crate::grid::Grid) accessors, so a wall on the very edge of the map
|
||||||
|
//! (with off-grid "neighbors") is handled without panic — an out-of-bounds
|
||||||
|
//! neighbor simply is not floor (spec §8).
|
||||||
|
//!
|
||||||
|
//! ## Determinism (spec §7)
|
||||||
|
//!
|
||||||
|
//! Cells are scanned in row-major order via [`Grid::iter`](crate::grid::Grid::iter);
|
||||||
|
//! membership is resolved through a [`BTreeMap`](std::collections::BTreeMap)
|
||||||
|
//! keyed by `(x, y)`, never a `HashMap`, so no iteration-order nondeterminism can
|
||||||
|
//! leak into which cell is chosen as an edge's representative door. This pass
|
||||||
|
//! draws **no** randomness — door placement is a pure function of the carved
|
||||||
|
//! geometry — so the passed `rng` is intentionally unused. Its tests build the
|
||||||
|
//! tiles + regions + graph precondition by hand, depending on no other pass.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::geometry::Point;
|
||||||
|
use crate::map::Tile;
|
||||||
|
use crate::pass::{GenContext, Pass};
|
||||||
|
use crate::region::{RegionId, RegionKind};
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
/// A door-placing placer pass (spec §4.7).
|
||||||
|
///
|
||||||
|
/// Converts room↔corridor boundary [`Wall`](crate::map::Tile::Wall) cells into
|
||||||
|
/// [`Door`](crate::map::Tile::Door)s and fills in each connected edge's `at`.
|
||||||
|
/// Construct one with [`DoorPlacer::new`] (or [`Default`]); it implements
|
||||||
|
/// [`Pass`] with the stable name `"door_placer"`. It carries no configuration in
|
||||||
|
/// v1 — door placement is fully determined by the carved geometry.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct DoorPlacer;
|
||||||
|
|
||||||
|
impl DoorPlacer {
|
||||||
|
/// Creates a door placer.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
DoorPlacer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a floor cell belongs to, for door-threshold detection.
|
||||||
|
///
|
||||||
|
/// Tracked per cell because a corridor may be carved *over* a room interior, so
|
||||||
|
/// a single cell can be both — and a door's two sides must be genuinely
|
||||||
|
/// different (a clean room floor facing corridor floor), not the same opened-up
|
||||||
|
/// run. Stored in a [`BTreeMap`] so the scan order that picks each edge's
|
||||||
|
/// representative door is deterministic.
|
||||||
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
|
struct Membership {
|
||||||
|
/// Some [`Room`](RegionKind::Room) region claims this cell. Carries the
|
||||||
|
/// owning room's id, used to attribute a door to an edge endpoint.
|
||||||
|
room: Option<RegionId>,
|
||||||
|
/// Some [`Corridor`](RegionKind::Corridor) region claims this cell.
|
||||||
|
corridor: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pass for DoorPlacer {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"door_placer"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&self, ctx: &mut GenContext, _rng: &mut Rng) {
|
||||||
|
// Build the per-cell membership lookup from the regions. Rooms are
|
||||||
|
// recorded first so a cell that a corridor later carves over still
|
||||||
|
// remembers its owning room id; `corridor` is OR-ed in independently.
|
||||||
|
let mut membership: BTreeMap<(i32, i32), Membership> = BTreeMap::new();
|
||||||
|
for region in &ctx.regions {
|
||||||
|
match region.kind {
|
||||||
|
RegionKind::Room => {
|
||||||
|
for &c in ®ion.cells {
|
||||||
|
let entry = membership.entry((c.x, c.y)).or_default();
|
||||||
|
// Keep the first (lowest-id) room that claims the cell.
|
||||||
|
if entry.room.is_none() {
|
||||||
|
entry.room = Some(region.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RegionKind::Corridor => {
|
||||||
|
for &c in ®ion.cells {
|
||||||
|
membership.entry((c.x, c.y)).or_default().corridor = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers reading the membership map. A cell is a "clean room floor" when
|
||||||
|
// a room claims it and no corridor does — that is the room interior side
|
||||||
|
// of a threshold. A "corridor floor" is any cell a corridor claims.
|
||||||
|
let clean_room = |p: Point| -> Option<RegionId> {
|
||||||
|
membership.get(&(p.x, p.y)).and_then(|m| {
|
||||||
|
if m.corridor {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
m.room
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let is_corridor = |p: Point| membership.get(&(p.x, p.y)).is_some_and(|m| m.corridor);
|
||||||
|
|
||||||
|
// Scan every cell in row-major order. A Wall becomes a Door when one of
|
||||||
|
// its opposite orthogonal neighbor pairs straddles a room/corridor
|
||||||
|
// threshold. Collect placements first (we only read tiles here), then
|
||||||
|
// apply the writes — keeping the borrow of `ctx.tiles` read-only during
|
||||||
|
// the scan. Each placement remembers which room it touched, so edges can
|
||||||
|
// claim a representative door afterward.
|
||||||
|
let mut placements: Vec<(Point, RegionId)> = Vec::new();
|
||||||
|
for (p, &tile) in ctx.tiles.iter() {
|
||||||
|
if tile != Tile::Wall {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The two opposite-neighbor axes: vertical (N/S) and horizontal
|
||||||
|
// (W/E). For each, a door needs one side a clean room floor and the
|
||||||
|
// opposite side corridor floor (in either orientation).
|
||||||
|
let axes = [
|
||||||
|
(p.offset(0, -1), p.offset(0, 1)),
|
||||||
|
(p.offset(-1, 0), p.offset(1, 0)),
|
||||||
|
];
|
||||||
|
for (a, b) in axes {
|
||||||
|
let room_then_corridor = clean_room(a).filter(|_| is_corridor(b));
|
||||||
|
let corridor_then_room = clean_room(b).filter(|_| is_corridor(a));
|
||||||
|
if let Some(room) = room_then_corridor.or(corridor_then_room) {
|
||||||
|
placements.push((p, room));
|
||||||
|
break; // One door per wall cell; don't double-count axes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the chosen walls to doors. Writes route through the bounds-safe
|
||||||
|
// accessor; every `p` here came from `iter()` so it is in bounds anyway.
|
||||||
|
for &(p, _) in &placements {
|
||||||
|
ctx.tiles.set(p, Tile::Door);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a representative door for each edge: the first placed door (in
|
||||||
|
// row-major scan order) whose room side is one of the edge's endpoints.
|
||||||
|
// Edges are visited in graph insertion order; nothing depends on hash
|
||||||
|
// iteration order.
|
||||||
|
for edge in ctx.graph.edges_mut() {
|
||||||
|
if edge.at.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(&(door, _)) = placements
|
||||||
|
.iter()
|
||||||
|
.find(|&&(_, room)| room == edge.a || room == edge.b)
|
||||||
|
{
|
||||||
|
edge.at = Some(door);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::blackboard::Blackboard;
|
||||||
|
use crate::geometry::{Point, Rect};
|
||||||
|
use crate::grid::Grid;
|
||||||
|
use crate::region::{ConnGraph, Region};
|
||||||
|
|
||||||
|
/// Carves a rectangle of `Floor` into `tiles` and returns its cells in
|
||||||
|
/// row-major order — the shape a `Room` interior takes after `RoomCarver`.
|
||||||
|
fn carve_rect(tiles: &mut Grid<Tile>, r: Rect) -> Vec<Point> {
|
||||||
|
let cells: Vec<Point> = r.iter().collect();
|
||||||
|
for &p in &cells {
|
||||||
|
tiles.set(p, Tile::Floor);
|
||||||
|
}
|
||||||
|
cells
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carves an explicit list of `Floor` cells (a corridor run) into `tiles` and
|
||||||
|
/// returns them — the shape a `Corridor` region takes after `CorridorCarver`.
|
||||||
|
fn carve_cells(tiles: &mut Grid<Tile>, cells: &[Point]) -> Vec<Point> {
|
||||||
|
for &p in cells {
|
||||||
|
tiles.set(p, Tile::Floor);
|
||||||
|
}
|
||||||
|
cells.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a region of `kind` with the given bounds + cells, assigning the
|
||||||
|
/// next sequential id (id-as-index invariant).
|
||||||
|
fn push_region(ctx: &mut GenContext, kind: RegionKind, bounds: Rect, cells: Vec<Point>) {
|
||||||
|
let id = RegionId(ctx.regions.len());
|
||||||
|
ctx.regions.push(Region {
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
bounds,
|
||||||
|
cells,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `DoorPlacer` over `ctx`, keyed exactly as the pipeline would
|
||||||
|
/// (`"door_placer#0"` sub-stream). The pass draws no randomness, but we hand
|
||||||
|
/// it a real forked stream to mirror the real call site.
|
||||||
|
fn run(ctx: &mut GenContext, seed: u64) {
|
||||||
|
let mut rng = Rng::from_seed(seed).fork("door_placer#0");
|
||||||
|
DoorPlacer::new().apply(ctx, &mut rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the canonical fixture: a room rectangle, a single `Wall` gap cell,
|
||||||
|
/// then a short corridor run on the far side of that gap, plus an edge.
|
||||||
|
///
|
||||||
|
/// Layout (a slice of a wider grid), with `R` room floor, `#` the wall gap,
|
||||||
|
/// `C` corridor floor, `.` untouched wall:
|
||||||
|
/// ```text
|
||||||
|
/// R R R R # C C
|
||||||
|
/// ```
|
||||||
|
/// The room occupies `x:2..6, y:3..7`; the gap wall is at `(6, 5)`; the
|
||||||
|
/// corridor runs `x:7..10` along `y = 5`. Room is region 0, corridor region 1,
|
||||||
|
/// linked by edge {0, 1} (a contrived self-meaningful edge for the test).
|
||||||
|
fn room_gap_corridor() -> (GenContext, Point) {
|
||||||
|
let mut tiles = Grid::new(16, 12, Tile::Wall);
|
||||||
|
|
||||||
|
let room_rect = Rect::new(2, 3, 4, 4); // x:2..6, y:3..7
|
||||||
|
let room_cells = carve_rect(&mut tiles, room_rect);
|
||||||
|
|
||||||
|
let gap = Point::new(6, 5); // stays Wall; this is the threshold
|
||||||
|
|
||||||
|
let corridor_pts: Vec<Point> = (7..10).map(|x| Point::new(x, 5)).collect();
|
||||||
|
let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
|
||||||
|
let corridor_bounds = Rect::new(7, 5, 3, 1);
|
||||||
|
|
||||||
|
let mut ctx = GenContext {
|
||||||
|
tiles,
|
||||||
|
regions: Vec::new(),
|
||||||
|
graph: ConnGraph::new(),
|
||||||
|
blackboard: Blackboard::new(),
|
||||||
|
};
|
||||||
|
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
|
||||||
|
push_region(&mut ctx, RegionKind::Corridor, corridor_bounds, corridor_cells);
|
||||||
|
ctx.graph.add_edge(RegionId(0), RegionId(1));
|
||||||
|
|
||||||
|
(ctx, gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pass identifies itself with the exact contract name.
|
||||||
|
#[test]
|
||||||
|
fn name_is_door_placer() {
|
||||||
|
assert_eq!(DoorPlacer::new().name(), "door_placer");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single wall cell between room floor and corridor floor becomes a Door.
|
||||||
|
#[test]
|
||||||
|
fn threshold_wall_becomes_door() {
|
||||||
|
let (mut ctx, gap) = room_gap_corridor();
|
||||||
|
assert_eq!(ctx.tiles.get(gap), Some(&Tile::Wall), "gap starts as Wall");
|
||||||
|
|
||||||
|
run(&mut ctx, 0xABCD);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ctx.tiles.get(gap),
|
||||||
|
Some(&Tile::Door),
|
||||||
|
"the room↔corridor threshold wall must become a Door"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No Door is placed in the middle of a room or in the middle of a corridor;
|
||||||
|
/// the only Door is the single threshold cell.
|
||||||
|
#[test]
|
||||||
|
fn no_spurious_doors_mid_room_or_corridor() {
|
||||||
|
let (mut ctx, gap) = room_gap_corridor();
|
||||||
|
run(&mut ctx, 0xABCD);
|
||||||
|
|
||||||
|
let doors: Vec<Point> = ctx
|
||||||
|
.tiles
|
||||||
|
.iter()
|
||||||
|
.filter(|&(_, &t)| t == Tile::Door)
|
||||||
|
.map(|(p, _)| p)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(doors, vec![gap], "exactly one door, at the threshold");
|
||||||
|
|
||||||
|
// No interior room cell or corridor cell was turned into a door (they
|
||||||
|
// were Floor and must stay Floor).
|
||||||
|
for &p in &ctx.regions[0].cells {
|
||||||
|
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "room floor untouched");
|
||||||
|
}
|
||||||
|
for &p in &ctx.regions[1].cells {
|
||||||
|
assert_eq!(
|
||||||
|
ctx.tiles.get(p),
|
||||||
|
Some(&Tile::Floor),
|
||||||
|
"corridor floor untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At least one connected edge has `at == Some(_)` after running, and it
|
||||||
|
/// points at the placed door.
|
||||||
|
#[test]
|
||||||
|
fn connected_edge_records_door_location() {
|
||||||
|
let (mut ctx, gap) = room_gap_corridor();
|
||||||
|
run(&mut ctx, 0xABCD);
|
||||||
|
|
||||||
|
let edges = ctx.graph.edges();
|
||||||
|
assert!(
|
||||||
|
edges.iter().any(|e| e.at.is_some()),
|
||||||
|
"a connected edge must record a door location"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
edges[0].at,
|
||||||
|
Some(gap),
|
||||||
|
"the edge's representative door is the threshold cell"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wall with floor on only one side (a plain room exterior wall) is NOT a
|
||||||
|
/// door: there is no corridor on the opposite side.
|
||||||
|
#[test]
|
||||||
|
fn plain_room_wall_is_not_a_door() {
|
||||||
|
let mut tiles = Grid::new(10, 10, Tile::Wall);
|
||||||
|
let room_rect = Rect::new(2, 2, 4, 4);
|
||||||
|
let room_cells = carve_rect(&mut tiles, room_rect);
|
||||||
|
|
||||||
|
let mut ctx = GenContext {
|
||||||
|
tiles,
|
||||||
|
regions: Vec::new(),
|
||||||
|
graph: ConnGraph::new(),
|
||||||
|
blackboard: Blackboard::new(),
|
||||||
|
};
|
||||||
|
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
|
||||||
|
|
||||||
|
run(&mut ctx, 7);
|
||||||
|
|
||||||
|
// No corridor anywhere, so no wall qualifies as a door.
|
||||||
|
assert!(
|
||||||
|
ctx.tiles.iter().all(|(_, &t)| t != Tile::Door),
|
||||||
|
"a room with no adjoining corridor must get no doors"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wall whose opposite sides are both corridor floor (corridor passing
|
||||||
|
/// straight through a wall gap with no room) is NOT a door: a door needs a
|
||||||
|
/// room side.
|
||||||
|
#[test]
|
||||||
|
fn corridor_only_gap_is_not_a_door() {
|
||||||
|
let mut tiles = Grid::new(10, 6, Tile::Wall);
|
||||||
|
// Two corridor segments with a single wall gap between them at (4, 3).
|
||||||
|
let left: Vec<Point> = (1..4).map(|x| Point::new(x, 3)).collect();
|
||||||
|
let right: Vec<Point> = (5..8).map(|x| Point::new(x, 3)).collect();
|
||||||
|
let left_cells = carve_cells(&mut tiles, &left);
|
||||||
|
let right_cells = carve_cells(&mut tiles, &right);
|
||||||
|
let gap = Point::new(4, 3);
|
||||||
|
|
||||||
|
let mut ctx = GenContext {
|
||||||
|
tiles,
|
||||||
|
regions: Vec::new(),
|
||||||
|
graph: ConnGraph::new(),
|
||||||
|
blackboard: Blackboard::new(),
|
||||||
|
};
|
||||||
|
push_region(&mut ctx, RegionKind::Corridor, Rect::new(1, 3, 3, 1), left_cells);
|
||||||
|
push_region(&mut ctx, RegionKind::Corridor, Rect::new(5, 3, 3, 1), right_cells);
|
||||||
|
|
||||||
|
run(&mut ctx, 11);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ctx.tiles.get(gap),
|
||||||
|
Some(&Tile::Wall),
|
||||||
|
"a corridor-to-corridor wall gap is not a door (no room side)"
|
||||||
|
);
|
||||||
|
assert!(ctx.tiles.iter().all(|(_, &t)| t != Tile::Door));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds-safety: a threshold sitting on the very edge of the map, with the
|
||||||
|
/// room's wall ring partly off-grid, must not panic. Here the room hugs the
|
||||||
|
/// top-left and the corridor reaches it from the right at row 0.
|
||||||
|
#[test]
|
||||||
|
fn threshold_at_map_edge_does_not_panic() {
|
||||||
|
let mut tiles = Grid::new(8, 8, Tile::Wall);
|
||||||
|
// Room floor at column 0, rows 0..3 (its left/top walls are off-grid).
|
||||||
|
let room_pts: Vec<Point> = (0..3).map(|y| Point::new(0, y)).collect();
|
||||||
|
let room_cells = carve_cells(&mut tiles, &room_pts);
|
||||||
|
// Gap wall at (1, 0); corridor floor at (2, 0).
|
||||||
|
let gap = Point::new(1, 0);
|
||||||
|
let corridor_cells = carve_cells(&mut tiles, &[Point::new(2, 0)]);
|
||||||
|
|
||||||
|
let mut ctx = GenContext {
|
||||||
|
tiles,
|
||||||
|
regions: Vec::new(),
|
||||||
|
graph: ConnGraph::new(),
|
||||||
|
blackboard: Blackboard::new(),
|
||||||
|
};
|
||||||
|
push_region(&mut ctx, RegionKind::Room, Rect::new(0, 0, 1, 3), room_cells);
|
||||||
|
push_region(&mut ctx, RegionKind::Corridor, Rect::new(2, 0, 1, 1), corridor_cells);
|
||||||
|
|
||||||
|
// Must not panic even with off-grid neighbor probes at the boundary.
|
||||||
|
run(&mut ctx, 0xDEAD);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ctx.tiles.get(gap),
|
||||||
|
Some(&Tile::Door),
|
||||||
|
"an edge-of-map threshold is still a door"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determinism: the same input reproduces identical door tiles and identical
|
||||||
|
/// edge `at` values.
|
||||||
|
#[test]
|
||||||
|
fn same_input_yields_identical_doors() {
|
||||||
|
let (mut a, _) = room_gap_corridor();
|
||||||
|
let (mut b, _) = room_gap_corridor();
|
||||||
|
run(&mut a, 0x5EED);
|
||||||
|
run(&mut b, 0x5EED);
|
||||||
|
|
||||||
|
assert_eq!(a.tiles, b.tiles, "door tiles must be reproducible");
|
||||||
|
|
||||||
|
let at_a: Vec<Option<Point>> = a.graph.edges().iter().map(|e| e.at).collect();
|
||||||
|
let at_b: Vec<Option<Point>> = b.graph.edges().iter().map(|e| e.at).collect();
|
||||||
|
assert_eq!(at_a, at_b, "edge door locations must be reproducible");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A corridor carved straight over a room interior (cells in both region
|
||||||
|
/// sets) does not spawn interior doors: the overlapped cells are corridor
|
||||||
|
/// floor, so no wall between them and the room is a clean-room/corridor
|
||||||
|
/// threshold inside the room.
|
||||||
|
#[test]
|
||||||
|
fn corridor_overlapping_room_makes_no_interior_doors() {
|
||||||
|
let mut tiles = Grid::new(12, 12, Tile::Wall);
|
||||||
|
let room_rect = Rect::new(2, 2, 5, 5);
|
||||||
|
let room_cells = carve_rect(&mut tiles, room_rect);
|
||||||
|
// Corridor runs along row 4 from inside the room out to the right.
|
||||||
|
let corridor_pts: Vec<Point> = (4..10).map(|x| Point::new(x, 4)).collect();
|
||||||
|
let corridor_cells = carve_cells(&mut tiles, &corridor_pts);
|
||||||
|
|
||||||
|
let mut ctx = GenContext {
|
||||||
|
tiles,
|
||||||
|
regions: Vec::new(),
|
||||||
|
graph: ConnGraph::new(),
|
||||||
|
blackboard: Blackboard::new(),
|
||||||
|
};
|
||||||
|
push_region(&mut ctx, RegionKind::Room, room_rect, room_cells);
|
||||||
|
push_region(
|
||||||
|
&mut ctx,
|
||||||
|
RegionKind::Corridor,
|
||||||
|
Rect::new(4, 4, 6, 1),
|
||||||
|
corridor_cells,
|
||||||
|
);
|
||||||
|
|
||||||
|
run(&mut ctx, 3);
|
||||||
|
|
||||||
|
// The only legitimate door is the wall at (7, 4): room floor at (6,4) is
|
||||||
|
// overlapped by the corridor, so the clean-room side is at the room's
|
||||||
|
// right wall... actually (6,4) is both room and corridor. The threshold
|
||||||
|
// door sits where a clean room cell faces corridor floor across a wall.
|
||||||
|
// No door should appear *inside* the room rectangle.
|
||||||
|
let interior = room_rect.inflate(-1);
|
||||||
|
for (p, &t) in ctx.tiles.iter() {
|
||||||
|
if t == Tile::Door {
|
||||||
|
assert!(
|
||||||
|
!interior.contains(p),
|
||||||
|
"door at {p:?} sits inside the room interior {interior:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,3 +34,6 @@ pub use connect::{ConnectConfig, MstConnect};
|
||||||
|
|
||||||
pub mod corridor;
|
pub mod corridor;
|
||||||
pub use corridor::CorridorCarver;
|
pub use corridor::CorridorCarver;
|
||||||
|
|
||||||
|
pub mod door;
|
||||||
|
pub use door::DoorPlacer;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue