reikhelm/reikhelm-viz/src/main.rs
Parley Hatch e7e8bb9187 feat(core): water & lava pools (PoolDecorator) + lava lighting
Seventh pass (before pillars): floods an organic blob of Water or Lava into
some larger rooms via randomized BFS growth.

- New Tile::Water / Tile::Lava (impassable hazards). Exhaustive matches updated
  (ascii '~'/'!', frozen viz colors, wasm codes 4/5).
- PoolDecorator/PoolConfig (water_chance, lava_chance, min_room): blob is
  interior-only (>= 2 from edge, never the center), and a connectivity guard
  verifies the room's remaining floor stays 4-connected with the center intact
  before committing — so a pool never orphans part of a room.
- DungeonConfig gains `pools`; recipe inserts PoolDecorator after DoorPlacer.
- Renderer: water as cool reflective pools; lava as molten rock that ALSO
  throws warm light onto nearby floor (BFS light field, wall-blocked) — lava
  chambers visibly glow. New water/lava sliders.

Core: 125 tests green (4 new: interior placement, floor stays connected,
zero/small-room skip, determinism); connectivity holds with pools in the
default recipe. clippy clean (core + viz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:41:48 -06:00

352 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `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,
Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0),
Tile::Water => Color::new(0.16, 0.34, 0.52, 1.0),
Tile::Lava => Color::new(0.85, 0.32, 0.10, 1.0),
}
}
/// 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));
}
}