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::Wall => '#',
|
||||||
Tile::Floor => '.',
|
Tile::Floor => '.',
|
||||||
Tile::Door => '+',
|
Tile::Door => '+',
|
||||||
|
Tile::Pillar => 'o',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
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:
|
//! 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!(
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue