feat(viz): macroquad dungeon renderer with seed reroll

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-28 22:32:21 -06:00
parent 3232cf3d09
commit 0372ce06d8

View file

@ -1,7 +1,297 @@
//! `reikhelm-viz` — visualization binary for generated maps. //! `reikhelm-viz` — an interactive macroquad viewer for generated maps (spec §6).
//! //!
//! This is a placeholder entry point; Task 13 replaces it with the real //! This binary is the thin renderer half of the core/viz contract: it calls a
//! macroquad-based viewer. //! recipe, receives a [`Map`](reikhelm_core::map::Map), and paints it. It holds
fn main() { //! **no generation logic** and reaches into **no engine internals** — it only
println!("reikhelm-viz: placeholder"); //! reads the public `Map` / `Pipeline` / `Snapshot` data and turns it into
//! colored rectangles, outlines, and lines. If this file ever needed to know
//! *how* a dungeon is built in order to draw it, the architectural boundary
//! would be wrong.
//!
//! ## Controls
//! - **Space** — reroll: bump the seed, regenerate, jump to the final stage.
//! - **Left / Right** — scrub backward / forward through the generation stages
//! (the per-pass snapshots). The final stage is the complete dungeon.
//!
//! ## How drawing works
//! Each frame we recompute a uniform cell size from the current window size and
//! the map dimensions (`min(sw/w, sh/h)`), then letterbox the grid so it stays
//! centered and square on resize. We paint the [`Snapshot`] at the current
//! stage: one filled rectangle per cell colored by its [`Tile`], plus the
//! semantic layer — region bounding-box outlines and graph edges drawn as lines
//! between the two endpoint regions' centers. Drawing the semantic layer is what
//! makes the partition and connect stages visible, since those passes change
//! only regions/edges and not tiles.
use macroquad::prelude::*;
use reikhelm_core::geometry::Rect;
use reikhelm_core::map::Tile;
use reikhelm_core::pass::Snapshot;
use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig};
use reikhelm_core::region::Edge;
/// Maps a [`Tile`] to the color it is painted with.
///
/// Purely a renderer concern — the core stores no color (spec §3). `Wall` is a
/// near-black so the carved space reads as light-on-dark; `Floor` is a warm
/// light gray; `Door` is an accent gold so thresholds pop.
fn color_of(tile: Tile) -> Color {
match tile {
Tile::Wall => Color::new(0.07, 0.07, 0.09, 1.0),
Tile::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
Tile::Door => GOLD,
}
}
/// How the grid is placed on screen this frame: a uniform `cell` size in pixels
/// and an `(ox, oy)` top-left offset that letterboxes (centers) the grid.
struct Layout {
cell: f32,
ox: f32,
oy: f32,
}
impl Layout {
/// Computes a centered, uniform-cell layout for a `w` × `h` grid filling the
/// current window as large as it can without distorting the aspect ratio.
fn fit(w: u32, h: u32) -> Layout {
let (sw, sh) = (screen_width(), screen_height());
// A degenerate (zero) dimension can't be drawn; fall back to a 1px cell
// so arithmetic stays finite. The default config never produces this.
let cell = if w == 0 || h == 0 {
1.0
} else {
(sw / w as f32).min(sh / h as f32)
};
let ox = (sw - cell * w as f32) / 2.0;
let oy = (sh - cell * h as f32) / 2.0;
Layout { cell, ox, oy }
}
/// Pixel x of grid column `x`'s left edge.
fn px(&self, x: i32) -> f32 {
self.ox + x as f32 * self.cell
}
/// Pixel y of grid row `y`'s top edge.
fn py(&self, y: i32) -> f32 {
self.oy + y as f32 * self.cell
}
}
/// A generated dungeon plus the per-pass snapshots and the seed that produced
/// them. Held in viz so reroll is a simple `seed + 1` counter — the core's
/// determinism is never touched (spec §6, §7).
struct Dungeon {
seed: u64,
/// One snapshot per pass, in pipeline order. The last snapshot is the
/// completed map, so it always exists for a valid config.
snapshots: Vec<Snapshot>,
/// Which snapshot is currently shown (`0..snapshots.len()`).
stage: usize,
/// The map's dimensions, taken from the final [`Map`] (snapshots carry only
/// the grid + semantic layer, so we keep the canvas size here).
width: u32,
height: u32,
}
impl Dungeon {
/// Generates a dungeon for `seed` from the default config and starts on the
/// final stage (the complete map).
///
/// The default [`DungeonConfig`] is known-valid (its own tests assert
/// `dungeon(DungeonConfig::default()).is_ok()`), so `expect` here can never
/// fire; the message documents that contract for any future config change.
fn generate(seed: u64) -> Dungeon {
let cfg = DungeonConfig::default();
let pipeline = dungeon(cfg).expect("the default DungeonConfig is valid by construction");
let (map, snapshots) = pipeline.run_with_snapshots(seed);
// Start on the final stage so the default view is the finished dungeon.
let stage = snapshots.len().saturating_sub(1);
Dungeon {
seed,
snapshots,
stage,
width: map.width,
height: map.height,
}
}
/// The snapshot currently being viewed, if any.
fn current(&self) -> Option<&Snapshot> {
self.snapshots.get(self.stage)
}
/// Steps the stage cursor backward (`-1`) or forward (`+1`), clamped.
fn scrub(&mut self, delta: i32) {
let last = self.snapshots.len().saturating_sub(1);
let next = (self.stage as i32 + delta).clamp(0, last as i32);
self.stage = next as usize;
}
}
/// Draws every cell of `snapshot.tiles` as a filled rectangle colored by tile.
fn draw_tiles(snapshot: &Snapshot, layout: &Layout) {
for (p, &tile) in snapshot.tiles.iter() {
draw_rectangle(
layout.px(p.x),
layout.py(p.y),
layout.cell,
layout.cell,
color_of(tile),
);
}
}
/// Draws each region's bounding box as an outline.
///
/// This makes the partition stage legible: `bsp_partition` fills regions while
/// the grid is still solid wall, so without outlines that stage would look
/// blank.
fn draw_region_bounds(snapshot: &Snapshot, layout: &Layout) {
let outline = SKYBLUE;
let thickness = (layout.cell * 0.12).max(1.0);
for region in &snapshot.regions {
let b: Rect = region.bounds;
if b.is_empty() {
continue;
}
draw_rectangle_lines(
layout.px(b.x),
layout.py(b.y),
b.w as f32 * layout.cell,
b.h as f32 * layout.cell,
thickness,
outline,
);
}
}
/// Draws each graph edge as a line between the centers of its two endpoint
/// regions.
///
/// `edge.a` / `edge.b` are [`RegionId`](reikhelm_core::region::RegionId)s, which
/// are indices into `snapshot.regions` (the id-as-index invariant). We resolve
/// each endpoint to its region's `bounds.center()`. This makes the connect stage
/// legible: `mst_connect` adds edges but carves no tiles.
fn draw_edges(snapshot: &Snapshot, layout: &Layout) {
let line_color = RED;
let thickness = (layout.cell * 0.15).max(1.0);
// Center a line endpoint in the middle of a cell (not its corner).
let half = layout.cell / 2.0;
for edge in &snapshot.edges {
let Edge { a, b, .. } = *edge;
let (Some(ra), Some(rb)) = (snapshot.regions.get(a.0), snapshot.regions.get(b.0)) else {
// An edge referencing a missing region would be a core bug; skip it
// rather than panic (the renderer only reads, it never asserts).
continue;
};
let ca = ra.bounds.center();
let cb = rb.bounds.center();
draw_line(
layout.px(ca.x) + half,
layout.py(ca.y) + half,
layout.px(cb.x) + half,
layout.py(cb.y) + half,
thickness,
line_color,
);
}
}
/// Draws the on-screen HUD: seed, current stage, and the controls hint.
fn draw_hud(dungeon: &Dungeon) {
let stage_count = dungeon.snapshots.len();
let stage_label = dungeon
.current()
.map(|s| s.label.as_str())
.unwrap_or("(empty)");
let is_final = dungeon.stage + 1 == stage_count;
let seed_line = format!("seed: {}", dungeon.seed);
let stage_line = format!(
"stage {}/{}: {}{}",
dungeon.stage + 1,
stage_count,
stage_label,
if is_final { " (final)" } else { "" },
);
let hint = "Space: reroll Left/Right: scrub stages";
// A dark plate behind the text so it stays readable over light floor tiles.
draw_rectangle(0.0, 0.0, screen_width(), 64.0, Color::new(0.0, 0.0, 0.0, 0.55));
draw_text(&seed_line, 12.0, 22.0, 24.0, WHITE);
draw_text(&stage_line, 12.0, 44.0, 22.0, SKYBLUE);
draw_text(hint, 12.0, 60.0, 18.0, LIGHTGRAY);
}
/// The macroquad entry point: build a dungeon, then loop forever painting it and
/// handling reroll / scrub input.
#[macroquad::main("reikhelm")]
async fn main() {
// The seed reroll counter lives entirely in viz (spec §6/§7): the core's
// determinism is unaffected — same seed always rebuilds the same dungeon.
let mut seed: u64 = 0;
let mut dungeon = Dungeon::generate(seed);
loop {
// --- input ---
if is_key_pressed(KeyCode::Space) {
seed = seed.wrapping_add(1);
dungeon = Dungeon::generate(seed);
}
if is_key_pressed(KeyCode::Left) {
dungeon.scrub(-1);
}
if is_key_pressed(KeyCode::Right) {
dungeon.scrub(1);
}
// --- draw ---
clear_background(Color::new(0.04, 0.04, 0.05, 1.0));
let layout = Layout::fit(dungeon.width, dungeon.height);
if let Some(snapshot) = dungeon.current() {
draw_tiles(snapshot, &layout);
// The semantic overlay (region boxes + edges) is most useful while
// scrubbing the early stages; it stays on the final view too, which
// doubles as a quick legibility check that rooms map to regions.
draw_region_bounds(snapshot, &layout);
draw_edges(snapshot, &layout);
} else {
// No snapshots at all would mean a pass-less pipeline — never the
// case for the dungeon recipe, but draw a note instead of a blank.
draw_text(
"no generation stages to display",
20.0,
screen_height() / 2.0,
28.0,
WHITE,
);
}
draw_hud(&dungeon);
next_frame().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `color_of` is total and distinguishes every tile kind (the three v1
/// variants paint to three distinct colors).
#[test]
fn color_of_is_distinct_per_tile() {
let wall = color_of(Tile::Wall);
let floor = color_of(Tile::Floor);
let door = color_of(Tile::Door);
// Compare via the f32 components; Color is not Eq (floats).
let rgba = |c: Color| (c.r, c.g, c.b, c.a);
assert_ne!(rgba(wall), rgba(floor));
assert_ne!(rgba(floor), rgba(door));
assert_ne!(rgba(wall), rgba(door));
}
} }