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:
parent
d6dc354a1f
commit
b1449248f8
9 changed files with 288 additions and 7 deletions
|
|
@ -24,6 +24,7 @@ const fn glyph(tile: Tile) -> char {
|
|||
Tile::Wall => '#',
|
||||
Tile::Floor => '.',
|
||||
Tile::Door => '+',
|
||||
Tile::Pillar => 'o',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -37,3 +37,6 @@ pub use corridor::CorridorCarver;
|
|||
|
||||
pub mod door;
|
||||
pub use door::{DoorConfig, DoorPlacer};
|
||||
|
||||
pub mod pillar;
|
||||
pub use pillar::{PillarConfig, PillarPlacer};
|
||||
|
|
|
|||
238
reikhelm-core/src/passes/pillar.rs
Normal file
238
reikhelm-core/src/passes/pillar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Pipeline, ConfigError> {
|
|||
.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!(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ fn tile_code(tile: Tile) -> u8 {
|
|||
Tile::Wall => 0,
|
||||
Tile::Floor => 1,
|
||||
Tile::Door => 2,
|
||||
Tile::Pillar => 3,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue