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:
parent
381a52c310
commit
00502153f3
11 changed files with 508 additions and 10 deletions
|
|
@ -85,6 +85,7 @@ mod tests {
|
|||
tiles,
|
||||
regions: Vec::new(),
|
||||
graph: ConnGraph::new(),
|
||||
entities: Vec::new(),
|
||||
seed: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
|
|
|
|||
57
reikhelm-core/src/entity.rs
Normal file
57
reikhelm-core/src/entity.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
pub mod ascii;
|
||||
pub mod blackboard;
|
||||
pub mod entity;
|
||||
pub mod geometry;
|
||||
pub mod grid;
|
||||
pub mod map;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::entity::Entity;
|
||||
use crate::grid::Grid;
|
||||
use crate::region::{ConnGraph, Region};
|
||||
|
||||
|
|
@ -59,6 +60,9 @@ pub struct Map {
|
|||
pub regions: Vec<Region>,
|
||||
/// How regions connect (each edge is a door/corridor link).
|
||||
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.
|
||||
pub seed: u64,
|
||||
/// Map width in cells.
|
||||
|
|
@ -70,6 +74,7 @@ pub struct Map {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::entity::{Entity, EntityKind};
|
||||
use crate::geometry::{Point, Rect};
|
||||
use crate::region::{RegionId, RegionKind};
|
||||
|
||||
|
|
@ -94,6 +99,7 @@ mod tests {
|
|||
tiles,
|
||||
regions: vec![region],
|
||||
graph,
|
||||
entities: vec![Entity::new(EntityKind::Treasure, Point::new(1, 0))],
|
||||
seed: 0xDEAD_BEEF,
|
||||
width: 3,
|
||||
height: 2,
|
||||
|
|
@ -116,6 +122,7 @@ mod tests {
|
|||
// Spot-check the semantic layer, which does implement `Eq`, directly.
|
||||
assert_eq!(original.regions, restored.regions);
|
||||
assert_eq!(original.graph, restored.graph);
|
||||
assert_eq!(original.entities, restored.entities);
|
||||
assert_eq!(original.seed, restored.seed);
|
||||
assert_eq!(
|
||||
(original.width, original.height),
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
//! resulting [`Map`] is identical for a given seed with or without snapshots.
|
||||
|
||||
use crate::blackboard::Blackboard;
|
||||
use crate::entity::Entity;
|
||||
use crate::geometry::{Point, Rect};
|
||||
use crate::grid::Grid;
|
||||
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 {
|
||||
tiles: ctx.tiles,
|
||||
regions: ctx.regions,
|
||||
graph: ctx.graph,
|
||||
entities,
|
||||
seed,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
|
|
|
|||
277
reikhelm-core/src/passes/entity_placer.rs
Normal file
277
reikhelm-core/src/passes/entity_placer.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -46,3 +46,6 @@ pub use pool::{PoolConfig, PoolDecorator};
|
|||
|
||||
pub mod pillar;
|
||||
pub use pillar::{PillarConfig, PillarPlacer};
|
||||
|
||||
pub mod entity_placer;
|
||||
pub use entity_placer::{EntityConfig, EntityPlacer};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//!
|
||||
//! ```text
|
||||
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
|
||||
//! → DoorPlacer → PoolDecorator → PillarPlacer
|
||||
//! → DoorPlacer → PoolDecorator → PillarPlacer → EntityPlacer
|
||||
//! ```
|
||||
//!
|
||||
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
||||
|
|
@ -24,6 +24,8 @@
|
|||
//! (a connectivity-guarded decorator — never orphans part of a room).
|
||||
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
|
||||
//! finishing decorator; isolated obstacles that never break connectivity).
|
||||
//! 8. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and
|
||||
//! scattered treasure and monsters — as an overlay layer (no terrain change).
|
||||
//!
|
||||
//! ## Why validation matters (spec §8)
|
||||
//!
|
||||
|
|
@ -46,8 +48,8 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::pass::Pipeline;
|
||||
use crate::passes::{
|
||||
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
|
||||
DoorPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig, PoolDecorator, RoomCarver,
|
||||
RoomConfig, ShapeWeights,
|
||||
DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
|
||||
PoolDecorator, RoomCarver, RoomConfig, ShapeWeights,
|
||||
};
|
||||
|
||||
/// Configuration for the [`dungeon`] recipe.
|
||||
|
|
@ -76,6 +78,9 @@ pub struct DungeonConfig {
|
|||
pub pools: PoolConfig,
|
||||
/// How free-standing pillars are scattered into larger rooms (decorator).
|
||||
pub pillars: PillarConfig,
|
||||
/// How the dungeon is populated with entities (entrance, exit, treasure,
|
||||
/// monsters).
|
||||
pub entities: EntityConfig,
|
||||
}
|
||||
|
||||
impl Default for DungeonConfig {
|
||||
|
|
@ -113,6 +118,7 @@ impl Default for DungeonConfig {
|
|||
doors: DoorConfig::default(),
|
||||
pools: PoolConfig::default(),
|
||||
pillars: PillarConfig::default(),
|
||||
entities: EntityConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -226,7 +232,8 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
|
|||
.then(CorridorCarver::new())
|
||||
.then(DoorPlacer::new(cfg.doors))
|
||||
.then(PoolDecorator::new(cfg.pools))
|
||||
.then(PillarPlacer::new(cfg.pillars));
|
||||
.then(PillarPlacer::new(cfg.pillars))
|
||||
.then(EntityPlacer::new(cfg.entities));
|
||||
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
|
@ -240,7 +247,7 @@ mod tests {
|
|||
use std::collections::{BTreeSet, VecDeque};
|
||||
|
||||
/// The pass names in pipeline order, used to check snapshot labels.
|
||||
const PASS_NAMES: [&str; 8] = [
|
||||
const PASS_NAMES: [&str; 9] = [
|
||||
"bsp_partition",
|
||||
"room_carver",
|
||||
"cave_shaper",
|
||||
|
|
@ -249,6 +256,7 @@ mod tests {
|
|||
"door_placer",
|
||||
"pool_decorator",
|
||||
"pillar_placer",
|
||||
"entity_placer",
|
||||
];
|
||||
|
||||
/// 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);
|
||||
|
||||
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();
|
||||
assert_eq!(
|
||||
|
|
@ -606,7 +614,32 @@ mod tests {
|
|||
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
|
||||
/// assertion (beyond non-empty output); it documents what the recipe makes.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
use serde::Serialize;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use reikhelm_core::entity::{Entity, EntityKind};
|
||||
use reikhelm_core::geometry::Point;
|
||||
use reikhelm_core::grid::Grid;
|
||||
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.
|
||||
#[derive(Serialize)]
|
||||
struct EdgeDto {
|
||||
|
|
@ -145,6 +172,7 @@ struct Envelope {
|
|||
tiles: Vec<Vec<u8>>,
|
||||
regions: Vec<RegionDto>,
|
||||
edges: Vec<EdgeDto>,
|
||||
entities: Vec<EntityDto>,
|
||||
snapshots: Vec<SnapshotDto>,
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +203,7 @@ fn build_envelope(seed: u64, map: &Map, snapshots: &[Snapshot]) -> Envelope {
|
|||
tiles: tiles_to_rows(&map.tiles),
|
||||
regions: map.regions.iter().map(RegionDto::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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const state = {
|
|||
env: null, // last successful envelope
|
||||
stage: 0, // snapshot index currently shown
|
||||
followFinal: true,
|
||||
opts: { lighting: true, outlines: false, graph: false, grid: false },
|
||||
opts: { lighting: true, outlines: false, graph: false, grid: false, entities: true },
|
||||
genMs: 0,
|
||||
};
|
||||
|
||||
|
|
@ -43,6 +43,8 @@ const SLIDERS = [
|
|||
['pillars.room_chance', 'pillars', 0, 1, 0.05],
|
||||
['pools.water_chance', '≈ water', 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) {
|
||||
|
|
@ -95,10 +97,14 @@ function updateReadout() {
|
|||
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 doors = countTiles(env, 2);
|
||||
const ents = env.entities || [];
|
||||
const byKind = (k) => ents.filter((e) => e.kind === k).length;
|
||||
readout.querySelector('#stats').innerHTML =
|
||||
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
|
||||
`<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 ---------------------------------------------------------
|
||||
|
|
@ -144,7 +150,7 @@ function buildControls() {
|
|||
// Toggles.
|
||||
const togRow = document.createElement('div');
|
||||
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>`)
|
||||
.join('');
|
||||
panel.appendChild(togRow);
|
||||
|
|
@ -195,6 +201,7 @@ window.reikhelm = {
|
|||
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
|
||||
water: state.env ? countTiles(state.env, 4) : 0,
|
||||
lava: state.env ? countTiles(state.env, 5) : 0,
|
||||
entities: state.env ? state.env.entities.length : 0,
|
||||
ok: !!state.env }),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ const PAL = {
|
|||
waterShallow: [66, 120, 152], // sheen
|
||||
lavaCrust: [120, 32, 10], // cooled crust
|
||||
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)',
|
||||
outlineCorr: 'rgba(150, 150, 170, 0.40)',
|
||||
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.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;
|
||||
}
|
||||
|
||||
// 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).
|
||||
function drawOutlines(ctx, regions, { cell, ox, oy }) {
|
||||
for (const r of regions) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue