Press S to begin seed entry, type digits (HUD shows the buffer), Enter applies and regenerates, Esc cancels, Backspace edits. Reroll still increments. Digit-only input; buffer capped under u64's range. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
//! `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<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.
|
||
///
|
||
/// 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<String> = 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::<u64>() {
|
||
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));
|
||
}
|
||
}
|