feat(core): room theming — RoomThemer classifier + themed renderer (10th pass)

Adds RegionTheme + RoomThemer: a pure classifier (no tile changes) that
labels each room (terrain-driven Forge/Cistern/Hall, special Throne/
Threshold, weighted Crypt/Den/Library/Vault/Stone). Renderer tints themed
rooms; EntityPlacer biases contents by theme. Entrance/exit now chosen
RNG-free from geometry so themer & placer agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 17:03:53 -06:00
parent e3501a02f3
commit 0aec5e927d
13 changed files with 673 additions and 38 deletions

View file

@ -76,7 +76,7 @@ mod tests {
use super::*; use super::*;
use crate::entity::{Entity, EntityKind}; use crate::entity::{Entity, EntityKind};
use crate::geometry::{Point, Rect}; use crate::geometry::{Point, Rect};
use crate::region::{RegionId, RegionKind}; use crate::region::{RegionId, RegionKind, RegionTheme};
/// Builds a tiny but structurally complete `Map` for serde exercises. /// Builds a tiny but structurally complete `Map` for serde exercises.
fn sample_map() -> Map { fn sample_map() -> Map {
@ -89,6 +89,7 @@ mod tests {
kind: RegionKind::Room, kind: RegionKind::Room,
bounds: Rect::new(1, 0, 1, 2), bounds: Rect::new(1, 0, 1, 2),
cells: vec![Point::new(1, 0), Point::new(1, 1)], cells: vec![Point::new(1, 0), Point::new(1, 1)],
theme: Some(RegionTheme::Vault),
}; };
let mut graph = ConnGraph::new(); let mut graph = ConnGraph::new();

View file

@ -75,6 +75,7 @@ impl GenContext {
kind, kind,
bounds, bounds,
cells, cells,
theme: None,
}); });
id id
} }

View file

@ -455,6 +455,7 @@ mod tests {
kind: RegionKind::Corridor, kind: RegionKind::Corridor,
bounds: Rect::new(30, 0, 4, 1), bounds: Rect::new(30, 0, 4, 1),
cells: vec![Point::new(30, 0)], cells: vec![Point::new(30, 0)],
theme: None,
}); });
run(&mut ctx, ConnectConfig::default(), 0xBEEF); run(&mut ctx, ConnectConfig::default(), 0xBEEF);

View file

@ -325,6 +325,7 @@ mod tests {
kind, kind,
bounds, bounds,
cells, cells,
theme: None,
}); });
} }

View file

