From 0aec5e927dfd007837b9c07e696887fd1d51dc39 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Sun, 31 May 2026 17:03:53 -0600 Subject: [PATCH] =?UTF-8?q?feat(core):=20room=20theming=20=E2=80=94=20Room?= =?UTF-8?q?Themer=20classifier=20+=20themed=20renderer=20(10th=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- reikhelm-core/src/map.rs | 3 +- reikhelm-core/src/pass.rs | 1 + reikhelm-core/src/passes/connect.rs | 1 + reikhelm-core/src/passes/door.rs | 1 + reikhelm-core/src/passes/entity_placer.rs | 110 +++++-- reikhelm-core/src/passes/mod.rs | 3 + reikhelm-core/src/passes/room.rs | 1 + reikhelm-core/src/passes/room_themer.rs | 337 ++++++++++++++++++++++ reikhelm-core/src/recipes/dungeon.rs | 81 +++++- reikhelm-core/src/region.rs | 63 ++++ reikhelm-wasm/src/lib.rs | 26 +- reikhelm-web/main.js | 24 +- reikhelm-web/render.js | 60 +++- 13 files changed, 673 insertions(+), 38 deletions(-) create mode 100644 reikhelm-core/src/passes/room_themer.rs diff --git a/reikhelm-core/src/map.rs b/reikhelm-core/src/map.rs index 19703aa..dd40e84 100644 --- a/reikhelm-core/src/map.rs +++ b/reikhelm-core/src/map.rs @@ -76,7 +76,7 @@ mod tests { use super::*; use crate::entity::{Entity, EntityKind}; 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. fn sample_map() -> Map { @@ -89,6 +89,7 @@ mod tests { kind: RegionKind::Room, bounds: Rect::new(1, 0, 1, 2), cells: vec![Point::new(1, 0), Point::new(1, 1)], + theme: Some(RegionTheme::Vault), }; let mut graph = ConnGraph::new(); diff --git a/reikhelm-core/src/pass.rs b/reikhelm-core/src/pass.rs index c601a22..82c91a9 100644 --- a/reikhelm-core/src/pass.rs +++ b/reikhelm-core/src/pass.rs @@ -75,6 +75,7 @@ impl GenContext { kind, bounds, cells, + theme: None, }); id } diff --git a/reikhelm-core/src/passes/connect.rs b/reikhelm-core/src/passes/connect.rs index 49a957d..5786304 100644 --- a/reikhelm-core/src/passes/connect.rs +++ b/reikhelm-core/src/passes/connect.rs @@ -455,6 +455,7 @@ mod tests { kind: RegionKind::Corridor, bounds: Rect::new(30, 0, 4, 1), cells: vec![Point::new(30, 0)], + theme: None, }); run(&mut ctx, ConnectConfig::default(), 0xBEEF); diff --git a/reikhelm-core/src/passes/door.rs b/reikhelm-core/src/passes/door.rs index 253a13d..6914b8b 100644 --- a/reikhelm-core/src/passes/door.rs +++ b/reikhelm-core/src/passes/door.rs @@ -325,6 +325,7 @@ mod tests { kind, bounds, cells, + theme: None, }); } diff --git a/reikhelm-core/src/passes/entity_placer.rs b/reikhelm-core/src/passes/entity_placer.rs index 861c23f..810d7c1 100644 --- a/reikhelm-core/src/passes/entity_placer.rs +++ b/reikhelm-core/src/passes/entity_placer.rs @@ -13,14 +13,18 @@ //! //! 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. //! 2. **Exit** — the room whose center is farthest (Manhattan) from the entrance, -//! again on its central floor cell. Gives the dungeon a traversal goal. -//! 3. **Treasure** — each room has a `treasure_chance` of holding one, on a random -//! open floor cell. -//! 4. **Monsters** — each room *except the entrance room* has a `monster_chance` of -//! holding 1..=`max_monsters`, scattered on open floor. +//! again on its central floor cell. Gives the dungeon a traversal goal. (The +//! entrance/exit choice is RNG-free so a themer can reproduce it and land its +//! `Throne`/`Threshold` themes on these very rooms.) +//! 3. **Treasure** — each room has a `treasure_chance` of holding one (scaled by +//! 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 //! 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::map::Tile; use crate::pass::{GenContext, Pass}; -use crate::region::{Region, RegionKind}; +use crate::region::{Region, RegionKind, RegionTheme}; 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. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct EntityConfig { @@ -82,6 +114,8 @@ struct RoomInfo { center: Point, /// The room's open-floor cells (Tile::Floor only), row-major. floor: Vec, + /// The room's theme, if a themer classified it (drives content biases). + theme: Option, } impl Pass for EntityPlacer { @@ -104,40 +138,49 @@ impl Pass for EntityPlacer { .copied() .filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor)) .collect(), + theme: r.theme, }) .collect(); let mut entities: Vec = Vec::new(); let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new(); - if !rooms.is_empty() { - // 1) Entrance — a random room, central floor cell. - let entrance_idx = rng.range(0, rooms.len() as i32) as usize; + // Entrance/exit are chosen by the shared geometry helper (no RNG), so the + // themer's Throne/Threshold land on these very rooms (see + // [`entrance_exit_indices`]). + let centers: Vec = rooms.iter().map(|r| r.center).collect(); + if let Some((entrance_idx, exit_idx)) = entrance_exit_indices(¢ers) { + // 1) Entrance — the way-in room, central floor cell. if let Some(p) = nearest_free(&rooms[entrance_idx], rooms[entrance_idx].center, &taken) { taken.insert((p.x, p.y)); entities.push(Entity::new(EntityKind::Entrance, p)); } - // 2) Exit — the room whose center is farthest from the entrance. - 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); + // 2) Exit — the far room, central floor cell. if let Some(p) = nearest_free(&rooms[exit_idx], rooms[exit_idx].center, &taken) { taken.insert((p.x, p.y)); entities.push(Entity::new(EntityKind::Exit, p)); } // 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() { - 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) { taken.insert((p.x, p.y)); entities.push(Entity::new(EntityKind::Treasure, p)); } } + // 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); for _ in 0..n { match choose_free(room, &taken, rng) { @@ -274,4 +317,37 @@ mod tests { let mut ctx = ctx_with_rooms(8, 8, &[]); 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"); + } } diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 4e8955d..5ea4f7f 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -47,5 +47,8 @@ pub use pool::{PoolConfig, PoolDecorator}; pub mod pillar; pub use pillar::{PillarConfig, PillarPlacer}; +pub mod room_themer; +pub use room_themer::{RoomThemer, ThemeConfig}; + pub mod entity_placer; pub use entity_placer::{EntityConfig, EntityPlacer}; diff --git a/reikhelm-core/src/passes/room.rs b/reikhelm-core/src/passes/room.rs index aea0c2e..acc277b 100644 --- a/reikhelm-core/src/passes/room.rs +++ b/reikhelm-core/src/passes/room.rs @@ -616,6 +616,7 @@ mod tests { kind: RegionKind::Corridor, bounds: corridor_bounds, cells: vec![Point::new(16, 0)], + theme: None, }); run(&mut ctx, RoomConfig::default(), 99); diff --git a/reikhelm-core/src/passes/room_themer.rs b/reikhelm-core/src/passes/room_themer.rs new file mode 100644 index 0000000..577238a --- /dev/null +++ b/reikhelm-core/src/passes/room_themer.rs @@ -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 1–5 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 = 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 = rooms.iter().map(|r| r.center).collect(); + let (entrance_k, exit_k) = match entrance_exit_indices(¢ers) { + 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 = 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> { + 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"); + } +} diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index d9b3cfe..7363fca 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -7,7 +7,7 @@ //! //! ```text //! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver -//! → DoorPlacer → PoolDecorator → PillarPlacer → EntityPlacer +//! → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer //! ``` //! //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder @@ -24,7 +24,10 @@ //! (a connectivity-guarded decorator — never orphans part of a room). //! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a //! 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). //! //! ## Why validation matters (spec §8) @@ -49,7 +52,7 @@ use crate::pass::Pipeline; use crate::passes::{ BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, - PoolDecorator, RoomCarver, RoomConfig, ShapeWeights, + PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig, }; /// Configuration for the [`dungeon`] recipe. @@ -78,6 +81,8 @@ pub struct DungeonConfig { pub pools: PoolConfig, /// How free-standing pillars are scattered into larger rooms (decorator). 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, /// monsters). pub entities: EntityConfig, @@ -118,6 +123,7 @@ impl Default for DungeonConfig { doors: DoorConfig::default(), pools: PoolConfig::default(), pillars: PillarConfig::default(), + themes: ThemeConfig::default(), entities: EntityConfig::default(), } } @@ -233,6 +239,7 @@ pub fn dungeon(cfg: DungeonConfig) -> Result { .then(DoorPlacer::new(cfg.doors)) .then(PoolDecorator::new(cfg.pools)) .then(PillarPlacer::new(cfg.pillars)) + .then(RoomThemer::new(cfg.themes)) .then(EntityPlacer::new(cfg.entities)); Ok(pipeline) @@ -247,7 +254,7 @@ mod tests { use std::collections::{BTreeSet, VecDeque}; /// The pass names in pipeline order, used to check snapshot labels. - const PASS_NAMES: [&str; 9] = [ + const PASS_NAMES: [&str; 10] = [ "bsp_partition", "room_carver", "cave_shaper", @@ -256,6 +263,7 @@ mod tests { "door_placer", "pool_decorator", "pillar_placer", + "room_themer", "entity_placer", ]; @@ -322,13 +330,13 @@ 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(), 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(); assert_eq!( labels, 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 --------------------------------------------- /// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an diff --git a/reikhelm-core/src/region.rs b/reikhelm-core/src/region.rs index 6b0516e..7e2176a 100644 --- a/reikhelm-core/src/region.rs +++ b/reikhelm-core/src/region.rs @@ -40,6 +40,65 @@ pub enum RegionKind { 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. /// /// `bounds` is the axis-aligned bounding box; `cells` is the exact set of @@ -56,6 +115,10 @@ pub struct Region { pub bounds: Rect, /// The exact cells this region occupies, in the order they were added. pub cells: Vec, + /// 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, } /// A connection between two regions in the [`ConnGraph`]. diff --git a/reikhelm-wasm/src/lib.rs b/reikhelm-wasm/src/lib.rs index 5d3b79f..7717486 100644 --- a/reikhelm-wasm/src/lib.rs +++ b/reikhelm-wasm/src/lib.rs @@ -29,7 +29,7 @@ use reikhelm_core::grid::Grid; use reikhelm_core::map::{Map, Tile}; use reikhelm_core::pass::Snapshot; 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. 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. fn tiles_to_rows(tiles: &Grid) -> Vec> { let w = tiles.width() as i32; @@ -73,13 +90,17 @@ struct RectDto { 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)] struct RegionDto { id: usize, kind: &'static str, bounds: RectDto, cells: Vec<[i32; 2]>, + #[serde(skip_serializing_if = "Option::is_none")] + theme: Option<&'static str>, } impl From<&Region> for RegionDto { @@ -94,6 +115,7 @@ impl From<&Region> for RegionDto { h: r.bounds.h, }, cells: r.cells.iter().map(|p| [p.x, p.y]).collect(), + theme: r.theme.map(theme_str), } } } diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index 0092fbe..ac22d9b 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -3,7 +3,7 @@ // a dungeon in-browser, and hands the envelope to the renderer. 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 ctx = canvas.getContext('2d'); @@ -21,7 +21,7 @@ const state = { env: null, // last successful envelope stage: 0, // snapshot index currently shown 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, }; @@ -92,6 +92,15 @@ function countTiles(env, code) { 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() { const env = state.env; 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 ents = env.entities || []; 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} ${counts[k]}`) + .join(' · '); readout.querySelector('#stats').innerHTML = `seed ${env.seed} · ${env.width}×${env.height} · ` + `${rooms} rooms · ${corrs} corridors · ${doors} doors · ` + `gen ${state.genMs.toFixed(1)} ms
` + `◆ ${byKind('Treasure')} treasure · ● ${byKind('Monster')} monsters · ` + - `${byKind('Entrance')} entrance · ${byKind('Exit')} exit`; + `${byKind('Entrance')} entrance · ${byKind('Exit')} exit` + + (legend ? `
${legend}` : ''); } // --- controls UI --------------------------------------------------------- @@ -150,7 +165,7 @@ function buildControls() { // Toggles. const togRow = document.createElement('div'); togRow.className = 'row toggles'; - togRow.innerHTML = ['lighting', 'entities', 'outlines', 'graph', 'grid'] + togRow.innerHTML = ['lighting', 'themes', 'entities', 'outlines', 'graph', 'grid'] .map((k) => ``) .join(''); panel.appendChild(togRow); @@ -202,6 +217,7 @@ window.reikhelm = { water: state.env ? countTiles(state.env, 4) : 0, lava: state.env ? countTiles(state.env, 5) : 0, entities: state.env ? state.env.entities.length : 0, + themes: state.env ? themeCounts(state.env) : {}, ok: !!state.env }), }; diff --git a/reikhelm-web/render.js b/reikhelm-web/render.js index 53ca8e1..b9e5acc 100644 --- a/reikhelm-web/render.js +++ b/reikhelm-web/render.js @@ -35,6 +35,25 @@ const PAL = { 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). const LIGHT = { ambient: 0.44, // floor brightness with no room glow @@ -120,6 +139,22 @@ function roomGlow(regions, w, h) { 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 // 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). @@ -172,6 +207,7 @@ export function renderMap(ctx, vw, vh, env, opts = {}) { const dist = lit ? distanceToWall(tiles, w, h) : null; const glow = lit ? roomGlow(regions, 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 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. + // 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 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); 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]; } ctx.fillStyle = rgb(col); @@ -268,12 +308,16 @@ export function renderMap(ctx, vw, vh, env, opts = {}) { ctx.fillRect(X, Y, cell, cell); // Rim-light every edge where this stone meets carved floor, so the room - // boundary reads crisply on all sides (catches the floor's glow). - ctx.fillStyle = rgb(scale3(PAL.wallRim, 0.6 * grain)); - if (isFloor(x, y - 1)) ctx.fillRect(X, Y, cell, rim); - if (isFloor(x, y + 1)) ctx.fillRect(X, Y + cell - rim, cell, rim); - if (isFloor(x - 1, y)) ctx.fillRect(X, Y, rim, cell); - if (isFloor(x + 1, y)) ctx.fillRect(X + cell - rim, Y, rim, cell); + // boundary reads crisply on all sides (catches the floor's glow). Each rim + // segment takes the accent of the themed room it borders (else plain rim). + const rimColor = (nx, ny) => { + const th = tfield ? tfield[ny * w + nx] : null; + return rgb(scale3(th ? th.accent : PAL.wallRim, 0.6 * grain)); + }; + 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); } } }