feat(core): data model + Map output contract + ASCII renderer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6ffe0cc68c
commit
659ef00017
6 changed files with 406 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -407,6 +407,7 @@ dependencies = [
|
|||
"rand",
|
||||
"rand_chacha",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -7,3 +7,6 @@ edition = "2021"
|
|||
rand = "0.10.1"
|
||||
rand_chacha = "0.10.0"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1.0.150"
|
||||
|
|
|
|||
106
reikhelm-core/src/ascii.rs
Normal file
106
reikhelm-core/src/ascii.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! ASCII rendering for [`Map`] (spec §4.9).
|
||||
//!
|
||||
//! Lives in `reikhelm-core` because it has no external dependencies and is the
|
||||
//! workhorse for unit tests and quick CLI checks. It is a *renderer*: it reads a
|
||||
//! `Map` and turns each [`Tile`] into a glyph, holding no generation logic.
|
||||
//!
|
||||
//! The glyph mapping is:
|
||||
//!
|
||||
//! | Tile | Glyph |
|
||||
//! |---------------|-------|
|
||||
//! | [`Tile::Wall`] | `#` |
|
||||
//! | [`Tile::Floor`] | `.` |
|
||||
//! | [`Tile::Door`] | `+` |
|
||||
//!
|
||||
//! Output is one grid row per line, in row-major order, with no trailing
|
||||
//! newline.
|
||||
|
||||
use crate::geometry::Point;
|
||||
use crate::map::{Map, Tile};
|
||||
|
||||
/// Returns the glyph for a tile, per the documented mapping.
|
||||
const fn glyph(tile: Tile) -> char {
|
||||
match tile {
|
||||
Tile::Wall => '#',
|
||||
Tile::Floor => '.',
|
||||
Tile::Door => '+',
|
||||
}
|
||||
}
|
||||
|
||||
impl Map {
|
||||
/// Renders the tile layer to a multi-line ASCII string.
|
||||
///
|
||||
/// One line per grid row, top to bottom, with cells left to right. There is
|
||||
/// no trailing newline. Glyphs follow the module's mapping. Any cell that
|
||||
/// somehow reads out of bounds (it cannot for a well-formed map) falls back
|
||||
/// to the wall glyph, so this method never panics.
|
||||
pub fn to_ascii(&self) -> String {
|
||||
let width = self.tiles.width();
|
||||
let height = self.tiles.height();
|
||||
// Each row is `width` glyphs; rows are joined by '\n' (height - 1 of them).
|
||||
let mut out = String::with_capacity((width as usize + 1) * height as usize);
|
||||
|
||||
for y in 0..height as i32 {
|
||||
if y != 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
for x in 0..width as i32 {
|
||||
let tile = self.tiles.get(Point::new(x, y)).copied().unwrap_or_default();
|
||||
out.push(glyph(tile));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Prints the ASCII rendering to standard output, followed by a newline.
|
||||
pub fn print_ascii(&self) {
|
||||
println!("{}", self.to_ascii());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::grid::Grid;
|
||||
use crate::region::ConnGraph;
|
||||
|
||||
/// Builds a 3x3 map with a known layout exercising all three glyphs.
|
||||
///
|
||||
/// Layout:
|
||||
/// ```text
|
||||
/// ###
|
||||
/// #.+
|
||||
/// ###
|
||||
/// ```
|
||||
fn hand_built_3x3() -> Map {
|
||||
let mut tiles = Grid::new(3, 3, Tile::Wall);
|
||||
tiles.set(Point::new(1, 1), Tile::Floor);
|
||||
tiles.set(Point::new(2, 1), Tile::Door);
|
||||
|
||||
Map {
|
||||
tiles,
|
||||
regions: Vec::new(),
|
||||
graph: ConnGraph::new(),
|
||||
seed: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_ascii_matches_known_layout_with_door() {
|
||||
let map = hand_built_3x3();
|
||||
let expected = "###\n#.+\n###";
|
||||
assert_eq!(map.to_ascii(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_ascii_has_no_trailing_newline_and_one_line_per_row() {
|
||||
let map = hand_built_3x3();
|
||||
let ascii = map.to_ascii();
|
||||
assert!(!ascii.ends_with('\n'));
|
||||
assert_eq!(ascii.lines().count(), 3);
|
||||
assert!(ascii.lines().all(|line| line.chars().count() == 3));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
//! This crate holds the pure generation logic with no rendering or GUI
|
||||
//! dependencies. Modules are added by later tasks.
|
||||
|
||||
pub mod ascii;
|
||||
pub mod geometry;
|
||||
pub mod grid;
|
||||
pub mod map;
|
||||
pub mod region;
|
||||
pub mod rng;
|
||||
|
|
|
|||
118
reikhelm-core/src/map.rs
Normal file
118
reikhelm-core/src/map.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
//! The core/viz output contract (spec §3) and the [`Tile`] terrain layer.
|
||||
//!
|
||||
//! [`Map`] is the single most important interface in the project: the core's
|
||||
//! whole job is to produce one, and a renderer's whole job is to read one. A
|
||||
//! `Map` describes **what is where** and carries **no rendering information** —
|
||||
//! no colors, sprites, or pixels. The same `Map` can be drawn as ASCII, colored
|
||||
//! rectangles, or sprites without the core changing.
|
||||
//!
|
||||
//! Every type here derives `serde::{Serialize, Deserialize}` (spec §3), so a
|
||||
//! generated map can round-trip to JSON for tooling or cross-program use.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::grid::Grid;
|
||||
use crate::region::{ConnGraph, Region};
|
||||
|
||||
/// The base terrain occupying a single cell (spec §4.3).
|
||||
///
|
||||
/// Starts minimal. New variants are additive (later: `Water`, `Lava`, `Road`,
|
||||
/// `Rubble`, …); passes match only on the variants they care about. The
|
||||
/// [`Default`] is [`Tile::Wall`], so a freshly allocated `Grid<Tile>` is solid
|
||||
/// rock waiting to be carved into.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Tile {
|
||||
/// Solid, impassable rock. The default fill for a new map.
|
||||
#[default]
|
||||
Wall,
|
||||
/// Open, walkable floor.
|
||||
Floor,
|
||||
/// A doorway between regions (walkable, but semantically a threshold).
|
||||
Door,
|
||||
}
|
||||
|
||||
/// The generator's output: what occupies each cell, the semantic regions, and
|
||||
/// how those regions connect (spec §3).
|
||||
///
|
||||
/// This is the core/viz contract. Fields are `pub` so any renderer can read the
|
||||
/// data directly; the type intentionally holds nothing visual. The `seed` is
|
||||
/// retained for reproducibility — the same seed reproduces this exact map.
|
||||
///
|
||||
/// `Map` does not derive `PartialEq`/`Eq` because its `tiles: Grid<Tile>` does
|
||||
/// not (the [`crate::grid::Grid`] storage type is generic and only derives the
|
||||
/// comparison traits where it chooses to). Equality of two maps is established
|
||||
/// via their serde representation instead — see the round-trip test.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Map {
|
||||
/// The base terrain layer: what tile occupies each cell.
|
||||
pub tiles: Grid<Tile>,
|
||||
/// The semantic areas (e.g. "Room #3 is this rect, kind = Room").
|
||||
pub regions: Vec<Region>,
|
||||
/// How regions connect (each edge is a door/corridor link).
|
||||
pub graph: ConnGraph,
|
||||
/// The seed that produced this map, for reproducibility.
|
||||
pub seed: u64,
|
||||
/// Map width in cells.
|
||||
pub width: u32,
|
||||
/// Map height in cells.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geometry::{Point, Rect};
|
||||
use crate::region::{RegionId, RegionKind};
|
||||
|
||||
/// Builds a tiny but structurally complete `Map` for serde exercises.
|
||||
fn sample_map() -> Map {
|
||||
let mut tiles = Grid::new(3, 2, Tile::Wall);
|
||||
tiles.set(Point::new(1, 0), Tile::Floor);
|
||||
tiles.set(Point::new(1, 1), Tile::Door);
|
||||
|
||||
let region = Region {
|
||||
id: RegionId(0),
|
||||
kind: RegionKind::Room,
|
||||
bounds: Rect::new(1, 0, 1, 2),
|
||||
cells: vec![Point::new(1, 0), Point::new(1, 1)],
|
||||
};
|
||||
|
||||
let mut graph = ConnGraph::new();
|
||||
graph.add_edge(RegionId(0), RegionId(0));
|
||||
graph.edges_mut()[0].at = Some(Point::new(1, 1));
|
||||
|
||||
Map {
|
||||
tiles,
|
||||
regions: vec![region],
|
||||
graph,
|
||||
seed: 0xDEAD_BEEF,
|
||||
width: 3,
|
||||
height: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_is_equal() {
|
||||
let original = sample_map();
|
||||
|
||||
let json = serde_json::to_string(&original).expect("Map serializes to JSON");
|
||||
let restored: Map = serde_json::from_str(&json).expect("Map deserializes from JSON");
|
||||
|
||||
// `Map` carries a `Grid<Tile>`, which does not implement `Eq`, so we
|
||||
// compare the maps through their (canonical) serde representation: a
|
||||
// faithful round-trip must re-serialize to byte-identical JSON.
|
||||
let reserialized = serde_json::to_string(&restored).expect("restored Map re-serializes");
|
||||
assert_eq!(json, reserialized);
|
||||
|
||||
// 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.seed, restored.seed);
|
||||
assert_eq!(
|
||||
(original.width, original.height),
|
||||
(restored.width, restored.height)
|
||||
);
|
||||
// And the tiles via their ASCII projection (a stable, total view).
|
||||
assert_eq!(original.to_ascii(), restored.to_ascii());
|
||||
}
|
||||
}
|
||||
175
reikhelm-core/src/region.rs
Normal file
175
reikhelm-core/src/region.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! Semantic regions and connectivity (spec §4.5).
|
||||
//!
|
||||
//! Where [`crate::grid::Grid`] answers *what tile is at a cell*, this module
|
||||
//! answers *what areas exist and how they connect*. A [`Region`] is a named
|
||||
//! semantic area (a room, a corridor) with a bounding box and the exact set of
|
||||
//! cells it occupies; a [`ConnGraph`] records which regions are linked and,
|
||||
//! once doors are placed, where the link physically sits.
|
||||
//!
|
||||
//! The shared vocabulary here is deliberately map-type agnostic: a dungeon is
|
||||
//! rooms joined by corridor edges, while a future town would be districts and
|
||||
//! buildings joined by road edges — same types, different passes producing
|
||||
//! them.
|
||||
//!
|
||||
//! All types derive `serde::{Serialize, Deserialize}` so they can travel inside
|
||||
//! a [`crate::map::Map`] through JSON (spec §3).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::geometry::{Point, Rect};
|
||||
|
||||
/// A handle to a [`Region`], equal to its index in the region list.
|
||||
///
|
||||
/// **Invariant:** `RegionId(n)` is always the index `n` of that region in
|
||||
/// `ctx.regions` / [`crate::map::Map::regions`]. Regions are append-only — never
|
||||
/// removed or reordered — so this id-as-index relationship holds for the life of
|
||||
/// the map. Edges in the [`ConnGraph`] and any lookup that turns an id back into
|
||||
/// a region rely on it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct RegionId(pub usize);
|
||||
|
||||
/// What kind of semantic area a [`Region`] represents.
|
||||
///
|
||||
/// Starts minimal; new variants are additive (later: `District`, `Plaza`,
|
||||
/// `Building`). Passes match only on the kinds they care about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RegionKind {
|
||||
/// A room: a carved, typically rectangular open area.
|
||||
Room,
|
||||
/// A corridor: a thin passage carved to connect rooms.
|
||||
Corridor,
|
||||
}
|
||||
|
||||
/// A semantic area of the map: an id, a kind, a bounding box, and its cells.
|
||||
///
|
||||
/// `bounds` is the axis-aligned bounding box; `cells` is the exact set of
|
||||
/// occupied cells, which lets non-rectangular regions (e.g. cave blobs) be
|
||||
/// represented faithfully rather than only as rectangles. Fields are `pub`
|
||||
/// because passes and visualization read them directly across modules.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// This region's stable id, equal to its index in the region list.
|
||||
pub id: RegionId,
|
||||
/// The semantic category of this region.
|
||||
pub kind: RegionKind,
|
||||
/// The axis-aligned bounding box enclosing all of `cells`.
|
||||
pub bounds: Rect,
|
||||
/// The exact cells this region occupies, in the order they were added.
|
||||
pub cells: Vec<Point>,
|
||||
}
|
||||
|
||||
/// A connection between two regions in the [`ConnGraph`].
|
||||
///
|
||||
/// `a` and `b` are the linked regions. `at` is the physical location of the
|
||||
/// connection (the door cell): it is [`None`] when the edge is first created by
|
||||
/// `MstConnect`, and is filled in later by `DoorPlacer`. Refines spec §4.5,
|
||||
/// where `at` was sketched as a bare `Point` — the door location simply is not
|
||||
/// known until placement runs. Fields are `pub` because `DoorPlacer` writes `at`
|
||||
/// and visualization reads all three.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Edge {
|
||||
/// One endpoint region.
|
||||
pub a: RegionId,
|
||||
/// The other endpoint region.
|
||||
pub b: RegionId,
|
||||
/// Where the connection physically sits, once known (e.g. the door cell).
|
||||
pub at: Option<Point>,
|
||||
}
|
||||
|
||||
/// The region connectivity graph: nodes are [`RegionId`]s, edges are [`Edge`]s.
|
||||
///
|
||||
/// Built incrementally — `MstConnect` adds the edges that should exist (with no
|
||||
/// location yet), then `DoorPlacer` populates each edge's `at`. The graph stores
|
||||
/// edges in insertion order, which is deterministic and the order
|
||||
/// [`ConnGraph::edges`] yields them in.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ConnGraph {
|
||||
edges: Vec<Edge>,
|
||||
}
|
||||
|
||||
impl ConnGraph {
|
||||
/// Creates an empty connectivity graph.
|
||||
pub fn new() -> Self {
|
||||
ConnGraph::default()
|
||||
}
|
||||
|
||||
/// Records that regions `a` and `b` are connected.
|
||||
///
|
||||
/// The new edge's location is left as `at: None`; `DoorPlacer` fills it in
|
||||
/// later. Edges are appended, so the order they were added is preserved.
|
||||
pub fn add_edge(&mut self, a: RegionId, b: RegionId) {
|
||||
self.edges.push(Edge { a, b, at: None });
|
||||
}
|
||||
|
||||
/// Returns the edges in insertion order.
|
||||
pub fn edges(&self) -> &[Edge] {
|
||||
&self.edges
|
||||
}
|
||||
|
||||
/// Returns the edges mutably, so `DoorPlacer` can populate each `at`.
|
||||
pub fn edges_mut(&mut self) -> &mut [Edge] {
|
||||
&mut self.edges
|
||||
}
|
||||
|
||||
/// Iterates the regions directly connected to `id`.
|
||||
///
|
||||
/// For every edge touching `id`, yields the *other* endpoint. An undirected
|
||||
/// link `{a, b}` therefore appears from both sides: `neighbors(a)` yields `b`
|
||||
/// and `neighbors(b)` yields `a`. Self-loops (`a == b == id`) yield `id`. The
|
||||
/// iteration order follows the edges' insertion order.
|
||||
pub fn neighbors(&self, id: RegionId) -> impl Iterator<Item = RegionId> + '_ {
|
||||
self.edges.iter().filter_map(move |e| {
|
||||
if e.a == id {
|
||||
Some(e.b)
|
||||
} else if e.b == id {
|
||||
Some(e.a)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn add_edge_stores_none_location() {
|
||||
let mut g = ConnGraph::new();
|
||||
g.add_edge(RegionId(0), RegionId(1));
|
||||
|
||||
assert_eq!(g.edges().len(), 1);
|
||||
let e = g.edges()[0];
|
||||
assert_eq!(e.a, RegionId(0));
|
||||
assert_eq!(e.b, RegionId(1));
|
||||
assert_eq!(e.at, None, "MstConnect leaves the door location unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbors_returns_both_endpoints_counterparts() {
|
||||
let mut g = ConnGraph::new();
|
||||
g.add_edge(RegionId(0), RegionId(1));
|
||||
g.add_edge(RegionId(1), RegionId(2));
|
||||
|
||||
// From region 1 we can reach both of its neighbors.
|
||||
let n1: Vec<RegionId> = g.neighbors(RegionId(1)).collect();
|
||||
assert_eq!(n1, vec![RegionId(0), RegionId(2)]);
|
||||
|
||||
// The link is undirected: each endpoint sees the other.
|
||||
let n0: Vec<RegionId> = g.neighbors(RegionId(0)).collect();
|
||||
assert_eq!(n0, vec![RegionId(1)]);
|
||||
let n2: Vec<RegionId> = g.neighbors(RegionId(2)).collect();
|
||||
assert_eq!(n2, vec![RegionId(1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_mut_lets_door_placer_populate_location() {
|
||||
let mut g = ConnGraph::new();
|
||||
g.add_edge(RegionId(0), RegionId(1));
|
||||
|
||||
// Simulate DoorPlacer writing the door cell into the edge.
|
||||
g.edges_mut()[0].at = Some(Point::new(4, 7));
|
||||
assert_eq!(g.edges()[0].at, Some(Point::new(4, 7)));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue