feat(core): items & creatures — entity layer + EntityPlacer (9th pass)

The roadmap capstone: the dungeon is now populated.

- New entity layer: src/entity.rs (Entity { kind, at }, EntityKind {Entrance,
  Exit, Treasure, Monster}); Map gains an `entities: Vec<Entity>` overlay field
  (serde round-tripped). Entities aren't terrain, so no new Tile and the frozen
  viz is untouched.
- EntityPlacer/EntityConfig: places one Entrance (random room), one Exit (the
  room farthest from it — a traversal goal), per-room Treasure (treasure_chance)
  and Monsters (monster_chance, 1..=max, never in the entrance room). All on
  plain Floor only, never two per cell, fully deterministic.
- Handoff via the Blackboard (the designed side channel for non-tile/region/edge
  data) under entity::BLACKBOARD_KEY, harvested by Pipeline into Map.entities —
  so GenContext's 25 literals stay untouched.
- Renderer draws iconic markers on the final stage: green beacon (entrance),
  cyan portal (exit), gold diamond (treasure), crimson eyed-blob (monster), each
  with a soft glow. New entities toggle + treasure/monster sliders + readout.

Core: 135 tests green (6 new: one entrance/one exit on distinct floor, entrance
room monster-free, determinism, empty-map, recipe populated-check). clippy
clean. Pipeline is now 9 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 01:11:13 -06:00
parent 381a52c310
commit 00502153f3
11 changed files with 508 additions and 10 deletions

View file

@ -85,6 +85,7 @@ mod tests {
tiles, tiles,
regions: Vec::new(), regions: Vec::new(),
graph: ConnGraph::new(), graph: ConnGraph::new(),
entities: Vec::new(),
seed: 0, seed: 0,
width: 3, width: 3,
height: 3, height: 3,

View file

@ -0,0 +1,57 @@
//! Entities: the things that *populate* a map, distinct from its terrain.
//!
//! Where [`Tile`](crate::map::Tile) says *what the ground is* and
//! [`Region`](crate::region::Region) says *what areas exist*, an [`Entity`] is a
//! discrete thing sitting **on** a walkable cell — a stairway, a chest, a
//! monster. Entities never change the terrain (they are an overlay layer on the
//! [`Map`](crate::map::Map)), so a renderer draws them on top of the tiles and a
//! game treats them as occupants of a cell.
//!
//! Like [`Tile`](crate::map::Tile) and [`RegionKind`](crate::region::RegionKind),
//! the kind enum starts minimal and grows additively; passes and renderers match
//! only on the kinds they care about. All types derive serde so entities travel
//! inside a `Map` through JSON.
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
/// The [`Blackboard`](crate::blackboard::Blackboard) key under which the entity
/// placer stashes its `Vec<Entity>` for [`Pipeline`](crate::pass::Pipeline) to
/// harvest into the finished [`Map`](crate::map::Map). Shared so the producer
/// (the placer pass) and the consumer (the engine) never drift on the name.
pub const BLACKBOARD_KEY: &str = "entities";
/// What an [`Entity`] is. Additive: new kinds (traps, NPCs, altars, …) slot in
/// without disturbing existing matches.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EntityKind {
/// Where the dungeon is entered from (the player's start / stairs up).
Entrance,
/// The dungeon's goal — the room farthest from the entrance (stairs down).
Exit,
/// A pickup: treasure, loot, an item.
Treasure,
/// A creature occupying the cell.
Monster,
}
/// A single entity: its [`EntityKind`] and the cell it occupies.
///
/// `at` is always a walkable [`Floor`](crate::map::Tile::Floor) cell in a
/// well-formed map; the placer only ever drops entities onto open floor and never
/// stacks two on one cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Entity {
/// What this entity is.
pub kind: EntityKind,
/// The cell it occupies.
pub at: Point,
}
impl Entity {
/// Creates an entity of `kind` at cell `at`.
pub const fn new(kind: EntityKind, at: Point) -> Self {
Entity { kind, at }
}
}

View file

@ -5,6 +5,7 @@
pub mod ascii; pub mod ascii;
pub mod blackboard; pub mod blackboard;
pub mod entity;
pub mod geometry; pub mod geometry;
pub mod grid; pub mod grid;
pub mod map; pub mod map;

View file

@ -11,6 +11,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::entity::Entity;
use crate::grid::Grid; use crate::grid::Grid;
use crate::region::{ConnGraph, Region}; use crate::region::{ConnGraph, Region};
@ -59,6 +60,9 @@ pub struct Map {
pub regions: Vec<Region>, pub regions: Vec<Region>,
/// How regions connect (each edge is a door/corridor link). /// How regions connect (each edge is a door/corridor link).
pub graph: ConnGraph, pub graph: ConnGraph,
/// The entities populating the map (entrance, exit, treasure, monsters) —
/// an overlay on the terrain, each sitting on a walkable cell.
pub entities: Vec<Entity>,
/// The seed that produced this map, for reproducibility. /// The seed that produced this map, for reproducibility.
pub seed: u64, pub seed: u64,
/// Map width in cells. /// Map width in cells.
@ -70,6 +74,7 @@ pub struct Map {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
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};
@ -94,6 +99,7 @@ mod tests {
tiles, tiles,
regions: vec![region], regions: vec![region],
graph, graph,
entities: vec![Entity::new(EntityKind::Treasure, Point::new(1, 0))],
seed: 0xDEAD_BEEF, seed: 0xDEAD_BEEF,
width: 3, width: 3,
height: 2, height: 2,
@ -116,6 +122,7 @@ mod tests {
// Spot-check the semantic layer, which does implement `Eq`, directly. // Spot-check the semantic layer, which does implement `Eq`, directly.
assert_eq!(original.regions, restored.regions); assert_eq!(original.regions, restored.regions);
assert_eq!(original.graph, restored.graph); assert_eq!(original.graph, restored.graph);
assert_eq!(original.entities, restored.entities);
assert_eq!(original.seed, restored.seed); assert_eq!(original.seed, restored.seed);
assert_eq!( assert_eq!(
(original.width, original.height), (original.width, original.height),

View file

@ -34,6 +34,7 @@
//! resulting [`Map`] is identical for a given seed with or without snapshots. //! resulting [`Map`] is identical for a given seed with or without snapshots.
use crate::blackboard::Blackboard; use crate::blackboard::Blackboard;
use crate::entity::Entity;
use crate::geometry::{Point, Rect}; use crate::geometry::{Point, Rect};
use crate::grid::Grid; use crate::grid::Grid;
use crate::map::{Map, Tile}; use crate::map::{Map, Tile};
@ -231,10 +232,18 @@ impl Pipeline {
} }
} }
// Harvest the entity layer the placer (if any) stashed on the blackboard
// — the side channel for data that isn't a tile/region/edge (§4.6). A
// recipe with no placer simply yields no entities.
let entities = ctx
.blackboard
.take::<Vec<Entity>>(crate::entity::BLACKBOARD_KEY)
.unwrap_or_default();
let map = Map { let map = Map {
tiles: ctx.tiles, tiles: ctx.tiles,
regions: ctx.regions, regions: ctx.regions,
graph: ctx.graph, graph: ctx.graph,
entities,
seed, seed,
width: self.width, width: self.width,
height: self.height, height: self.height,

View file

@ -0,0 +1,277 @@
//! 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** — a random room; 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.
//!
//! 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};
use crate::rng::Rng;
/// 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>,
}
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(),
})
.collect();
let mut entities: Vec<Entity> = 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;
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);
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.
for (i, room) in rooms.iter().enumerate() {
if rng.chance(self.cfg.treasure_chance) {
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 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, RegionId};
/// 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());
}
}

View file

@ -46,3 +46,6 @@ pub use pool::{PoolConfig, PoolDecorator};
pub mod pillar; pub mod pillar;
pub use pillar::{PillarConfig, PillarPlacer}; pub use pillar::{PillarConfig, PillarPlacer};
pub mod entity_placer;
pub use entity_placer::{EntityConfig, EntityPlacer};

View file

@ -7,7 +7,7 @@
//! //!
//! ```text //! ```text
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver //! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
//! → DoorPlacer → PoolDecorator → PillarPlacer //! → DoorPlacer → PoolDecorator → PillarPlacer → EntityPlacer
//! ``` //! ```
//! //!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder //! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
@ -24,6 +24,8 @@
//! (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
//! scattered treasure and monsters — as an overlay layer (no terrain change).
//! //!
//! ## Why validation matters (spec §8) //! ## Why validation matters (spec §8)
//! //!
@ -46,8 +48,8 @@ use serde::{Deserialize, Serialize};
use crate::pass::Pipeline; 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, MstConnect, PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver, DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
RoomConfig, ShapeWeights, PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
}; };
/// Configuration for the [`dungeon`] recipe. /// Configuration for the [`dungeon`] recipe.
@ -76,6 +78,9 @@ 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,
/// How the dungeon is populated with entities (entrance, exit, treasure,
/// monsters).
pub entities: EntityConfig,
} }
impl Default for DungeonConfig { impl Default for DungeonConfig {
@ -113,6 +118,7 @@ impl Default for DungeonConfig {
doors: DoorConfig::default(), doors: DoorConfig::default(),
pools: PoolConfig::default(), pools: PoolConfig::default(),
pillars: PillarConfig::default(), pillars: PillarConfig::default(),
entities: EntityConfig::default(),
} }
} }
} }
@ -226,7 +232,8 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
.then(CorridorCarver::new()) .then(CorridorCarver::new())
.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(EntityPlacer::new(cfg.entities));
Ok(pipeline) Ok(pipeline)
} }
@ -240,7 +247,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; 8] = [ const PASS_NAMES: [&str; 9] = [
"bsp_partition", "bsp_partition",
"room_carver", "room_carver",
"cave_shaper", "cave_shaper",
@ -249,6 +256,7 @@ mod tests {
"door_placer", "door_placer",
"pool_decorator", "pool_decorator",
"pillar_placer", "pillar_placer",
"entity_placer",
]; ];
/// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)? /// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)?
@ -314,7 +322,7 @@ mod tests {
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7); let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
assert_eq!(plain, snapped, "snapshotting must not change the map"); assert_eq!(plain, snapped, "snapshotting must not change the map");
assert_eq!(snapshots.len(), 8, "one snapshot per pass (eight passes)"); assert_eq!(snapshots.len(), 9, "one snapshot per pass (nine 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!(
@ -606,7 +614,32 @@ mod tests {
assert!(total_full > 0, "door_chance=1.0 must place doors"); assert!(total_full > 0, "door_chance=1.0 must place doors");
} }
// --- Test 7: eyeball render --------------------------------------------- // --- Test 7: entities ---------------------------------------------------
/// The populated default dungeon has exactly one entrance and one exit across
/// many seeds, and every entity sits on a walkable Floor cell (never a wall,
/// door, pool, or pillar).
#[test]
fn default_dungeon_is_populated_with_entities() {
use crate::entity::EntityKind;
let cfg = DungeonConfig::default();
for seed in 1..=16u64 {
let map = dungeon(cfg).unwrap().run(seed);
let entrances = map.entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
let exits = map.entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
assert_eq!(entrances, 1, "seed {seed}: expected exactly one entrance");
assert_eq!(exits, 1, "seed {seed}: expected exactly one exit");
for e in &map.entities {
assert_eq!(
map.tiles.get(e.at),
Some(&Tile::Floor),
"seed {seed}: entity {e:?} must sit on Floor"
);
}
}
}
// --- Test 8: eyeball render ---------------------------------------------
/// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an /// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an
/// assertion (beyond non-empty output); it documents what the recipe makes. /// assertion (beyond non-empty output); it documents what the recipe makes.

View file

@ -23,6 +23,7 @@
use serde::Serialize; use serde::Serialize;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use reikhelm_core::entity::{Entity, EntityKind};
use reikhelm_core::geometry::Point; use reikhelm_core::geometry::Point;
use reikhelm_core::grid::Grid; use reikhelm_core::grid::Grid;
use reikhelm_core::map::{Map, Tile}; use reikhelm_core::map::{Map, Tile};
@ -97,6 +98,32 @@ impl From<&Region> for RegionDto {
} }
} }
/// The entity-kind tag sent on the wire.
fn entity_kind_str(kind: EntityKind) -> &'static str {
match kind {
EntityKind::Entrance => "Entrance",
EntityKind::Exit => "Exit",
EntityKind::Treasure => "Treasure",
EntityKind::Monster => "Monster",
}
}
/// An entity on the wire: its kind tag and the `[x, y]` cell it occupies.
#[derive(Serialize)]
struct EntityDto {
kind: &'static str,
at: [i32; 2],
}
impl From<&Entity> for EntityDto {
fn from(e: &Entity) -> Self {
EntityDto {
kind: entity_kind_str(e.kind),
at: [e.at.x, e.at.y],
}
}
}
/// A connectivity edge on the wire. `at` is the door cell once known. /// A connectivity edge on the wire. `at` is the door cell once known.
#[derive(Serialize)] #[derive(Serialize)]
struct EdgeDto { struct EdgeDto {
@ -145,6 +172,7 @@ struct Envelope {
tiles: Vec<Vec<u8>>, tiles: Vec<Vec<u8>>,
regions: Vec<RegionDto>, regions: Vec<RegionDto>,
edges: Vec<EdgeDto>, edges: Vec<EdgeDto>,
entities: Vec<EntityDto>,
snapshots: Vec<SnapshotDto>, snapshots: Vec<SnapshotDto>,
} }
@ -175,6 +203,7 @@ fn build_envelope(seed: u64, map: &Map, snapshots: &[Snapshot]) -> Envelope {
tiles: tiles_to_rows(&map.tiles), tiles: tiles_to_rows(&map.tiles),
regions: map.regions.iter().map(RegionDto::from).collect(), regions: map.regions.iter().map(RegionDto::from).collect(),
edges: map.graph.edges().iter().map(EdgeDto::from).collect(), edges: map.graph.edges().iter().map(EdgeDto::from).collect(),
entities: map.entities.iter().map(EntityDto::from).collect(),
snapshots: snapshots.iter().map(SnapshotDto::from).collect(), snapshots: snapshots.iter().map(SnapshotDto::from).collect(),
} }
} }

View file

@ -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 }, opts: { lighting: true, outlines: false, graph: false, grid: false, entities: true },
genMs: 0, genMs: 0,
}; };
@ -43,6 +43,8 @@ const SLIDERS = [
['pillars.room_chance', 'pillars', 0, 1, 0.05], ['pillars.room_chance', 'pillars', 0, 1, 0.05],
['pools.water_chance', '≈ water', 0, 1, 0.05], ['pools.water_chance', '≈ water', 0, 1, 0.05],
['pools.lava_chance', '♨ lava', 0, 1, 0.05], ['pools.lava_chance', '♨ lava', 0, 1, 0.05],
['entities.treasure_chance', '◆ treasure', 0, 1, 0.05],
['entities.monster_chance', '● monsters', 0, 1, 0.05],
]; ];
function getPath(obj, path) { function getPath(obj, path) {
@ -95,10 +97,14 @@ function updateReadout() {
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;
const corrs = env.regions.filter((r) => r.kind === 'Corridor').length; const corrs = env.regions.filter((r) => r.kind === 'Corridor').length;
const doors = countTiles(env, 2); const doors = countTiles(env, 2);
const ents = env.entities || [];
const byKind = (k) => ents.filter((e) => e.kind === k).length;
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 · ` +
`${env.edges.length} edges · gen <b>${state.genMs.toFixed(1)}</b> ms`; `gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit`;
} }
// --- controls UI --------------------------------------------------------- // --- controls UI ---------------------------------------------------------
@ -144,7 +150,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', 'outlines', 'graph', 'grid'] togRow.innerHTML = ['lighting', '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);
@ -195,6 +201,7 @@ window.reikhelm = {
pillarTiles: state.env ? countTiles(state.env, 3) : 0, pillarTiles: state.env ? countTiles(state.env, 3) : 0,
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,
ok: !!state.env }), ok: !!state.env }),
}; };

View file

@ -25,6 +25,10 @@ const PAL = {
waterShallow: [66, 120, 152], // sheen waterShallow: [66, 120, 152], // sheen
lavaCrust: [120, 32, 10], // cooled crust lavaCrust: [120, 32, 10], // cooled crust
lavaHot: [255, 152, 48], // molten core lavaHot: [255, 152, 48], // molten core
entrance: [96, 214, 124], // green beacon
exit: [96, 200, 232], // cyan portal
treasure: [242, 202, 92], // gold
monster: [226, 84, 72], // crimson
outlineRoom: 'rgba(122, 184, 240, 0.85)', outlineRoom: 'rgba(122, 184, 240, 0.85)',
outlineCorr: 'rgba(150, 150, 170, 0.40)', outlineCorr: 'rgba(150, 150, 170, 0.40)',
edge: 'rgba(232, 96, 84, 0.85)', edge: 'rgba(232, 96, 84, 0.85)',
@ -336,9 +340,79 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
if (opts.graph) drawGraph(ctx, regions, edges, layout); if (opts.graph) drawGraph(ctx, regions, edges, layout);
if (opts.grid) drawGrid(ctx, layout); if (opts.grid) drawGrid(ctx, layout);
// 6) Entities (entrance/exit/treasure/monsters) — only exist on the final
// stage, drawn on top of everything as the dungeon's inhabitants.
const lastStage = (env.snapshots?.length ?? 1) - 1;
const isFinal = (opts.stage ?? lastStage) >= lastStage;
if (isFinal && opts.entities !== false && env.entities) {
drawEntities(ctx, env.entities, layout);
}
return layout; return layout;
} }
// Draw each entity as an iconic marker centered in its cell, with a soft glow so
// it reads over the lit floor.
function drawEntities(ctx, entities, { cell, ox, oy }) {
const r = Math.max(2.5, cell * 0.3);
const center = (e) => [ox + e.at[0] * cell + cell / 2, oy + e.at[1] * cell + cell / 2];
const glow = (cx, cy, col, rad) => {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
g.addColorStop(0, `rgba(${col[0]},${col[1]},${col[2]},0.55)`);
g.addColorStop(1, `rgba(${col[0]},${col[1]},${col[2]},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
ctx.fill();
};
for (const e of entities) {
const [cx, cy] = center(e);
switch (e.kind) {
case 'Entrance': {
glow(cx, cy, PAL.entrance, r * 2.4);
ctx.fillStyle = rgb(PAL.entrance);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath(); ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2); ctx.fill();
break;
}
case 'Exit': {
glow(cx, cy, PAL.exit, r * 2.6);
ctx.strokeStyle = rgb(PAL.exit);
ctx.lineWidth = Math.max(1.5, r * 0.34);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r * 0.45, 0, Math.PI * 2); ctx.stroke();
break;
}
case 'Treasure': {
glow(cx, cy, PAL.treasure, r * 2.0);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.PI / 4); // diamond
ctx.fillStyle = rgb(PAL.treasure);
const s = r * 1.25;
ctx.fillRect(-s / 2, -s / 2, s, s);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillRect(-s / 2, -s / 2, s * 0.32, s * 0.32);
ctx.restore();
break;
}
case 'Monster': {
glow(cx, cy, PAL.monster, r * 1.9);
ctx.fillStyle = rgb(PAL.monster);
ctx.beginPath(); ctx.arc(cx, cy, r * 0.92, 0, Math.PI * 2); ctx.fill();
// two dark "eyes"
ctx.fillStyle = 'rgba(20,8,8,0.92)';
const eye = Math.max(0.7, r * 0.2);
ctx.beginPath(); ctx.arc(cx - r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
break;
}
}
}
}
// Trace each region's true cell-set boundary (shows polygonal room shapes). // Trace each region's true cell-set boundary (shows polygonal room shapes).
function drawOutlines(ctx, regions, { cell, ox, oy }) { function drawOutlines(ctx, regions, { cell, ox, oy }) {
for (const r of regions) { for (const r of regions) {