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:
parent
e3501a02f3
commit
0aec5e927d
13 changed files with 673 additions and 38 deletions
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ impl GenContext {
|
||||||
kind,
|
kind,
|
||||||
bounds,
|
bounds,
|
||||||
cells,
|
cells,
|
||||||
|
theme: None,
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,7 @@ mod tests {
|
||||||
kind,
|
kind,
|
||||||
bounds,
|
bounds,
|
||||||
cells,
|
cells,
|
||||||
|
theme: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(¢ers) {
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
337
reikhelm-core/src/passes/room_themer.rs
Normal file
337
reikhelm-core/src/passes/room_themer.rs
Normal 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 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<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(¢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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`].
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue