diff --git a/reikhelm-core/src/ascii.rs b/reikhelm-core/src/ascii.rs index a227540..72bc999 100644 --- a/reikhelm-core/src/ascii.rs +++ b/reikhelm-core/src/ascii.rs @@ -24,6 +24,7 @@ const fn glyph(tile: Tile) -> char { Tile::Wall => '#', Tile::Floor => '.', Tile::Door => '+', + Tile::Pillar => 'o', } } diff --git a/reikhelm-core/src/map.rs b/reikhelm-core/src/map.rs index 156feac..e3f9b01 100644 --- a/reikhelm-core/src/map.rs +++ b/reikhelm-core/src/map.rs @@ -29,6 +29,10 @@ pub enum Tile { Floor, /// A doorway between regions (walkable, but semantically a threshold). Door, + /// A free-standing pillar: an impassable column carved into open floor by a + /// decorator. Not walkable (treated like [`Tile::Wall`] for movement), but + /// semantically distinct so renderers can draw it as a column on lit floor. + Pillar, } /// The generator's output: what occupies each cell, the semantic regions, and diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 2a392de..032da34 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -37,3 +37,6 @@ pub use corridor::CorridorCarver; pub mod door; pub use door::{DoorConfig, DoorPlacer}; + +pub mod pillar; +pub use pillar::{PillarConfig, PillarPlacer}; diff --git a/reikhelm-core/src/passes/pillar.rs b/reikhelm-core/src/passes/pillar.rs new file mode 100644 index 0000000..940565a --- /dev/null +++ b/reikhelm-core/src/passes/pillar.rs @@ -0,0 +1,238 @@ +//! The [`PillarPlacer`] decorator pass. +//! +//! A *decorator* is a finishing pass: it embellishes an already-carved, +//! already-connected map without changing its topology. `PillarPlacer` drops +//! free-standing [`Pillar`](crate::map::Tile::Pillar) columns into the interior +//! of larger rooms, on a regularly spaced grid, to give halls the look of a +//! pillared chamber. +//! +//! ## Connectivity is preserved by construction +//! +//! Pillars are placed as **isolated single cells** on a grid with spacing `>= 2` +//! and only in the room *interior* — never within two cells of the room's +//! bounding edge, and never on the room center. Because every pillar is a lone +//! obstacle with open floor on all four sides (the grid leaves at least one +//! floor cell between pillars), the room's floor stays fully 4-connected: you +//! can always walk around any pillar. So this pass, which runs last in the +//! dungeon recipe (after [`DoorPlacer`](crate::passes::door::DoorPlacer)), cannot +//! orphan a room — a property its tests assert directly. +//! +//! Pillars land **only on cells that are currently [`Floor`](crate::map::Tile::Floor)** +//! and that belong to a room's carved `cells`, so they respect non-rectangular +//! room shapes (a pillar never appears outside the actual carved floor) and never +//! overwrite a [`Door`](crate::map::Tile::Door) or a corridor. + +use serde::{Deserialize, Serialize}; + +use crate::map::Tile; +use crate::pass::{GenContext, Pass}; +use crate::region::RegionKind; +use crate::rng::Rng; + +/// Configuration for a [`PillarPlacer`] pass. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PillarConfig { + /// Probability that a qualifying (large enough) room is given pillars. + /// Clamped to `[0.0, 1.0]`; `0.0` places none. + pub room_chance: f64, + /// Minimum room extent (the smaller of width/height, in cells) for a room to + /// qualify for pillars. Small rooms are left clear. + pub min_room: i32, + /// Grid spacing between pillars, in cells. Clamped to `>= 2` so pillars never + /// form a solid block (which is what keeps the floor 4-connected). + pub spacing: i32, +} + +impl Default for PillarConfig { + /// A sensible default: a little over half of large rooms (min extent >= 7) + /// get a grid of pillars spaced 3 cells apart. + fn default() -> Self { + PillarConfig { + room_chance: 0.55, + min_room: 7, + spacing: 3, + } + } +} + +/// A pillar-placing decorator pass. +/// +/// Construct one with [`PillarPlacer::new`]; it implements [`Pass`] with the +/// stable name `"pillar_placer"`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PillarPlacer { + cfg: PillarConfig, +} + +impl PillarPlacer { + /// Creates a pillar placer with the given configuration. + pub fn new(cfg: PillarConfig) -> Self { + PillarPlacer { cfg } + } +} + +impl Pass for PillarPlacer { + fn name(&self) -> &str { + "pillar_placer" + } + + fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) { + let spacing = self.cfg.spacing.max(2); + let off = spacing / 2; // phase the grid so it sits off the room edges + + // Visit rooms in id order. Collect each room's (bounds, cells) first so we + // can mutate the grid without holding a borrow on `ctx.regions`. + let rooms: Vec<(crate::geometry::Rect, Vec)> = ctx + .regions + .iter() + .filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty()) + .map(|r| (r.bounds, r.cells.clone())) + .collect(); + + for (b, cells) in rooms { + // Only larger rooms qualify; non-qualifying rooms draw no randomness. + if b.w.min(b.h) < self.cfg.min_room { + continue; + } + // One coin per qualifying room decides whether it gets pillars. + if !rng.chance(self.cfg.room_chance) { + continue; + } + + let center = b.center(); + for p in cells { + let lx = p.x - b.x; + let ly = p.y - b.y; + // Keep a clear 2-cell border inside the room and skip the center. + if lx < 2 || ly < 2 || lx > b.w - 3 || ly > b.h - 3 || p == center { + continue; + } + // Place on the spaced grid, but only on actual room floor (so the + // pattern respects the room's shape and never hits a door). + if (lx - off).rem_euclid(spacing) == 0 + && (ly - off).rem_euclid(spacing) == 0 + && ctx.tiles.get(p) == Some(&Tile::Floor) + { + ctx.tiles.set(p, Tile::Pillar); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::blackboard::Blackboard; + use crate::geometry::{Point, Rect}; + use crate::grid::Grid; + use crate::region::{ConnGraph, RegionId}; + use std::collections::BTreeSet; + + /// Builds a context with a single rectangular room carved into Floor — the + /// post-RoomCarver state a decorator would see, constructed by hand. + fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext { + let mut ctx = GenContext { + tiles: Grid::new(w, h, Tile::Wall), + regions: Vec::new(), + graph: ConnGraph::new(), + blackboard: Blackboard::new(), + }; + let cells: Vec = room.iter().collect(); + for &p in &cells { + ctx.tiles.set(p, Tile::Floor); + } + ctx.add_region(RegionKind::Room, room, cells); + ctx + } + + fn run(ctx: &mut GenContext, cfg: PillarConfig, seed: u64) { + let mut rng = Rng::from_seed(seed).fork("pillar_placer#0"); + PillarPlacer::new(cfg).apply(ctx, &mut rng); + } + + fn pillar_count(ctx: &GenContext) -> usize { + ctx.tiles.iter().filter(|&(_, &t)| t == Tile::Pillar).count() + } + + #[test] + fn name_is_pillar_placer() { + assert_eq!(PillarPlacer::new(PillarConfig::default()).name(), "pillar_placer"); + } + + /// With `room_chance = 1.0` a large room gets pillars; every pillar sits on + /// former floor, inside the 2-cell border, and not on the center. + #[test] + fn places_pillars_only_on_interior_floor() { + let room = Rect::new(0, 0, 16, 12); + let mut ctx = ctx_with_room(16, 12, room); + run(&mut ctx, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 42); + + assert!(pillar_count(&ctx) > 0, "a 16x12 room should receive pillars"); + let center = room.center(); + for (p, &t) in ctx.tiles.iter() { + if t == Tile::Pillar { + let (lx, ly) = (p.x - room.x, p.y - room.y); + assert!(lx >= 2 && ly >= 2 && lx <= room.w - 3 && ly <= room.h - 3, + "pillar at {p:?} is inside the clear border"); + assert_ne!(p, center, "the room center stays clear"); + } + } + } + + /// The room floor (Floor|Door) stays 4-connected after pillars are placed — + /// pillars never wall off part of a room. + #[test] + fn pillars_keep_room_floor_connected() { + let room = Rect::new(0, 0, 18, 14); + let mut ctx = ctx_with_room(18, 14, room); + run(&mut ctx, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 7); + + // Total walkable floor cells remaining. + let floor: BTreeSet<(i32, i32)> = ctx + .tiles + .iter() + .filter(|&(_, &t)| t == Tile::Floor) + .map(|(p, _)| (p.x, p.y)) + .collect(); + assert!(!floor.is_empty()); + + // Flood from any one floor cell; it must reach all of them. + let start = *floor.iter().next().unwrap(); + let mut seen = BTreeSet::new(); + let mut stack = vec![start]; + while let Some((x, y)) = stack.pop() { + if !floor.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(), floor.len(), "pillars must leave the floor 4-connected"); + } + + /// `room_chance = 0.0` places no pillars; a small room never qualifies. + #[test] + fn zero_chance_and_small_rooms_get_no_pillars() { + let big = Rect::new(0, 0, 16, 12); + let mut ctx = ctx_with_room(16, 12, big); + run(&mut ctx, PillarConfig { room_chance: 0.0, min_room: 7, spacing: 3 }, 99); + assert_eq!(pillar_count(&ctx), 0, "room_chance 0.0 places nothing"); + + let small = Rect::new(0, 0, 6, 6); + let mut ctx2 = ctx_with_room(6, 6, small); + run(&mut ctx2, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 99); + assert_eq!(pillar_count(&ctx2), 0, "a room below min_room gets none"); + } + + /// Same seed reproduces the same pillar layout. + #[test] + fn pillars_are_deterministic() { + let room = Rect::new(0, 0, 16, 12); + let cfg = PillarConfig::default(); + let mut a = ctx_with_room(16, 12, room); + let mut b = ctx_with_room(16, 12, room); + run(&mut a, cfg, 0x5EED); + run(&mut b, cfg, 0x5EED); + assert_eq!(a.tiles, b.tiles); + } +} diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index b15e09f..187baba 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -6,7 +6,7 @@ //! order: //! //! ```text -//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer +//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer → PillarPlacer //! ``` //! //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder @@ -17,6 +17,8 @@ //! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge. //! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as //! doors (purely cosmetic — connectivity is already established by the floor). +//! 6. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a +//! finishing decorator; isolated obstacles that never break connectivity). //! //! ## Why validation matters (spec §8) //! @@ -39,7 +41,7 @@ use serde::{Deserialize, Serialize}; use crate::pass::Pipeline; use crate::passes::{ BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect, - RoomCarver, RoomConfig, ShapeWeights, + PillarConfig, PillarPlacer, RoomCarver, RoomConfig, ShapeWeights, }; /// Configuration for the [`dungeon`] recipe. @@ -62,6 +64,8 @@ pub struct DungeonConfig { pub connect: ConnectConfig, /// How room↔corridor pierce points are marked as doors. pub doors: DoorConfig, + /// How free-standing pillars are scattered into larger rooms (decorator). + pub pillars: PillarConfig, } impl Default for DungeonConfig { @@ -96,6 +100,7 @@ impl Default for DungeonConfig { extra_edge_ratio: 0.30, }, doors: DoorConfig::default(), + pillars: PillarConfig::default(), } } } @@ -206,7 +211,8 @@ pub fn dungeon(cfg: DungeonConfig) -> Result { .then(RoomCarver::new(cfg.rooms)) .then(MstConnect::new(cfg.connect)) .then(CorridorCarver::new()) - .then(DoorPlacer::new(cfg.doors)); + .then(DoorPlacer::new(cfg.doors)) + .then(PillarPlacer::new(cfg.pillars)); Ok(pipeline) } @@ -219,13 +225,14 @@ mod tests { use crate::region::RegionKind; use std::collections::{BTreeSet, VecDeque}; - /// The five pass names in pipeline order, used to check snapshot labels. - const PASS_NAMES: [&str; 5] = [ + /// The pass names in pipeline order, used to check snapshot labels. + const PASS_NAMES: [&str; 6] = [ "bsp_partition", "room_carver", "mst_connect", "corridor_carver", "door_placer", + "pillar_placer", ]; /// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)? @@ -291,7 +298,7 @@ mod tests { let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7); assert_eq!(plain, snapped, "snapshotting must not change the map"); - assert_eq!(snapshots.len(), 5, "one snapshot per pass (five passes)"); + assert_eq!(snapshots.len(), 6, "one snapshot per pass (six passes)"); let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect(); assert_eq!( diff --git a/reikhelm-viz/src/main.rs b/reikhelm-viz/src/main.rs index cf11749..ec4e1d3 100644 --- a/reikhelm-viz/src/main.rs +++ b/reikhelm-viz/src/main.rs @@ -41,6 +41,7 @@ fn color_of(tile: Tile) -> Color { Tile::Wall => Color::new(0.07, 0.07, 0.09, 1.0), Tile::Floor => Color::new(0.80, 0.76, 0.66, 1.0), Tile::Door => GOLD, + Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0), } } diff --git a/reikhelm-wasm/src/lib.rs b/reikhelm-wasm/src/lib.rs index 9c7dff6..a1a24bd 100644 --- a/reikhelm-wasm/src/lib.rs +++ b/reikhelm-wasm/src/lib.rs @@ -36,6 +36,7 @@ fn tile_code(tile: Tile) -> u8 { Tile::Wall => 0, Tile::Floor => 1, Tile::Door => 2, + Tile::Pillar => 3, } } diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index d85897b..f797ef4 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -40,6 +40,7 @@ const SLIDERS = [ ['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1], ['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1], ['rooms.shapes.plus', '✚ plus', 0, 3, 0.1], + ['pillars.room_chance', 'pillars', 0, 1, 0.05], ]; function getPath(obj, path) { diff --git a/reikhelm-web/render.js b/reikhelm-web/render.js index 1e1c0a8..120faf0 100644 --- a/reikhelm-web/render.js +++ b/reikhelm-web/render.js @@ -6,7 +6,7 @@ // same contract the Rust renderers honor. All art (color, light, depth) lives // here; the core stores none of it. -const WALL = 0, FLOOR = 1, DOOR = 2; +const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3; // Palette — renderer-owned. Warm stone on near-black, amber thresholds. const PAL = { @@ -19,6 +19,8 @@ const PAL = { wallRim: [120, 110, 128], // rim where stone meets carved floor doorWarm: [212, 165, 74], doorDark: [120, 84, 32], + pillarTop: [122, 112, 100], // lit top-left of a stone column + pillarBody: [60, 54, 54], // shadowed lower-right outlineRoom: 'rgba(122, 184, 240, 0.85)', outlineCorr: 'rgba(150, 150, 170, 0.40)', edge: 'rgba(232, 96, 84, 0.85)', @@ -242,6 +244,29 @@ export function renderMap(ctx, vw, vh, env, opts = {}) { } } + // 3.5) Pillars — free-standing stone columns standing on the lit floor (the + // floor under them was already painted in pass 1). + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (tiles[y][x] !== PILLAR) continue; + const cxp = px(x) + cell / 2, cyp = py(y) + cell / 2; + const r = cell * 0.36; + // Contact shadow pooled at the base. + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + ctx.beginPath(); + ctx.ellipse(cxp, cyp + cell * 0.18, r * 1.05, r * 0.62, 0, 0, Math.PI * 2); + ctx.fill(); + // Column body, lit from the top-left. + const grd = ctx.createRadialGradient(cxp - r * 0.4, cyp - r * 0.45, r * 0.1, cxp, cyp, r * 1.1); + grd.addColorStop(0, rgb(PAL.pillarTop)); + grd.addColorStop(1, rgb(PAL.pillarBody)); + ctx.fillStyle = grd; + ctx.beginPath(); + ctx.arc(cxp, cyp, r, 0, Math.PI * 2); + ctx.fill(); + } + } + // 4) Vignette over the whole grid for mood. if (lit && LIGHT.vignette > 0) { const cxp = ox + gw / 2, cyp = oy + gh / 2;