diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 79ddfcb..2a392de 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -27,7 +27,7 @@ pub mod bsp; pub use bsp::{BspConfig, BspPartition}; pub mod room; -pub use room::{RoomCarver, RoomConfig}; +pub use room::{RoomCarver, RoomConfig, ShapeWeights}; pub mod connect; pub use connect::{ConnectConfig, MstConnect}; diff --git a/reikhelm-core/src/passes/room.rs b/reikhelm-core/src/passes/room.rs index fea491e..aea0c2e 100644 --- a/reikhelm-core/src/passes/room.rs +++ b/reikhelm-core/src/passes/room.rs @@ -36,7 +36,7 @@ use serde::{Deserialize, Serialize}; -use crate::geometry::Rect; +use crate::geometry::{Point, Rect}; use crate::map::Tile; use crate::pass::{GenContext, Pass}; 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 /// the leaf boundary so the surrounding wall — and the corridors that pierce it /// — have room to exist. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] 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. @@ -59,26 +59,92 @@ pub struct RoomConfig { pub max_size: i32, /// Cells reserved on every side between the room and its leaf edge. 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 { /// 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 { RoomConfig { min_size: 4, max_size: 10, 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). /// /// 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)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct RoomCarver { /// The size/margin limits rooms are carved under. cfg: RoomConfig, @@ -122,6 +188,129 @@ impl RoomCarver { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { @@ -144,19 +333,27 @@ impl Pass for RoomCarver { // cells. Never removed — that would break RegionId-as-index. 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 - // accessor, so any cell outside the grid is silently skipped. - for p in room.iter() { + // Carve Floor over the shaped cells. Writes route through the + // bounds-safe accessor, so any cell outside the grid is silently + // skipped. + for &p in &cells { 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. + // Finalize the region: bounds is the picked rect (the AABB enclosing + // the shape, whose center is guaranteed carved), and cells are + // exactly the carved interior floor cells. DoorPlacer relies on + // `cells` being the room interior only. let region = &mut ctx.regions[idx]; region.bounds = room; - region.cells = room.iter().collect(); + region.cells = cells; } } } @@ -247,6 +444,7 @@ mod tests { min_size: 3, max_size: 6, margin: 2, + shapes: ShapeWeights::default(), }; let leaves = [Rect::new(2, 2, 18, 18)]; let mut ctx = ctx_with_leaves(24, 24, &leaves); @@ -283,6 +481,7 @@ mod tests { min_size: 6, max_size: 10, margin: 1, + shapes: ShapeWeights::default(), }; // 4x4 leaf: after a 1-cell margin the interior is 2x2 < min_size 6. let leaves = [Rect::new(1, 1, 4, 4)]; @@ -311,6 +510,7 @@ mod tests { min_size: 4, max_size: 8, margin: 1, + shapes: ShapeWeights::default(), }; let leaves = [ Rect::new(0, 0, 16, 16), // big: carved @@ -380,6 +580,7 @@ mod tests { min_size: 4, max_size: 8, margin: 1, + shapes: ShapeWeights::default(), }; // Grid is 8x8, but the leaf claims x:4..16 — half of any room may fall // outside the grid. The carve must clip silently. @@ -426,4 +627,53 @@ mod tests { // The room region was carved. 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(¢er), "{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"); + } + } } diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index 0e38825..b15e09f 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -39,7 +39,7 @@ use serde::{Deserialize, Serialize}; use crate::pass::Pipeline; use crate::passes::{ BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect, - RoomCarver, RoomConfig, + RoomCarver, RoomConfig, ShapeWeights, }; /// Configuration for the [`dungeon`] recipe. @@ -90,6 +90,7 @@ impl Default for DungeonConfig { min_size: 5, max_size: 11, margin: 1, + shapes: ShapeWeights::varied(), }, connect: ConnectConfig { extra_edge_ratio: 0.30, @@ -410,6 +411,7 @@ mod tests { min_size: 5, max_size: 10, margin: 1, + shapes: ShapeWeights::default(), }, ..DungeonConfig::default() }; @@ -432,6 +434,7 @@ mod tests { min_size: 4, max_size: 10, margin: 1, + shapes: ShapeWeights::default(), }, ..DungeonConfig::default() }; @@ -450,6 +453,7 @@ mod tests { min_size: 8, max_size: 4, margin: 0, + shapes: ShapeWeights::default(), }, bsp: BspConfig { min_leaf: 20, @@ -464,6 +468,7 @@ mod tests { min_size: 0, max_size: 4, margin: 0, + shapes: ShapeWeights::default(), }, ..DungeonConfig::default() }; @@ -474,6 +479,7 @@ mod tests { min_size: 2, max_size: 4, margin: -1, + shapes: ShapeWeights::default(), }, ..DungeonConfig::default() }; diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index d73eb72..d85897b 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -36,6 +36,10 @@ const SLIDERS = [ ['rooms.margin', 'margin', 0, 3, 1], ['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02], ['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) {