@ -13,14 +13,18 @@
//! //!
//! Placement, in a fixed (deterministic) order: //! Placement, in a fixed (deterministic) order:
//! //!
//! 1. **Entrance** — a random room; the entity sits on the floor cell nearest its //! 1. **Entrance** — the way-in room chosen *purely from geometry* (no RNG) by
//! [`entrance_exit_indices`]; the entity sits on the floor cell nearest its
//! center. //! center.
//! 2. **Exit** — the room whose center is farthest (Manhattan) from the entrance, //! 2. **Exit** — the room whose center is farthest (Manhattan) from the entrance,
//! again on its central floor cell. Gives the dungeon a traversal goal. //! again on its central floor cell. Gives the dungeon a traversal goal. (The
//! 3. **Treasure** — each room has a `treasure_chance` of holding one, on a random //! entrance/exit choice is RNG-free so a themer can reproduce it and land its
//! open floor cell. //! `Throne`/`Threshold` themes on these very rooms.)
//! 4. **Monsters** — each room *except the entrance room* has a `monster_chance` of //! 3. **Treasure** — each room has a `treasure_chance` of holding one (scaled by
//! holding 1..=`max_monsters`, scattered on open floor. //! the room's [`RegionTheme`] treasure bias), on a random open floor cell.
//! 4. **Monsters** — each room *except the entrance room* has a `monster_chance`
//! (scaled by the room's theme monster bias) of holding 1..=`max_monsters`,
//! scattered on open floor.
//! //!
//! Entities only ever land on plain [`Floor`](crate::map::Tile::Floor) (never a //! Entities only ever land on plain [`Floor`](crate::map::Tile::Floor) (never a
//! door, pool, pillar, or wall), and never two on one cell, so a renderer or game //! door, pool, pillar, or wall), and never two on one cell, so a renderer or game
@ -33,9 +37,37 @@ use crate::entity::{Entity, EntityKind, BLACKBOARD_KEY};
use crate::geometry::Point; use crate::geometry::Point;
use crate::map::Tile; use crate::map::Tile;
use crate::pass::{GenContext, Pass}; use crate::pass::{GenContext, Pass};
use crate::region::{Region, RegionKind}; use crate::region::{Region, RegionKind, RegionTheme};
use crate::rng::Rng; use crate::rng::Rng;
/// Picks `(entrance_index, exit_index)` into a room list purely from geometry —
/// **no RNG** — so any pass can reproduce the same choice without coupling to
/// another pass's random stream. `centers[i]` is room `i`'s center.
///
/// - The **entrance** is the room whose center is lexicographically smallest
/// (top-most row, then left-most column): a stable, corner-ward way in.
/// - The **exit** is the room whose center is farthest (Manhattan) from the
/// entrance — the natural traversal goal.
///
/// Returns `None` for an empty list; with a single room both indices are `0`.
/// [`RoomThemer`](crate::passes::room_themer::RoomThemer) calls this with the
/// same room set to land its `Throne`/`Threshold` themes on the rooms that
/// actually receive the `Exit`/`Entrance` markers — the two passes agree by
/// construction rather than by replaying each other's randomness.
pub(crate) fn entrance_exit_indices(centers: &[Point]) -> Option<(usize, usize)> {
if centers.is_empty() {
return None;
}
let entrance = (0..centers.len())
.min_by_key(|&i| (centers[i].y, centers[i].x))
.expect("non-empty");
let from = centers[entrance];
let exit = (0..centers.len())
.max_by_key(|&i| centers[i].manhattan(from))
.unwrap_or(entrance);
Some((entrance, exit))
}
/// Configuration for an [`EntityPlacer`] pass. /// Configuration for an [`EntityPlacer`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EntityConfig { pub struct EntityConfig {
@ -82,6 +114,8 @@ struct RoomInfo {
center: Point, center: Point,
/// The room's open-floor cells (Tile::Floor only), row-major. /// The room's open-floor cells (Tile::Floor only), row-major.
floor: Vec<Point>, floor: Vec<Point>,
/// The room's theme, if a themer classified it (drives content biases).
theme: Option<RegionTheme>,
} }
impl Pass for EntityPlacer { impl Pass for EntityPlacer {
@ -104,40 +138,49 @@ impl Pass for EntityPlacer {
.copied() .copied()
.filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor)) .filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor))
.collect(), .collect(),
theme: r.theme,
}) })
.collect(); .collect();
let mut entities: Vec<Entity> = Vec::new(); let mut entities: Vec<Entity> = Vec::new();
let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new(); let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new();
if !rooms.is_empty() { // Entrance/exit are chosen by the shared geometry helper (no RNG), so the
// 1) Entrance — a random room, central floor cell. // themer's Throne/Threshold land on these very rooms (see
let entrance_idx = rng.range(0, rooms.len() as i32) as usize; // [`entrance_exit_indices`]).
let centers: Vec<Point> = rooms.iter().map(|r| r.center).collect();
if let Some((entrance_idx, exit_idx)) = entrance_exit_indices(&centers) {
// 1) Entrance — the way-in room, central floor cell.
if let Some(p) = nearest_free(&rooms[entrance_idx], rooms[entrance_idx].center, &taken) { if let Some(p) = nearest_free(&rooms[entrance_idx], rooms[entrance_idx].center, &taken) {
taken.insert((p.x, p.y)); taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Entrance, p)); entities.push(Entity::new(EntityKind::Entrance, p));
} }
// 2) Exit — the room whose center is farthest from the entrance. // 2) Exit — the far room, central floor cell.
let from = rooms[entrance_idx].center;
let exit_idx = (0..rooms.len())
.max_by_key(|&i| rooms[i].center.manhattan(from))
.unwrap_or(entrance_idx);
if let Some(p) = nearest_free(&rooms[exit_idx], rooms[exit_idx].center, &taken) { if let Some(p) = nearest_free(&rooms[exit_idx], rooms[exit_idx].center, &taken) {
taken.insert((p.x, p.y)); taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Exit, p)); entities.push(Entity::new(EntityKind::Exit, p));
} }
// 3 & 4) Per-room treasure and monsters, in room order for determinism. // 3 & 4) Per-room treasure and monsters, in room order for determinism.
// Each room's theme scales the base chances (no theme = neutral 1.0),
// clamped to a valid probability — so a Vault is loot-rich and a Den
// is monster-dense, while the entrance room stays monster-free.
for (i, room) in rooms.iter().enumerate() { for (i, room) in rooms.iter().enumerate() {
if rng.chance(self.cfg.treasure_chance) { let (treasure_bias, monster_bias) =
room.theme.map(RegionTheme::biases).unwrap_or((1.0, 1.0));
let treasure_p = (self.cfg.treasure_chance * treasure_bias).clamp(0.0, 1.0);
if rng.chance(treasure_p) {
if let Some(p) = choose_free(room, &taken, rng) { if let Some(p) = choose_free(room, &taken, rng) {
taken.insert((p.x, p.y)); taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Treasure, p)); entities.push(Entity::new(EntityKind::Treasure, p));
} }
} }
// Keep the entrance room clear of monsters. // Keep the entrance room clear of monsters.
if i != entrance_idx && rng.chance(self.cfg.monster_chance) { let monster_p = (self.cfg.monster_chance * monster_bias).clamp(0.0, 1.0);
if i != entrance_idx && rng.chance(monster_p) {
let n = rng.range(1, self.cfg.max_monsters.max(1) + 1); let n = rng.range(1, self.cfg.max_monsters.max(1) + 1);
for _ in 0..n { for _ in 0..n {
match choose_free(room, &taken, rng) { match choose_free(room, &taken, rng) {
@ -274,4 +317,37 @@ mod tests {
let mut ctx = ctx_with_rooms(8, 8, &[]); let mut ctx = ctx_with_rooms(8, 8, &[]);
assert!(run(&mut ctx, EntityConfig::default(), 1).is_empty()); assert!(run(&mut ctx, EntityConfig::default(), 1).is_empty());
} }
/// Theme biases scale per-room density: a `Threshold` room (monster_bias 0.0)
/// gets no monsters even at monster_chance 1.0, while a `Vault`
/// (treasure_bias 2.5) is guaranteed treasure when its scaled chance saturates
/// to 1.0. A `None` theme stays neutral.
#[test]
fn theme_biases_scale_treasure_and_monsters() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
// Entrance (geometry) = room 0; exit = room 1. Theme room 1 a Threshold
// (monster_bias 0.0) and room 2 a Vault (treasure_bias 2.5).
ctx.regions[1].theme = Some(RegionTheme::Threshold);
ctx.regions[2].theme = Some(RegionTheme::Vault);
// 0.4 treasure * 2.5 Vault = 1.0 (guaranteed); 1.0 monster * 0.0 = 0.0.
let cfg = EntityConfig { treasure_chance: 0.4, monster_chance: 1.0, max_monsters: 3 };
let entities = run(&mut ctx, cfg, 0xBADF00D);
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
// The Threshold room (room 1) holds no monsters despite monster_chance 1.0.
let monsters_in_room1 = entities
.iter()
.filter(|e| e.kind == EntityKind::Monster && in_room(e.at, rooms[1]))
.count();
assert_eq!(monsters_in_room1, 0, "monster_bias 0.0 must suppress all monsters");
// The Vault room (room 2) is guaranteed a treasure (scaled chance == 1.0).
let treasure_in_room2 = entities
.iter()
.any(|e| e.kind == EntityKind::Treasure && in_room(e.at, rooms[2]));
assert!(treasure_in_room2, "treasure_bias 2.5 must guarantee Vault treasure");
}
} }

View file

@ -47,5 +47,8 @@ pub use pool::{PoolConfig, PoolDecorator};
pub mod pillar; pub mod pillar;
pub use pillar::{PillarConfig, PillarPlacer}; pub use pillar::{PillarConfig, PillarPlacer};
pub mod room_themer;
pub use room_themer::{RoomThemer, ThemeConfig};
pub mod entity_placer; pub mod entity_placer;
pub use entity_placer::{EntityConfig, EntityPlacer}; pub use entity_placer::{EntityConfig, EntityPlacer};

View file

@ -616,6 +616,7 @@ mod tests {
kind: RegionKind::Corridor, kind: RegionKind::Corridor,
bounds: corridor_bounds, bounds: corridor_bounds,
cells: vec![Point::new(16, 0)], cells: vec![Point::new(16, 0)],
theme: None,
}); });
run(&mut ctx, RoomConfig::default(), 99); run(&mut ctx, RoomConfig::default(), 99);

