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:
parent
3232cf3d09
commit
0372ce06d8
1 changed files with 295 additions and 5 deletions
|
|
@ -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<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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue