feat(wasm+web): live browser dungeon playground (WASM core + atmospheric renderer)

Phase 0+1 of the browser viz effort:
- core: drop unused `rand` dep (removes getrandom/libc → clean wasm32 build);
  derive serde on DungeonConfig + 5 sub-configs so the bridge can (de)serialize
- reikhelm-wasm: thin wasm-bindgen cdylib exposing generate(seed, config_json)
  -> JSON envelope (tiles as int codes, regions, edges, per-pass snapshots) and
  default_config_json(); holds no generation logic
- workspace: add reikhelm-wasm member; default-members keeps host cargo
  test/clippy on core+viz only
- reikhelm-web: vanilla-JS + Canvas 2D renderer running the real core live in
  the browser. Atmospheric look — lit stone floor, raised stone walls with
  rim-lit carved edges + top bevel, room light-pooling, amber door thresholds,
  vignette. Controls: seed/reroll, 9 config sliders, stage scrubber, semantic
  overlays (region outlines / graph / grid). window.reikhelm hooks for
  Playwright-driven iteration.

Core stays 115 tests green. Driven + screenshotted via Playwright MCP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parley Hatch 2026-05-31 00:07:24 -06:00
parent 9d8d8b28f0
commit f849d82d31
15 changed files with 970 additions and 257 deletions

7
.gitignore vendored
View file

@ -1 +1,8 @@
/target
# WASM build output (regenerated by wasm-pack)
reikhelm-web/pkg/
# Playwright MCP artifacts + iteration screenshots (scratch, not source)
.playwright-mcp/
/*.png

299
Cargo.lock generated
View file

@ -14,12 +14,6 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "autocfg"
version = "1.5.1"
@ -33,10 +27,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
@ -56,32 +50,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@ -128,24 +102,10 @@ version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
dependencies = [
"hashbrown 0.15.5",
"hashbrown",
"ttf-parser",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
"wasip2",
"wasip3",
]
[[package]]
name = "glam"
version = "0.27.0"
@ -163,24 +123,6 @@ dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
version = "0.24.9"
@ -194,42 +136,18 @@ dependencies = [
"png",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
[[package]]
name = "macroquad"
version = "0.4.15"
@ -311,13 +229,19 @@ dependencies = [
"malloc_buf",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
@ -333,16 +257,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -367,23 +281,6 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.10.0"
@ -404,7 +301,6 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
name = "reikhelm-core"
version = "0.1.0"
dependencies = [
"rand",
"rand_chacha",
"serde",
"serde_json",
@ -419,10 +315,20 @@ dependencies = [
]
[[package]]
name = "semver"
version = "1.0.28"
name = "reikhelm-wasm"
version = "0.1.0"
dependencies = [
"reikhelm-core",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "serde"
@ -497,61 +403,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
name = "wasm-bindgen"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "wasip2"
version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
dependencies = [
"wit-bindgen 0.57.1",
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
name = "wasm-bindgen-macro"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
dependencies = [
"wit-bindgen 0.51.0",
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
name = "wasm-bindgen-macro-support"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
dependencies = [
"leb128fmt",
"wasmparser",
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
name = "wasm-bindgen-shared"
version = "0.2.122"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
"unicode-ident",
]
[[package]]
@ -576,100 +469,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.49"

View file

@ -1,3 +1,8 @@
[workspace]
resolver = "2"
members = ["reikhelm-core", "reikhelm-viz"]
members = ["reikhelm-core", "reikhelm-viz", "reikhelm-wasm"]
# A bare `cargo build`/`test`/`clippy` at the root operates only on the
# host-native crates; `reikhelm-wasm` is built deliberately for wasm32 via
# wasm-pack, so it stays out of the default host set (but remains a member so it
# shares the workspace lockfile and `cargo build -p reikhelm-wasm` works).
default-members = ["reikhelm-core", "reikhelm-viz"]

View file

@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2021"
[dependencies]
rand = "0.10.1"
rand_chacha = "0.10.0"
serde = { version = "1.0.228", features = ["derive"] }

View file

@ -14,6 +14,8 @@
//! [`Wall`](crate::map::Tile::Wall). A later `RoomCarver` pass consumes these
//! placeholders, shrinks each to an actual room, and carves floor.
use serde::{Deserialize, Serialize};
use crate::geometry::Rect;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
@ -25,7 +27,7 @@ use crate::rng::Rng;
/// leaf rectangle: a cut is only made when both resulting halves keep every
/// dimension at or above this size. `max_depth` caps how many times the
/// recursion may split; `max_depth: 0` produces a single leaf — the whole map.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BspConfig {
/// The minimum allowed extent of any leaf dimension, in cells.
pub min_leaf: i32,

View file

@ -33,6 +33,8 @@
//! deterministic from the rooms alone; `rng` is accepted to honor the [`Pass`]
//! contract and leave room for future randomized loop selection.)
use serde::{Deserialize, Serialize};
use crate::pass::{GenContext, Pass};
use crate::region::{RegionId, RegionKind};
use crate::rng::Rng;
@ -46,7 +48,7 @@ use crate::rng::Rng;
/// minimum spanning tree with no loops. Values are clamped to `>= 0.0` and the
/// extra count is capped at the number of available non-tree pairs, so an
/// out-of-range or huge ratio never panics.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ConnectConfig {
/// Fraction of extra loop edges to add, relative to the `N - 1` tree edges.
/// `0.0` means a pure MST.

View file

@ -80,6 +80,8 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
@ -94,7 +96,7 @@ use crate::rng::Rng;
/// the underlying [`Rng::chance`]: `0.0` places no doors at all, `1.0` makes
/// every threshold a door. Connectivity is via the corridor floor and is
/// unaffected by this knob either way.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DoorConfig {
/// Probability in `[0.0, 1.0]` that a pierce threshold becomes a `Door`
/// instead of an open archway.

View file

@ -34,6 +34,8 @@
//! depend on `BspPartition`'s code — only on the documented "placeholder Room
//! regions" precondition, which its tests construct directly.
use serde::{Deserialize, Serialize};
use crate::geometry::Rect;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
@ -47,7 +49,7 @@ use crate::rng::Rng;
/// `margin` of cells is reserved on every side. The margin keeps room floors off
/// the leaf boundary so the surrounding wall — and the corridors that pierce it
/// — have room to exist.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomConfig {
/// The smallest allowed room extent (in cells) on either axis. A leaf that
/// cannot hold a `min_size`-square room (after the margin) is skipped.

View file

@ -34,6 +34,8 @@
use std::error::Error;
use std::fmt;
use serde::{Deserialize, Serialize};
use crate::pass::Pipeline;
use crate::passes::{
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
@ -46,7 +48,7 @@ use crate::passes::{
/// produces a recognizable multi-room dungeon on a 64×40 canvas, with per-pass
/// defaults chosen to be mutually consistent so **no** BSP leaf is ever skipped
/// by [`RoomCarver`] (see [`validate`](DungeonConfig::validate)).
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct DungeonConfig {
/// Canvas width in cells.
pub width: u32,

15
reikhelm-wasm/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "reikhelm-wasm"
version = "0.1.0"
edition = "2021"
# `cdylib` is what wasm-pack/wasm-bindgen need to emit a `.wasm` module;
# `rlib` lets host tooling (cargo check/clippy) link the crate too.
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
reikhelm-core = { path = "../reikhelm-core" }
wasm-bindgen = "0.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"

215
reikhelm-wasm/src/lib.rs Normal file
View file

@ -0,0 +1,215 @@
//! `reikhelm-wasm` — the browser bridge to `reikhelm-core`.
//!
//! This crate is the WebAssembly half of the core/renderer contract. It is a
//! *thin adapter*: it calls the dungeon recipe and serializes the resulting
//! [`Map`](reikhelm_core::map::Map) (plus the per-pass snapshots) into a compact
//! JSON envelope the browser renderer can read. It holds **no generation logic**
//! and reaches into **no engine internals** — exactly like `reikhelm-viz`, just
//! targeting the browser instead of a native window. If this crate ever needed
//! to know *how* a dungeon is built in order to serialize it, the architectural
//! boundary would be wrong.
//!
//! ## The envelope
//!
//! The core's own serde representation is faithful but verbose for a renderer:
//! `Tile` serializes as the strings `"Wall"`/`"Floor"`/`"Door"`, and every cell
//! is a `{ "x":.., "y":.. }` object. For the wire we instead send tiles as a
//! row-major grid of integer codes (`0=Wall, 1=Floor, 2=Door`) and points as
//! `[x, y]` pairs. That mapping is a *renderer concern*, so it lives here — the
//! core stores no codes and no colors. Timing is deliberately **not** included;
//! the page measures wall-clock around the call with `performance.now()` (and
//! `std::time::Instant` panics on `wasm32-unknown-unknown` anyway).
use serde::Serialize;
use wasm_bindgen::prelude::*;
use reikhelm_core::geometry::Point;
use reikhelm_core::grid::Grid;
use reikhelm_core::map::{Map, Tile};
use reikhelm_core::pass::Snapshot;
use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig};
use reikhelm_core::region::{Edge, Region, RegionKind};
/// The integer tile code sent on the wire. Renderer concern; not a core type.
fn tile_code(tile: Tile) -> u8 {
match tile {
Tile::Wall => 0,
Tile::Floor => 1,
Tile::Door => 2,
}
}
/// The region-kind tag sent on the wire.
fn kind_str(kind: RegionKind) -> &'static str {
match kind {
RegionKind::Room => "Room",
RegionKind::Corridor => "Corridor",
}
}
/// A tile grid flattened to row-major rows of integer codes.
fn tiles_to_rows(tiles: &Grid<Tile>) -> Vec<Vec<u8>> {
let w = tiles.width() as i32;
let h = tiles.height() as i32;
(0..h)
.map(|y| {
(0..w)
.map(|x| tile_code(tiles.get(Point::new(x, y)).copied().unwrap_or_default()))
.collect()
})
.collect()
}
/// A region's bounding box on the wire.
#[derive(Serialize)]
struct RectDto {
x: i32,
y: i32,
w: i32,
h: i32,
}
/// A semantic region on the wire: id, kind tag, bounds, and `[x, y]` cells.
#[derive(Serialize)]
struct RegionDto {
id: usize,
kind: &'static str,
bounds: RectDto,
cells: Vec<[i32; 2]>,
}
impl From<&Region> for RegionDto {
fn from(r: &Region) -> Self {
RegionDto {
id: r.id.0,
kind: kind_str(r.kind),
bounds: RectDto {
x: r.bounds.x,
y: r.bounds.y,
w: r.bounds.w,
h: r.bounds.h,
},
cells: r.cells.iter().map(|p| [p.x, p.y]).collect(),
}
}
}
/// A connectivity edge on the wire. `at` is the door cell once known.
#[derive(Serialize)]
struct EdgeDto {
a: usize,
b: usize,
at: Option<[i32; 2]>,
}
impl From<&Edge> for EdgeDto {
fn from(e: &Edge) -> Self {
EdgeDto {
a: e.a.0,
b: e.b.0,
at: e.at.map(|p| [p.x, p.y]),
}
}
}
/// One generation stage (post-pass snapshot) on the wire.
#[derive(Serialize)]
struct SnapshotDto {
label: String,
tiles: Vec<Vec<u8>>,
regions: Vec<RegionDto>,
edges: Vec<EdgeDto>,
}
impl From<&Snapshot> for SnapshotDto {
fn from(s: &Snapshot) -> Self {
SnapshotDto {
label: s.label.clone(),
tiles: tiles_to_rows(&s.tiles),
regions: s.regions.iter().map(RegionDto::from).collect(),
edges: s.edges.iter().map(EdgeDto::from).collect(),
}
}
}
/// The successful generation envelope.
#[derive(Serialize)]
struct Envelope {
ok: bool,
seed: u64,
width: u32,
height: u32,
tiles: Vec<Vec<u8>>,
regions: Vec<RegionDto>,
edges: Vec<EdgeDto>,
snapshots: Vec<SnapshotDto>,
}
/// The error envelope (bad config, parse failure). `ok: false` so the page can
/// branch on a single field.
#[derive(Serialize)]
struct ErrorEnvelope {
ok: bool,
error: String,
}
/// Build the error JSON. Infallible: a fixed-shape struct of owned strings.
fn error_json(msg: String) -> String {
serde_json::to_string(&ErrorEnvelope {
ok: false,
error: msg,
})
.unwrap_or_else(|_| r#"{"ok":false,"error":"serialization failed"}"#.to_string())
}
/// Assemble the success envelope from a generated map and its snapshots.
fn build_envelope(seed: u64, map: &Map, snapshots: &[Snapshot]) -> Envelope {
Envelope {
ok: true,
seed,
width: map.width,
height: map.height,
tiles: tiles_to_rows(&map.tiles),
regions: map.regions.iter().map(RegionDto::from).collect(),
edges: map.graph.edges().iter().map(EdgeDto::from).collect(),
snapshots: snapshots.iter().map(SnapshotDto::from).collect(),
}
}
/// The default [`DungeonConfig`] as JSON, so the page can populate its controls
/// from the real defaults rather than hardcoding them.
#[wasm_bindgen]
pub fn default_config_json() -> String {
serde_json::to_string(&DungeonConfig::default())
.unwrap_or_else(|_| error_json("could not serialize default config".to_string()))
}
/// Generate a dungeon for `seed` with the given JSON `config` and return the
/// envelope as a JSON string.
///
/// `seed` is a JS number (`f64`); integer seeds below 2^53 are exact. An empty
/// or whitespace `config` uses [`DungeonConfig::default`]. A malformed config or
/// one that fails recipe validation returns an `{ "ok": false, "error": ... }`
/// envelope rather than throwing — generation itself never panics by construction.
#[wasm_bindgen]
pub fn generate(seed: f64, config: &str) -> String {
let seed = seed as u64;
let cfg: DungeonConfig = if config.trim().is_empty() {
DungeonConfig::default()
} else {
match serde_json::from_str(config) {
Ok(c) => c,
Err(e) => return error_json(format!("config parse error: {e}")),
}
};
let pipeline = match dungeon(cfg) {
Ok(p) => p,
Err(e) => return error_json(format!("invalid config: {e}")),
};
let (map, snapshots) = pipeline.run_with_snapshots(seed);
serde_json::to_string(&build_envelope(seed, &map, &snapshots))
.unwrap_or_else(|_| error_json("could not serialize map".to_string()))
}

36
reikhelm-web/index.html Normal file
View file

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reikhelm · dungeon playground</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main id="stage-area">
<canvas id="view"></canvas>
</main>
<aside id="sidebar">
<h1>reikhelm</h1>
<p class="sub">procedural dungeon · live WASM core</p>
<div id="readout">
<div id="stats">generating…</div>
<div id="err"></div>
</div>
<div id="controls"></div>
<div id="stage-wrap" class="row stage">
<label>stage</label>
<input type="range" id="stage" min="0" max="0" step="1" value="0">
<span class="val" id="stage-label"></span>
</div>
<p class="hint">R reroll · ←/→ scrub stages</p>
</aside>
<script type="module" src="main.js"></script>
</body>
</html>

205
reikhelm-web/main.js Normal file
View file

@ -0,0 +1,205 @@
// main.js — app state, controls, and the WASM bridge wiring for the reikhelm
// browser playground. Loads the real reikhelm-core compiled to WASM, generates
// a dungeon in-browser, and hands the envelope to the renderer.
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
import { renderMap, getStage } from './render.js';
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const panel = document.getElementById('controls');
const readout = document.getElementById('readout');
const stageWrap = document.getElementById('stage-wrap');
const stageSlider = document.getElementById('stage');
const stageLabel = document.getElementById('stage-label');
// --- app state -----------------------------------------------------------
const state = {
seed: 1,
config: null, // parsed DungeonConfig (nested object)
env: null, // last successful envelope
stage: 0, // snapshot index currently shown
followFinal: true,
opts: { lighting: true, outlines: false, graph: false, grid: false },
genMs: 0,
};
// Slider definitions: [config path, label, min, max, step].
const SLIDERS = [
['width', 'map width', 32, 160, 1],
['height', 'map height', 24, 100, 1],
['bsp.max_depth', 'BSP depth', 1, 7, 1],
['bsp.min_leaf', 'min leaf', 6, 24, 1],
['rooms.min_size', 'room min', 3, 16, 1],
['rooms.max_size', 'room max', 4, 22, 1],
['rooms.margin', 'margin', 0, 3, 1],
['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02],
['doors.door_chance', 'door chance', 0, 1, 0.05],
];
function getPath(obj, path) {
return path.split('.').reduce((o, k) => o[k], obj);
}
function setPath(obj, path, val) {
const keys = path.split('.');
const last = keys.pop();
keys.reduce((o, k) => o[k], obj)[last] = val;
}
// --- generation + draw ---------------------------------------------------
function regenerate() {
const t0 = performance.now();
const json = generate(state.seed, JSON.stringify(state.config));
state.genMs = performance.now() - t0;
const env = JSON.parse(json);
if (!env.ok) {
readout.querySelector('#err').textContent = '⚠ ' + env.error;
return; // keep last good render
}
readout.querySelector('#err').textContent = '';
state.env = env;
const lastStage = (env.snapshots?.length ?? 1) - 1;
if (state.followFinal) state.stage = lastStage;
stageSlider.max = String(lastStage);
stageSlider.value = String(state.stage);
draw();
updateReadout();
}
function draw() {
if (!state.env) return;
const rect = canvas.getBoundingClientRect();
renderMap(ctx, rect.width, rect.height, state.env, { ...state.opts, stage: state.stage });
const st = getStage(state.env, state.stage);
const last = (state.env.snapshots?.length ?? 1) - 1;
stageLabel.textContent = `${state.stage + 1}/${last + 1} · ${st.label}${state.stage === last ? ' (final)' : ''}`;
}
function countTiles(env, code) {
let n = 0;
for (const row of env.tiles) for (const t of row) if (t === code) n++;
return n;
}
function updateReadout() {
const env = state.env;
const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length;
const corrs = env.regions.filter((r) => r.kind === 'Corridor').length;
const doors = countTiles(env, 2);
readout.querySelector('#stats').innerHTML =
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
`${env.edges.length} edges · gen <b>${state.genMs.toFixed(1)}</b> ms`;
}
// --- controls UI ---------------------------------------------------------
function buildControls() {
// Seed row.
const seedRow = document.createElement('div');
seedRow.className = 'row seed-row';
seedRow.innerHTML = `
<label>seed</label>
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
<button id="reroll"> reroll</button>`;
panel.appendChild(seedRow);
seedRow.querySelector('#seed').addEventListener('input', (e) => {
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
regenerate();
});
seedRow.querySelector('#reroll').addEventListener('click', () => {
state.seed = Math.floor(Math.random() * 1e9);
document.getElementById('seed').value = String(state.seed);
regenerate();
});
// Sliders.
for (const [path, label, min, max, step] of SLIDERS) {
const row = document.createElement('div');
row.className = 'row';
const val = getPath(state.config, path);
row.innerHTML = `
<label>${label}</label>
<input type="range" data-path="${path}" min="${min}" max="${max}" step="${step}" value="${val}">
<span class="val" data-for="${path}">${fmt(val, step)}</span>`;
panel.appendChild(row);
const slider = row.querySelector('input');
slider.addEventListener('input', (e) => {
const v = step < 1 ? parseFloat(e.target.value) : parseInt(e.target.value, 10);
setPath(state.config, path, v);
row.querySelector('.val').textContent = fmt(v, step);
regenerate();
});
}
// Toggles.
const togRow = document.createElement('div');
togRow.className = 'row toggles';
togRow.innerHTML = ['lighting', 'outlines', 'graph', 'grid']
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
.join('');
panel.appendChild(togRow);
togRow.querySelectorAll('input').forEach((cb) => {
cb.addEventListener('change', (e) => {
state.opts[e.target.dataset.opt] = e.target.checked;
draw();
});
});
}
function fmt(v, step) { return step < 1 ? v.toFixed(2) : String(v); }
// Stage scrubber.
stageSlider.addEventListener('input', (e) => {
state.stage = parseInt(e.target.value, 10);
const last = (state.env?.snapshots?.length ?? 1) - 1;
state.followFinal = state.stage === last;
draw();
});
// Keyboard: R reroll, ←/→ scrub stages.
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'r' || e.key === 'R') document.getElementById('reroll').click();
if (e.key === 'ArrowLeft') { stageSlider.value = String(Math.max(0, state.stage - 1)); stageSlider.dispatchEvent(new Event('input')); }
if (e.key === 'ArrowRight') { const last = (state.env?.snapshots?.length ?? 1) - 1; stageSlider.value = String(Math.min(last, state.stage + 1)); stageSlider.dispatchEvent(new Event('input')); }
});
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
window.addEventListener('resize', resize);
// --- automation hooks (used by Playwright-driven iteration) --------------
window.reikhelm = {
setSeed(s) { state.seed = s | 0; const el = document.getElementById('seed'); if (el) el.value = String(s); regenerate(); },
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
setOpt(k, v) { state.opts[k] = v; const cb = document.querySelector(`[data-opt="${k}"]`); if (cb) cb.checked = v; draw(); },
setConfig(path, v) { setPath(state.config, path, v); regenerate(); },
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
ok: !!state.env }),
};
// --- boot ----------------------------------------------------------------
async function boot() {
await init();
state.config = JSON.parse(default_config_json());
buildControls();
resize(); // sizes the canvas and triggers the first draw via regenerate below
regenerate();
document.body.dataset.ready = '1'; // a flag Playwright can wait on
}
boot().catch((e) => {
readout.querySelector('#err').textContent = 'boot failed: ' + e;
console.error(e);
});

301
reikhelm-web/render.js Normal file
View file

@ -0,0 +1,301 @@
// render.js — atmospheric Canvas 2D renderer for reikhelm map envelopes.
//
// This module is a *renderer*: it reads the JSON envelope the WASM bridge
// produces (tiles as integer codes, regions, edges) and paints it. It holds NO
// generation logic and knows nothing about how a dungeon is built — exactly the
// same contract the Rust renderers honor. All art (color, light, depth) lives
// here; the core stores none of it.
const WALL = 0, FLOOR = 1, DOOR = 2;
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
const PAL = {
bg: '#07070a',
stoneWarm: [158, 144, 118],
stoneCool: [118, 122, 136],
wallBody: [40, 37, 47], // visible dark slate (distinct from the void bg)
wallTop: [92, 84, 100], // lit top edge of a raised wall block
wallFace: [58, 53, 64], // lit vertical face at the base of a wall
wallRim: [120, 110, 128], // rim where stone meets carved floor
doorWarm: [212, 165, 74],
doorDark: [120, 84, 32],
outlineRoom: 'rgba(122, 184, 240, 0.85)',
outlineCorr: 'rgba(150, 150, 170, 0.40)',
edge: 'rgba(232, 96, 84, 0.85)',
grid: 'rgba(255,255,255,0.045)',
};
// Lighting tunables (tweaked by eye via screenshots).
const LIGHT = {
ambient: 0.46, // floor brightness with no room glow
corridorFloor: 0.40, // ambient for cells in no room (corridors)
glow: 0.70, // peak extra brightness at a room's lit core
aoFloor: 0.64, // brightness multiplier right next to a wall
aoDepth: 2.6, // cells from wall at which AO is fully open
maxB: 1.2, // clamp so highlights don't blow out
vignette: 0.30, // strength of the screen-edge darkening
};
// --- small helpers -------------------------------------------------------
function lerp(a, b, t) { return a + (b - a) * t; }
function lerp3(a, b, t) { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; }
function scale3(c, s) { return [c[0] * s, c[1] * s, c[2] * s]; }
function rgb(c) { return `rgb(${c[0] | 0},${c[1] | 0},${c[2] | 0})`; }
// Deterministic per-cell value noise in [0,1) — stable floor texture.
function hash2(x, y) {
let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263)) >>> 0;
h = (Math.imul(h ^ (h >>> 13), 1274126177)) >>> 0;
return (h >>> 0) % 1000 / 1000;
}
// Resolve the stage to draw: snapshots[stage], defaulting to the final stage.
export function getStage(env, stage) {
const snaps = env.snapshots || [];
if (snaps.length === 0) {
return { tiles: env.tiles, regions: env.regions, edges: env.edges, label: 'final' };
}
const i = Math.max(0, Math.min(snaps.length - 1, stage ?? snaps.length - 1));
return snaps[i];
}
// --- precomputed fields --------------------------------------------------
// Multi-source BFS distance (in cells) from every floor cell to the nearest
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion.
function distanceToWall(tiles, w, h) {
const dist = new Float32Array(w * h).fill(Infinity);
const q = new Int32Array(w * h);
let tail = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] === WALL) { dist[y * w + x] = 0; q[tail++] = y * w + x; }
}
}
let head = 0;
while (head < tail) {
const idx = q[head++];
const d = dist[idx];
const x = idx % w, y = (idx / w) | 0;
if (x > 0) { const n = idx - 1; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (x < w - 1) { const n = idx + 1; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (y > 0) { const n = idx - w; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
if (y < h - 1) { const n = idx + w; if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; } }
}
return dist;
}
// Per-cell room glow in [0,1]: bright at each room's core, fading to its edge.
// Applied per-region over that region's own cells, so light never bleeds
// through a wall into the next room — cheap, and it makes rooms pool light.
function roomGlow(regions, w, h) {
const glow = new Float32Array(w * h).fill(0);
for (const r of regions) {
if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue;
let cx = 0, cy = 0;
for (const [x, y] of r.cells) { cx += x; cy += y; }
cx /= r.cells.length; cy /= r.cells.length;
const radius = Math.max(2.2, Math.sqrt(r.cells.length) * 0.78);
for (const [x, y] of r.cells) {
const dx = x - cx, dy = y - cy;
const dd = Math.sqrt(dx * dx + dy * dy);
const g = Math.max(0, 1 - dd / (radius * 1.7));
const i = y * w + x;
if (g > glow[i]) glow[i] = g;
}
}
return glow;
}
// --- main entry ----------------------------------------------------------
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
export function renderMap(ctx, vw, vh, env, opts = {}) {
const { tiles, regions, edges } = getStage(env, opts.stage);
const w = env.width, h = env.height;
ctx.fillStyle = PAL.bg;
ctx.fillRect(0, 0, vw, vh);
if (!tiles || w === 0 || h === 0) return null;
// Layout: integer cell size, centered (letterboxed).
const pad = 18;
const cell = Math.max(2, Math.floor(Math.min((vw - pad * 2) / w, (vh - pad * 2) / h)));
const gw = cell * w, gh = cell * h;
const ox = Math.floor((vw - gw) / 2);
const oy = Math.floor((vh - gh) / 2);
const layout = { cell, ox, oy, w, h };
const lit = opts.lighting !== false;
const dist = lit ? distanceToWall(tiles, w, h) : null;
const glow = lit ? roomGlow(regions, w, h) : null;
const px = (x) => ox + x * cell;
const py = (y) => oy + y * cell;
const isFloor = (x, y) => x >= 0 && y >= 0 && x < w && y < h && tiles[y][x] !== WALL;
const isWall = (x, y) => x < 0 || y < 0 || x >= w || y >= h || tiles[y][x] === WALL;
// 1) Floor + doors, lit.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const t = tiles[y][x];
if (t === WALL) continue;
const i = y * w + x;
let b;
if (lit) {
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
const g = glow[i];
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g) * aoMul);
} else {
b = 0.85;
}
// Warm/cool stone variation + fine grain, from a stable hash.
const tint = 0.45 + 0.55 * hash2(x * 3 + 1, y * 7 + 2);
const grain = 0.9 + 0.2 * hash2(x, y);
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, tint);
ctx.fillStyle = rgb(scale3(stone, b * grain));
ctx.fillRect(px(x), py(y), cell, cell);
// Shadow cast by a wall to the north, falling onto this floor cell.
if (lit && isWall(x, y - 1)) {
const grd = ctx.createLinearGradient(0, py(y), 0, py(y) + cell * 0.7);
grd.addColorStop(0, 'rgba(0,0,0,0.45)');
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = grd;
ctx.fillRect(px(x), py(y), cell, Math.ceil(cell * 0.7));
}
}
}
// 2) Walls that border open space, drawn as raised stone blocks for depth.
const rim = Math.max(1, Math.round(cell * 0.12));
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== WALL) continue;
// Only paint walls adjacent to floor (interior rock stays background).
let visible = false;
for (let dy = -1; dy <= 1 && !visible; dy++)
for (let dx = -1; dx <= 1; dx++)
if ((dx || dy) && isFloor(x + dx, y + dy)) { visible = true; break; }
if (!visible) continue;
const X = px(x), Y = py(y);
const grain = 0.82 + 0.34 * hash2(x * 5 + 3, y * 9 + 1);
if (isFloor(x, y + 1)) {
// Floor to the south: this is a room's north wall, presenting a face to
// the lit room. Light the top (the block's top surface), shadow the base.
const grd = ctx.createLinearGradient(0, Y, 0, Y + cell);
grd.addColorStop(0, rgb(scale3(PAL.wallTop, grain)));
grd.addColorStop(0.45, rgb(scale3(PAL.wallBody, grain)));
grd.addColorStop(1, rgb(scale3(PAL.wallBody, grain * 0.78)));
ctx.fillStyle = grd;
} else {
ctx.fillStyle = rgb(scale3(PAL.wallBody, grain));
}
ctx.fillRect(X, Y, cell, cell);
// Rim-light every edge where this stone meets carved floor, so the room
// boundary reads crisply on all sides (catches the floor's glow).
ctx.fillStyle = rgb(scale3(PAL.wallRim, 0.6 * grain));
if (isFloor(x, y - 1)) ctx.fillRect(X, Y, cell, rim);
if (isFloor(x, y + 1)) ctx.fillRect(X, Y + cell - rim, cell, rim);
if (isFloor(x - 1, y)) ctx.fillRect(X, Y, rim, cell);
if (isFloor(x + 1, y)) ctx.fillRect(X + cell - rim, Y, rim, cell);
}
}
// 3) Doors — amber threshold slabs with jamb posts; archways stay plain floor.
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== DOOR) continue;
const horizontal = isWall(x, y - 1) && isWall(x, y + 1); // jambs N/S → door faces E/W
const cx = px(x), cy = py(y);
const grd = ctx.createLinearGradient(cx, cy, cx + cell, cy + cell);
grd.addColorStop(0, rgb(PAL.doorWarm));
grd.addColorStop(1, rgb(PAL.doorDark));
ctx.fillStyle = grd;
const inset = Math.max(1, cell * 0.12);
ctx.fillRect(cx + inset, cy + inset, cell - inset * 2, cell - inset * 2);
// Jamb posts on the wall sides.
ctx.fillStyle = rgb(scale3(PAL.doorWarm, 0.5));
const t = Math.max(1, cell * 0.16);
if (horizontal) {
ctx.fillRect(cx, cy, cell, t);
ctx.fillRect(cx, cy + cell - t, cell, t);
} else {
ctx.fillRect(cx, cy, t, cell);
ctx.fillRect(cx + cell - t, cy, t, cell);
}
}
}
// 4) Vignette over the whole grid for mood.
if (lit && LIGHT.vignette > 0) {
const cxp = ox + gw / 2, cyp = oy + gh / 2;
const grd = ctx.createRadialGradient(cxp, cyp, Math.min(gw, gh) * 0.30, cxp, cyp, Math.max(gw, gh) * 0.72);
grd.addColorStop(0, 'rgba(0,0,0,0)');
grd.addColorStop(1, `rgba(0,0,0,${LIGHT.vignette})`);
ctx.fillStyle = grd;
ctx.fillRect(ox, oy, gw, gh);
}
// 5) Optional semantic overlays.
if (opts.outlines) drawOutlines(ctx, regions, layout);
if (opts.graph) drawGraph(ctx, regions, edges, layout);
if (opts.grid) drawGrid(ctx, layout);
return layout;
}
// Trace each region's true cell-set boundary (shows polygonal room shapes).
function drawOutlines(ctx, regions, { cell, ox, oy }) {
for (const r of regions) {
if (!r.cells || r.cells.length === 0) continue;
const inRegion = new Set(r.cells.map(([x, y]) => x + ',' + y));
ctx.strokeStyle = r.kind === 'Room' ? PAL.outlineRoom : PAL.outlineCorr;
ctx.lineWidth = Math.max(1, cell * 0.1);
ctx.beginPath();
for (const [x, y] of r.cells) {
const X = ox + x * cell, Y = oy + y * cell;
if (!inRegion.has(x + ',' + (y - 1))) { ctx.moveTo(X, Y); ctx.lineTo(X + cell, Y); }
if (!inRegion.has(x + ',' + (y + 1))) { ctx.moveTo(X, Y + cell); ctx.lineTo(X + cell, Y + cell); }
if (!inRegion.has((x - 1) + ',' + y)) { ctx.moveTo(X, Y); ctx.lineTo(X, Y + cell); }
if (!inRegion.has((x + 1) + ',' + y)) { ctx.moveTo(X + cell, Y); ctx.lineTo(X + cell, Y + cell); }
}
ctx.stroke();
}
}
// Draw the connectivity graph: a line between each edge's region centers.
function drawGraph(ctx, regions, edges, { cell, ox, oy }) {
if (!edges) return;
const center = (r) => [ox + (r.bounds.x + r.bounds.w / 2) * cell, oy + (r.bounds.y + r.bounds.h / 2) * cell];
ctx.strokeStyle = PAL.edge;
ctx.lineWidth = Math.max(1.5, cell * 0.14);
ctx.fillStyle = PAL.edge;
for (const e of edges) {
const ra = regions[e.a], rb = regions[e.b];
if (!ra || !rb) continue;
const [ax, ay] = center(ra), [bx, by] = center(rb);
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
}
for (const r of regions) {
const [x, y] = center(r);
ctx.beginPath(); ctx.arc(x, y, Math.max(2, cell * 0.18), 0, Math.PI * 2); ctx.fill();
}
}
function drawGrid(ctx, { cell, ox, oy, w, h }) {
ctx.strokeStyle = PAL.grid;
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= w; x++) { ctx.moveTo(ox + x * cell, oy); ctx.lineTo(ox + x * cell, oy + h * cell); }
for (let y = 0; y <= h; y++) { ctx.moveTo(ox, oy + y * cell); ctx.lineTo(ox + w * cell, oy + y * cell); }
ctx.stroke();
}

121
reikhelm-web/style.css Normal file
View file

@ -0,0 +1,121 @@
:root {
--bg: #07070a;
--panel: #121218;
--panel-edge: #23232e;
--ink: #d8d4cc;
--ink-dim: #8a8a98;
--accent: #d4a54a;
--accent-cool: #7ab8f0;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--ink);
font: 13px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
overflow: hidden;
}
body {
display: grid;
grid-template-columns: 1fr 300px;
height: 100vh;
}
#stage-area {
position: relative;
overflow: hidden;
}
#view {
width: 100%;
height: 100%;
display: block;
}
#sidebar {
background: var(--panel);
border-left: 1px solid var(--panel-edge);
padding: 16px 16px 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
h1 {
margin: 0;
font-size: 20px;
letter-spacing: 0.08em;
color: var(--accent);
font-weight: 600;
}
.sub { margin: -6px 0 4px; color: var(--ink-dim); font-size: 11px; }
#readout {
background: #0c0c11;
border: 1px solid var(--panel-edge);
border-radius: 6px;
padding: 8px 10px;
font-size: 11px;
color: var(--ink-dim);
}
#readout b { color: var(--ink); font-weight: 600; }
#err { color: #e06a5a; margin-top: 4px; min-height: 0; }
#err:empty { display: none; }
#controls { display: flex; flex-direction: column; gap: 7px; }
.row {
display: grid;
grid-template-columns: 78px 1fr 42px;
align-items: center;
gap: 8px;
}
.row label { color: var(--ink-dim); font-size: 11px; }
.row .val { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
input[type="range"] {
width: 100%;
accent-color: var(--accent);
height: 4px;
}
.seed-row { grid-template-columns: 40px 1fr auto; }
.seed-row input[type="number"] {
background: #0c0c11;
border: 1px solid var(--panel-edge);
color: var(--ink);
border-radius: 4px;
padding: 4px 6px;
font: inherit;
width: 100%;
}
button {
background: #1c1c24;
border: 1px solid var(--panel-edge);
color: var(--ink);
border-radius: 4px;
padding: 5px 9px;
font: inherit;
cursor: pointer;
}
button:hover { background: #26262f; border-color: var(--accent); color: var(--accent); }
.toggles {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
grid-template-columns: none;
margin-top: 2px;
}
.tog { display: flex; align-items: center; gap: 5px; color: var(--ink-dim); cursor: pointer; }
.tog input { accent-color: var(--accent-cool); }
.stage { margin-top: auto; }
.stage .val { width: auto; text-align: right; font-size: 10px; color: var(--accent-cool); }
.hint { color: var(--ink-dim); font-size: 10px; margin: 4px 0 0; text-align: center; }