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>
353 lines
15 KiB
Rust
353 lines
15 KiB
Rust
//! The [`EntityPlacer`] pass: populate the dungeon with entities.
|
|
//!
|
|
//! A *placer* that drops [`Entity`](crate::entity::Entity)s onto open floor — the
|
|
//! final pass of the dungeon recipe, after all terrain (rooms, caves, corridors,
|
|
//! doors, pools, pillars) is settled. It changes **no** tiles or regions; it
|
|
//! produces an overlay layer the engine harvests into [`Map::entities`](crate::map::Map::entities).
|
|
//!
|
|
//! Because [`GenContext`] has no entity field (entities aren't tiles, regions, or
|
|
//! edges), this pass stashes its `Vec<Entity>` on the [`Blackboard`](crate::blackboard::Blackboard)
|
|
//! under [`crate::entity::BLACKBOARD_KEY`] — the side channel designed for exactly
|
|
//! this kind of cross-pass/-engine handoff (spec §4.6) — and
|
|
//! [`Pipeline`](crate::pass::Pipeline) takes it out into the final `Map`.
|
|
//!
|
|
//! Placement, in a fixed (deterministic) order:
|
|
//!
|
|
//! 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. (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
|
|
//! can treat `at` as a free walkable cell.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeSet;
|
|
|
|
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, 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 {
|
|
/// Probability that a given room holds a treasure.
|
|
pub treasure_chance: f64,
|
|
/// Probability that a given room (other than the entrance room) holds
|
|
/// monsters.
|
|
pub monster_chance: f64,
|
|
/// The most monsters a single monster-room may hold (each placed on its own
|
|
/// floor cell). The actual count is `1..=max_monsters`.
|
|
pub max_monsters: i32,
|
|
}
|
|
|
|
impl Default for EntityConfig {
|
|
/// A sensible default: about a third of rooms hold treasure, half hold up to
|
|
/// three monsters.
|
|
fn default() -> Self {
|
|
EntityConfig {
|
|
treasure_chance: 0.35,
|
|
monster_chance: 0.50,
|
|
max_monsters: 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An entity-placing pass.
|
|
///
|
|
/// Construct one with [`EntityPlacer::new`]; it implements [`Pass`] with the
|
|
/// stable name `"entity_placer"`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct EntityPlacer {
|
|
cfg: EntityConfig,
|
|
}
|
|
|
|
impl EntityPlacer {
|
|
/// Creates an entity placer with the given configuration.
|
|
pub fn new(cfg: EntityConfig) -> Self {
|
|
EntityPlacer { cfg }
|
|
}
|
|
}
|
|
|
|
/// A real room's data, snapshotted so placement doesn't hold a borrow on `ctx`.
|
|
struct RoomInfo {
|
|
center: Point,
|
|
/// The room's open-floor cells (Tile::Floor only), row-major.
|
|
floor: Vec<Point>,
|
|
/// The room's theme, if a themer classified it (drives content biases).
|
|
theme: Option<RegionTheme>,
|
|
}
|
|
|
|
impl Pass for EntityPlacer {
|
|
fn name(&self) -> &str {
|
|
"entity_placer"
|
|
}
|
|
|
|
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
|
|
// Snapshot each real room's center and its open-floor cells. Only plain
|
|
// Floor counts — doors, pools, pillars and walls are never entity homes.
|
|
let rooms: Vec<RoomInfo> = ctx
|
|
.regions
|
|
.iter()
|
|
.filter(|r: &&Region| r.kind == RegionKind::Room && !r.cells.is_empty())
|
|
.map(|r| RoomInfo {
|
|
center: r.bounds.center(),
|
|
floor: r
|
|
.cells
|
|
.iter()
|
|
.copied()
|
|
.filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor))
|
|
.collect(),
|
|
theme: r.theme,
|
|
})
|
|
.collect();
|
|
|
|
let mut entities: Vec<Entity> = Vec::new();
|
|
let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new();
|
|
|
|
// 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<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) {
|
|
taken.insert((p.x, p.y));
|
|
entities.push(Entity::new(EntityKind::Entrance, p));
|
|
}
|
|
|
|
// 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() {
|
|
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.
|
|
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) {
|
|
Some(p) => {
|
|
taken.insert((p.x, p.y));
|
|
entities.push(Entity::new(EntityKind::Monster, p));
|
|
}
|
|
None => break, // room is full
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.blackboard.insert(BLACKBOARD_KEY, entities);
|
|
}
|
|
}
|
|
|
|
/// The room's free floor cell nearest `target`, or [`None`] if none are free.
|
|
fn nearest_free(room: &RoomInfo, target: Point, taken: &BTreeSet<(i32, i32)>) -> Option<Point> {
|
|
room.floor
|
|
.iter()
|
|
.copied()
|
|
.filter(|p| !taken.contains(&(p.x, p.y)))
|
|
.min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2))
|
|
}
|
|
|
|
/// A uniformly random free floor cell of the room, drawn from `rng`, or [`None`].
|
|
fn choose_free(room: &RoomInfo, taken: &BTreeSet<(i32, i32)>, rng: &mut Rng) -> Option<Point> {
|
|
let free: Vec<Point> = room
|
|
.floor
|
|
.iter()
|
|
.copied()
|
|
.filter(|p| !taken.contains(&(p.x, p.y)))
|
|
.collect();
|
|
rng.choose(&free).copied()
|
|
}
|
|
|
|
#[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)`).
|
|
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: EntityConfig, seed: u64) -> Vec<Entity> {
|
|
let mut rng = Rng::from_seed(seed).fork("entity_placer#0");
|
|
EntityPlacer::new(cfg).apply(ctx, &mut rng);
|
|
ctx.blackboard.take::<Vec<Entity>>(BLACKBOARD_KEY).unwrap_or_default()
|
|
}
|
|
|
|
#[test]
|
|
fn name_is_entity_placer() {
|
|
assert_eq!(EntityPlacer::new(EntityConfig::default()).name(), "entity_placer");
|
|
}
|
|
|
|
/// A multi-room map gets exactly one entrance and one exit, on distinct floor
|
|
/// cells, and every entity sits on a Floor cell with no two sharing a cell.
|
|
#[test]
|
|
fn places_one_entrance_one_exit_on_distinct_floor() {
|
|
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
|
|
let mut ctx = ctx_with_rooms(32, 24, &rooms);
|
|
let entities = run(&mut ctx, EntityConfig::default(), 0x1234);
|
|
|
|
let entrances = entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
|
|
let exits = entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
|
|
assert_eq!(entrances, 1, "exactly one entrance");
|
|
assert_eq!(exits, 1, "exactly one exit");
|
|
|
|
// Every entity on Floor; all on distinct cells.
|
|
let mut cells = BTreeSet::new();
|
|
for e in &entities {
|
|
assert_eq!(ctx.tiles.get(e.at), Some(&Tile::Floor), "entity {e:?} not on Floor");
|
|
assert!(cells.insert((e.at.x, e.at.y)), "two entities share cell {:?}", e.at);
|
|
}
|
|
|
|
// Entrance and exit are different cells (different rooms here).
|
|
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap();
|
|
let exit = entities.iter().find(|e| e.kind == EntityKind::Exit).unwrap();
|
|
assert_ne!(entrance.at, exit.at);
|
|
}
|
|
|
|
/// No monster shares the entrance room; the entrance room is a safe start.
|
|
#[test]
|
|
fn entrance_room_has_no_monsters() {
|
|
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
|
|
let mut ctx = ctx_with_rooms(32, 24, &rooms);
|
|
// Force monsters everywhere they're allowed.
|
|
let cfg = EntityConfig { treasure_chance: 0.0, monster_chance: 1.0, max_monsters: 3 };
|
|
let entities = run(&mut ctx, cfg, 7);
|
|
|
|
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap().at;
|
|
// Which room contains the entrance?
|
|
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
|
|
let entrance_room = rooms.iter().copied().find(|&r| in_room(entrance, r)).unwrap();
|
|
for m in entities.iter().filter(|e| e.kind == EntityKind::Monster) {
|
|
assert!(!in_room(m.at, entrance_room), "monster {m:?} spawned in the entrance room");
|
|
}
|
|
}
|
|
|
|
/// Same seed reproduces the exact same entity layer.
|
|
#[test]
|
|
fn placement_is_deterministic() {
|
|
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8), (20, 12, 8, 8)];
|
|
let cfg = EntityConfig::default();
|
|
let mut a = ctx_with_rooms(32, 24, &rooms);
|
|
let mut b = ctx_with_rooms(32, 24, &rooms);
|
|
assert_eq!(run(&mut a, cfg, 0x5EED), run(&mut b, cfg, 0x5EED));
|
|
}
|
|
|
|
/// An empty map (no rooms) places no entities and does not panic.
|
|
#[test]
|
|
fn no_rooms_places_nothing() {
|
|
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");
|
|
}
|
|
}
|