diff --git a/reikhelm-core/src/ascii.rs b/reikhelm-core/src/ascii.rs index 6f5c954..f0d3e8f 100644 --- a/reikhelm-core/src/ascii.rs +++ b/reikhelm-core/src/ascii.rs @@ -85,6 +85,7 @@ mod tests { tiles, regions: Vec::new(), graph: ConnGraph::new(), + entities: Vec::new(), seed: 0, width: 3, height: 3, diff --git a/reikhelm-core/src/entity.rs b/reikhelm-core/src/entity.rs new file mode 100644 index 0000000..f8c372e --- /dev/null +++ b/reikhelm-core/src/entity.rs @@ -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` 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 } + } +} diff --git a/reikhelm-core/src/lib.rs b/reikhelm-core/src/lib.rs index 8815f5b..09fcd09 100644 --- a/reikhelm-core/src/lib.rs +++ b/reikhelm-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod ascii; pub mod blackboard; +pub mod entity; pub mod geometry; pub mod grid; pub mod map; diff --git a/reikhelm-core/src/map.rs b/reikhelm-core/src/map.rs index 91f0da7..19703aa 100644 --- a/reikhelm-core/src/map.rs +++ b/reikhelm-core/src/map.rs @@ -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, /// 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, /// 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), diff --git a/reikhelm-core/src/pass.rs b/reikhelm-core/src/pass.rs index 9498007..c601a22 100644 --- a/reikhelm-core/src/pass.rs +++ b/reikhelm-core/src/pass.rs @@ -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::>(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, diff --git a/reikhelm-core/src/passes/entity_placer.rs b/reikhelm-core/src/passes/entity_placer.rs new file mode 100644 index 0000000..b81b665 --- /dev/null +++ b/reikhelm-core/src/passes/entity_placer.rs @@ -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` 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, +} + +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 = 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 = 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 { + 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 { + let free: Vec = 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 = 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 { + let mut rng = Rng::from_seed(seed).fork("entity_placer#0"); + EntityPlacer::new(cfg).apply(ctx, &mut rng); + ctx.blackboard.take::>(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()); + } +} diff --git a/reikhelm-core/src/passes/mod.rs b/reikhelm-core/src/passes/mod.rs index 2ac1b1f..4e8955d 100644 --- a/reikhelm-core/src/passes/mod.rs +++ b/reikhelm-core/src/passes/mod.rs @@ -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}; diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index def32c7..d9b3cfe 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -7,7 +7,7 @@ //! //! ```text //! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver -//! → DoorPlacer → PoolDecorator → PillarPlacer +//! → 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 { .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. diff --git a/reikhelm-wasm/src/lib.rs b/reikhelm-wasm/src/lib.rs index 8b7c8f3..5d3b79f 100644 --- a/reikhelm-wasm/src/lib.rs +++ b/reikhelm-wasm/src/lib.rs @@ -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>, regions: Vec, edges: Vec, + entities: Vec, snapshots: Vec, } @@ -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(), } } diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js index 4abffc6..0092fbe 100644 --- a/reikhelm-web/main.js +++ b/reikhelm-web/main.js @@ -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 ${env.seed} · ${env.width}×${env.height} · ` + `${rooms} rooms · ${corrs} corridors · ${doors} doors · ` + - `${env.edges.length} edges · gen ${state.genMs.toFixed(1)} ms`; + `gen ${state.genMs.toFixed(1)} ms
` + + `◆ ${byKind('Treasure')} treasure · ● ${byKind('Monster')} 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) => ``) .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 }), }; diff --git a/reikhelm-web/render.js b/reikhelm-web/render.js index 6b49a73..53ca8e1 100644 --- a/reikhelm-web/render.js +++ b/reikhelm-web/render.js @@ -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) {