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:
parent
9d8d8b28f0
commit
f849d82d31
15 changed files with 970 additions and 257 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
299
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
15
reikhelm-wasm/Cargo.toml
Normal 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
215
reikhelm-wasm/src/lib.rs
Normal 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
36
reikhelm-web/index.html
Normal 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
205
reikhelm-web/main.js
Normal 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
301
reikhelm-web/render.js
Normal 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
121
reikhelm-web/style.css
Normal 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; }
|
||||
Loading…
Reference in a new issue