diff --git a/.gitignore b/.gitignore index ea8c4bf..a4455a3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock index e1bbae3..8245be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3e13537..6441bd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/reikhelm-core/Cargo.toml b/reikhelm-core/Cargo.toml index 6b2e906..f2e0e08 100644 --- a/reikhelm-core/Cargo.toml +++ b/reikhelm-core/Cargo.toml @@ -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"] } diff --git a/reikhelm-core/src/passes/bsp.rs b/reikhelm-core/src/passes/bsp.rs index 16b8a39..201f611 100644 --- a/reikhelm-core/src/passes/bsp.rs +++ b/reikhelm-core/src/passes/bsp.rs @@ -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, diff --git a/reikhelm-core/src/passes/connect.rs b/reikhelm-core/src/passes/connect.rs index 110cb3d..49a957d 100644 --- a/reikhelm-core/src/passes/connect.rs +++ b/reikhelm-core/src/passes/connect.rs @@ -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. diff --git a/reikhelm-core/src/passes/door.rs b/reikhelm-core/src/passes/door.rs index 8555bba..253a13d 100644 --- a/reikhelm-core/src/passes/door.rs +++ b/reikhelm-core/src/passes/door.rs @@ -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. diff --git a/reikhelm-core/src/passes/room.rs b/reikhelm-core/src/passes/room.rs index aba87cb..fea491e 100644 --- a/reikhelm-core/src/passes/room.rs +++ b/reikhelm-core/src/passes/room.rs @@ -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. diff --git a/reikhelm-core/src/recipes/dungeon.rs b/reikhelm-core/src/recipes/dungeon.rs index 2903f50..0e38825 100644 --- a/reikhelm-core/src/recipes/dungeon.rs +++ b/reikhelm-core/src/recipes/dungeon.rs @@ -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, diff --git a/reikhelm-wasm/Cargo.toml b/reikhelm-wasm/Cargo.toml new file mode 100644 index 0000000..758e4e6 --- /dev/null +++ b/reikhelm-wasm/Cargo.toml @@ -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" diff --git a/reikhelm-wasm/src/lib.rs b/reikhelm-wasm/src/lib.rs new file mode 100644 index 0000000..9c7dff6 --- /dev/null +++ b/reikhelm-wasm/src/lib.rs @@ -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) -> Vec> { + 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>, + regions: Vec, + edges: Vec, +} + +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>, + regions: Vec, + edges: Vec, + snapshots: Vec, +} + +/// 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())) +} diff --git a/reikhelm-web/index.html b/reikhelm-web/index.html new file mode 100644 index 0000000..6be6e42 --- /dev/null +++ b/reikhelm-web/index.html @@ -0,0 +1,36 @@ + + + + + + reikhelm · dungeon playground + + + +
+ +
+ + + + + + diff --git a/reikhelm-web/main.js b/reikhelm-web/main.js new file mode 100644 index 0000000..d73eb72 --- /dev/null +++ b/reikhelm-web/main.js @@ -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 ${env.seed} · ${env.width}×${env.height} · ` + + `${rooms} rooms · ${corrs} corridors · ${doors} doors · ` + + `${env.edges.length} edges · gen ${state.genMs.toFixed(1)} ms`; +} + +// --- controls UI --------------------------------------------------------- + +function buildControls() { + // Seed row. + const seedRow = document.createElement('div'); + seedRow.className = 'row seed-row'; + seedRow.innerHTML = ` + + + `; + 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 = ` + + + ${fmt(val, step)}`; + 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) => ``) + .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); +}); diff --git a/reikhelm-web/render.js b/reikhelm-web/render.js new file mode 100644 index 0000000..fdfab89 --- /dev/null +++ b/reikhelm-web/render.js @@ -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(); +} diff --git a/reikhelm-web/style.css b/reikhelm-web/style.css new file mode 100644 index 0000000..9da440a --- /dev/null +++ b/reikhelm-web/style.css @@ -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; }