View file

@ -0,0 +1,337 @@
//! The [`RoomThemer`] classifier pass: give every room a [`RegionTheme`].
//!
//! A *classifier* — the one pass that reads the finished terrain and labels what
//! each room **is** without changing a single tile. It runs after every shaper
//! and decorator (rooms, caves, corridors, doors, pools, pillars) and before
//! [`EntityPlacer`](crate::passes::entity_placer::EntityPlacer), writing a
//! [`RegionTheme`] onto each real [`Room`](crate::region::RegionKind::Room)
//! region's [`theme`](crate::region::Region::theme) field. Because it mutates no
//! tiles, regions' shapes, or edges, it carries **zero connectivity risk**.
//!
//! Two consumers read the theme it assigns: the renderer (which tints a themed
//! room's floor/walls) and `EntityPlacer` (whose treasure/monster density is
//! scaled by the theme's [`biases`](RegionTheme::biases)).
//!
//! ## Assignment (deterministic, first match wins)
//!
//! For each real room, in region-id order, the first matching rule applies:
//!
//! 1. **Forge** — the room contains any [`Lava`](crate::map::Tile::Lava) tile.
//! 2. **Cistern** — it contains any [`Water`](crate::map::Tile::Water) tile.
//! 3. **Hall** — it contains any [`Pillar`](crate::map::Tile::Pillar) tile.
//! 4. **Throne** — it is the dungeon's *exit* room (far end / boss seat).
//! 5. **Threshold** — it is the *entrance* room (the safe way in).
//! 6. otherwise a weighted-random pick from the *plain* pool
//! ({Vault, Library, Den, Crypt, Stone}, weights from [`ThemeConfig`]).
//!
//! Rules 15 draw **no** randomness, so only plain rooms consume a draw from this
//! pass's stream — keeping the weighted pool reproducible regardless of how many
//! terrain/special rooms precede a given room. Entrance and exit are chosen by the
//! same geometry helper [`EntityPlacer`](crate::passes::entity_placer::EntityPlacer)
//! uses ([`entrance_exit_indices`](crate::passes::entity_placer::entrance_exit_indices)),
//! so the `Throne`/`Threshold` themes always land on the rooms that actually get
//! the `Exit`/`Entrance` markers — the two passes agree by construction.
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::passes::entity_placer::entrance_exit_indices;
use crate::region::{RegionKind, RegionTheme};
use crate::rng::Rng;
/// Configuration for a [`RoomThemer`] pass: the relative weights of the *plain*
/// (non-terrain, non-entrance/exit) room themes.
///
/// Terrain-driven themes (Forge/Cistern/Hall) and the special Throne/Threshold are
/// not weighted — they are assigned with certainty when their condition holds.
/// Only a plain room rolls against these weights. A `0.0` weight disables a theme;
/// an all-zero set degenerates to [`Stone`](RegionTheme::Stone).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Weight of the loot-jackpot [`Vault`](RegionTheme::Vault) — rare by default.
pub vault: f64,
/// Weight of the [`Library`](RegionTheme::Library).
pub library: f64,
/// Weight of the monster-dense [`Den`](RegionTheme::Den).
pub den: f64,
/// Weight of the undead-heavy [`Crypt`](RegionTheme::Crypt).
pub crypt: f64,
/// Weight of plain [`Stone`](RegionTheme::Stone) — the heaviest, so most rooms
/// stay neutral and the special themes read as discoveries.
pub stone: f64,
}
impl Default for ThemeConfig {
/// A sensible mix: mostly bare stone, a healthy spread of crypts and dens,
/// some libraries, and the occasional vault.
fn default() -> Self {
ThemeConfig {
vault: 1.0,
library: 2.0,
den: 3.0,
crypt: 3.0,
stone: 6.0,
}
}
}
/// A room-theming classifier pass.
///
/// Construct one with [`RoomThemer::new`]; it implements [`Pass`] with the stable
/// name `"room_themer"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RoomThemer {
cfg: ThemeConfig,
}
impl RoomThemer {
/// Creates a room themer with the given configuration.
pub fn new(cfg: ThemeConfig) -> Self {
RoomThemer { cfg }
}
/// Draws one weighted plain-pool theme from `rng`. Weights are scaled to
/// integer "milli-weights" so the pick is a single [`Rng::range`] draw; an
/// all-zero (or negative) set degenerates to [`Stone`](RegionTheme::Stone).
fn pick_pool_theme(&self, rng: &mut Rng) -> RegionTheme {
let c = self.cfg;
let table = [
(RegionTheme::Vault, c.vault),
(RegionTheme::Library, c.library),
(RegionTheme::Den, c.den),
(RegionTheme::Crypt, c.crypt),
(RegionTheme::Stone, c.stone),
];
let scaled: [(RegionTheme, i32); 5] =
table.map(|(t, w)| (t, (w.max(0.0) * 1000.0) as i32));
let total: i32 = scaled.iter().map(|(_, n)| n).sum();
if total <= 0 {
return RegionTheme::Stone;
}
let mut pick = rng.range(0, total);
for (t, n) in scaled {
if pick < n {
return t;
}
pick -= n;
}
RegionTheme::Stone
}
}
/// A real room's data, snapshotted so theming doesn't hold a borrow on `ctx`.
struct RoomData {
/// Index of this room's region in `ctx.regions` (its `RegionId`).
idx: usize,
center: Point,
has_lava: bool,
has_water: bool,
has_pillar: bool,
}
impl Pass for RoomThemer {
fn name(&self) -> &str {
"room_themer"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// Snapshot every real room (kind==Room, non-empty cells) with a cheap
// terrain summary. Read-only over regions+tiles; we mutate themes after.
let rooms: Vec<RoomData> = ctx
.regions
.iter()
.enumerate()
.filter(|(_, r)| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|(idx, r)| {
let mut has_lava = false;
let mut has_water = false;
let mut has_pillar = false;
for &p in &r.cells {
match ctx.tiles.get(p) {
Some(&Tile::Lava) => has_lava = true,
Some(&Tile::Water) => has_water = true,
Some(&Tile::Pillar) => has_pillar = true,
_ => {}
}
}
RoomData {
idx,
center: r.bounds.center(),
has_lava,
has_water,
has_pillar,
}
})
.collect();
// Entrance/exit by the same geometry helper EntityPlacer uses, so
// Throne/Threshold match the rooms that get the Exit/Entrance markers.
let centers: Vec<Point> = rooms.iter().map(|r| r.center).collect();
let (entrance_k, exit_k) = match entrance_exit_indices(&centers) {
Some(pair) => pair,
None => return, // no real rooms — nothing to theme
};
// Resolve each room's theme by the priority walk, then write them back.
// Only the plain-pool branch draws from `rng`, in room (id) order.
let assignments: Vec<(usize, RegionTheme)> = rooms
.iter()
.enumerate()
.map(|(k, room)| {
let theme = if room.has_lava {
RegionTheme::Forge
} else if room.has_water {
RegionTheme::Cistern
} else if room.has_pillar {
RegionTheme::Hall
} else if k == exit_k {
RegionTheme::Throne
} else if k == entrance_k {
RegionTheme::Threshold
} else {
self.pick_pool_theme(rng)
};
(room.idx, theme)
})
.collect();
for (idx, theme) in assignments {
ctx.regions[idx].theme = Some(theme);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::ConnGraph;
/// Builds a context with `rooms` rectangular Floor rooms (each `(x,y,w,h)`),
/// the post-decorator state a classifier would see.
fn ctx_with_rooms(w: u32, h: u32, rooms: &[(i32, i32, i32, i32)]) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
for &(x, y, rw, rh) in rooms {
let bounds = Rect::new(x, y, rw, rh);
let cells: Vec<Point> = bounds.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, bounds, cells);
}
ctx
}
fn run(ctx: &mut GenContext, cfg: ThemeConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("room_themer#0");
RoomThemer::new(cfg).apply(ctx, &mut rng);
}
fn themes(ctx: &GenContext) -> Vec<Option<RegionTheme>> {
ctx.regions.iter().map(|r| r.theme).collect()
}
/// Force the pool to always pick Stone, so non-terrain/non-special rooms are
/// deterministic in tests that probe the special/terrain rules.
fn stone_only() -> ThemeConfig {
ThemeConfig {
vault: 0.0,
library: 0.0,
den: 0.0,
crypt: 0.0,
stone: 1.0,
}
}
#[test]
fn name_is_room_themer() {
assert_eq!(RoomThemer::new(ThemeConfig::default()).name(), "room_themer");
}
/// Terrain wins, in priority order: a room with lava is a Forge, with water a
/// Cistern, with a pillar a Hall — regardless of entrance/exit position.
#[test]
fn terrain_drives_forge_cistern_hall() {
// Three rooms, each seeded with one terrain tile inside its interior.
let mut ctx = ctx_with_rooms(40, 12, &[(0, 0, 8, 8), (12, 0, 8, 8), (24, 0, 8, 8)]);
ctx.tiles.set(Point::new(3, 3), Tile::Lava); // room 0
ctx.tiles.set(Point::new(15, 3), Tile::Water); // room 1
ctx.tiles.set(Point::new(27, 3), Tile::Pillar); // room 2
run(&mut ctx, stone_only(), 1);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Forge));
assert_eq!(ctx.regions[1].theme, Some(RegionTheme::Cistern));
assert_eq!(ctx.regions[2].theme, Some(RegionTheme::Hall));
}
/// Lava beats water beats pillar when a room holds more than one (priority).
#[test]
fn terrain_priority_lava_beats_water_beats_pillar() {
let mut ctx = ctx_with_rooms(16, 12, &[(0, 0, 10, 10)]);
ctx.tiles.set(Point::new(2, 2), Tile::Pillar);
ctx.tiles.set(Point::new(3, 3), Tile::Water);
ctx.tiles.set(Point::new(4, 4), Tile::Lava);
run(&mut ctx, stone_only(), 1);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Forge));
}
/// With no terrain, the geometric entrance (top-left-most) is the Threshold
/// and the farthest room is the Throne; the rest fall to the pool (Stone here).
#[test]
fn entrance_is_threshold_and_far_room_is_throne() {
// Centers: (4,4) top-left, (24,4), (4,16). Entrance = room 0; farthest
// (Manhattan from (4,4)) is room 1 (dist 20) > room 2 (dist 12).
let mut ctx = ctx_with_rooms(32, 24, &[(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)]);
run(&mut ctx, stone_only(), 7);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Threshold), "entrance room");
assert_eq!(ctx.regions[1].theme, Some(RegionTheme::Throne), "far/exit room");
assert_eq!(ctx.regions[2].theme, Some(RegionTheme::Stone), "plain room");
}
/// The pass changes no tiles — it is a pure classifier.
#[test]
fn changes_no_tiles() {
let mut ctx = ctx_with_rooms(32, 16, &[(0, 0, 10, 10), (16, 0, 10, 10)]);
let before = ctx.tiles.clone();
run(&mut ctx, ThemeConfig::default(), 42);
assert_eq!(ctx.tiles, before, "RoomThemer must not touch any tile");
// Every real room got a theme; corridors/none stay None (there are none here).
assert!(ctx.regions.iter().all(|r| r.theme.is_some()));
}
/// Same seed reproduces the same themes.
#[test]
fn theming_is_deterministic() {
let layout = [(0, 0, 9, 9), (14, 0, 9, 9), (0, 12, 9, 9), (14, 12, 9, 9)];
let cfg = ThemeConfig::default();
let mut a = ctx_with_rooms(28, 24, &layout);
let mut b = ctx_with_rooms(28, 24, &layout);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(themes(&a), themes(&b));
}
/// Corridors and empty-`cells` placeholder rooms are never themed.
#[test]
fn corridors_and_empty_rooms_stay_unthemed() {
let mut ctx = ctx_with_rooms(40, 12, &[(0, 0, 9, 9), (20, 0, 9, 9)]);
// An uncarved placeholder room (empty cells) and a corridor.
ctx.add_region(RegionKind::Room, Rect::new(31, 0, 4, 4), Vec::new());
ctx.add_region(RegionKind::Corridor, Rect::new(9, 4, 11, 1), vec![Point::new(9, 4)]);
run(&mut ctx, ThemeConfig::default(), 3);
assert!(ctx.regions[0].theme.is_some());
assert!(ctx.regions[1].theme.is_some());
assert_eq!(ctx.regions[2].theme, None, "empty placeholder room unthemed");
assert_eq!(ctx.regions[3].theme, None, "corridor unthemed");
}
}

