From 0372ce06d8e0ad10028ebe4f2303f52e90fc4277 Mon Sep 17 00:00:00 2001 From: Parley Hatch Date: Thu, 28 May 2026 22:32:21 -0600 Subject: [PATCH] feat(viz): macroquad dungeon renderer with seed reroll Co-Authored-By: Claude Opus 4.8 (1M context) --- reikhelm-viz/src/main.rs | 300 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 295 insertions(+), 5 deletions(-) diff --git a/reikhelm-viz/src/main.rs b/reikhelm-viz/src/main.rs index 76b70e7..38cbcd5 100644 --- a/reikhelm-viz/src/main.rs +++ b/reikhelm-viz/src/main.rs @@ -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 -//! macroquad-based viewer. -fn main() { - println!("reikhelm-viz: placeholder"); +//! This binary is the thin renderer half of the core/viz contract: it calls a +//! recipe, receives a [`Map`](reikhelm_core::map::Map), and paints it. It holds +//! **no generation logic** and reaches into **no engine internals** — it only +//! 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, + /// 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)); + } }