feat(core): pillars — PillarPlacer decorator + Tile::Pillar

A finishing decorator that scatters free-standing pillar columns into larger
rooms on a spaced interior grid. Sixth pass in the dungeon recipe.

- New Tile::Pillar (impassable like Wall, drawn distinctly). Exhaustive matches
  updated: ascii ('o'), frozen macroquad viz (still compiles), wasm code 3.
- PillarPlacer/PillarConfig (room_chance, min_room, spacing): places isolated
  single-cell pillars only on interior room floor (>= 2 from the edge, never the
  center), on a grid with spacing >= 2 — so the floor stays 4-connected by
  construction and a room is never orphaned. Respects non-rect room shapes
  (pillars land only on actual carved floor).
- DungeonConfig gains `pillars`; recipe appends PillarPlacer after DoorPlacer.
- Renderer draws pillars as lit stone columns with a contact shadow; new
  "pillars" density slider.

Core: 121 tests green (5 new: interior-only placement, floor stays connected,
zero-chance/small-room skip, determinism). clippy clean (core + viz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 00:25:37 -06:00
parent d6dc354a1f
commit b1449248f8
9 changed files with 288 additions and 7 deletions

View file

@ -24,6 +24,7 @@ const fn glyph(tile: Tile) -> char {
Tile::Wall => '#', Tile::Wall => '#',
Tile::Floor => '.', Tile::Floor => '.',
Tile::Door => '+', Tile::Door => '+',
Tile::Pillar => 'o',
} }
} }

View file

@ -29,6 +29,10 @@ pub enum Tile {
Floor, Floor,
/// A doorway between regions (walkable, but semantically a threshold). /// A doorway between regions (walkable, but semantically a threshold).
Door, 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 /// The generator's output: what occupies each cell, the semantic regions, and

View file

@ -37,3 +37,6 @@ pub use corridor::CorridorCarver;
pub mod door; pub mod door;
pub use door::{DoorConfig, DoorPlacer}; pub use door::{DoorConfig, DoorPlacer};
pub mod pillar;
pub use pillar::{PillarConfig, PillarPlacer};

View file

@ -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<crate::geometry::Point>)> = 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<Point> = 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);
}
}

View file

@ -6,7 +6,7 @@
//! order: //! order:
//! //!
//! ```text //! ```text
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer //! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer → PillarPlacer
//! ``` //! ```
//! //!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! 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. //! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as //! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
//! doors (purely cosmetic — connectivity is already established by the floor). //! 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) //! ## Why validation matters (spec §8)
//! //!
@ -39,7 +41,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, ShapeWeights, PillarConfig, PillarPlacer, RoomCarver, RoomConfig, ShapeWeights,
}; };
/// Configuration for the [`dungeon`] recipe. /// Configuration for the [`dungeon`] recipe.
@ -62,6 +64,8 @@ pub struct DungeonConfig {
pub connect: ConnectConfig, pub connect: ConnectConfig,
/// How room↔corridor pierce points are marked as doors. /// How room↔corridor pierce points are marked as doors.
pub doors: DoorConfig, pub doors: DoorConfig,
/// How free-standing pillars are scattered into larger rooms (decorator).
pub pillars: PillarConfig,
} }
impl Default for DungeonConfig { impl Default for DungeonConfig {
@ -96,6 +100,7 @@ impl Default for DungeonConfig {
extra_edge_ratio: 0.30, extra_edge_ratio: 0.30,
}, },
doors: DoorConfig::default(), doors: DoorConfig::default(),
pillars: PillarConfig::default(),
} }
} }
} }
@ -206,7 +211,8 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
.then(RoomCarver::new(cfg.rooms)) .then(RoomCarver::new(cfg.rooms))
.then(MstConnect::new(cfg.connect)) .then(MstConnect::new(cfg.connect))
.then(CorridorCarver::new()) .then(CorridorCarver::new())
.then(DoorPlacer::new(cfg.doors)); .then(DoorPlacer::new(cfg.doors))
.then(PillarPlacer::new(cfg.pillars));
Ok(pipeline) Ok(pipeline)
} }
@ -219,13 +225,14 @@ mod tests {
use crate::region::RegionKind; use crate::region::RegionKind;
use std::collections::{BTreeSet, VecDeque}; use std::collections::{BTreeSet, VecDeque};
/// The five pass names in pipeline order, used to check snapshot labels. /// The pass names in pipeline order, used to check snapshot labels.
const PASS_NAMES: [&str; 5] = [ const PASS_NAMES: [&str; 6] = [
"bsp_partition", "bsp_partition",
"room_carver", "room_carver",
"mst_connect", "mst_connect",
"corridor_carver", "corridor_carver",
"door_placer", "door_placer",
"pillar_placer",
]; ];
/// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)? /// 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); let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
assert_eq!(plain, snapped, "snapshotting must not change the map"); 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(); let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
assert_eq!( assert_eq!(

View file

@ -41,6 +41,7 @@ fn color_of(tile: Tile) -> Color {
Tile::Wall => Color::new(0.07, 0.07, 0.09, 1.0), 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::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
Tile::Door => GOLD, Tile::Door => GOLD,
Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0),
} }
} }

View file

@ -36,6 +36,7 @@ fn tile_code(tile: Tile) -> u8 {
Tile::Wall => 0, Tile::Wall => 0,
Tile::Floor => 1, Tile::Floor => 1,
Tile::Door => 2, Tile::Door => 2,
Tile::Pillar => 3,
} }
} }

View file

@ -40,6 +40,7 @@ const SLIDERS = [
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1], ['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1], ['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1], ['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
['pillars.room_chance', 'pillars', 0, 1, 0.05],
]; ];
function getPath(obj, path) { function getPath(obj, path) {

View file

@ -6,7 +6,7 @@
// same contract the Rust renderers honor. All art (color, light, depth) lives // same contract the Rust renderers honor. All art (color, light, depth) lives
// here; the core stores none of it. // 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. // Palette — renderer-owned. Warm stone on near-black, amber thresholds.
const PAL = { const PAL = {
@ -19,6 +19,8 @@ const PAL = {
wallRim: [120, 110, 128], // rim where stone meets carved floor wallRim: [120, 110, 128], // rim where stone meets carved floor
doorWarm: [212, 165, 74], doorWarm: [212, 165, 74],
doorDark: [120, 84, 32], 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)', outlineRoom: 'rgba(122, 184, 240, 0.85)',
outlineCorr: 'rgba(150, 150, 170, 0.40)', outlineCorr: 'rgba(150, 150, 170, 0.40)',
edge: 'rgba(232, 96, 84, 0.85)', 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. // 4) Vignette over the whole grid for mood.
if (lit && LIGHT.vignette > 0) { if (lit && LIGHT.vignette > 0) {
const cxp = ox + gw / 2, cyp = oy + gh / 2; const cxp = ox + gw / 2, cyp = oy + gh / 2;