View file

@ -7,7 +7,7 @@
//! //!
//! ```text //! ```text
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver //! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
//! → DoorPlacer → PoolDecorator → PillarPlacer → EntityPlacer //! → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer
//! ``` //! ```
//! //!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
@ -24,7 +24,10 @@
//! (a connectivity-guarded decorator — never orphans part of a room). //! (a connectivity-guarded decorator — never orphans part of a room).
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a //! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
//! finishing decorator; isolated obstacles that never break connectivity). //! finishing decorator; isolated obstacles that never break connectivity).
//! 8. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and //! 8. [`RoomThemer`] reads the finished terrain and labels each room with a
//! [`RegionTheme`](crate::region::RegionTheme) — a pure classifier that
//! changes no tiles, driving the renderer's tint and the entity biases.
//! 9. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and
//! scattered treasure and monsters — as an overlay layer (no terrain change). //! scattered treasure and monsters — as an overlay layer (no terrain change).
//! //!
//! ## Why validation matters (spec §8) //! ## Why validation matters (spec §8)
@ -49,7 +52,7 @@ use crate::pass::Pipeline;
use crate::passes::{ use crate::passes::{
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig, BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
PoolDecorator, RoomCarver, RoomConfig, ShapeWeights, PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig,
}; };
/// Configuration for the [`dungeon`] recipe. /// Configuration for the [`dungeon`] recipe.
@ -78,6 +81,8 @@ pub struct DungeonConfig {
pub pools: PoolConfig, pub pools: PoolConfig,
/// How free-standing pillars are scattered into larger rooms (decorator). /// How free-standing pillars are scattered into larger rooms (decorator).
pub pillars: PillarConfig, pub pillars: PillarConfig,
/// The relative weights of the plain (non-terrain) room themes (classifier).
pub themes: ThemeConfig,
/// How the dungeon is populated with entities (entrance, exit, treasure, /// How the dungeon is populated with entities (entrance, exit, treasure,
/// monsters). /// monsters).
pub entities: EntityConfig, pub entities: EntityConfig,
@ -118,6 +123,7 @@ impl Default for DungeonConfig {
doors: DoorConfig::default(), doors: DoorConfig::default(),
pools: PoolConfig::default(), pools: PoolConfig::default(),
pillars: PillarConfig::default(), pillars: PillarConfig::default(),
themes: ThemeConfig::default(),
entities: EntityConfig::default(), entities: EntityConfig::default(),
} }
} }
@ -233,6 +239,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
.then(DoorPlacer::new(cfg.doors)) .then(DoorPlacer::new(cfg.doors))
.then(PoolDecorator::new(cfg.pools)) .then(PoolDecorator::new(cfg.pools))
.then(PillarPlacer::new(cfg.pillars)) .then(PillarPlacer::new(cfg.pillars))
.then(RoomThemer::new(cfg.themes))
.then(EntityPlacer::new(cfg.entities)); .then(EntityPlacer::new(cfg.entities));
Ok(pipeline) Ok(pipeline)
@ -247,7 +254,7 @@ mod tests {
use std::collections::{BTreeSet, VecDeque}; use std::collections::{BTreeSet, VecDeque};
/// The 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; 9] = [ const PASS_NAMES: [&str; 10] = [
"bsp_partition", "bsp_partition",
"room_carver", "room_carver",
"cave_shaper", "cave_shaper",
@ -256,6 +263,7 @@ mod tests {
"door_placer", "door_placer",
"pool_decorator", "pool_decorator",
"pillar_placer", "pillar_placer",
"room_themer",
"entity_placer", "entity_placer",
]; ];
@ -322,13 +330,13 @@ 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(), 9, "one snapshot per pass (nine passes)"); assert_eq!(snapshots.len(), 10, "one snapshot per pass (ten 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!(
labels, labels,
PASS_NAMES.to_vec(), PASS_NAMES.to_vec(),
"snapshot labels must be the five pass names in pipeline order" "snapshot labels must be the ten pass names in pipeline order"
); );
} }
@ -639,6 +647,67 @@ mod tests {
} }
} }
// --- Test 7b: room themes -----------------------------------------------
/// Every real room is themed (none left `None`); corridors are never themed;
/// each dungeon has *at most* one Throne and one Threshold (the exit/entrance
/// rooms — unless terrain outranks them); and across many seeds both special
/// themes do appear.
#[test]
fn default_dungeon_themes_every_room_with_special_rooms() {
use crate::region::RegionTheme;
let cfg = DungeonConfig::default();
let (mut total_throne, mut total_threshold) = (0usize, 0usize);
for seed in 1..=24u64 {
let map = dungeon(cfg).unwrap().run(seed);
for room in real_rooms(&map) {
assert!(
room.theme.is_some(),
"seed {seed}: real room {:?} was left unthemed",
room.id
);
}
// Corridors carry no theme.
for r in map.regions.iter().filter(|r| r.kind == RegionKind::Corridor) {
assert_eq!(r.theme, None, "seed {seed}: corridor {:?} was themed", r.id);
}
let count = |t: RegionTheme| real_rooms(&map).iter().filter(|r| r.theme == Some(t)).count();
// Throne/Threshold mark single rooms — terrain may pre-empt them
// (a lava exit reads Forge), so the count is 0 or 1, never more.
assert!(count(RegionTheme::Throne) <= 1, "seed {seed}: more than one Throne");
assert!(count(RegionTheme::Threshold) <= 1, "seed {seed}: more than one Threshold");
total_throne += count(RegionTheme::Throne);
total_threshold += count(RegionTheme::Threshold);
}
assert!(total_throne > 0, "no Throne appeared across 24 seeds");
assert!(total_threshold > 0, "no Threshold appeared across 24 seeds");
}
/// A room flooded with lava is always a Forge, and one with water a Cistern —
/// the terrain→theme correspondence the renderer relies on. We scan seeds with
/// pools forced on until we see each, so the assertion is a real guard.
#[test]
fn lava_and_water_rooms_are_forge_and_cistern() {
use crate::region::RegionTheme;
let cfg = DungeonConfig {
pools: crate::passes::PoolConfig { water_chance: 0.6, lava_chance: 0.6, min_room: 7 },
..DungeonConfig::default()
};
for seed in 1..=24u64 {
let map = dungeon(cfg).unwrap().run(seed);
for room in real_rooms(&map) {
let has = |t: Tile| room.cells.iter().any(|&c| map.tiles.get(c) == Some(&t));
if has(Tile::Lava) {
assert_eq!(room.theme, Some(RegionTheme::Forge), "lava room must be a Forge");
} else if has(Tile::Water) {
assert_eq!(room.theme, Some(RegionTheme::Cistern), "water room must be a Cistern");
}
}
}
}
// --- Test 8: eyeball render --------------------------------------------- // --- Test 8: eyeball render ---------------------------------------------
/// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an /// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an

