feat(core): polygonal room shapes (rect/octagon/ellipse/plus)

RoomCarver now rolls a per-room shape within its chosen rectangle, weighted by
a new RoomConfig.shapes (ShapeWeights). The rect is still picked first and
unchanged, so room centers — and the corridors wired between them — are
identical to before; only the carved interior differs.

- Shapes built from per-row contiguous spans → guaranteed 4-connected and
  convex, so flood-fill never strands a cell.
- Hard invariant: every shape contains rect.center() (the corridor target),
  with a fallback to a full rect for tiny rooms or any non-covering shape —
  keeps MstConnect/CorridorCarver correct.
- ShapeWeights::default() is rect-only (preserves old behavior + existing
  tests); the dungeon recipe opts into ShapeWeights::varied().
- Configs lose Eq (now carry f64 weights); serde unaffected.

Core: 116 tests green (new forced-shape invariants test: in-bounds, center
carved, non-rectangular, 4-connected); connectivity + determinism + door tests
all pass with varied shapes active. Surfaced as 4 shape-weight sliders in the
web playground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 00:16:45 -06:00
parent f849d82d31
commit a1d2a63937
4 changed files with 273 additions and 13 deletions

View file

@ -27,7 +27,7 @@ pub mod bsp;
pub use bsp::{BspConfig, BspPartition}; pub use bsp::{BspConfig, BspPartition};
pub mod room; pub mod room;
pub use room::{RoomCarver, RoomConfig}; pub use room::{RoomCarver, RoomConfig, ShapeWeights};
pub mod connect; pub mod connect;
pub use connect::{ConnectConfig, MstConnect}; pub use connect::{ConnectConfig, MstConnect};

View file

@ -36,7 +36,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::geometry::Rect; use crate::geometry::{Point, Rect};
use crate::map::Tile; use crate::map::Tile;
use crate::pass::{GenContext, Pass}; use crate::pass::{GenContext, Pass};
use crate::region::RegionKind; use crate::region::RegionKind;
@ -49,7 +49,7 @@ use crate::rng::Rng;
/// `margin` of cells is reserved on every side. The margin keeps room floors off /// `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 /// the leaf boundary so the surrounding wall — and the corridors that pierce it
/// — have room to exist. /// — have room to exist.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RoomConfig { pub struct RoomConfig {
/// The smallest allowed room extent (in cells) on either axis. A leaf that /// 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. /// cannot hold a `min_size`-square room (after the margin) is skipped.
@ -59,26 +59,92 @@ pub struct RoomConfig {
pub max_size: i32, pub max_size: i32,
/// Cells reserved on every side between the room and its leaf edge. /// Cells reserved on every side between the room and its leaf edge.
pub margin: i32, pub margin: i32,
/// Relative weights for the shape each room is carved in (see
/// [`ShapeWeights`]). The primitive default is rectangle-only.
pub shapes: ShapeWeights,
} }
impl Default for RoomConfig { impl Default for RoomConfig {
/// A sensible default for the v1 dungeon: rooms between 4 and 10 cells on a /// A sensible default for the v1 dungeon: rooms between 4 and 10 cells on a
/// side, set in one cell from each leaf edge. /// side, set in one cell from each leaf edge, carved as plain rectangles.
fn default() -> Self { fn default() -> Self {
RoomConfig { RoomConfig {
min_size: 4, min_size: 4,
max_size: 10, max_size: 10,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
} }
} }
} }
/// Relative weights for the shape a room is carved in within its chosen
/// rectangle. Each room independently rolls a [`RoomShape`] with probability
/// proportional to these weights; a `0.0` weight disables that shape, and an
/// all-zero set falls back to a plain rectangle.
///
/// The [`Default`] is **rectangle-only** — the original behavior — so a bare
/// [`RoomCarver`] still carves rectangles. The dungeon recipe opts into a richer
/// mix via [`ShapeWeights::varied`] (see [`DungeonConfig`](crate::recipes::dungeon::DungeonConfig)).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ShapeWeights {
/// Weight of a full axis-aligned rectangle (the whole picked rect).
pub rect: f64,
/// Weight of an octagon — a rectangle with chamfered (cut) corners.
pub octagon: f64,
/// Weight of an inscribed ellipse — a rounded room.
pub ellipse: f64,
/// Weight of a plus/cross — central bars spanning the rect's width and height.
pub plus: f64,
}
impl Default for ShapeWeights {
/// Rectangle-only — the original room shape. Recipes opt into variety.
fn default() -> Self {
ShapeWeights {
rect: 1.0,
octagon: 0.0,
ellipse: 0.0,
plus: 0.0,
}
}
}
impl ShapeWeights {
/// A balanced mix used by the dungeon recipe: rectangles stay common, with a
/// healthy share of octagons and ellipses and the occasional plus.
pub fn varied() -> Self {
ShapeWeights {
rect: 2.0,
octagon: 1.5,
ellipse: 1.5,
plus: 1.0,
}
}
}
/// The shape a room is carved in within its chosen rectangle.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RoomShape {
/// The full rectangle (every cell).
Rect,
/// A rectangle with chamfered corners.
Octagon,
/// An inscribed ellipse.
Ellipse,
/// A central cross spanning the rect.
Plus,
}
/// Below this extent on either axis a room is always a plain rectangle — a
/// shape too small to read as anything else (and to keep tiny rooms solid).
const SHAPE_MIN: i32 = 5;
/// A room-carving shaper pass (spec §4.7). /// A room-carving shaper pass (spec §4.7).
/// ///
/// Turns each placeholder [`Room`](crate::region::RegionKind::Room) region into /// Turns each placeholder [`Room`](crate::region::RegionKind::Room) region into
/// an actual carved room. Construct one with [`RoomCarver::new`]; it implements /// an actual carved room. Construct one with [`RoomCarver::new`]; it implements
/// [`Pass`] with the stable name `"room_carver"`. /// [`Pass`] with the stable name `"room_carver"`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq)]
pub struct RoomCarver { pub struct RoomCarver {
/// The size/margin limits rooms are carved under. /// The size/margin limits rooms are carved under.
cfg: RoomConfig, cfg: RoomConfig,
@ -122,6 +188,129 @@ impl RoomCarver {
Some(Rect::new(x, y, w, h)) Some(Rect::new(x, y, w, h))
} }
/// Rolls a [`RoomShape`] weighted by the config, drawing exactly one value
/// from `rng`. Weights are scaled to integer "milli-weights" so the pick is
/// a single deterministic [`Rng::range`] draw; an all-zero (or negative) set
/// degenerates to [`RoomShape::Rect`].
fn pick_shape(&self, rng: &mut Rng) -> RoomShape {
let w = self.cfg.shapes;
let table = [
(RoomShape::Rect, w.rect),
(RoomShape::Octagon, w.octagon),
(RoomShape::Ellipse, w.ellipse),
(RoomShape::Plus, w.plus),
];
let scaled: [(RoomShape, i32); 4] = table.map(|(s, wt)| (s, (wt.max(0.0) * 1000.0) as i32));
let total: i32 = scaled.iter().map(|(_, n)| n).sum();
if total <= 0 {
return RoomShape::Rect;
}
let mut pick = rng.range(0, total);
for (s, n) in scaled {
if pick < n {
return s;
}
pick -= n;
}
RoomShape::Rect
}
}
/// The interior floor cells for `shape` within `rect`, in row-major order.
///
/// Every shape is built from per-row **contiguous** column spans, which makes
/// the cell set 4-connected and convex enough that a flood-fill reaches every
/// cell from any other (so a shaped room never strands part of itself). As a
/// hard invariant the result always contains `rect.center()` — the cell
/// `MstConnect`/`CorridorCarver` wire corridors to — and a rect too small to
/// read as a shape (or, defensively, any shape that failed to cover the center)
/// falls back to the full rectangle.
fn shape_cells(rect: Rect, shape: RoomShape) -> Vec<Point> {
if shape == RoomShape::Rect || rect.w < SHAPE_MIN || rect.h < SHAPE_MIN {
return rect.iter().collect();
}
let cells = match shape {
RoomShape::Rect => unreachable!("handled above"),
RoomShape::Octagon => octagon_cells(rect),
RoomShape::Ellipse => ellipse_cells(rect),
RoomShape::Plus => plus_cells(rect),
};
if cells.contains(&rect.center()) {
cells
} else {
rect.iter().collect()
}
}
/// Collect row-major cells where row `r` (0-based within `rect`) spans the
/// inclusive *local* column range returned by `span(r)` — clamped to the rect.
/// A row whose `right < left` contributes nothing.
fn span_cells(rect: Rect, mut span: impl FnMut(i32) -> (i32, i32)) -> Vec<Point> {
let mut cells = Vec::new();
for r in 0..rect.h {
let (l, right) = span(r);
let lo = l.max(0);
let hi = right.min(rect.w - 1);
for c in lo..=hi {
cells.push(Point::new(rect.x + c, rect.y + r));
}
}
cells
}
/// A rectangle with chamfered corners: each corner has a triangular bite of
/// side `min(w, h) / 3` removed.
fn octagon_cells(rect: Rect) -> Vec<Point> {
let (w, h) = (rect.w, rect.h);
let cut = (w.min(h) / 3).max(1);
span_cells(rect, |r| {
let top = (cut - r).max(0);
let bottom = (cut - (h - 1 - r)).max(0);
let inset = top.max(bottom);
(inset, w - 1 - inset)
})
}
/// An ellipse inscribed in the rect, filled by per-row half-widths so each row
/// is one contiguous span (the center row spans the full width, so the bounding
/// box stays exactly `rect`).
fn ellipse_cells(rect: Rect) -> Vec<Point> {
let a = rect.w as f64 / 2.0;
let b = rect.h as f64 / 2.0;
let ecx = (rect.w - 1) as f64 / 2.0;
let ecy = (rect.h - 1) as f64 / 2.0;
span_cells(rect, |r| {
let dy = (r as f64 - ecy) / b;
let s = 1.0 - dy * dy;
if s < 0.0 {
return (1, 0); // empty row
}
let hw = a * s.sqrt();
((ecx - hw).ceil() as i32, (ecx + hw).floor() as i32)
})
}
/// A plus/cross: a vertical bar (thickness `w/3`) and a horizontal bar
/// (thickness `h/3`), both centered on `rect.center()` so the center is always
/// carved and the bars reach all four edges.
fn plus_cells(rect: Rect) -> Vec<Point> {
let (w, h) = (rect.w, rect.h);
let tw = (w / 3).max(1);
let th = (h / 3).max(1);
let cx = w / 2; // local center column, matching Rect::center
let cy = h / 2; // local center row
let vx0 = (cx - tw / 2).max(0);
let vx1 = (vx0 + tw - 1).min(w - 1);
let hy0 = (cy - th / 2).max(0);
let hy1 = (hy0 + th - 1).min(h - 1);
span_cells(rect, |r| {
if r >= hy0 && r <= hy1 {
(0, w - 1) // a full-width row through the horizontal bar
} else {
(vx0, vx1) // the vertical bar only
}
})
} }
impl Pass for RoomCarver { impl Pass for RoomCarver {
@ -144,19 +333,27 @@ impl Pass for RoomCarver {
// cells. Never removed — that would break RegionId-as-index. // cells. Never removed — that would break RegionId-as-index.
continue; continue;
}; };
// Roll the room's shape and compute its interior cells. The rect is
// picked first (above) exactly as before, so room positions/sizes —
// and therefore the corridors wired between room centers — are
// unchanged by shaping; only the carved interior differs.
let shape = self.pick_shape(rng);
let cells = shape_cells(room, shape);
// Carve Floor over the room rect. Writes route through the bounds-safe // Carve Floor over the shaped cells. Writes route through the
// accessor, so any cell outside the grid is silently skipped. // bounds-safe accessor, so any cell outside the grid is silently
for p in room.iter() { // skipped.
for &p in &cells {
ctx.tiles.set(p, Tile::Floor); ctx.tiles.set(p, Tile::Floor);
} }
// Finalize the region: bounds become the actual room rect, and cells // Finalize the region: bounds is the picked rect (the AABB enclosing
// become exactly the room interior floor cells (row-major order). // the shape, whose center is guaranteed carved), and cells are
// DoorPlacer relies on `cells` being the room interior only. // exactly the carved interior floor cells. DoorPlacer relies on
// `cells` being the room interior only.
let region = &mut ctx.regions[idx]; let region = &mut ctx.regions[idx];
region.bounds = room; region.bounds = room;
region.cells = room.iter().collect(); region.cells = cells;
} }
} }
} }
@ -247,6 +444,7 @@ mod tests {
min_size: 3, min_size: 3,
max_size: 6, max_size: 6,
margin: 2, margin: 2,
shapes: ShapeWeights::default(),
}; };
let leaves = [Rect::new(2, 2, 18, 18)]; let leaves = [Rect::new(2, 2, 18, 18)];
let mut ctx = ctx_with_leaves(24, 24, &leaves); let mut ctx = ctx_with_leaves(24, 24, &leaves);
@ -283,6 +481,7 @@ mod tests {
min_size: 6, min_size: 6,
max_size: 10, max_size: 10,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
}; };
// 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6. // 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6.
let leaves = [Rect::new(1, 1, 4, 4)]; let leaves = [Rect::new(1, 1, 4, 4)];
@ -311,6 +510,7 @@ mod tests {
min_size: 4, min_size: 4,
max_size: 8, max_size: 8,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
}; };
let leaves = [ let leaves = [
Rect::new(0, 0, 16, 16), // big: carved Rect::new(0, 0, 16, 16), // big: carved
@ -380,6 +580,7 @@ mod tests {
min_size: 4, min_size: 4,
max_size: 8, max_size: 8,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
}; };
// Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall // Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall
// outside the grid. The carve must clip silently. // outside the grid. The carve must clip silently.
@ -426,4 +627,53 @@ mod tests {
// The room region was carved. // The room region was carved.
assert!(!ctx.regions[0].cells.is_empty()); assert!(!ctx.regions[0].cells.is_empty());
} }
/// Each non-rectangular shape, when forced, carves a room that: stays inside
/// its bounds (all Floor), contains its `bounds.center()` as a carved cell
/// (the corridor-target invariant), is strictly smaller than its bounding
/// area (genuinely not a rectangle), and is 4-connected (no stranded cells).
#[test]
fn forced_shapes_fit_bounds_contain_center_and_are_connected() {
use std::collections::BTreeSet;
let only = |octagon, ellipse, plus| ShapeWeights { rect: 0.0, octagon, ellipse, plus };
let cases = [
("octagon", only(1.0, 0.0, 0.0)),
("ellipse", only(0.0, 1.0, 0.0)),
("plus", only(0.0, 0.0, 1.0)),
];
for (name, weights) in cases {
let cfg = RoomConfig { min_size: 9, max_size: 13, margin: 1, shapes: weights };
let leaves = [Rect::new(0, 0, 20, 20)];
let mut ctx = ctx_with_leaves(20, 20, &leaves);
run(&mut ctx, cfg, 0x9001);
let r = &ctx.regions[0];
assert!(!r.cells.is_empty(), "{name}: carved nothing");
for &p in &r.cells {
assert!(r.bounds.contains(p), "{name}: cell {p:?} outside bounds {:?}", r.bounds);
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "{name}: cell {p:?} not Floor");
}
let center = r.bounds.center();
assert!(r.cells.contains(&center), "{name}: center {center:?} must be carved");
assert_eq!(ctx.tiles.get(center), Some(&Tile::Floor));
assert!(
(r.cells.len() as i32) < r.bounds.w * r.bounds.h,
"{name}: a shaped room must carve fewer cells than its bounding area"
);
// Flood-fill from the first cell must reach every cell (4-connected).
let set: BTreeSet<(i32, i32)> = r.cells.iter().map(|p| (p.x, p.y)).collect();
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(), "{name}: shape must be 4-connected");
}
}
} }

View file

@ -39,7 +39,7 @@ 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, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
RoomCarver, RoomConfig, RoomCarver, RoomConfig, ShapeWeights,
}; };
/// Configuration for the [`dungeon`] recipe. /// Configuration for the [`dungeon`] recipe.
@ -90,6 +90,7 @@ impl Default for DungeonConfig {
min_size: 5, min_size: 5,
max_size: 11, max_size: 11,
margin: 1, margin: 1,
shapes: ShapeWeights::varied(),
}, },
connect: ConnectConfig { connect: ConnectConfig {
extra_edge_ratio: 0.30, extra_edge_ratio: 0.30,
@ -410,6 +411,7 @@ mod tests {
min_size: 5, min_size: 5,
max_size: 10, max_size: 10,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
}, },
..DungeonConfig::default() ..DungeonConfig::default()
}; };
@ -432,6 +434,7 @@ mod tests {
min_size: 4, min_size: 4,
max_size: 10, max_size: 10,
margin: 1, margin: 1,
shapes: ShapeWeights::default(),
}, },
..DungeonConfig::default() ..DungeonConfig::default()
}; };
@ -450,6 +453,7 @@ mod tests {
min_size: 8, min_size: 8,
max_size: 4, max_size: 4,
margin: 0, margin: 0,
shapes: ShapeWeights::default(),
}, },
bsp: BspConfig { bsp: BspConfig {
min_leaf: 20, min_leaf: 20,
@ -464,6 +468,7 @@ mod tests {
min_size: 0, min_size: 0,
max_size: 4, max_size: 4,
margin: 0, margin: 0,
shapes: ShapeWeights::default(),
}, },
..DungeonConfig::default() ..DungeonConfig::default()
}; };
@ -474,6 +479,7 @@ mod tests {
min_size: 2, min_size: 2,
max_size: 4, max_size: 4,
margin: -1, margin: -1,
shapes: ShapeWeights::default(),
}, },
..DungeonConfig::default() ..DungeonConfig::default()
}; };

View file

@ -36,6 +36,10 @@ const SLIDERS = [
['rooms.margin', 'margin', 0, 3, 1], ['rooms.margin', 'margin', 0, 3, 1],
['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02], ['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02],
['doors.door_chance', 'door chance', 0, 1, 0.05], ['doors.door_chance', 'door chance', 0, 1, 0.05],
['rooms.shapes.rect', '▢ rect', 0, 3, 0.1],
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
]; ];
function getPath(obj, path) { function getPath(obj, path) {