//! `reikhelm-viz` — an interactive macroquad viewer for generated maps (spec §6). //! //! 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. /// /// When `seed_input` is `Some`, the user is typing a seed: the top line becomes /// an entry prompt showing the buffer, and the hint switches to the entry keys. fn draw_hud(dungeon: &Dungeon, seed_input: Option<&str>) { 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; // The top line doubles as the seed-entry prompt while typing. let (top_line, hint) = match seed_input { Some(buf) => ( format!("set seed: {buf}_"), "Enter: apply Esc: cancel (digits only)", ), None => ( format!("seed: {}", dungeon.seed), "Space: reroll Left/Right: scrub stages S: set seed", ), }; let stage_line = format!( "stage {}/{}: {}{}", dungeon.stage + 1, stage_count, stage_label, if is_final { " (final)" } else { "" }, ); // 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)); let top_color = if seed_input.is_some() { GOLD } else { WHITE }; draw_text(&top_line, 12.0, 22.0, 24.0, top_color); 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); // `Some(buffer)` while the user is typing a seed; `None` in normal mode. let mut seed_input: Option = None; loop { // --- input --- if seed_input.is_none() { // Normal mode: reroll, scrub, or begin seed entry. 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); } if is_key_pressed(KeyCode::S) { // Begin seed entry; digits are collected on the following frames. seed_input = Some(String::new()); } } else { // Seed-entry mode: collect digits, apply on Enter, cancel on Esc. let mut exit = false; { let buf = seed_input.as_mut().expect("in seed-entry mode"); // Drain this frame's typed chars; keep digits only (so the `S` // that opened the mode and any other keys are ignored). Cap the // length well under u64's 20 digits so it always parses. while let Some(c) = get_char_pressed() { if c.is_ascii_digit() && buf.len() < 19 { buf.push(c); } } if is_key_pressed(KeyCode::Backspace) { buf.pop(); } if is_key_pressed(KeyCode::Enter) { // Empty or unparseable buffer simply cancels (no change). if let Ok(parsed) = buf.parse::() { seed = parsed; dungeon = Dungeon::generate(seed); } exit = true; } else if is_key_pressed(KeyCode::Escape) { exit = true; } } if exit { seed_input = None; } } // --- 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, seed_input.as_deref()); 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)); } }