View file

@ -40,6 +40,65 @@ pub enum RegionKind {
Corridor, Corridor,
} }
/// An optional *flavor* a recipe may assign to a region — a layer of meaning
/// **orthogonal** to [`RegionKind`] (kind says *room vs corridor*; theme says
/// *what kind of place this room is*).
///
/// A themer pass classifies each room into one of these (e.g. a room flooded
/// with lava reads as a [`Forge`](RegionTheme::Forge)); renderers tint the room
/// accordingly and a populator biases its contents. Like [`RegionKind`] and
/// [`crate::map::Tile`], the set is **additive** — a future town recipe could add
/// `Market`/`Temple`/`Slum` without disturbing existing matches — and entirely
/// optional: a region with `theme: None` is simply unthemed (the neutral look).
///
/// Each variant carries a pair of *content biases* via [`biases`](RegionTheme::biases),
/// the only gameplay knob the core attaches to a theme. All color/lighting is a
/// renderer concern and lives in the renderer, keyed by the theme's wire tag.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RegionTheme {
/// A magma smithy — terrain-driven by lava. Loot-rich, a touch dangerous.
Forge,
/// A flooded reservoir — terrain-driven by water. Damp, low loot.
Cistern,
/// A pillared ceremonial hall — terrain-driven by pillars. Stately.
Hall,
/// The boss seat at the dungeon's far end (the exit room). High risk, high reward.
Throne,
/// The safe entrance chamber. Understated, kept monster-free.
Threshold,
/// A sealed treasury — the loot jackpot, lightly guarded.
Vault,
/// A scriptorium of tomes and scrolls. Loot-rich, low danger.
Library,
/// A monster lair / guardroom. Combat-dense, little loot.
Den,
/// A cold tomb of the restless dead. Undead-heavy, moderate grave-goods.
Crypt,
/// Plain torch-lit stone — the neutral baseline (all biases 1.0).
Stone,
}
impl RegionTheme {
/// The `(treasure_bias, monster_bias)` multipliers a populator applies to its
/// base per-room treasure/monster chances. `Stone` is the `(1.0, 1.0)`
/// neutral baseline; biases above 1 saturate the chance (a near-certain
/// "this room always has loot / a boss" telegraph), below 1 thin it out.
pub fn biases(self) -> (f64, f64) {
match self {
RegionTheme::Forge => (1.4, 1.2),
RegionTheme::Cistern => (0.7, 1.1),
RegionTheme::Hall => (1.1, 0.85),
RegionTheme::Throne => (2.4, 2.3),
RegionTheme::Threshold => (0.5, 0.0),
RegionTheme::Vault => (2.5, 0.4),
RegionTheme::Library => (1.6, 0.5),
RegionTheme::Den => (0.7, 2.4),
RegionTheme::Crypt => (1.2, 1.8),
RegionTheme::Stone => (1.0, 1.0),
}
}
}
/// A semantic area of the map: an id, a kind, a bounding box, and its cells. /// A semantic area of the map: an id, a kind, a bounding box, and its cells.
/// ///
/// `bounds` is the axis-aligned bounding box; `cells` is the exact set of /// `bounds` is the axis-aligned bounding box; `cells` is the exact set of
@ -56,6 +115,10 @@ pub struct Region {
pub bounds: Rect, pub bounds: Rect,
/// The exact cells this region occupies, in the order they were added. /// The exact cells this region occupies, in the order they were added.
pub cells: Vec<Point>, pub cells: Vec<Point>,
/// An optional flavor for this region (see [`RegionTheme`]). `None` until a
/// themer pass classifies it; corridors and unthemed rooms stay `None`.
#[serde(default)]
pub theme: Option<RegionTheme>,
} }
/// A connection between two regions in the [`ConnGraph`]. /// A connection between two regions in the [`ConnGraph`].

View file

@ -29,7 +29,7 @@ use reikhelm_core::grid::Grid;
use reikhelm_core::map::{Map, Tile}; use reikhelm_core::map::{Map, Tile};
use reikhelm_core::pass::Snapshot; use reikhelm_core::pass::Snapshot;
use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig}; use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig};
use reikhelm_core::region::{Edge, Region, RegionKind}; use reikhelm_core::region::{Edge, Region, RegionKind, RegionTheme};
/// The integer tile code sent on the wire. Renderer concern; not a core type. /// The integer tile code sent on the wire. Renderer concern; not a core type.
fn tile_code(tile: Tile) -> u8 { fn tile_code(tile: Tile) -> u8 {
@ -51,6 +51,23 @@ fn kind_str(kind: RegionKind) -> &'static str {
} }
} }
/// The region-theme tag sent on the wire (snake_case; the renderer keys its
/// per-theme palette off these). Colors live in the renderer, never the core.
fn theme_str(theme: RegionTheme) -> &'static str {
match theme {
RegionTheme::Forge => "forge",
RegionTheme::Cistern => "cistern",
RegionTheme::Hall => "hall",
RegionTheme::Throne => "throne",
RegionTheme::Threshold => "threshold",
RegionTheme::Vault => "vault",
RegionTheme::Library => "library",
RegionTheme::Den => "den",
RegionTheme::Crypt => "crypt",
RegionTheme::Stone => "stone",
}
}
/// A tile grid flattened to row-major rows of integer codes. /// A tile grid flattened to row-major rows of integer codes.
fn tiles_to_rows(tiles: &Grid<Tile>) -> Vec<Vec<u8>> { fn tiles_to_rows(tiles: &Grid<Tile>) -> Vec<Vec<u8>> {
let w = tiles.width() as i32; let w = tiles.width() as i32;
@ -73,13 +90,17 @@ struct RectDto {
h: i32, h: i32,
} }
/// A semantic region on the wire: id, kind tag, bounds, and `[x, y]` cells. /// A semantic region on the wire: id, kind tag, bounds, `[x, y]` cells, and an
/// optional theme tag (present once a themer has run — `None` for corridors,
/// unthemed rooms, and the pre-themer snapshot stages).
#[derive(Serialize)] #[derive(Serialize)]
struct RegionDto { struct RegionDto {
id: usize, id: usize,
kind: &'static str, kind: &'static str,
bounds: RectDto, bounds: RectDto,
cells: Vec<[i32; 2]>, cells: Vec<[i32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
theme: Option<&'static str>,
} }
impl From<&Region> for RegionDto { impl From<&Region> for RegionDto {
@ -94,6 +115,7 @@ impl From<&Region> for RegionDto {
h: r.bounds.h, h: r.bounds.h,
}, },
cells: r.cells.iter().map(|p| [p.x, p.y]).collect(), cells: r.cells.iter().map(|p| [p.x, p.y]).collect(),
theme: r.theme.map(theme_str),
} }
} }
} }

View file

@ -3,7 +3,7 @@
// a dungeon in-browser, and hands the envelope to the renderer. // a dungeon in-browser, and hands the envelope to the renderer.
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js'; import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
import { renderMap, getStage } from './render.js'; import { renderMap, getStage, THEMES } from './render.js';
const canvas = document.getElementById('view'); const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@ -21,7 +21,7 @@ const state = {
env: null, // last successful envelope env: null, // last successful envelope
stage: 0, // snapshot index currently shown stage: 0, // snapshot index currently shown
followFinal: true, followFinal: true,
opts: { lighting: true, outlines: false, graph: false, grid: false, entities: true }, opts: { lighting: true, themes: true, entities: true, outlines: false, graph: false, grid: false },
genMs: 0, genMs: 0,
}; };
@ -92,6 +92,15 @@ function countTiles(env, code) {
return n; return n;
} }
// Count themed rooms by theme id (final-stage regions carry the theme tag).
function themeCounts(env) {
const counts = {};
for (const r of env.regions) {
if (r.kind === 'Room' && r.theme) counts[r.theme] = (counts[r.theme] || 0) + 1;
}
return counts;
}
function updateReadout() { function updateReadout() {
const env = state.env; const env = state.env;
const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length; const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length;
@ -99,12 +108,18 @@ function updateReadout() {
const doors = countTiles(env, 2); const doors = countTiles(env, 2);
const ents = env.entities || []; const ents = env.entities || [];
const byKind = (k) => ents.filter((e) => e.kind === k).length; const byKind = (k) => ents.filter((e) => e.kind === k).length;
const counts = themeCounts(env);
const legend = Object.keys(THEMES)
.filter((k) => counts[k])
.map((k) => `${THEMES[k].glyph} ${THEMES[k].label} <b>${counts[k]}</b>`)
.join(' · ');
readout.querySelector('#stats').innerHTML = readout.querySelector('#stats').innerHTML =
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` + `seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` + `<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
`gen <b>${state.genMs.toFixed(1)}</b> ms<br>` + `gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` + `◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit`; `${byKind('Entrance')} entrance · ${byKind('Exit')} exit` +
(legend ? `<br>${legend}` : '');
} }
// --- controls UI --------------------------------------------------------- // --- controls UI ---------------------------------------------------------
@ -150,7 +165,7 @@ function buildControls() {
// Toggles. // Toggles.
const togRow = document.createElement('div'); const togRow = document.createElement('div');
togRow.className = 'row toggles'; togRow.className = 'row toggles';
togRow.innerHTML = ['lighting', 'entities', 'outlines', 'graph', 'grid'] togRow.innerHTML = ['lighting', 'themes', 'entities', 'outlines', 'graph', 'grid']
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`) .map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
.join(''); .join('');
panel.appendChild(togRow); panel.appendChild(togRow);
@ -202,6 +217,7 @@ window.reikhelm = {
water: state.env ? countTiles(state.env, 4) : 0, water: state.env ? countTiles(state.env, 4) : 0,
lava: state.env ? countTiles(state.env, 5) : 0, lava: state.env ? countTiles(state.env, 5) : 0,
entities: state.env ? state.env.entities.length : 0, entities: state.env ? state.env.entities.length : 0,
themes: state.env ? themeCounts(state.env) : {},
ok: !!state.env }), ok: !!state.env }),
}; };

View file

@ -35,6 +35,25 @@ const PAL = {
grid: 'rgba(255,255,255,0.045)', grid: 'rgba(255,255,255,0.045)',
}; };
// Room themes — renderer-owned palette keyed by the wire theme id. Each theme
// retints a room's floor (`floor`) and wall rim (`accent`) and biases its torch
// warmth (`warm`, where 0.5 reproduces the untinted baseline). All floor values
// sit in the ~90..170/channel band so the lighting model reads unchanged —
// themes shift hue/temperature, never brightness. `glyph`/`label` feed the page
// legend. Tuned by eye against the warm-stone base.
export const THEMES = {
forge: { floor: [150, 98, 70], accent: [228, 118, 50], warm: 1.0, glyph: '🔥', label: 'Forge' },
cistern: { floor: [98, 116, 134], accent: [84, 148, 178], warm: 0.12, glyph: '💧', label: 'Cistern' },
hall: { floor: [144, 136, 118], accent: [190, 162, 104], warm: 0.6, glyph: '🏛', label: 'Hall' },
throne: { floor: [120, 100, 140], accent: [202, 142, 200], warm: 0.5, glyph: '👑', label: 'Throne' },
threshold: { floor: [126, 138, 128], accent: [116, 192, 148], warm: 0.42, glyph: '🚪', label: 'Threshold' },
vault: { floor: [142, 132, 102], accent: [216, 184, 92], warm: 0.6, glyph: '💰', label: 'Vault' },
library: { floor: [138, 120, 94], accent: [120, 90, 60], warm: 0.68, glyph: '📜', label: 'Library' },
den: { floor: [120, 96, 90], accent: [186, 92, 80], warm: 0.4, glyph: '⚔', label: 'Den' },
crypt: { floor: [106, 112, 130], accent: [126, 138, 168], warm: 0.08, glyph: '💀', label: 'Crypt' },
stone: { floor: [138, 133, 127], accent: [120, 110, 128], warm: 0.5, glyph: '⛏', label: 'Stone' },
};
// Lighting tunables (tweaked by eye via screenshots). // Lighting tunables (tweaked by eye via screenshots).
const LIGHT = { const LIGHT = {
ambient: 0.44, // floor brightness with no room glow ambient: 0.44, // floor brightness with no room glow
@ -120,6 +139,22 @@ function roomGlow(regions, w, h) {
return glow; return glow;
} }
// Per-cell theme lookup: map every cell of a themed Room region to its theme
// object (from THEMES), null elsewhere (corridors, unthemed rooms, pre-themer
// snapshot stages). Rooms don't overlap, so the assignment is unambiguous.
function themeField(regions, w, h) {
const field = new Array(w * h).fill(null);
for (const r of regions) {
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
const th = THEMES[r.theme];
if (!th) continue;
for (const [x, y] of r.cells) {
if (x >= 0 && y >= 0 && x < w && y < h) field[y * w + x] = th;
}
}
return field;
}
// Multi-source BFS distance (in cells) from lava to each open cell — lava light // Multi-source BFS distance (in cells) from lava to each open cell — lava light
// pools out through open space and is blocked by walls. Returns null if there's // pools out through open space and is blocked by walls. Returns null if there's
// no lava (so the caller can skip the warm-light contribution entirely). // no lava (so the caller can skip the warm-light contribution entirely).
@ -172,6 +207,7 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
const dist = lit ? distanceToWall(tiles, w, h) : null; const dist = lit ? distanceToWall(tiles, w, h) : null;
const glow = lit ? roomGlow(regions, w, h) : null; const glow = lit ? roomGlow(regions, w, h) : null;
const lavaField = lit ? lavaLightField(tiles, w, h) : null; const lavaField = lit ? lavaLightField(tiles, w, h) : null;
const tfield = opts.themes !== false ? themeField(regions, w, h) : null;
const px = (x) => ox + x * cell; const px = (x) => ox + x * cell;
const py = (y) => oy + y * cell; const py = (y) => oy + y * cell;
@ -217,12 +253,16 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
} }
// Floor / door / pillar underlay: lit stone, warmed by torch + lava light. // Floor / door / pillar underlay: lit stone, warmed by torch + lava light.
// A themed room substitutes its own floor hue (and scales the torch warmth
// by its `warm`); unthemed cells keep the cool↔warm stone lerp.
const th = tfield ? tfield[i] : null;
const coarse = hash2((x >> 2) + 11, (y >> 2) + 7); const coarse = hash2((x >> 2) + 11, (y >> 2) + 7);
const grain = 0.93 + 0.09 * hash2(x, y); const grain = 0.93 + 0.09 * hash2(x, y);
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse); const stone = th ? th.floor : lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
let col = scale3(stone, b * grain); let col = scale3(stone, b * grain);
if (lit) { if (lit) {
const warm = g * 58 + lavaB * 95; // torch + lava both pool warm const warmScale = th ? 0.5 + th.warm : 1; // warm=0.5 ⇒ unchanged baseline
const warm = g * 58 * warmScale + lavaB * 95; // torch + lava both pool warm
col = [col[0] + warm, col[1] + warm * 0.5, col[2] + warm * 0.1]; col = [col[0] + warm, col[1] + warm * 0.5, col[2] + warm * 0.1];
} }
ctx.fillStyle = rgb(col); ctx.fillStyle = rgb(col);
@ -268,12 +308,16 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
ctx.fillRect(X, Y, cell, cell); ctx.fillRect(X, Y, cell, cell);
// Rim-light every edge where this stone meets carved floor, so the room // Rim-light every edge where this stone meets carved floor, so the room
// boundary reads crisply on all sides (catches the floor's glow). // boundary reads crisply on all sides (catches the floor's glow). Each rim
ctx.fillStyle = rgb(scale3(PAL.wallRim, 0.6 * grain)); // segment takes the accent of the themed room it borders (else plain rim).
if (isFloor(x, y - 1)) ctx.fillRect(X, Y, cell, rim); const rimColor = (nx, ny) => {
if (isFloor(x, y + 1)) ctx.fillRect(X, Y + cell - rim, cell, rim); const th = tfield ? tfield[ny * w + nx] : null;
if (isFloor(x - 1, y)) ctx.fillRect(X, Y, rim, cell); return rgb(scale3(th ? th.accent : PAL.wallRim, 0.6 * grain));
if (isFloor(x + 1, y)) ctx.fillRect(X + cell - rim, Y, rim, cell); };
if (isFloor(x, y - 1)) { ctx.fillStyle = rimColor(x, y - 1); ctx.fillRect(X, Y, cell, rim); }
if (isFloor(x, y + 1)) { ctx.fillStyle = rimColor(x, y + 1); ctx.fillRect(X, Y + cell - rim, cell, rim); }
if (isFloor(x - 1, y)) { ctx.fillStyle = rimColor(x - 1, y); ctx.fillRect(X, Y, rim, cell); }
if (isFloor(x + 1, y)) { ctx.fillStyle = rimColor(x + 1, y); ctx.fillRect(X + cell - rim, Y, rim, cell); }
} }
} }