Compare commits

..

10 commits

Author SHA1 Message Date
ed00e31009 feat(render): real-geometry depth export + first-person explorable atlas
Render actual generated dungeons through the AI pipeline — both top-down and
first-person (Eye-of-the-Beholder style) — instead of synthetic test depth.
All renderer-side: derived from the envelope's tiles+regions+theme, the core
stores none of it.

- depth.js: top-down height-field depth exporter (walls raised, pools recessed,
  pillars as bumps, subtle per-theme relief), calibrated to the proven room_depth
  levels so one tall room can't crush every floor dark.
- fpv.js: first-person depth via a Wolfenstein-style grid raycaster (16:9),
  reports straight-ahead distance for opening detection.
- explore.js: room->room nav graph derived from REAL tile openings (scan
  boundary gaps, bin by cardinal, flood the corridor to the destination room) —
  nav + open share one source of truth with the rendered passages.
- bake_atlas.py: bakes per-room x4-facing pixel-art frames; per-theme + per-facing
  (wall vs passage) prompts, fixed diffusion seed for cross-frame style coherence,
  per-run unique prefixes so re-bakes don't silently skip.
- explore.html + explore-viewer.js: WASD first-person dungeon explorer
  (room-to-room movement, turning, minimap), integer-pixel fullscreen.
- serve.py: stdlib dev server — static web root + /out tree + POST /save, no-store.
- main.js/render.js/style.css: export hooks, the depth button, distanceToWall export.
- tools/README.md: Stage 0/1/2/3 docs; .dev/2026-06-01-fpv-prompts.md: prompt
  research (cfg-1 negatives are inert; trigger words summon hands; empty-ruin
  reframe; fixed seed = consistency).

Proven end-to-end on seed 7 (17 rooms). Outputs organized under git-ignored
tools/out/. Control strength: 0.80-0.85 top-down, 0.85 first-person.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:02:14 -06:00
e3a30f6962 feat(tools): AI-render → pixel-art pipeline (depth render + Primordyn dither)
- comfy-spike/comfy.py: depth → ComfyUI/Z-Image render bridge over LAN
- pixelart/pixelate.py: locked Primordyn recipe (Floyd–Steinberg, OKLab,
  linear-light) limited-256 dither; montage.py helper; primordyn_v2 palette
- README + .gitignore; curated before/after + contact-sheet samples

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:36:33 -06:00
0aec5e927d feat(core): room theming — RoomThemer classifier + themed renderer (10th pass)
Adds RegionTheme + RoomThemer: a pure classifier (no tile changes) that
labels each room (terrain-driven Forge/Cistern/Hall, special Throne/
Threshold, weighted Crypt/Den/Library/Vault/Stone). Renderer tints themed
rooms; EntityPlacer biases contents by theme. Entrance/exit now chosen
RNG-free from geometry so themer & placer agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:03:53 -06:00
e3501a02f3 chore(core): drop unused RegionId imports in pass tests (clippy --all-targets clean)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:12:14 -06:00
00502153f3 feat(core): items & creatures — entity layer + EntityPlacer (9th pass)
The roadmap capstone: the dungeon is now populated.

- New entity layer: src/entity.rs (Entity { kind, at }, EntityKind {Entrance,
  Exit, Treasure, Monster}); Map gains an `entities: Vec<Entity>` overlay field
  (serde round-tripped). Entities aren't terrain, so no new Tile and the frozen
  viz is untouched.
- EntityPlacer/EntityConfig: places one Entrance (random room), one Exit (the
  room farthest from it — a traversal goal), per-room Treasure (treasure_chance)
  and Monsters (monster_chance, 1..=max, never in the entrance room). All on
  plain Floor only, never two per cell, fully deterministic.
- Handoff via the Blackboard (the designed side channel for non-tile/region/edge
  data) under entity::BLACKBOARD_KEY, harvested by Pipeline into Map.entities —
  so GenContext's 25 literals stay untouched.
- Renderer draws iconic markers on the final stage: green beacon (entrance),
  cyan portal (exit), gold diamond (treasure), crimson eyed-blob (monster), each
  with a soft glow. New entities toggle + treasure/monster sliders + readout.

Core: 135 tests green (6 new: one entrance/one exit on distinct floor, entrance
room monster-free, determinism, empty-map, recipe populated-check). clippy
clean. Pipeline is now 9 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:11:13 -06:00
381a52c310 feat(core): cellular-automata caverns (CaveShaper) + robust corridor anchoring
Eighth pass (after RoomCarver, before MstConnect): re-sculpts some rooms into
organic caves.

- CaveShaper/CaveConfig (cave_chance, min_room, fill, steps): fills the room
  rect with noise, runs the classic 4-5 cellular automaton `steps` times, keeps
  the LARGEST 4-connected floor component, and re-carves the room to that blob
  (cells + bounds updated; the rest of the footprint reverts to Wall). Too-small
  results leave the room clean. Caves stay Room regions — their organic outline
  reads as a cavern, so the renderer needs no change.
- CorridorCarver now anchors to the room cell NEAREST bounds.center() instead of
  the center point itself. Output-preserving for convex/centered shapes (the
  center cell is already floor), but robust for irregular cave blobs whose AABB
  center may fall in rock — so a cavernized room is always reachable.
- DungeonConfig gains `caves`; recipe inserts CaveShaper after RoomCarver.

Core: 129 tests green (4 new: cave is connected floor within room, small/zero
skip, determinism); connectivity holds with caves in the default recipe. clippy
clean. Pipeline is now 8 passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:46:33 -06:00
e7e8bb9187 feat(core): water & lava pools (PoolDecorator) + lava lighting
Seventh pass (before pillars): floods an organic blob of Water or Lava into
some larger rooms via randomized BFS growth.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:41:48 -06:00
128dce6310 tune(core): make pillars an occasional special-room feature
Per feedback ("not every room needs pillars"): default PillarConfig drops to
room_chance 0.30 / min_room 7 (was 0.55/7). A typical dungeon now has a couple
of distinctive pillared halls rather than pillars in most rooms — measured ~14
of 24 default seeds carry any pillars at all, usually just one room's worth.
The pillars slider still goes to 1.0 for full colonnades.

Also expose pillarTiles in the web state() hook (iteration/measurement aid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:29:14 -06:00
b1449248f8 feat(core): pillars — PillarPlacer decorator + Tile::Pillar
A finishing decorator that scatters free-standing pillar columns into larger
rooms on a spaced interior grid. Sixth pass in the dungeon recipe.

- New Tile::Pillar (impassable like Wall, drawn distinctly). Exhaustive matches
  updated: ascii ('o'), frozen macroquad viz (still compiles), wasm code 3.
- PillarPlacer/PillarConfig (room_chance, min_room, spacing): places isolated
  single-cell pillars only on interior room floor (>= 2 from the edge, never the
  center), on a grid with spacing >= 2 — so the floor stays 4-connected by
  construction and a room is never orphaned. Respects non-rect room shapes
  (pillars land only on actual carved floor).
- DungeonConfig gains `pillars`; recipe appends PillarPlacer after DoorPlacer.
- Renderer draws pillars as lit stone columns with a contact shadow; new
  "pillars" density slider.

Core: 121 tests green (5 new: interior-only placement, floor stays connected,
zero-chance/small-room skip, determinism). clippy clean (core + viz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:25:37 -06:00
d6dc354a1f polish(web): calmer stone texture + warm torchlight pooling
Renderer-only: low-frequency stone mottling (per ~4x4 block) with faint grain
instead of high-frequency per-cell noise; room glow now warms toward amber so
lit chambers read as torch-lit while corridors fall to shadow. Stronger
room/corridor light contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:18:15 -06:00
42 changed files with 5575 additions and 49 deletions

View file

@ -0,0 +1,118 @@
# First-Person Dungeon FPV Prompts (Z-Image-Turbo + Depth Fun-ControlNet)
**Researched:** 2026-06-01
**Pipeline:** Z-Image-Turbo (Qwen3-4B encoder), 8 steps, cfg 1.0, Fun-ControlNet Union depth patch, strength ~0.65, res_multistep/simple. Output downsampled to 256-color pixel art.
## TL;DR
- At **cfg 1.0 the negative prompt is mechanically dead** (classifier-free guidance is off). Hands/figures must be killed by the POSITIVE prompt + avoiding trigger words, NOT by negatives.
- Top triggers that summon a player character: **"first person", "POV", "dungeon crawler", "Eye of the Beholder", "adventurer", "hero", "you"**. Drop them all. Frame as **architectural photography of an empty/abandoned place** instead.
- Let the depth map own the geometry; the prompt owns material + light. Don't fight the controlnet by over-describing structure.
---
## 1. Positive-prompt templates (copy-paste)
Frame every shot as a deserted architectural photograph. The model never adds a person to "an empty abandoned stone hall, no people."
### Corridor
```
empty abandoned stone dungeon corridor, deserted, no people, uninhabited,
narrow ancient stonework passage receding into darkness, wet mossy flagstone
floor, rough granite block walls, iron sconces with flickering torchlight,
warm orange firelight and deep black shadows, volumetric haze, damp cold air,
dark fantasy, architectural photography of an empty ruin, atmospheric, detailed stone
```
### Chamber / room
```
empty abandoned stone dungeon chamber, deserted, no people, uninhabited,
vast vaulted hall of ancient masonry, weathered granite pillars, cracked
flagstone floor, iron braziers with flickering torchlight, warm firelight
pooling in deep shadow, volumetric haze, dripping damp walls, dark fantasy,
architectural photography of an empty ruin, atmospheric, detailed stone
```
### Tighter variant (if long prompts drift)
```
empty stone dungeon corridor, no people, abandoned ancient ruin, mossy
flagstone, granite walls, torchlight, deep shadows, volumetric fog, dark
fantasy, architectural photo
```
### Style anchor block (PREPEND identically to EVERY frame in an atlas)
```
dark fantasy dungeon, ancient torchlit stone, warm orange torchlight, deep
black shadows, wet mossy granite masonry, volumetric haze,
```
Then append the per-room body (corridor vs chamber, any theme word like "crypt"/"flooded"/"library").
### Words to AVOID (they summon a character / break the empty frame)
| Avoid | Why | Use instead |
|---|---|---|
| first person, POV, first-person view | model fills the bottom edge with hands/weapon | (nothing — say "corridor receding into darkness") |
| dungeon crawler, Eye of the Beholder, Daggerfall | strong "RPG with a player" prior → arms/HUD | "dark fantasy", "old-school RPG art style" |
| adventurer, hero, explorer, party, you, your | literal person | "abandoned", "deserted" |
| holding, wielding, torch in hand | summons a hand+weapon | "iron sconce", "wall torch", "brazier" |
| game screenshot, HUD, interface, UI | summons fake UI overlay | "architectural photograph" |
Note "torch" alone is fine (wall torches); only **"holding/carrying a torch"** triggers a hand. Anchor light to fixtures: **sconce, brazier, wall torch.**
---
## 2. Negative prompt — and why it barely matters here
**Caveat first:** Z-Image-Turbo is a distilled model running at **cfg/guidance 1.0**, where classifier-free guidance is effectively off, so the negative branch is **ignored or near-inert.** Two independent sources confirm the Qwen-family text encoder + distillation means "the model does not use negative prompts at all." Do NOT rely on it to remove hands. The positive prompt and trigger-word avoidance do ~95% of the work.
Still set one (cheap insurance; matters only if you ever nudge cfg to ~1.21.5), ordered by importance:
```
person, people, human, figure, character, hands, fingers, hand, arm, arms,
holding weapon, sword, feet, legs, body, silhouette, UI, HUD, health bar,
text, watermark, signature, modern, photorealistic skin
```
**If hands still appear, the real fixes (in order):**
1. Remove every trigger word above from the positive prompt.
2. Add stronger empty-scene tokens up front: `empty, deserted, no people, uninhabited, abandoned`.
3. Floor-bias the depth so the lower frame is clearly continuous floor (your `corridor_depth --floor-bias`) — fewer ambiguous near-camera regions for the model to "fill" with a body. A bright featureless blob at the bottom of the depth map invites hands.
4. Only as a last resort raise cfg to ~1.31.5 (re-enables negatives) and accept slower, slightly stiffer renders.
---
## 3. Cross-frame style consistency (atlas of one dungeon)
Goal: many views, one coherent place. Levers, strongest first:
1. **Identical style-anchor block on every frame** (see §1). This is the #1 consistency lever since the prompt owns all style/lighting/material and the depth map varies per view. Keep the lighting + material tokens byte-identical across the whole atlas; only swap the geometry-neutral body ("corridor" vs "chamber") and optional theme word.
2. **Fix the seed across the whole batch.** Same seed + same model + same style block → consistent palette and lighting character; the per-view depth map supplies the differing geometry. (Note: Z-Image is reported to "hardwire" composition to the seed even across prompt changes — that's a *feature* here, it locks the look.)
3. **Lock everything else:** sampler `res_multistep`, scheduler `simple`, steps 8, cfg 1.0, shift, resolution. Any change to these shifts the rendering character.
4. **Keep control strength constant** across frames (one value, e.g. 0.65) so geometry adherence — and thus how much "model style" bleeds in — is uniform.
5. Bake a fixed `<style block>` constant in code (sibling of the prompt strings in `tools/comfy-spike/comfy.py`) and concatenate per-room, so it can never drift between frames.
6. Optional later: a small **style LoRA** is the documented route for hard brand/style lock if prompt+seed isn't tight enough. The 256-color pixel-art downsample after the fact also strongly homogenizes the palette, hiding minor per-frame drift — lean on it.
---
## 4. Depth-ControlNet-specific tips
- **Don't re-describe geometry that's already in the depth map.** Documented guidance: "Fix the reference, not the prompt" — prompt+model handle *style*, controlnet handles *structure/composition*. Saying "archway ahead" when the depth already encodes it is redundant at best; if it conflicts with the depth it fights the controlnet and can warp the scene. Describe **materials and light**, let depth dictate walls/floor/vanishing point.
- A geometry-neutral noun ("corridor"/"chamber") is fine and helps — it tells the model what *kind* of surface to paint, not where. Avoid spatial directives ("door on the left", "stairs ahead").
- **Control strength:** 0.6 is the documented sweet spot; your 0.65 is good. 0.4 = more style freedom / loose geometry; 0.8 = near-locked geometry; 1.0 = stiff/over-constrained; 0.2 = barely controls. For a clean architectural atlas you want geometry honored, so **0.60.7** is right. If renders look flat/over-rigid, drop toward 0.55.
- **Depth map hygiene = anatomy control.** Near=white/far=black is correct. The bottom-center near region is exactly where hands spontaneously appear; keep that area as unambiguous *floor* (use `--floor-bias` to push the vanishing point up so the lower frame reads as continuous ground, not a vague near object). Smooth falloff (your default `bands=0`) avoids hard ring seams that the model misreads as objects.
- Prompt-vs-control balance: with strength ~0.65 and cfg 1.0, prompt adherence is gentle — which is *good* for empty scenes (less chance of hallucinating a subject), but means you must front-load the most important tokens (empty/no-people/material) early in the prompt.
---
## 5. Open tension to test (flagged, not resolved)
Your brief assumes "turbo → short concise prompts beat long flowery ones." The **official Tongyi-MAI prompting guide says the opposite for Z-Image**: it favors **long, detailed, structured prompts (80250 words)** and "responds very strongly to lighting keywords." These can both be true — *concrete* beats *flowery* regardless of length; Z-Image likes detail but not purple prose. The templates above are concrete-detailed (not flowery, not terse). Recommendation: A/B the long corridor template vs the tighter variant at fixed seed and keep whichever yields cleaner empty frames in your atlas. Detail seems to *help* this model; just keep every token concrete and avoid character words.
---
## Sources
- Z-Image-Turbo ControlNet Guide (control strength sweet spot, "fix the reference not the prompt"): https://wavespeed.ai/blog/posts/blog-z-image-turbo-controlnet-guide/
- Negative prompts inert at cfg 1 / distilled turbo models: https://github.com/lllyasviel/stable-diffusion-webui-forge/issues/1115 , https://stable-diffusion-art.com/sdxl-turbo/
- Qwen/Z-Image ignore negatives → use positive engineering: https://blog.promptmaster.pro/posts/qwen-image-negative-prompts/
- Z-Image distilled, guidance 0/low, 8 steps, negatives unused, control via positive prompt: https://www.apatero.com/blog/z-image-turbo-complete-guide-comfyui-2025 , https://zimageturbo.com/blog/z-image-turbo-comfyui-workflow-guide
- Official Z-Image-Turbo prompting style (long detailed structured, lighting-sensitive, English+Chinese): https://gist.github.com/illuminatianon/c42f8e57f1e3ebf037dd58043da9de32 , https://huggingface.co/Tongyi-MAI/Z-Image-Turbo/discussions/8
- Seed hardwires composition (consistency lever): https://github.com/Tongyi-MAI/Z-Image/issues/27
- Seed/consistency + fixed sampler/res for batch: https://fal.ai/learn/devs/z-image-turbo-developer-guide
- Avoid hands by not framing perspectives that require them: https://learnprompting.org/docs/image_prompting/fix_deformed_generations

View file

@ -24,6 +24,9 @@ const fn glyph(tile: Tile) -> char {
Tile::Wall => '#',
Tile::Floor => '.',
Tile::Door => '+',
Tile::Pillar => 'o',
Tile::Water => '~',
Tile::Lava => '!',
}
}
@ -82,6 +85,7 @@ mod tests {
tiles,
regions: Vec::new(),
graph: ConnGraph::new(),
entities: Vec::new(),
seed: 0,
width: 3,
height: 3,

View file

@ -0,0 +1,57 @@
//! Entities: the things that *populate* a map, distinct from its terrain.
//!
//! Where [`Tile`](crate::map::Tile) says *what the ground is* and
//! [`Region`](crate::region::Region) says *what areas exist*, an [`Entity`] is a
//! discrete thing sitting **on** a walkable cell — a stairway, a chest, a
//! monster. Entities never change the terrain (they are an overlay layer on the
//! [`Map`](crate::map::Map)), so a renderer draws them on top of the tiles and a
//! game treats them as occupants of a cell.
//!
//! Like [`Tile`](crate::map::Tile) and [`RegionKind`](crate::region::RegionKind),
//! the kind enum starts minimal and grows additively; passes and renderers match
//! only on the kinds they care about. All types derive serde so entities travel
//! inside a `Map` through JSON.
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
/// The [`Blackboard`](crate::blackboard::Blackboard) key under which the entity
/// placer stashes its `Vec<Entity>` for [`Pipeline`](crate::pass::Pipeline) to
/// harvest into the finished [`Map`](crate::map::Map). Shared so the producer
/// (the placer pass) and the consumer (the engine) never drift on the name.
pub const BLACKBOARD_KEY: &str = "entities";
/// What an [`Entity`] is. Additive: new kinds (traps, NPCs, altars, …) slot in
/// without disturbing existing matches.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum EntityKind {
/// Where the dungeon is entered from (the player's start / stairs up).
Entrance,
/// The dungeon's goal — the room farthest from the entrance (stairs down).
Exit,
/// A pickup: treasure, loot, an item.
Treasure,
/// A creature occupying the cell.
Monster,
}
/// A single entity: its [`EntityKind`] and the cell it occupies.
///
/// `at` is always a walkable [`Floor`](crate::map::Tile::Floor) cell in a
/// well-formed map; the placer only ever drops entities onto open floor and never
/// stacks two on one cell.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Entity {
/// What this entity is.
pub kind: EntityKind,
/// The cell it occupies.
pub at: Point,
}
impl Entity {
/// Creates an entity of `kind` at cell `at`.
pub const fn new(kind: EntityKind, at: Point) -> Self {
Entity { kind, at }
}
}

View file

@ -5,6 +5,7 @@
pub mod ascii;
pub mod blackboard;
pub mod entity;
pub mod geometry;
pub mod grid;
pub mod map;

View file

@ -11,6 +11,7 @@
use serde::{Deserialize, Serialize};
use crate::entity::Entity;
use crate::grid::Grid;
use crate::region::{ConnGraph, Region};
@ -29,6 +30,15 @@ pub enum Tile {
Floor,
/// A doorway between regions (walkable, but semantically a threshold).
Door,
/// A free-standing pillar: an impassable column carved into open floor by a
/// decorator. Not walkable (treated like [`Tile::Wall`] for movement), but
/// semantically distinct so renderers can draw it as a column on lit floor.
Pillar,
/// A pool of water: an impassable hazard you route around. Not walkable.
Water,
/// A pool of lava: an impassable hazard. Not walkable; renderers may treat it
/// as a warm light source.
Lava,
}
/// The generator's output: what occupies each cell, the semantic regions, and
@ -50,6 +60,9 @@ pub struct Map {
pub regions: Vec<Region>,
/// How regions connect (each edge is a door/corridor link).
pub graph: ConnGraph,
/// The entities populating the map (entrance, exit, treasure, monsters) —
/// an overlay on the terrain, each sitting on a walkable cell.
pub entities: Vec<Entity>,
/// The seed that produced this map, for reproducibility.
pub seed: u64,
/// Map width in cells.
@ -61,8 +74,9 @@ pub struct Map {
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{Entity, EntityKind};
use crate::geometry::{Point, Rect};
use crate::region::{RegionId, RegionKind};
use crate::region::{RegionId, RegionKind, RegionTheme};
/// Builds a tiny but structurally complete `Map` for serde exercises.
fn sample_map() -> Map {
@ -75,6 +89,7 @@ mod tests {
kind: RegionKind::Room,
bounds: Rect::new(1, 0, 1, 2),
cells: vec![Point::new(1, 0), Point::new(1, 1)],
theme: Some(RegionTheme::Vault),
};
let mut graph = ConnGraph::new();
@ -85,6 +100,7 @@ mod tests {
tiles,
regions: vec![region],
graph,
entities: vec![Entity::new(EntityKind::Treasure, Point::new(1, 0))],
seed: 0xDEAD_BEEF,
width: 3,
height: 2,
@ -107,6 +123,7 @@ mod tests {
// Spot-check the semantic layer, which does implement `Eq`, directly.
assert_eq!(original.regions, restored.regions);
assert_eq!(original.graph, restored.graph);
assert_eq!(original.entities, restored.entities);
assert_eq!(original.seed, restored.seed);
assert_eq!(
(original.width, original.height),

View file

@ -34,6 +34,7 @@
//! resulting [`Map`] is identical for a given seed with or without snapshots.
use crate::blackboard::Blackboard;
use crate::entity::Entity;
use crate::geometry::{Point, Rect};
use crate::grid::Grid;
use crate::map::{Map, Tile};
@ -74,6 +75,7 @@ impl GenContext {
kind,
bounds,
cells,
theme: None,
});
id
}
@ -231,10 +233,18 @@ impl Pipeline {
}
}
// Harvest the entity layer the placer (if any) stashed on the blackboard
// — the side channel for data that isn't a tile/region/edge (§4.6). A
// recipe with no placer simply yields no entities.
let entities = ctx
.blackboard
.take::<Vec<Entity>>(crate::entity::BLACKBOARD_KEY)
.unwrap_or_default();
let map = Map {
tiles: ctx.tiles,
regions: ctx.regions,
graph: ctx.graph,
entities,
seed,
width: self.width,
height: self.height,

View file

@ -0,0 +1,340 @@
//! The [`CaveShaper`] shaper pass: organic cellular-automata caverns.
//!
//! A *shaper* that re-sculpts some already-carved rooms into natural-looking
//! caves. It runs **after** [`RoomCarver`](crate::passes::room::RoomCarver) and
//! **before** [`MstConnect`](crate::passes::connect::MstConnect), so connectors
//! see the final cave footprint when they wire and carve corridors.
//!
//! For each qualifying room (large enough, chosen by chance) it:
//!
//! 1. Fills the room's bounding rect with random noise (interior cells start as
//! wall with probability `fill`; a one-cell border starts as wall so the cave
//! pulls inward), then runs `steps` rounds of the classic **4-5 cellular
//! automaton**: a cell becomes wall when 5+ of its 8 neighbors are wall (cells
//! off the local grid count as wall), which relaxes noise into blobby caverns.
//! 2. Keeps the **largest 4-connected floor component** — so the resulting cave
//! is a single connected blob, never scattered pockets.
//! 3. Re-carves the room: cells in the cave become [`Floor`](crate::map::Tile::Floor),
//! the rest of the room's old footprint reverts to [`Wall`](crate::map::Tile::Wall);
//! the region's `cells` become the cave blob and `bounds` its bounding box.
//!
//! If the cave would be too small (or empty), the room is left as it was. Because
//! the cave is one connected component and [`CorridorCarver`](crate::passes::corridor::CorridorCarver)
//! anchors to the room cell nearest the bounds center (a cave cell), a
//! cavernized room is always reachable — connectivity is preserved.
//!
//! Caves stay [`Room`](crate::region::RegionKind::Room) regions: their organic
//! outline is what reads as a cavern, so renderers need no special case.
use serde::{Deserialize, Serialize};
use crate::geometry::{Point, Rect};
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// Configuration for a [`CaveShaper`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct CaveConfig {
/// Probability a qualifying room is turned into a cave.
pub cave_chance: f64,
/// Minimum room extent (smaller of width/height) to qualify. Caves need room
/// to breathe; small rooms stay clean.
pub min_room: i32,
/// Initial wall probability for interior cells before smoothing (~0.45 gives
/// good caves). Clamped to `[0.0, 1.0]`.
pub fill: f64,
/// Number of cellular-automaton smoothing rounds.
pub steps: u32,
}
impl Default for CaveConfig {
/// A sensible default: about a third of larger rooms (min extent >= 7) become
/// caves, from 45%-wall noise smoothed four times.
fn default() -> Self {
CaveConfig {
cave_chance: 0.30,
min_room: 7,
fill: 0.45,
steps: 4,
}
}
}
/// Smallest cave (in cells) worth keeping; below this the room is left clean.
const MIN_CAVE_CELLS: usize = 8;
/// A cellular-automata cave shaper pass.
///
/// Construct one with [`CaveShaper::new`]; it implements [`Pass`] with the stable
/// name `"cave_shaper"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CaveShaper {
cfg: CaveConfig,
}
impl CaveShaper {
/// Creates a cave shaper with the given configuration.
pub fn new(cfg: CaveConfig) -> Self {
CaveShaper { cfg }
}
/// Builds a cave blob (as global [`Point`]s) within `bounds`, or [`None`] if
/// the smoothed automaton leaves nothing big enough to keep. Draws all
/// randomness from `rng`.
fn carve_cave(&self, bounds: Rect, rng: &mut Rng) -> Option<Vec<Point>> {
let w = bounds.w.max(0) as usize;
let h = bounds.h.max(0) as usize;
if w < 3 || h < 3 {
return None;
}
let idx = |x: usize, y: usize| y * w + x;
let fill = self.cfg.fill.clamp(0.0, 1.0);
// Initialize: border is wall, interior is wall with probability `fill`.
let mut wall = vec![true; w * h];
for y in 1..h - 1 {
for x in 1..w - 1 {
wall[idx(x, y)] = rng.chance(fill);
}
}
// Smooth with the 4-5 rule. Cells off the grid count as wall.
for _ in 0..self.cfg.steps {
let mut next = wall.clone();
for y in 0..h {
for x in 0..w {
let mut walls = 0;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let nx = x as i32 + dx;
let ny = y as i32 + dy;
if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
walls += 1; // out of bounds counts as wall
} else if wall[idx(nx as usize, ny as usize)] {
walls += 1;
}
}
}
next[idx(x, y)] = walls >= 5;
}
}
wall = next;
}
// Keep the largest 4-connected floor component.
let component = largest_floor_component(&wall, w, h)?;
if component.len() < MIN_CAVE_CELLS {
return None;
}
// Map local cells to global points, in row-major order.
let mut cells: Vec<Point> = component
.into_iter()
.map(|i| Point::new(bounds.x + (i % w) as i32, bounds.y + (i / w) as i32))
.collect();
cells.sort_by_key(|p| (p.y, p.x));
Some(cells)
}
}
impl Pass for CaveShaper {
fn name(&self) -> &str {
"cave_shaper"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
for idx in 0..ctx.regions.len() {
let region = &ctx.regions[idx];
if region.kind != RegionKind::Room || region.cells.is_empty() {
continue;
}
let bounds = region.bounds;
if bounds.w.min(bounds.h) < self.cfg.min_room {
continue;
}
if !rng.chance(self.cfg.cave_chance) {
continue;
}
let Some(cave) = self.carve_cave(bounds, rng) else {
continue;
};
// Re-carve: clear the room's old footprint, then carve the cave. The
// old cells are this room's only carved area at this stage (corridors
// run later), so clearing them touches nothing else.
let old_cells = ctx.regions[idx].cells.clone();
for &p in &old_cells {
ctx.tiles.set(p, Tile::Wall);
}
for &p in &cave {
ctx.tiles.set(p, Tile::Floor);
}
// Tighten bounds to the cave and replace the region's cells.
let region = &mut ctx.regions[idx];
region.bounds = bounding_rect(&cave);
region.cells = cave;
}
}
}
/// Finds the largest 4-connected component of floor cells (`!wall`) in a `w`×`h`
/// grid, returned as the set of flat indices, or [`None`] if there is no floor.
fn largest_floor_component(wall: &[bool], w: usize, h: usize) -> Option<Vec<usize>> {
let mut seen = vec![false; w * h];
let mut best: Option<Vec<usize>> = None;
for start in 0..w * h {
if wall[start] || seen[start] {
continue;
}
// BFS this component.
let mut comp = Vec::new();
let mut stack = vec![start];
seen[start] = true;
while let Some(i) = stack.pop() {
comp.push(i);
let (x, y) = (i % w, i / w);
let mut push = |nx: usize, ny: usize| {
let n = ny * w + nx;
if !wall[n] && !seen[n] {
seen[n] = true;
stack.push(n);
}
};
if x + 1 < w {
push(x + 1, y);
}
if x > 0 {
push(x - 1, y);
}
if y + 1 < h {
push(x, y + 1);
}
if y > 0 {
push(x, y - 1);
}
}
if best.as_ref().map(|b| comp.len() > b.len()).unwrap_or(true) {
best = Some(comp);
}
}
best
}
/// The axis-aligned bounding box enclosing every point in `cells` (empty rect for
/// an empty slice).
fn bounding_rect(cells: &[Point]) -> Rect {
let Some(&first) = cells.first() else {
return Rect::new(0, 0, 0, 0);
};
let (mut min_x, mut min_y, mut max_x, mut max_y) = (first.x, first.y, first.x, first.y);
for &p in &cells[1..] {
min_x = min_x.min(p.x);
min_y = min_y.min(p.y);
max_x = max_x.max(p.x);
max_y = max_y.max(p.y);
}
Rect::new(min_x, min_y, max_x - min_x + 1, max_y - min_y + 1)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::grid::Grid;
use crate::region::ConnGraph;
use std::collections::BTreeSet;
fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
let cells: Vec<Point> = room.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, room, cells);
ctx
}
fn run(ctx: &mut GenContext, cfg: CaveConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("cave_shaper#0");
CaveShaper::new(cfg).apply(ctx, &mut rng);
}
#[test]
fn name_is_cave_shaper() {
assert_eq!(CaveShaper::new(CaveConfig::default()).name(), "cave_shaper");
}
/// A guaranteed cave in a large room: it is non-empty, smaller than the full
/// rect (organic), 4-connected, every cell is Floor, every cell stays inside
/// the original room rect, and the region's cells/bounds match the cave.
#[test]
fn cave_is_connected_floor_within_room() {
let room = Rect::new(0, 0, 22, 18);
let mut ctx = ctx_with_room(22, 18, room);
run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 0x1234);
let r = &ctx.regions[0];
assert!(!r.cells.is_empty(), "cave should carve cells");
assert!((r.cells.len() as i32) < room.w * room.h, "a cave is not the full rect");
let set: BTreeSet<(i32, i32)> = r.cells.iter().map(|p| (p.x, p.y)).collect();
for &p in &r.cells {
assert!(room.contains(p), "cave cell {p:?} escaped the room rect");
assert_eq!(ctx.tiles.get(p), Some(&Tile::Floor), "cave cell {p:?} not Floor");
assert!(r.bounds.contains(p), "cave cell {p:?} escaped bounds {:?}", r.bounds);
}
// 4-connected: flood from the first cell reaches all of them.
let mut seen = BTreeSet::new();
let mut stack = vec![(r.cells[0].x, r.cells[0].y)];
while let Some((x, y)) = stack.pop() {
if !set.contains(&(x, y)) || !seen.insert((x, y)) {
continue;
}
stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
}
assert_eq!(seen.len(), r.cells.len(), "cave must be one 4-connected blob");
}
/// A small room never becomes a cave; `cave_chance` 0.0 never caves anything.
#[test]
fn small_rooms_and_zero_chance_stay_clean() {
let small = Rect::new(0, 0, 5, 5);
let mut ctx = ctx_with_room(5, 5, small);
let before = ctx.regions[0].cells.clone();
run(&mut ctx, CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 }, 1);
assert_eq!(ctx.regions[0].cells, before, "small room must stay a clean rect");
let big = Rect::new(0, 0, 20, 16);
let mut ctx2 = ctx_with_room(20, 16, big);
let before2 = ctx2.regions[0].cells.clone();
run(&mut ctx2, CaveConfig { cave_chance: 0.0, min_room: 7, fill: 0.45, steps: 4 }, 1);
assert_eq!(ctx2.regions[0].cells, before2, "cave_chance 0.0 changes nothing");
}
/// Same seed reproduces an identical cave (tiles + region).
#[test]
fn caves_are_deterministic() {
let room = Rect::new(0, 0, 20, 16);
let cfg = CaveConfig { cave_chance: 1.0, min_room: 7, fill: 0.45, steps: 4 };
let mut a = ctx_with_room(20, 16, room);
let mut b = ctx_with_room(20, 16, room);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(a.tiles, b.tiles);
assert_eq!(a.regions[0].cells, b.regions[0].cells);
assert_eq!(a.regions[0].bounds, b.regions[0].bounds);
}
}

View file

@ -455,6 +455,7 @@ mod tests {
kind: RegionKind::Corridor,
bounds: Rect::new(30, 0, 4, 1),
cells: vec![Point::new(30, 0)],
theme: None,
});
run(&mut ctx, ConnectConfig::default(), 0xBEEF);

View file

@ -121,15 +121,31 @@ impl Pass for CorridorCarver {
}
}
/// Returns the center of the `Room` region with id `index`, or [`None`] if the
/// id is out of range or names a non-Room region.
/// Returns a corridor anchor for the `Room` region with id `index`, or [`None`]
/// if the id is out of range or names a non-Room region.
///
/// Endpoint ids come from `MstConnect`, which only ever links real rooms, so in
/// the assembled recipe this always resolves; the guard simply keeps the pass
/// total against a hand-built or malformed graph.
/// The anchor is the room's carved cell **nearest its bounding-box center**. For
/// a convex, centered room (rectangle, octagon, ellipse, plus) the center cell is
/// itself carved, so this is exactly `bounds.center()` — i.e. output-preserving.
/// For an irregular room whose AABB center may fall in rock (a cave blob), it
/// snaps to the nearest actual floor cell, so the corridor always meets carved
/// floor and the room cannot be left unreachable.
///
/// Endpoint ids come from `MstConnect`, which only ever links real rooms (with
/// non-empty `cells`), so in the assembled recipe this always resolves to a real
/// cell; the guards keep the pass total against a hand-built or malformed graph.
fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option<Point> {
let region = ctx.regions.get(index.0)?;
(region.kind == RegionKind::Room).then(|| region.bounds.center())
if region.kind != RegionKind::Room {
return None;
}
let target = region.bounds.center();
region
.cells
.iter()
.copied()
.min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2))
.or(Some(target))
}
/// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid)

View file

@ -325,6 +325,7 @@ mod tests {
kind,
bounds,
cells,
theme: None,
});
}

View file

@ -0,0 +1,353 @@
//! The [`EntityPlacer`] pass: populate the dungeon with entities.
//!
//! A *placer* that drops [`Entity`](crate::entity::Entity)s onto open floor — the
//! final pass of the dungeon recipe, after all terrain (rooms, caves, corridors,
//! doors, pools, pillars) is settled. It changes **no** tiles or regions; it
//! produces an overlay layer the engine harvests into [`Map::entities`](crate::map::Map::entities).
//!
//! Because [`GenContext`] has no entity field (entities aren't tiles, regions, or
//! edges), this pass stashes its `Vec<Entity>` on the [`Blackboard`](crate::blackboard::Blackboard)
//! under [`crate::entity::BLACKBOARD_KEY`] — the side channel designed for exactly
//! this kind of cross-pass/-engine handoff (spec §4.6) — and
//! [`Pipeline`](crate::pass::Pipeline) takes it out into the final `Map`.
//!
//! Placement, in a fixed (deterministic) order:
//!
//! 1. **Entrance** — the way-in room chosen *purely from geometry* (no RNG) by
//! [`entrance_exit_indices`]; the entity sits on the floor cell nearest its
//! center.
//! 2. **Exit** — the room whose center is farthest (Manhattan) from the entrance,
//! again on its central floor cell. Gives the dungeon a traversal goal. (The
//! entrance/exit choice is RNG-free so a themer can reproduce it and land its
//! `Throne`/`Threshold` themes on these very rooms.)
//! 3. **Treasure** — each room has a `treasure_chance` of holding one (scaled by
//! the room's [`RegionTheme`] treasure bias), on a random open floor cell.
//! 4. **Monsters** — each room *except the entrance room* has a `monster_chance`
//! (scaled by the room's theme monster bias) of holding 1..=`max_monsters`,
//! scattered on open floor.
//!
//! Entities only ever land on plain [`Floor`](crate::map::Tile::Floor) (never a
//! door, pool, pillar, or wall), and never two on one cell, so a renderer or game
//! can treat `at` as a free walkable cell.
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use crate::entity::{Entity, EntityKind, BLACKBOARD_KEY};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::{Region, RegionKind, RegionTheme};
use crate::rng::Rng;
/// Picks `(entrance_index, exit_index)` into a room list purely from geometry —
/// **no RNG** — so any pass can reproduce the same choice without coupling to
/// another pass's random stream. `centers[i]` is room `i`'s center.
///
/// - The **entrance** is the room whose center is lexicographically smallest
/// (top-most row, then left-most column): a stable, corner-ward way in.
/// - The **exit** is the room whose center is farthest (Manhattan) from the
/// entrance — the natural traversal goal.
///
/// Returns `None` for an empty list; with a single room both indices are `0`.
/// [`RoomThemer`](crate::passes::room_themer::RoomThemer) calls this with the
/// same room set to land its `Throne`/`Threshold` themes on the rooms that
/// actually receive the `Exit`/`Entrance` markers — the two passes agree by
/// construction rather than by replaying each other's randomness.
pub(crate) fn entrance_exit_indices(centers: &[Point]) -> Option<(usize, usize)> {
if centers.is_empty() {
return None;
}
let entrance = (0..centers.len())
.min_by_key(|&i| (centers[i].y, centers[i].x))
.expect("non-empty");
let from = centers[entrance];
let exit = (0..centers.len())
.max_by_key(|&i| centers[i].manhattan(from))
.unwrap_or(entrance);
Some((entrance, exit))
}
/// Configuration for an [`EntityPlacer`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EntityConfig {
/// Probability that a given room holds a treasure.
pub treasure_chance: f64,
/// Probability that a given room (other than the entrance room) holds
/// monsters.
pub monster_chance: f64,
/// The most monsters a single monster-room may hold (each placed on its own
/// floor cell). The actual count is `1..=max_monsters`.
pub max_monsters: i32,
}
impl Default for EntityConfig {
/// A sensible default: about a third of rooms hold treasure, half hold up to
/// three monsters.
fn default() -> Self {
EntityConfig {
treasure_chance: 0.35,
monster_chance: 0.50,
max_monsters: 3,
}
}
}
/// An entity-placing pass.
///
/// Construct one with [`EntityPlacer::new`]; it implements [`Pass`] with the
/// stable name `"entity_placer"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EntityPlacer {
cfg: EntityConfig,
}
impl EntityPlacer {
/// Creates an entity placer with the given configuration.
pub fn new(cfg: EntityConfig) -> Self {
EntityPlacer { cfg }
}
}
/// A real room's data, snapshotted so placement doesn't hold a borrow on `ctx`.
struct RoomInfo {
center: Point,
/// The room's open-floor cells (Tile::Floor only), row-major.
floor: Vec<Point>,
/// The room's theme, if a themer classified it (drives content biases).
theme: Option<RegionTheme>,
}
impl Pass for EntityPlacer {
fn name(&self) -> &str {
"entity_placer"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// Snapshot each real room's center and its open-floor cells. Only plain
// Floor counts — doors, pools, pillars and walls are never entity homes.
let rooms: Vec<RoomInfo> = ctx
.regions
.iter()
.filter(|r: &&Region| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|r| RoomInfo {
center: r.bounds.center(),
floor: r
.cells
.iter()
.copied()
.filter(|&p| ctx.tiles.get(p) == Some(&Tile::Floor))
.collect(),
theme: r.theme,
})
.collect();
let mut entities: Vec<Entity> = Vec::new();
let mut taken: BTreeSet<(i32, i32)> = BTreeSet::new();
// Entrance/exit are chosen by the shared geometry helper (no RNG), so the
// themer's Throne/Threshold land on these very rooms (see
// [`entrance_exit_indices`]).
let centers: Vec<Point> = rooms.iter().map(|r| r.center).collect();
if let Some((entrance_idx, exit_idx)) = entrance_exit_indices(&centers) {
// 1) Entrance — the way-in room, central floor cell.
if let Some(p) = nearest_free(&rooms[entrance_idx], rooms[entrance_idx].center, &taken) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Entrance, p));
}
// 2) Exit — the far room, central floor cell.
if let Some(p) = nearest_free(&rooms[exit_idx], rooms[exit_idx].center, &taken) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Exit, p));
}
// 3 & 4) Per-room treasure and monsters, in room order for determinism.
// Each room's theme scales the base chances (no theme = neutral 1.0),
// clamped to a valid probability — so a Vault is loot-rich and a Den
// is monster-dense, while the entrance room stays monster-free.
for (i, room) in rooms.iter().enumerate() {
let (treasure_bias, monster_bias) =
room.theme.map(RegionTheme::biases).unwrap_or((1.0, 1.0));
let treasure_p = (self.cfg.treasure_chance * treasure_bias).clamp(0.0, 1.0);
if rng.chance(treasure_p) {
if let Some(p) = choose_free(room, &taken, rng) {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Treasure, p));
}
}
// Keep the entrance room clear of monsters.
let monster_p = (self.cfg.monster_chance * monster_bias).clamp(0.0, 1.0);
if i != entrance_idx && rng.chance(monster_p) {
let n = rng.range(1, self.cfg.max_monsters.max(1) + 1);
for _ in 0..n {
match choose_free(room, &taken, rng) {
Some(p) => {
taken.insert((p.x, p.y));
entities.push(Entity::new(EntityKind::Monster, p));
}
None => break, // room is full
}
}
}
}
}
ctx.blackboard.insert(BLACKBOARD_KEY, entities);
}
}
/// The room's free floor cell nearest `target`, or [`None`] if none are free.
fn nearest_free(room: &RoomInfo, target: Point, taken: &BTreeSet<(i32, i32)>) -> Option<Point> {
room.floor
.iter()
.copied()
.filter(|p| !taken.contains(&(p.x, p.y)))
.min_by_key(|p| (p.x - target.x).pow(2) + (p.y - target.y).pow(2))
}
/// A uniformly random free floor cell of the room, drawn from `rng`, or [`None`].
fn choose_free(room: &RoomInfo, taken: &BTreeSet<(i32, i32)>, rng: &mut Rng) -> Option<Point> {
let free: Vec<Point> = room
.floor
.iter()
.copied()
.filter(|p| !taken.contains(&(p.x, p.y)))
.collect();
rng.choose(&free).copied()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::ConnGraph;
/// Builds a context with `rooms` rectangular Floor rooms (each `(x,y,w,h)`).
fn ctx_with_rooms(w: u32, h: u32, rooms: &[(i32, i32, i32, i32)]) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
for &(x, y, rw, rh) in rooms {
let bounds = Rect::new(x, y, rw, rh);
let cells: Vec<Point> = bounds.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, bounds, cells);
}
ctx
}
fn run(ctx: &mut GenContext, cfg: EntityConfig, seed: u64) -> Vec<Entity> {
let mut rng = Rng::from_seed(seed).fork("entity_placer#0");
EntityPlacer::new(cfg).apply(ctx, &mut rng);
ctx.blackboard.take::<Vec<Entity>>(BLACKBOARD_KEY).unwrap_or_default()
}
#[test]
fn name_is_entity_placer() {
assert_eq!(EntityPlacer::new(EntityConfig::default()).name(), "entity_placer");
}
/// A multi-room map gets exactly one entrance and one exit, on distinct floor
/// cells, and every entity sits on a Floor cell with no two sharing a cell.
#[test]
fn places_one_entrance_one_exit_on_distinct_floor() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
let entities = run(&mut ctx, EntityConfig::default(), 0x1234);
let entrances = entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
let exits = entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
assert_eq!(entrances, 1, "exactly one entrance");
assert_eq!(exits, 1, "exactly one exit");
// Every entity on Floor; all on distinct cells.
let mut cells = BTreeSet::new();
for e in &entities {
assert_eq!(ctx.tiles.get(e.at), Some(&Tile::Floor), "entity {e:?} not on Floor");
assert!(cells.insert((e.at.x, e.at.y)), "two entities share cell {:?}", e.at);
}
// Entrance and exit are different cells (different rooms here).
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap();
let exit = entities.iter().find(|e| e.kind == EntityKind::Exit).unwrap();
assert_ne!(entrance.at, exit.at);
}
/// No monster shares the entrance room; the entrance room is a safe start.
#[test]
fn entrance_room_has_no_monsters() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
// Force monsters everywhere they're allowed.
let cfg = EntityConfig { treasure_chance: 0.0, monster_chance: 1.0, max_monsters: 3 };
let entities = run(&mut ctx, cfg, 7);
let entrance = entities.iter().find(|e| e.kind == EntityKind::Entrance).unwrap().at;
// Which room contains the entrance?
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
let entrance_room = rooms.iter().copied().find(|&r| in_room(entrance, r)).unwrap();
for m in entities.iter().filter(|e| e.kind == EntityKind::Monster) {
assert!(!in_room(m.at, entrance_room), "monster {m:?} spawned in the entrance room");
}
}
/// Same seed reproduces the exact same entity layer.
#[test]
fn placement_is_deterministic() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8), (20, 12, 8, 8)];
let cfg = EntityConfig::default();
let mut a = ctx_with_rooms(32, 24, &rooms);
let mut b = ctx_with_rooms(32, 24, &rooms);
assert_eq!(run(&mut a, cfg, 0x5EED), run(&mut b, cfg, 0x5EED));
}
/// An empty map (no rooms) places no entities and does not panic.
#[test]
fn no_rooms_places_nothing() {
let mut ctx = ctx_with_rooms(8, 8, &[]);
assert!(run(&mut ctx, EntityConfig::default(), 1).is_empty());
}
/// Theme biases scale per-room density: a `Threshold` room (monster_bias 0.0)
/// gets no monsters even at monster_chance 1.0, while a `Vault`
/// (treasure_bias 2.5) is guaranteed treasure when its scaled chance saturates
/// to 1.0. A `None` theme stays neutral.
#[test]
fn theme_biases_scale_treasure_and_monsters() {
let rooms = [(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)];
let mut ctx = ctx_with_rooms(32, 24, &rooms);
// Entrance (geometry) = room 0; exit = room 1. Theme room 1 a Threshold
// (monster_bias 0.0) and room 2 a Vault (treasure_bias 2.5).
ctx.regions[1].theme = Some(RegionTheme::Threshold);
ctx.regions[2].theme = Some(RegionTheme::Vault);
// 0.4 treasure * 2.5 Vault = 1.0 (guaranteed); 1.0 monster * 0.0 = 0.0.
let cfg = EntityConfig { treasure_chance: 0.4, monster_chance: 1.0, max_monsters: 3 };
let entities = run(&mut ctx, cfg, 0xBADF00D);
let in_room = |p: Point, r: (i32, i32, i32, i32)| Rect::new(r.0, r.1, r.2, r.3).contains(p);
// The Threshold room (room 1) holds no monsters despite monster_chance 1.0.
let monsters_in_room1 = entities
.iter()
.filter(|e| e.kind == EntityKind::Monster && in_room(e.at, rooms[1]))
.count();
assert_eq!(monsters_in_room1, 0, "monster_bias 0.0 must suppress all monsters");
// The Vault room (room 2) is guaranteed a treasure (scaled chance == 1.0).
let treasure_in_room2 = entities
.iter()
.any(|e| e.kind == EntityKind::Treasure && in_room(e.at, rooms[2]));
assert!(treasure_in_room2, "treasure_bias 2.5 must guarantee Vault treasure");
}
}

View file

@ -29,6 +29,9 @@ pub use bsp::{BspConfig, BspPartition};
pub mod room;
pub use room::{RoomCarver, RoomConfig, ShapeWeights};
pub mod cave;
pub use cave::{CaveConfig, CaveShaper};
pub mod connect;
pub use connect::{ConnectConfig, MstConnect};
@ -37,3 +40,15 @@ pub use corridor::CorridorCarver;
pub mod door;
pub use door::{DoorConfig, DoorPlacer};
pub mod pool;
pub use pool::{PoolConfig, PoolDecorator};
pub mod pillar;
pub use pillar::{PillarConfig, PillarPlacer};
pub mod room_themer;
pub use room_themer::{RoomThemer, ThemeConfig};
pub mod entity_placer;
pub use entity_placer::{EntityConfig, EntityPlacer};

View file

@ -0,0 +1,240 @@
//! The [`PillarPlacer`] decorator pass.
//!
//! A *decorator* is a finishing pass: it embellishes an already-carved,
//! already-connected map without changing its topology. `PillarPlacer` drops
//! free-standing [`Pillar`](crate::map::Tile::Pillar) columns into the interior
//! of larger rooms, on a regularly spaced grid, to give halls the look of a
//! pillared chamber.
//!
//! ## Connectivity is preserved by construction
//!
//! Pillars are placed as **isolated single cells** on a grid with spacing `>= 2`
//! and only in the room *interior* — never within two cells of the room's
//! bounding edge, and never on the room center. Because every pillar is a lone
//! obstacle with open floor on all four sides (the grid leaves at least one
//! floor cell between pillars), the room's floor stays fully 4-connected: you
//! can always walk around any pillar. So this pass, which runs last in the
//! dungeon recipe (after [`DoorPlacer`](crate::passes::door::DoorPlacer)), cannot
//! orphan a room — a property its tests assert directly.
//!
//! Pillars land **only on cells that are currently [`Floor`](crate::map::Tile::Floor)**
//! and that belong to a room's carved `cells`, so they respect non-rectangular
//! room shapes (a pillar never appears outside the actual carved floor) and never
//! overwrite a [`Door`](crate::map::Tile::Door) or a corridor.
use serde::{Deserialize, Serialize};
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// Configuration for a [`PillarPlacer`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PillarConfig {
/// Probability that a qualifying (large enough) room is given pillars.
/// Clamped to `[0.0, 1.0]`; `0.0` places none.
pub room_chance: f64,
/// Minimum room extent (the smaller of width/height, in cells) for a room to
/// qualify for pillars. Small rooms are left clear.
pub min_room: i32,
/// Grid spacing between pillars, in cells. Clamped to `>= 2` so pillars never
/// form a solid block (which is what keeps the floor 4-connected).
pub spacing: i32,
}
impl Default for PillarConfig {
/// A sensible default: pillars are an *occasional* feature, not the norm —
/// only larger rooms (min extent >= 7) qualify, and only about a third of
/// those get a grid of pillars spaced 3 cells apart. So a typical dungeon has
/// a couple of distinctive pillared halls — never every chamber.
fn default() -> Self {
PillarConfig {
room_chance: 0.30,
min_room: 7,
spacing: 3,
}
}
}
/// A pillar-placing decorator pass.
///
/// Construct one with [`PillarPlacer::new`]; it implements [`Pass`] with the
/// stable name `"pillar_placer"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PillarPlacer {
cfg: PillarConfig,
}
impl PillarPlacer {
/// Creates a pillar placer with the given configuration.
pub fn new(cfg: PillarConfig) -> Self {
PillarPlacer { cfg }
}
}
impl Pass for PillarPlacer {
fn name(&self) -> &str {
"pillar_placer"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
let spacing = self.cfg.spacing.max(2);
let off = spacing / 2; // phase the grid so it sits off the room edges
// Visit rooms in id order. Collect each room's (bounds, cells) first so we
// can mutate the grid without holding a borrow on `ctx.regions`.
let rooms: Vec<(crate::geometry::Rect, Vec<crate::geometry::Point>)> = ctx
.regions
.iter()
.filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|r| (r.bounds, r.cells.clone()))
.collect();
for (b, cells) in rooms {
// Only larger rooms qualify; non-qualifying rooms draw no randomness.
if b.w.min(b.h) < self.cfg.min_room {
continue;
}
// One coin per qualifying room decides whether it gets pillars.
if !rng.chance(self.cfg.room_chance) {
continue;
}
let center = b.center();
for p in cells {
let lx = p.x - b.x;
let ly = p.y - b.y;
// Keep a clear 2-cell border inside the room and skip the center.
if lx < 2 || ly < 2 || lx > b.w - 3 || ly > b.h - 3 || p == center {
continue;
}
// Place on the spaced grid, but only on actual room floor (so the
// pattern respects the room's shape and never hits a door).
if (lx - off).rem_euclid(spacing) == 0
&& (ly - off).rem_euclid(spacing) == 0
&& ctx.tiles.get(p) == Some(&Tile::Floor)
{
ctx.tiles.set(p, Tile::Pillar);
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::{Point, Rect};
use crate::grid::Grid;
use crate::region::ConnGraph;
use std::collections::BTreeSet;
/// Builds a context with a single rectangular room carved into Floor — the
/// post-RoomCarver state a decorator would see, constructed by hand.
fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
let cells: Vec<Point> = room.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, room, cells);
ctx
}
fn run(ctx: &mut GenContext, cfg: PillarConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("pillar_placer#0");
PillarPlacer::new(cfg).apply(ctx, &mut rng);
}
fn pillar_count(ctx: &GenContext) -> usize {
ctx.tiles.iter().filter(|&(_, &t)| t == Tile::Pillar).count()
}
#[test]
fn name_is_pillar_placer() {
assert_eq!(PillarPlacer::new(PillarConfig::default()).name(), "pillar_placer");
}
/// With `room_chance = 1.0` a large room gets pillars; every pillar sits on
/// former floor, inside the 2-cell border, and not on the center.
#[test]
fn places_pillars_only_on_interior_floor() {
let room = Rect::new(0, 0, 16, 12);
let mut ctx = ctx_with_room(16, 12, room);
run(&mut ctx, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 42);
assert!(pillar_count(&ctx) > 0, "a 16x12 room should receive pillars");
let center = room.center();
for (p, &t) in ctx.tiles.iter() {
if t == Tile::Pillar {
let (lx, ly) = (p.x - room.x, p.y - room.y);
assert!(lx >= 2 && ly >= 2 && lx <= room.w - 3 && ly <= room.h - 3,
"pillar at {p:?} is inside the clear border");
assert_ne!(p, center, "the room center stays clear");
}
}
}
/// The room floor (Floor|Door) stays 4-connected after pillars are placed —
/// pillars never wall off part of a room.
#[test]
fn pillars_keep_room_floor_connected() {
let room = Rect::new(0, 0, 18, 14);
let mut ctx = ctx_with_room(18, 14, room);
run(&mut ctx, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 7);
// Total walkable floor cells remaining.
let floor: BTreeSet<(i32, i32)> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Floor)
.map(|(p, _)| (p.x, p.y))
.collect();
assert!(!floor.is_empty());
// Flood from any one floor cell; it must reach all of them.
let start = *floor.iter().next().unwrap();
let mut seen = BTreeSet::new();
let mut stack = vec![start];
while let Some((x, y)) = stack.pop() {
if !floor.contains(&(x, y)) || !seen.insert((x, y)) {
continue;
}
stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
}
assert_eq!(seen.len(), floor.len(), "pillars must leave the floor 4-connected");
}
/// `room_chance = 0.0` places no pillars; a small room never qualifies.
#[test]
fn zero_chance_and_small_rooms_get_no_pillars() {
let big = Rect::new(0, 0, 16, 12);
let mut ctx = ctx_with_room(16, 12, big);
run(&mut ctx, PillarConfig { room_chance: 0.0, min_room: 7, spacing: 3 }, 99);
assert_eq!(pillar_count(&ctx), 0, "room_chance 0.0 places nothing");
let small = Rect::new(0, 0, 6, 6);
let mut ctx2 = ctx_with_room(6, 6, small);
run(&mut ctx2, PillarConfig { room_chance: 1.0, min_room: 7, spacing: 3 }, 99);
assert_eq!(pillar_count(&ctx2), 0, "a room below min_room gets none");
}
/// Same seed reproduces the same pillar layout.
#[test]
fn pillars_are_deterministic() {
let room = Rect::new(0, 0, 16, 12);
let cfg = PillarConfig::default();
let mut a = ctx_with_room(16, 12, room);
let mut b = ctx_with_room(16, 12, room);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(a.tiles, b.tiles);
}
}

View file

@ -0,0 +1,312 @@
//! The [`PoolDecorator`] pass: water and lava pools.
//!
//! A *decorator* finishing pass that floods an organic blob of
//! [`Water`](crate::map::Tile::Water) or [`Lava`](crate::map::Tile::Lava) into
//! the interior of some larger rooms — a hazard you route around, and (for lava)
//! a light source a renderer can play with.
//!
//! ## Connectivity is guarded, not assumed
//!
//! Water and lava are **not walkable**, so a careless pool could wall a room in
//! half or cover the cell a corridor connects to. This pass is conservative:
//!
//! - A pool is a single contiguous blob grown only over **interior** room floor
//! — at least two cells from the room's bounding edge (so the perimeter ring a
//! corridor pierces stays floor) and never on the room center (the corridor's
//! target).
//! - After growing a candidate blob, the pass **verifies** that the room's
//! remaining floor is still 4-connected and still contains the center; only
//! then does it commit. Otherwise the pool is dropped for that room.
//!
//! Because every room's center stays floor and connected to its perimeter, and
//! corridors are unchanged, the whole dungeon stays connected — a property the
//! recipe's connectivity test exercises with pools enabled.
//!
//! Pools land only on cells that are currently [`Floor`](crate::map::Tile::Floor),
//! so they respect non-rectangular room shapes and never overwrite a door,
//! corridor, or pillar.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::region::RegionKind;
use crate::rng::Rng;
/// Configuration for a [`PoolDecorator`] pass.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PoolConfig {
/// Probability that a qualifying room is flooded with a **water** pool.
pub water_chance: f64,
/// Probability that a qualifying room (that did not get water) is flooded
/// with a **lava** pool. Evaluated after the water roll, so a room holds at
/// most one pool.
pub lava_chance: f64,
/// Minimum room extent (smaller of width/height) for a room to qualify.
pub min_room: i32,
}
impl Default for PoolConfig {
/// A sensible default: water is an occasional feature of larger rooms, lava
/// rarer still.
fn default() -> Self {
PoolConfig {
water_chance: 0.20,
lava_chance: 0.10,
min_room: 7,
}
}
}
/// A water/lava pool decorator pass.
///
/// Construct one with [`PoolDecorator::new`]; it implements [`Pass`] with the
/// stable name `"pool_decorator"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PoolDecorator {
cfg: PoolConfig,
}
impl PoolDecorator {
/// Creates a pool decorator with the given configuration.
pub fn new(cfg: PoolConfig) -> Self {
PoolDecorator { cfg }
}
}
impl Pass for PoolDecorator {
fn name(&self) -> &str {
"pool_decorator"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
let rooms: Vec<(crate::geometry::Rect, Vec<Point>)> = ctx
.regions
.iter()
.filter(|r| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|r| (r.bounds, r.cells.clone()))
.collect();
for (b, cells) in rooms {
if b.w.min(b.h) < self.cfg.min_room {
continue;
}
// One roll per qualifying room selects water, lava, or nothing.
let roll = rng.chance(self.cfg.water_chance);
let tile = if roll {
Tile::Water
} else if rng.chance(self.cfg.lava_chance) {
Tile::Lava
} else {
continue;
};
let center = b.center();
// Candidate interior floor: >=2 from the bounds edge, not the center,
// currently Floor (so we respect the room's shape and skip pillars).
let interior: Vec<Point> = cells
.iter()
.copied()
.filter(|&p| {
let (lx, ly) = (p.x - b.x, p.y - b.y);
lx >= 2
&& ly >= 2
&& lx <= b.w - 3
&& ly <= b.h - 3
&& p != center
&& ctx.tiles.get(p) == Some(&Tile::Floor)
})
.collect();
if interior.is_empty() {
continue;
}
let interior_set: BTreeSet<(i32, i32)> = interior.iter().map(|p| (p.x, p.y)).collect();
// Grow an organic blob from a random interior seed via randomized BFS.
let area = (b.w * b.h) as usize;
let target = (area / 6).clamp(3, 26) + rng.range(0, 5) as usize;
let seed = *rng.choose(&interior).expect("interior is non-empty");
let blob = grow_blob(seed, &interior_set, target, rng);
// Commit only if the room's remaining floor stays connected with the
// center intact — otherwise the pool would orphan part of the room.
if !floor_stays_connected(&cells, &blob, center) {
continue;
}
for &(x, y) in &blob {
ctx.tiles.set(Point::new(x, y), tile);
}
}
}
}
/// Grow a contiguous blob from `seed` over `interior` cells via randomized
/// breadth-first expansion, up to `target` cells. The randomized frontier order
/// gives an organic (non-circular) outline. Draws from `rng`.
fn grow_blob(
seed: Point,
interior: &BTreeSet<(i32, i32)>,
target: usize,
rng: &mut Rng,
) -> BTreeSet<(i32, i32)> {
let mut blob = BTreeSet::new();
blob.insert((seed.x, seed.y));
let mut frontier: Vec<(i32, i32)> = neighbors4(seed.x, seed.y)
.into_iter()
.filter(|c| interior.contains(c))
.collect();
while blob.len() < target && !frontier.is_empty() {
// Pop a random frontier cell so the blob grows unevenly.
let i = rng.range(0, frontier.len() as i32) as usize;
let cell = frontier.swap_remove(i);
if !blob.insert(cell) {
continue;
}
for n in neighbors4(cell.0, cell.1) {
if interior.contains(&n) && !blob.contains(&n) {
frontier.push(n);
}
}
}
blob
}
/// Returns true if the room's floor — its `cells` minus the pool — is non-empty,
/// still contains `center`, and is 4-connected (so the pool splits nothing off).
fn floor_stays_connected(cells: &[Point], pool: &BTreeSet<(i32, i32)>, center: Point) -> bool {
let floor: BTreeSet<(i32, i32)> = cells
.iter()
.map(|p| (p.x, p.y))
.filter(|c| !pool.contains(c))
.collect();
if floor.is_empty() || !floor.contains(&(center.x, center.y)) {
return false;
}
let mut seen = BTreeSet::new();
let mut stack = vec![(center.x, center.y)];
while let Some(c) = stack.pop() {
if !floor.contains(&c) || !seen.insert(c) {
continue;
}
for n in neighbors4(c.0, c.1) {
stack.push(n);
}
}
seen.len() == floor.len()
}
fn neighbors4(x: i32, y: i32) -> [(i32, i32); 4] {
[(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::ConnGraph;
fn ctx_with_room(w: u32, h: u32, room: Rect) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
let cells: Vec<Point> = room.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, room, cells);
ctx
}
fn run(ctx: &mut GenContext, cfg: PoolConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("pool_decorator#0");
PoolDecorator::new(cfg).apply(ctx, &mut rng);
}
fn count(ctx: &GenContext, tile: Tile) -> usize {
ctx.tiles.iter().filter(|&(_, &t)| t == tile).count()
}
#[test]
fn name_is_pool_decorator() {
assert_eq!(PoolDecorator::new(PoolConfig::default()).name(), "pool_decorator");
}
/// A guaranteed water pool lands only on interior floor and keeps the room's
/// remaining floor 4-connected (water never orphans part of a room).
#[test]
fn water_pool_is_interior_and_keeps_floor_connected() {
let room = Rect::new(0, 0, 18, 14);
let mut ctx = ctx_with_room(18, 14, room);
run(&mut ctx, PoolConfig { water_chance: 1.0, lava_chance: 0.0, min_room: 7 }, 5);
let water: Vec<Point> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Water)
.map(|(p, _)| p)
.collect();
assert!(!water.is_empty(), "a large room should get a water pool");
let center = room.center();
for &p in &water {
let (lx, ly) = (p.x - room.x, p.y - room.y);
assert!(lx >= 2 && ly >= 2 && lx <= room.w - 3 && ly <= room.h - 3, "pool {p:?} not interior");
assert_ne!(p, center, "pool must not cover the room center");
}
// Remaining floor (Floor only) is 4-connected.
let floor: BTreeSet<(i32, i32)> = ctx
.tiles
.iter()
.filter(|&(_, &t)| t == Tile::Floor)
.map(|(p, _)| (p.x, p.y))
.collect();
let start = *floor.iter().next().unwrap();
let mut seen = BTreeSet::new();
let mut stack = vec![start];
while let Some(c) = stack.pop() {
if !floor.contains(&c) || !seen.insert(c) {
continue;
}
for n in neighbors4(c.0, c.1) {
stack.push(n);
}
}
assert_eq!(seen.len(), floor.len(), "pool must leave the floor 4-connected");
}
/// Zero chances place nothing; a small room never qualifies.
#[test]
fn zero_chance_and_small_rooms_get_no_pools() {
let room = Rect::new(0, 0, 18, 14);
let mut ctx = ctx_with_room(18, 14, room);
run(&mut ctx, PoolConfig { water_chance: 0.0, lava_chance: 0.0, min_room: 7 }, 9);
assert_eq!(count(&ctx, Tile::Water) + count(&ctx, Tile::Lava), 0);
let small = Rect::new(0, 0, 6, 6);
let mut ctx2 = ctx_with_room(6, 6, small);
run(&mut ctx2, PoolConfig { water_chance: 1.0, lava_chance: 1.0, min_room: 7 }, 9);
assert_eq!(count(&ctx2, Tile::Water) + count(&ctx2, Tile::Lava), 0);
}
/// Same seed reproduces the same pool.
#[test]
fn pools_are_deterministic() {
let room = Rect::new(0, 0, 18, 14);
let cfg = PoolConfig { water_chance: 1.0, lava_chance: 0.0, min_room: 7 };
let mut a = ctx_with_room(18, 14, room);
let mut b = ctx_with_room(18, 14, room);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(a.tiles, b.tiles);
}
}

View file

@ -616,6 +616,7 @@ mod tests {
kind: RegionKind::Corridor,
bounds: corridor_bounds,
cells: vec![Point::new(16, 0)],
theme: None,
});
run(&mut ctx, RoomConfig::default(), 99);

View file

@ -0,0 +1,337 @@
//! The [`RoomThemer`] classifier pass: give every room a [`RegionTheme`].
//!
//! A *classifier* — the one pass that reads the finished terrain and labels what
//! each room **is** without changing a single tile. It runs after every shaper
//! and decorator (rooms, caves, corridors, doors, pools, pillars) and before
//! [`EntityPlacer`](crate::passes::entity_placer::EntityPlacer), writing a
//! [`RegionTheme`] onto each real [`Room`](crate::region::RegionKind::Room)
//! region's [`theme`](crate::region::Region::theme) field. Because it mutates no
//! tiles, regions' shapes, or edges, it carries **zero connectivity risk**.
//!
//! Two consumers read the theme it assigns: the renderer (which tints a themed
//! room's floor/walls) and `EntityPlacer` (whose treasure/monster density is
//! scaled by the theme's [`biases`](RegionTheme::biases)).
//!
//! ## Assignment (deterministic, first match wins)
//!
//! For each real room, in region-id order, the first matching rule applies:
//!
//! 1. **Forge** — the room contains any [`Lava`](crate::map::Tile::Lava) tile.
//! 2. **Cistern** — it contains any [`Water`](crate::map::Tile::Water) tile.
//! 3. **Hall** — it contains any [`Pillar`](crate::map::Tile::Pillar) tile.
//! 4. **Throne** — it is the dungeon's *exit* room (far end / boss seat).
//! 5. **Threshold** — it is the *entrance* room (the safe way in).
//! 6. otherwise a weighted-random pick from the *plain* pool
//! ({Vault, Library, Den, Crypt, Stone}, weights from [`ThemeConfig`]).
//!
//! Rules 15 draw **no** randomness, so only plain rooms consume a draw from this
//! pass's stream — keeping the weighted pool reproducible regardless of how many
//! terrain/special rooms precede a given room. Entrance and exit are chosen by the
//! same geometry helper [`EntityPlacer`](crate::passes::entity_placer::EntityPlacer)
//! uses ([`entrance_exit_indices`](crate::passes::entity_placer::entrance_exit_indices)),
//! so the `Throne`/`Threshold` themes always land on the rooms that actually get
//! the `Exit`/`Entrance` markers — the two passes agree by construction.
use serde::{Deserialize, Serialize};
use crate::geometry::Point;
use crate::map::Tile;
use crate::pass::{GenContext, Pass};
use crate::passes::entity_placer::entrance_exit_indices;
use crate::region::{RegionKind, RegionTheme};
use crate::rng::Rng;
/// Configuration for a [`RoomThemer`] pass: the relative weights of the *plain*
/// (non-terrain, non-entrance/exit) room themes.
///
/// Terrain-driven themes (Forge/Cistern/Hall) and the special Throne/Threshold are
/// not weighted — they are assigned with certainty when their condition holds.
/// Only a plain room rolls against these weights. A `0.0` weight disables a theme;
/// an all-zero set degenerates to [`Stone`](RegionTheme::Stone).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ThemeConfig {
/// Weight of the loot-jackpot [`Vault`](RegionTheme::Vault) — rare by default.
pub vault: f64,
/// Weight of the [`Library`](RegionTheme::Library).
pub library: f64,
/// Weight of the monster-dense [`Den`](RegionTheme::Den).
pub den: f64,
/// Weight of the undead-heavy [`Crypt`](RegionTheme::Crypt).
pub crypt: f64,
/// Weight of plain [`Stone`](RegionTheme::Stone) — the heaviest, so most rooms
/// stay neutral and the special themes read as discoveries.
pub stone: f64,
}
impl Default for ThemeConfig {
/// A sensible mix: mostly bare stone, a healthy spread of crypts and dens,
/// some libraries, and the occasional vault.
fn default() -> Self {
ThemeConfig {
vault: 1.0,
library: 2.0,
den: 3.0,
crypt: 3.0,
stone: 6.0,
}
}
}
/// A room-theming classifier pass.
///
/// Construct one with [`RoomThemer::new`]; it implements [`Pass`] with the stable
/// name `"room_themer"`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RoomThemer {
cfg: ThemeConfig,
}
impl RoomThemer {
/// Creates a room themer with the given configuration.
pub fn new(cfg: ThemeConfig) -> Self {
RoomThemer { cfg }
}
/// Draws one weighted plain-pool theme from `rng`. Weights are scaled to
/// integer "milli-weights" so the pick is a single [`Rng::range`] draw; an
/// all-zero (or negative) set degenerates to [`Stone`](RegionTheme::Stone).
fn pick_pool_theme(&self, rng: &mut Rng) -> RegionTheme {
let c = self.cfg;
let table = [
(RegionTheme::Vault, c.vault),
(RegionTheme::Library, c.library),
(RegionTheme::Den, c.den),
(RegionTheme::Crypt, c.crypt),
(RegionTheme::Stone, c.stone),
];
let scaled: [(RegionTheme, i32); 5] =
table.map(|(t, w)| (t, (w.max(0.0) * 1000.0) as i32));
let total: i32 = scaled.iter().map(|(_, n)| n).sum();
if total <= 0 {
return RegionTheme::Stone;
}
let mut pick = rng.range(0, total);
for (t, n) in scaled {
if pick < n {
return t;
}
pick -= n;
}
RegionTheme::Stone
}
}
/// A real room's data, snapshotted so theming doesn't hold a borrow on `ctx`.
struct RoomData {
/// Index of this room's region in `ctx.regions` (its `RegionId`).
idx: usize,
center: Point,
has_lava: bool,
has_water: bool,
has_pillar: bool,
}
impl Pass for RoomThemer {
fn name(&self) -> &str {
"room_themer"
}
fn apply(&self, ctx: &mut GenContext, rng: &mut Rng) {
// Snapshot every real room (kind==Room, non-empty cells) with a cheap
// terrain summary. Read-only over regions+tiles; we mutate themes after.
let rooms: Vec<RoomData> = ctx
.regions
.iter()
.enumerate()
.filter(|(_, r)| r.kind == RegionKind::Room && !r.cells.is_empty())
.map(|(idx, r)| {
let mut has_lava = false;
let mut has_water = false;
let mut has_pillar = false;
for &p in &r.cells {
match ctx.tiles.get(p) {
Some(&Tile::Lava) => has_lava = true,
Some(&Tile::Water) => has_water = true,
Some(&Tile::Pillar) => has_pillar = true,
_ => {}
}
}
RoomData {
idx,
center: r.bounds.center(),
has_lava,
has_water,
has_pillar,
}
})
.collect();
// Entrance/exit by the same geometry helper EntityPlacer uses, so
// Throne/Threshold match the rooms that get the Exit/Entrance markers.
let centers: Vec<Point> = rooms.iter().map(|r| r.center).collect();
let (entrance_k, exit_k) = match entrance_exit_indices(&centers) {
Some(pair) => pair,
None => return, // no real rooms — nothing to theme
};
// Resolve each room's theme by the priority walk, then write them back.
// Only the plain-pool branch draws from `rng`, in room (id) order.
let assignments: Vec<(usize, RegionTheme)> = rooms
.iter()
.enumerate()
.map(|(k, room)| {
let theme = if room.has_lava {
RegionTheme::Forge
} else if room.has_water {
RegionTheme::Cistern
} else if room.has_pillar {
RegionTheme::Hall
} else if k == exit_k {
RegionTheme::Throne
} else if k == entrance_k {
RegionTheme::Threshold
} else {
self.pick_pool_theme(rng)
};
(room.idx, theme)
})
.collect();
for (idx, theme) in assignments {
ctx.regions[idx].theme = Some(theme);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blackboard::Blackboard;
use crate::geometry::Rect;
use crate::grid::Grid;
use crate::region::ConnGraph;
/// Builds a context with `rooms` rectangular Floor rooms (each `(x,y,w,h)`),
/// the post-decorator state a classifier would see.
fn ctx_with_rooms(w: u32, h: u32, rooms: &[(i32, i32, i32, i32)]) -> GenContext {
let mut ctx = GenContext {
tiles: Grid::new(w, h, Tile::Wall),
regions: Vec::new(),
graph: ConnGraph::new(),
blackboard: Blackboard::new(),
};
for &(x, y, rw, rh) in rooms {
let bounds = Rect::new(x, y, rw, rh);
let cells: Vec<Point> = bounds.iter().collect();
for &p in &cells {
ctx.tiles.set(p, Tile::Floor);
}
ctx.add_region(RegionKind::Room, bounds, cells);
}
ctx
}
fn run(ctx: &mut GenContext, cfg: ThemeConfig, seed: u64) {
let mut rng = Rng::from_seed(seed).fork("room_themer#0");
RoomThemer::new(cfg).apply(ctx, &mut rng);
}
fn themes(ctx: &GenContext) -> Vec<Option<RegionTheme>> {
ctx.regions.iter().map(|r| r.theme).collect()
}
/// Force the pool to always pick Stone, so non-terrain/non-special rooms are
/// deterministic in tests that probe the special/terrain rules.
fn stone_only() -> ThemeConfig {
ThemeConfig {
vault: 0.0,
library: 0.0,
den: 0.0,
crypt: 0.0,
stone: 1.0,
}
}
#[test]
fn name_is_room_themer() {
assert_eq!(RoomThemer::new(ThemeConfig::default()).name(), "room_themer");
}
/// Terrain wins, in priority order: a room with lava is a Forge, with water a
/// Cistern, with a pillar a Hall — regardless of entrance/exit position.
#[test]
fn terrain_drives_forge_cistern_hall() {
// Three rooms, each seeded with one terrain tile inside its interior.
let mut ctx = ctx_with_rooms(40, 12, &[(0, 0, 8, 8), (12, 0, 8, 8), (24, 0, 8, 8)]);
ctx.tiles.set(Point::new(3, 3), Tile::Lava); // room 0
ctx.tiles.set(Point::new(15, 3), Tile::Water); // room 1
ctx.tiles.set(Point::new(27, 3), Tile::Pillar); // room 2
run(&mut ctx, stone_only(), 1);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Forge));
assert_eq!(ctx.regions[1].theme, Some(RegionTheme::Cistern));
assert_eq!(ctx.regions[2].theme, Some(RegionTheme::Hall));
}
/// Lava beats water beats pillar when a room holds more than one (priority).
#[test]
fn terrain_priority_lava_beats_water_beats_pillar() {
let mut ctx = ctx_with_rooms(16, 12, &[(0, 0, 10, 10)]);
ctx.tiles.set(Point::new(2, 2), Tile::Pillar);
ctx.tiles.set(Point::new(3, 3), Tile::Water);
ctx.tiles.set(Point::new(4, 4), Tile::Lava);
run(&mut ctx, stone_only(), 1);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Forge));
}
/// With no terrain, the geometric entrance (top-left-most) is the Threshold
/// and the farthest room is the Throne; the rest fall to the pool (Stone here).
#[test]
fn entrance_is_threshold_and_far_room_is_throne() {
// Centers: (4,4) top-left, (24,4), (4,16). Entrance = room 0; farthest
// (Manhattan from (4,4)) is room 1 (dist 20) > room 2 (dist 12).
let mut ctx = ctx_with_rooms(32, 24, &[(0, 0, 8, 8), (20, 0, 8, 8), (0, 12, 8, 8)]);
run(&mut ctx, stone_only(), 7);
assert_eq!(ctx.regions[0].theme, Some(RegionTheme::Threshold), "entrance room");
assert_eq!(ctx.regions[1].theme, Some(RegionTheme::Throne), "far/exit room");
assert_eq!(ctx.regions[2].theme, Some(RegionTheme::Stone), "plain room");
}
/// The pass changes no tiles — it is a pure classifier.
#[test]
fn changes_no_tiles() {
let mut ctx = ctx_with_rooms(32, 16, &[(0, 0, 10, 10), (16, 0, 10, 10)]);
let before = ctx.tiles.clone();
run(&mut ctx, ThemeConfig::default(), 42);
assert_eq!(ctx.tiles, before, "RoomThemer must not touch any tile");
// Every real room got a theme; corridors/none stay None (there are none here).
assert!(ctx.regions.iter().all(|r| r.theme.is_some()));
}
/// Same seed reproduces the same themes.
#[test]
fn theming_is_deterministic() {
let layout = [(0, 0, 9, 9), (14, 0, 9, 9), (0, 12, 9, 9), (14, 12, 9, 9)];
let cfg = ThemeConfig::default();
let mut a = ctx_with_rooms(28, 24, &layout);
let mut b = ctx_with_rooms(28, 24, &layout);
run(&mut a, cfg, 0x5EED);
run(&mut b, cfg, 0x5EED);
assert_eq!(themes(&a), themes(&b));
}
/// Corridors and empty-`cells` placeholder rooms are never themed.
#[test]
fn corridors_and_empty_rooms_stay_unthemed() {
let mut ctx = ctx_with_rooms(40, 12, &[(0, 0, 9, 9), (20, 0, 9, 9)]);
// An uncarved placeholder room (empty cells) and a corridor.
ctx.add_region(RegionKind::Room, Rect::new(31, 0, 4, 4), Vec::new());
ctx.add_region(RegionKind::Corridor, Rect::new(9, 4, 11, 1), vec![Point::new(9, 4)]);
run(&mut ctx, ThemeConfig::default(), 3);
assert!(ctx.regions[0].theme.is_some());
assert!(ctx.regions[1].theme.is_some());
assert_eq!(ctx.regions[2].theme, None, "empty placeholder room unthemed");
assert_eq!(ctx.regions[3].theme, None, "corridor unthemed");
}
}

View file

@ -6,17 +6,29 @@
//! order:
//!
//! ```text
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
//! → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer
//! ```
//!
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
//! Room region per leaf).
//! 2. [`RoomCarver`] carves an actual room inside each leaf.
//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which
//! [`CaveShaper`] re-sculpts some rooms into organic cellular-automata caves
//! (each a single connected blob; the rest stay clean polygons).
//! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus
//! optional loop edges).
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
//! doors (purely cosmetic — connectivity is already established by the floor).
//! 6. [`PoolDecorator`] floods organic water/lava pools into some larger rooms
//! (a connectivity-guarded decorator — never orphans part of a room).
//! 7. [`PillarPlacer`] scatters free-standing pillars into larger rooms (a
//! finishing decorator; isolated obstacles that never break connectivity).
//! 8. [`RoomThemer`] reads the finished terrain and labels each room with a
//! [`RegionTheme`](crate::region::RegionTheme) — a pure classifier that
//! changes no tiles, driving the renderer's tint and the entity biases.
//! 9. [`EntityPlacer`] populates the dungeon — an entrance, a far-off exit, and
//! scattered treasure and monsters — as an overlay layer (no terrain change).
//!
//! ## Why validation matters (spec §8)
//!
@ -38,8 +50,9 @@ use serde::{Deserialize, Serialize};
use crate::pass::Pipeline;
use crate::passes::{
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
RoomCarver, RoomConfig, ShapeWeights,
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig,
};
/// Configuration for the [`dungeon`] recipe.
@ -58,10 +71,21 @@ pub struct DungeonConfig {
pub bsp: BspConfig,
/// How a room is carved inside each leaf.
pub rooms: RoomConfig,
/// How some rooms are re-sculpted into organic cellular-automata caves.
pub caves: CaveConfig,
/// How rooms are linked into a connectivity graph.
pub connect: ConnectConfig,
/// How room↔corridor pierce points are marked as doors.
pub doors: DoorConfig,
/// How water/lava pools are flooded into larger rooms (decorator).
pub pools: PoolConfig,
/// How free-standing pillars are scattered into larger rooms (decorator).
pub pillars: PillarConfig,
/// The relative weights of the plain (non-terrain) room themes (classifier).
pub themes: ThemeConfig,
/// How the dungeon is populated with entities (entrance, exit, treasure,
/// monsters).
pub entities: EntityConfig,
}
impl Default for DungeonConfig {
@ -92,10 +116,15 @@ impl Default for DungeonConfig {
margin: 1,
shapes: ShapeWeights::varied(),
},
caves: CaveConfig::default(),
connect: ConnectConfig {
extra_edge_ratio: 0.30,
},
doors: DoorConfig::default(),
pools: PoolConfig::default(),
pillars: PillarConfig::default(),
themes: ThemeConfig::default(),
entities: EntityConfig::default(),
}
}
}
@ -204,9 +233,14 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
let pipeline = Pipeline::new(cfg.width, cfg.height)
.then(BspPartition::new(cfg.bsp))
.then(RoomCarver::new(cfg.rooms))
.then(CaveShaper::new(cfg.caves))
.then(MstConnect::new(cfg.connect))
.then(CorridorCarver::new())
.then(DoorPlacer::new(cfg.doors));
.then(DoorPlacer::new(cfg.doors))
.then(PoolDecorator::new(cfg.pools))
.then(PillarPlacer::new(cfg.pillars))
.then(RoomThemer::new(cfg.themes))
.then(EntityPlacer::new(cfg.entities));
Ok(pipeline)
}
@ -219,13 +253,18 @@ mod tests {
use crate::region::RegionKind;
use std::collections::{BTreeSet, VecDeque};
/// The five pass names in pipeline order, used to check snapshot labels.
const PASS_NAMES: [&str; 5] = [
/// The pass names in pipeline order, used to check snapshot labels.
const PASS_NAMES: [&str; 10] = [
"bsp_partition",
"room_carver",
"cave_shaper",
"mst_connect",
"corridor_carver",
"door_placer",
"pool_decorator",
"pillar_placer",
"room_themer",
"entity_placer",
];
/// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)?
@ -291,13 +330,13 @@ mod tests {
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
assert_eq!(plain, snapped, "snapshotting must not change the map");
assert_eq!(snapshots.len(), 5, "one snapshot per pass (five passes)");
assert_eq!(snapshots.len(), 10, "one snapshot per pass (ten passes)");
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
assert_eq!(
labels,
PASS_NAMES.to_vec(),
"snapshot labels must be the five pass names in pipeline order"
"snapshot labels must be the ten pass names in pipeline order"
);
}
@ -583,7 +622,93 @@ mod tests {
assert!(total_full > 0, "door_chance=1.0 must place doors");
}
// --- Test 7: eyeball render ---------------------------------------------
// --- Test 7: entities ---------------------------------------------------
/// The populated default dungeon has exactly one entrance and one exit across
/// many seeds, and every entity sits on a walkable Floor cell (never a wall,
/// door, pool, or pillar).
#[test]
fn default_dungeon_is_populated_with_entities() {
use crate::entity::EntityKind;
let cfg = DungeonConfig::default();
for seed in 1..=16u64 {
let map = dungeon(cfg).unwrap().run(seed);
let entrances = map.entities.iter().filter(|e| e.kind == EntityKind::Entrance).count();
let exits = map.entities.iter().filter(|e| e.kind == EntityKind::Exit).count();
assert_eq!(entrances, 1, "seed {seed}: expected exactly one entrance");
assert_eq!(exits, 1, "seed {seed}: expected exactly one exit");
for e in &map.entities {
assert_eq!(
map.tiles.get(e.at),
Some(&Tile::Floor),
"seed {seed}: entity {e:?} must sit on Floor"
);
}
}
}
// --- Test 7b: room themes -----------------------------------------------
/// Every real room is themed (none left `None`); corridors are never themed;
/// each dungeon has *at most* one Throne and one Threshold (the exit/entrance
/// rooms — unless terrain outranks them); and across many seeds both special
/// themes do appear.
#[test]
fn default_dungeon_themes_every_room_with_special_rooms() {
use crate::region::RegionTheme;
let cfg = DungeonConfig::default();
let (mut total_throne, mut total_threshold) = (0usize, 0usize);
for seed in 1..=24u64 {
let map = dungeon(cfg).unwrap().run(seed);
for room in real_rooms(&map) {
assert!(
room.theme.is_some(),
"seed {seed}: real room {:?} was left unthemed",
room.id
);
}
// Corridors carry no theme.
for r in map.regions.iter().filter(|r| r.kind == RegionKind::Corridor) {
assert_eq!(r.theme, None, "seed {seed}: corridor {:?} was themed", r.id);
}
let count = |t: RegionTheme| real_rooms(&map).iter().filter(|r| r.theme == Some(t)).count();
// Throne/Threshold mark single rooms — terrain may pre-empt them
// (a lava exit reads Forge), so the count is 0 or 1, never more.
assert!(count(RegionTheme::Throne) <= 1, "seed {seed}: more than one Throne");
assert!(count(RegionTheme::Threshold) <= 1, "seed {seed}: more than one Threshold");
total_throne += count(RegionTheme::Throne);
total_threshold += count(RegionTheme::Threshold);
}
assert!(total_throne > 0, "no Throne appeared across 24 seeds");
assert!(total_threshold > 0, "no Threshold appeared across 24 seeds");
}
/// A room flooded with lava is always a Forge, and one with water a Cistern —
/// the terrain→theme correspondence the renderer relies on. We scan seeds with
/// pools forced on until we see each, so the assertion is a real guard.
#[test]
fn lava_and_water_rooms_are_forge_and_cistern() {
use crate::region::RegionTheme;
let cfg = DungeonConfig {
pools: crate::passes::PoolConfig { water_chance: 0.6, lava_chance: 0.6, min_room: 7 },
..DungeonConfig::default()
};
for seed in 1..=24u64 {
let map = dungeon(cfg).unwrap().run(seed);
for room in real_rooms(&map) {
let has = |t: Tile| room.cells.iter().any(|&c| map.tiles.get(c) == Some(&t));
if has(Tile::Lava) {
assert_eq!(room.theme, Some(RegionTheme::Forge), "lava room must be a Forge");
} else if has(Tile::Water) {
assert_eq!(room.theme, Some(RegionTheme::Cistern), "water room must be a Cistern");
}
}
}
}
// --- Test 8: eyeball render ---------------------------------------------
/// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an
/// assertion (beyond non-empty output); it documents what the recipe makes.

View file

@ -40,6 +40,65 @@ pub enum RegionKind {
Corridor,
}
/// An optional *flavor* a recipe may assign to a region — a layer of meaning
/// **orthogonal** to [`RegionKind`] (kind says *room vs corridor*; theme says
/// *what kind of place this room is*).
///
/// A themer pass classifies each room into one of these (e.g. a room flooded
/// with lava reads as a [`Forge`](RegionTheme::Forge)); renderers tint the room
/// accordingly and a populator biases its contents. Like [`RegionKind`] and
/// [`crate::map::Tile`], the set is **additive** — a future town recipe could add
/// `Market`/`Temple`/`Slum` without disturbing existing matches — and entirely
/// optional: a region with `theme: None` is simply unthemed (the neutral look).
///
/// Each variant carries a pair of *content biases* via [`biases`](RegionTheme::biases),
/// the only gameplay knob the core attaches to a theme. All color/lighting is a
/// renderer concern and lives in the renderer, keyed by the theme's wire tag.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RegionTheme {
/// A magma smithy — terrain-driven by lava. Loot-rich, a touch dangerous.
Forge,
/// A flooded reservoir — terrain-driven by water. Damp, low loot.
Cistern,
/// A pillared ceremonial hall — terrain-driven by pillars. Stately.
Hall,
/// The boss seat at the dungeon's far end (the exit room). High risk, high reward.
Throne,
/// The safe entrance chamber. Understated, kept monster-free.
Threshold,
/// A sealed treasury — the loot jackpot, lightly guarded.
Vault,
/// A scriptorium of tomes and scrolls. Loot-rich, low danger.
Library,
/// A monster lair / guardroom. Combat-dense, little loot.
Den,
/// A cold tomb of the restless dead. Undead-heavy, moderate grave-goods.
Crypt,
/// Plain torch-lit stone — the neutral baseline (all biases 1.0).
Stone,
}
impl RegionTheme {
/// The `(treasure_bias, monster_bias)` multipliers a populator applies to its
/// base per-room treasure/monster chances. `Stone` is the `(1.0, 1.0)`
/// neutral baseline; biases above 1 saturate the chance (a near-certain
/// "this room always has loot / a boss" telegraph), below 1 thin it out.
pub fn biases(self) -> (f64, f64) {
match self {
RegionTheme::Forge => (1.4, 1.2),
RegionTheme::Cistern => (0.7, 1.1),
RegionTheme::Hall => (1.1, 0.85),
RegionTheme::Throne => (2.4, 2.3),
RegionTheme::Threshold => (0.5, 0.0),
RegionTheme::Vault => (2.5, 0.4),
RegionTheme::Library => (1.6, 0.5),
RegionTheme::Den => (0.7, 2.4),
RegionTheme::Crypt => (1.2, 1.8),
RegionTheme::Stone => (1.0, 1.0),
}
}
}
/// A semantic area of the map: an id, a kind, a bounding box, and its cells.
///
/// `bounds` is the axis-aligned bounding box; `cells` is the exact set of
@ -56,6 +115,10 @@ pub struct Region {
pub bounds: Rect,
/// The exact cells this region occupies, in the order they were added.
pub cells: Vec<Point>,
/// An optional flavor for this region (see [`RegionTheme`]). `None` until a
/// themer pass classifies it; corridors and unthemed rooms stay `None`.
#[serde(default)]
pub theme: Option<RegionTheme>,
}
/// A connection between two regions in the [`ConnGraph`].

View file

@ -41,6 +41,9 @@ fn color_of(tile: Tile) -> Color {
Tile::Wall => Color::new(0.07, 0.07, 0.09, 1.0),
Tile::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
Tile::Door => GOLD,
Tile::Pillar => Color::new(0.30, 0.28, 0.26, 1.0),
Tile::Water => Color::new(0.16, 0.34, 0.52, 1.0),
Tile::Lava => Color::new(0.85, 0.32, 0.10, 1.0),
}
}

View file

@ -23,12 +23,13 @@
use serde::Serialize;
use wasm_bindgen::prelude::*;
use reikhelm_core::entity::{Entity, EntityKind};
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};
use reikhelm_core::region::{Edge, Region, RegionKind, RegionTheme};
/// The integer tile code sent on the wire. Renderer concern; not a core type.
fn tile_code(tile: Tile) -> u8 {
@ -36,6 +37,9 @@ fn tile_code(tile: Tile) -> u8 {
Tile::Wall => 0,
Tile::Floor => 1,
Tile::Door => 2,
Tile::Pillar => 3,
Tile::Water => 4,
Tile::Lava => 5,
}
}
@ -47,6 +51,23 @@ fn kind_str(kind: RegionKind) -> &'static str {
}
}
/// The region-theme tag sent on the wire (snake_case; the renderer keys its
/// per-theme palette off these). Colors live in the renderer, never the core.
fn theme_str(theme: RegionTheme) -> &'static str {
match theme {
RegionTheme::Forge => "forge",
RegionTheme::Cistern => "cistern",
RegionTheme::Hall => "hall",
RegionTheme::Throne => "throne",
RegionTheme::Threshold => "threshold",
RegionTheme::Vault => "vault",
RegionTheme::Library => "library",
RegionTheme::Den => "den",
RegionTheme::Crypt => "crypt",
RegionTheme::Stone => "stone",
}
}
/// 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;
@ -69,13 +90,17 @@ struct RectDto {
h: i32,
}
/// A semantic region on the wire: id, kind tag, bounds, and `[x, y]` cells.
/// A semantic region on the wire: id, kind tag, bounds, `[x, y]` cells, and an
/// optional theme tag (present once a themer has run — `None` for corridors,
/// unthemed rooms, and the pre-themer snapshot stages).
#[derive(Serialize)]
struct RegionDto {
id: usize,
kind: &'static str,
bounds: RectDto,
cells: Vec<[i32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
theme: Option<&'static str>,
}
impl From<&Region> for RegionDto {
@ -90,6 +115,33 @@ impl From<&Region> for RegionDto {
h: r.bounds.h,
},
cells: r.cells.iter().map(|p| [p.x, p.y]).collect(),
theme: r.theme.map(theme_str),
}
}
}
/// The entity-kind tag sent on the wire.
fn entity_kind_str(kind: EntityKind) -> &'static str {
match kind {
EntityKind::Entrance => "Entrance",
EntityKind::Exit => "Exit",
EntityKind::Treasure => "Treasure",
EntityKind::Monster => "Monster",
}
}
/// An entity on the wire: its kind tag and the `[x, y]` cell it occupies.
#[derive(Serialize)]
struct EntityDto {
kind: &'static str,
at: [i32; 2],
}
impl From<&Entity> for EntityDto {
fn from(e: &Entity) -> Self {
EntityDto {
kind: entity_kind_str(e.kind),
at: [e.at.x, e.at.y],
}
}
}
@ -142,6 +194,7 @@ struct Envelope {
tiles: Vec<Vec<u8>>,
regions: Vec<RegionDto>,
edges: Vec<EdgeDto>,
entities: Vec<EntityDto>,
snapshots: Vec<SnapshotDto>,
}
@ -172,6 +225,7 @@ fn build_envelope(seed: u64, map: &Map, snapshots: &[Snapshot]) -> Envelope {
tiles: tiles_to_rows(&map.tiles),
regions: map.regions.iter().map(RegionDto::from).collect(),
edges: map.graph.edges().iter().map(EdgeDto::from).collect(),
entities: map.entities.iter().map(EntityDto::from).collect(),
snapshots: snapshots.iter().map(SnapshotDto::from).collect(),
}
}

236
reikhelm-web/depth.js Normal file
View file

@ -0,0 +1,236 @@
// depth.js — top-down depth-map exporter for reikhelm map envelopes.
//
// A *renderer* concern, exactly like render.js: it reads the same JSON envelope
// (integer tile codes, regions, themes) and derives an 8-bit grayscale depth map
// for the diffusion control pipeline (tools/comfy-spike → ComfyUI Z-Image). It
// holds NO generation logic and the core stores none of this — height, like
// color, lives renderer-side and is derived from tiles + regions + theme.
//
// Convention (matches tools/comfy-spike/comfy.py): a top-down depth map where
// NEAR the camera = white(255), FAR = black(0). Looking straight down, the tops
// of walls are closest to the camera (white), the floor sits mid-gray, and the
// bottoms of recessed pools are farthest (black). So depth here is a *height
// field*: brightness ∝ height.
//
// Note on the BFS field: render.js's `distanceToWall` is an openness/cavity map,
// not a depth map — feeding it raw would dome the floors toward the camera (room
// centres bulging), which is wrong. We build a height model instead, and reuse
// the distance field only for a subtle ambient-occlusion darkening in the
// crevices where stone meets floor.
import { getStage, distanceToWall } from './render.js';
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
// Per-tile base height in [0,1] (1 = tallest = nearest the top-down camera =
// whitest). The wall plateau is the high reference; pools recess below the
// floor; a door notches just under floor level so passages read as openings;
// a pillar stands as a tall column on the floor. Renderer-owned, like PAL.
const HEIGHT = {
[WALL]: 1.00, // flat wall-top plateau (uniform: from directly above, all wall tops sit level)
[FLOOR]: 0.50, // mid reference plane
[DOOR]: 0.45, // threshold, a hair below floor → reads as a gap in the wall line
[PILLAR]: 0.84, // free-standing column rising off the floor
[WATER]: 0.15, // recessed pool
[LAVA]: 0.10, // recessed molten channel (lowest)
};
// Per-theme relief (Stage-B layer, kept subtle so it modulates the room without
// swamping the wall↔pool contrast that carries the structure). Floor bias raises
// or sinks a themed room's floor; wall bias makes its walls stand a touch taller.
// Keyed by the wire theme id (render.js THEMES). Absent themes → flat (0,0).
const THEME_RELIEF = {
throne: { floor: 0.07, wall: 0.06 }, // a raised dais under taller walls
vault: { floor: 0.04, wall: 0.03 }, // built-up stone room
hall: { floor: 0.025, wall: 0.02 },
library: { floor: 0.02, wall: 0.01 },
forge: { floor: 0.0, wall: 0.01 }, // lava already carries the relief
threshold: { floor: 0.0, wall: 0.0 },
stone: { floor: 0.0, wall: 0.0 },
den: { floor: -0.02, wall: 0.0 },
cistern: { floor: -0.06, wall: 0.0 }, // sunken water room
crypt: { floor: -0.05, wall: 0.0 }, // sunken vault
};
const AO = { depth: 2.4, strength: 0.07 }; // crevice darkening near walls (reuses distanceToWall)
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
// Per-cell theme relief: for every cell of a themed Room, its {floor,wall} bias;
// {0,0} elsewhere. Rooms don't overlap, so the assignment is unambiguous.
function reliefField(regions, w, h) {
const fl = new Float32Array(w * h);
const wl = new Float32Array(w * h);
for (const r of regions) {
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
const rel = THEME_RELIEF[r.theme];
if (!rel) continue;
for (const [x, y] of r.cells) {
if (x < 0 || y < 0 || x >= w || y >= h) continue;
fl[y * w + x] = rel.floor;
wl[y * w + x] = rel.wall;
}
}
return { fl, wl };
}
// In-place separable box blur over a Float32 field (two passes ≈ Gaussian).
// Used as a small bevel so wall edges read as sloped faces rather than 1px
// cliffs, without melting the flat plateaus. radius in pixels; clamps at edges.
function boxBlur(src, w, h, radius, passes = 2) {
if (radius < 1) return src;
let buf = src;
const tmp = new Float32Array(w * h);
const norm = 1 / (radius * 2 + 1);
for (let p = 0; p < passes; p++) {
// horizontal
for (let y = 0; y < h; y++) {
const row = y * w;
let acc = 0;
for (let k = -radius; k <= radius; k++) acc += buf[row + clamp(k, 0, w - 1)];
for (let x = 0; x < w; x++) {
tmp[row + x] = acc * norm;
const out = row + clamp(x - radius, 0, w - 1);
const inc = row + clamp(x + radius + 1, 0, w - 1);
acc += buf[inc] - buf[out];
}
}
// vertical
for (let x = 0; x < w; x++) {
let acc = 0;
for (let k = -radius; k <= radius; k++) acc += tmp[clamp(k, 0, h - 1) * w + x];
for (let y = 0; y < h; y++) {
buf[y * w + x] = acc * norm;
const out = clamp(y - radius, 0, h - 1) * w + x;
const inc = clamp(y + radius + 1, 0, h - 1) * w + x;
acc += tmp[inc] - tmp[out];
}
}
}
return buf;
}
// --- main entry ----------------------------------------------------------
// Derive a depth height field from an envelope. Returns { width, height, gray }
// where gray is a Uint8ClampedArray (row-major, one 8-bit value per pixel),
// full-range normalized with near(tall)=255.
//
// opts: { stage?, scale?, bevel?, ao? }
// stage — snapshot to read (default final)
// scale — pixels per cell (default ≈ 1024 / longest map edge, clamped 6..48)
// bevel — edge-softening as a fraction of a cell (default 0.16; 0 = hard steps)
// ao — crevice AO strength (default AO.strength; 0 = off)
export function computeDepthField(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const scale = opts.scale ?? clamp(Math.round(1024 / Math.max(w, h)), 6, 48);
const aoStrength = opts.ao ?? AO.strength;
// 1) Per-cell height = tile base + theme relief crevice AO.
const dist = distanceToWall(tiles, w, h);
const { fl, wl } = reliefField(regions, w, h);
const cell = new Float32Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const t = tiles[y][x];
let v = HEIGHT[t] ?? HEIGHT[FLOOR];
if (t === WALL) {
v += wl[i]; // taller themed walls
} else {
v += fl[i]; // raised/sunken themed floors
// Crevice AO: open cells right against stone sit slightly lower. `dist`
// is 0 in walls and grows outward; openness = min(dist/aoDepth, 1).
const openness = Math.min(1, dist[i] / AO.depth);
v -= (1 - openness) * aoStrength;
}
cell[i] = v;
}
}
// 2) Upscale to pixels as flat blocks (nearest), preserving plateaus/pools —
// hard depth steps at wall tops are physically correct top-down.
const W = w * scale, H = h * scale;
const px = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
const cy = (y / scale) | 0;
for (let x = 0; x < W; x++) {
px[y * W + x] = cell[cy * w + ((x / scale) | 0)];
}
}
// 3) Light bevel: a small blur turns the 1px block edges into sloped wall
// faces the depth ControlNet can read, without rounding off the flat tops.
const bevel = opts.bevel ?? 0.16;
const radius = Math.max(0, Math.round(scale * bevel));
boxBlur(px, W, H, radius);
// 4) Height → 8-bit gray, near(tall)=white. Default ('levels') maps heights
// onto the *proven* room_depth levels (floor≈120, wall≈235, recessed pools
// ≈3045 — the input distribution the Z-Image depth patch was validated
// against): gray = h·230 + 5, so floor(0.5)→120 and wall(1.0)→235. This
// keeps the floor a stable mid-gray across seeds instead of letting one tall
// themed room crush every other floor toward black (what literal full-range
// did). `normalize:'range'` opts back into data-driven full-range.
const gray = new Uint8ClampedArray(W * H);
if (opts.normalize === 'range') {
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < px.length; i++) { const v = px[i]; if (v < lo) lo = v; if (v > hi) hi = v; }
const span = hi - lo || 1;
for (let i = 0; i < px.length; i++) gray[i] = ((px[i] - lo) / span) * 255 + 0.5;
} else {
for (let i = 0; i < px.length; i++) gray[i] = px[i] * 230 + 5 + 0.5;
}
return { width: W, height: H, gray };
}
// --- canvas / export glue ------------------------------------------------
// Paint a depth field onto a fresh offscreen canvas (R=G=B=gray).
export function depthToCanvas(field) {
const { width, height, gray } = field;
const cv = document.createElement('canvas');
cv.width = width; cv.height = height;
const c = cv.getContext('2d');
const img = c.createImageData(width, height);
const d = img.data;
for (let i = 0; i < gray.length; i++) {
const v = gray[i], j = i * 4;
d[j] = v; d[j + 1] = v; d[j + 2] = v; d[j + 3] = 255;
}
c.putImageData(img, 0, 0);
return cv;
}
// Build the depth PNG and return its data URL plus dimensions.
export function exportDepthDataURL(env, opts = {}) {
const field = computeDepthField(env, opts);
return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height };
}
// Trigger a browser download of the depth PNG (the human / button path).
export function downloadDepth(env, opts = {}) {
const { url, width, height } = exportDepthDataURL(env, opts);
const a = document.createElement('a');
a.href = url;
a.download = `reikhelm-depth-seed${env.seed}-${width}x${height}.png`;
document.body.appendChild(a);
a.click();
a.remove();
return { width, height };
}
// POST the depth PNG to the dev save-server (tools/serve.py) under `name` — the
// automated-pipeline path (Playwright / scripted export). Returns the server's
// JSON ({ok, path, bytes}) merged with the dimensions; throws if no server is
// listening (use downloadDepth() for the browser-download path instead).
export async function exportDepthToServer(env, name, opts = {}) {
const field = computeDepthField(env, opts);
const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png'));
const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob });
if (!resp.ok) throw new Error('save failed: ' + resp.status);
const info = await resp.json();
return { ...info, width: field.width, height: field.height };
}

View file

@ -0,0 +1,145 @@
// explore-viewer.js — walk a pre-baked first-person dungeon.
//
// Loads an atlas manifest (room graph + tiles) and the baked frames from
// /out/atlas/<seed>/, and lets you move through it Eye-of-the-Beholder style:
// you occupy a room and a cardinal facing; W/S step forward/back to the room in
// that direction, A/D turn. Each frame is the pre-rendered pixel-art view for the
// current (room, facing). Movement is just swapping the image — the dungeon was
// "burned off" ahead of time by tools/bake_atlas.py.
const params = new URLSearchParams(location.search);
const SEED = params.get('seed') || '7';
const BASE = `/out/atlas/${SEED}`;
const DIRS = ['N', 'E', 'S', 'W'];
const VEC = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
const img = document.getElementById('frame');
const hud = document.getElementById('hud');
const miss = document.getElementById('miss');
const missDetail = document.getElementById('miss-detail');
const mini = document.getElementById('minimap');
const mctx = mini.getContext('2d');
let manifest, byId;
const state = { room: null, facing: 'N' };
// One cache-bust token per page load: re-bakes show fresh art on reload, but
// turning/stepping within a session still hits the browser cache (snappy).
const BUST = `?t=${Date.now()}`;
const frameURL = (id, dir) => `${BASE}/r${id}_${dir}.png${BUST}`;
const room = () => byId.get(state.room);
const opposite = (d) => DIRS[(DIRS.indexOf(d) + 2) % 4];
async function boot() {
try {
manifest = await (await fetch(`${BASE}/manifest.json`)).json();
} catch {
hud.innerHTML = `no atlas for seed <b>${SEED}</b> — bake one with <span class="dim">tools/bake_atlas.py --seed ${SEED}</span>`;
return;
}
byId = new Map(manifest.rooms.map((r) => [r.id, r]));
// ?room=&facing= jump straight to a room (demo/debug); else start at the entrance.
const wantRoom = Number(params.get('room'));
state.room = (params.has('room') && byId.has(wantRoom)) ? wantRoom : manifest.startRoom;
const wantFacing = (params.get('facing') || '').toUpperCase();
state.facing = DIRS.includes(wantFacing) ? wantFacing : (firstOpenFacing(state.room) || 'N');
show();
preloadAround();
}
function firstOpenFacing(id) {
const nav = byId.get(id).nav;
return DIRS.find((d) => nav[d] != null);
}
function show() {
miss.classList.remove('show');
img.src = frameURL(state.room, state.facing);
updateHud();
drawMinimap();
}
function updateHud() {
const r = room();
const exits = DIRS.filter((d) => r.nav[d] != null);
const ahead = r.nav[state.facing] != null ? `→ room ${r.nav[state.facing]}` : 'wall';
hud.innerHTML =
`seed <b>${manifest.seed}</b> · room <b>${r.id}</b>${r.theme ? ` · ${r.theme}` : ''} · ` +
`facing <b>${state.facing}</b> <span class="dim">(${ahead})</span><br>` +
`<span class="dim">exits</span> ${exits.join(' ') || '—'}`;
}
function turn(delta) {
state.facing = DIRS[(DIRS.indexOf(state.facing) + delta + 4) % 4];
show();
}
function step(forward) {
const dir = forward ? state.facing : opposite(state.facing);
const target = room().nav[dir];
if (target != null) {
state.room = target;
show();
preloadAround();
} else {
img.animate(
[{ filter: 'brightness(1)' }, { filter: 'brightness(0.55)' }, { filter: 'brightness(1)' }],
{ duration: 150 },
);
}
}
window.addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'w' || k === 'arrowup') step(true);
else if (k === 's' || k === 'arrowdown') step(false);
else if (k === 'a' || k === 'arrowleft') turn(-1);
else if (k === 'd' || k === 'arrowright') turn(1);
else return;
e.preventDefault();
});
// A frame that 404s just hasn't been baked yet — show a hint instead of a broken image.
img.addEventListener('error', () => {
if (!img.getAttribute('src')) return;
miss.classList.add('show');
missDetail.textContent = `room ${state.room}, facing ${state.facing}`;
});
// Warm the browser cache for instant turning/stepping.
function preloadAround() {
const seen = new Set();
const pre = (id) => DIRS.forEach((d) => {
const u = frameURL(id, d);
if (!seen.has(u)) { seen.add(u); new Image().src = u; }
});
pre(state.room);
const nav = room().nav;
DIRS.forEach((d) => { if (nav[d] != null) pre(nav[d]); });
}
function drawMinimap() {
const { tiles, width, height } = manifest;
const s = Math.max(2, Math.floor(170 / Math.max(width, height)));
mini.width = width * s; mini.height = height * s;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = tiles[y][x];
mctx.fillStyle = t === 0 ? '#15151c' : t === 2 ? '#caa24a' : '#34343f';
mctx.fillRect(x * s, y * s, s, s);
}
}
for (const r of manifest.rooms) { // every room as a faint node
mctx.fillStyle = 'rgba(122,160,220,0.45)';
mctx.fillRect(r.vantage[0] * s, r.vantage[1] * s, s, s);
}
const [vx, vy] = room().vantage; // the player + facing arrow
const cx = vx * s + s / 2, cy = vy * s + s / 2;
mctx.fillStyle = '#e8dcc8';
mctx.beginPath(); mctx.arc(cx, cy, Math.max(2, s * 0.7), 0, Math.PI * 2); mctx.fill();
const [dx, dy] = VEC[state.facing];
mctx.strokeStyle = '#d4a54a'; mctx.lineWidth = Math.max(1.5, s * 0.5);
mctx.beginPath(); mctx.moveTo(cx, cy); mctx.lineTo(cx + dx * s * 2.4, cy + dy * s * 2.4); mctx.stroke();
}
boot();

42
reikhelm-web/explore.html Normal file
View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>reikhelm · dungeon explorer</title>
<style>
:root { --accent: #d4a54a; --ink: #e0d4cc; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #000; color: var(--ink);
font: 13px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow: hidden; }
/* The pre-rendered frame, integer-pixel scaled to fill the screen. */
#frame { position: fixed; inset: 0; width: 100%; height: 100%;
object-fit: contain; image-rendering: pixelated; background: #000;
transition: filter .12s; }
.vignette { position: fixed; inset: 0; pointer-events: none;
box-shadow: inset 0 0 220px 60px rgba(0,0,0,0.7); }
#hud { position: fixed; top: 14px; left: 16px; padding: 8px 12px;
background: rgba(8,8,11,0.62); border: 1px solid rgba(212,165,74,0.35);
border-radius: 6px; backdrop-filter: blur(2px); }
#hud b { color: var(--accent); }
#hud .dim { color: #8a8a98; }
#minimap { position: fixed; bottom: 14px; left: 16px; image-rendering: pixelated;
background: rgba(8,8,11,0.55); border: 1px solid rgba(212,165,74,0.30);
border-radius: 4px; padding: 6px; }
#miss { position: fixed; inset: 0; display: none; place-items: center; text-align: center;
color: #8a8a98; }
#miss.show { display: grid; }
.keys { position: fixed; bottom: 16px; right: 18px; color: #6a6a76; text-align: right; }
.keys b { color: var(--ink); }
</style>
</head>
<body>
<img id="frame" alt="dungeon view">
<div class="vignette"></div>
<div id="hud">loading atlas…</div>
<div id="miss"><div>⛏ this view isn't baked yet<br><span id="miss-detail"></span></div></div>
<canvas id="minimap" width="180" height="120"></canvas>
<p class="keys"><b>W</b>/<b>S</b> forward·back · <b>A</b>/<b>D</b> turn</p>
<script type="module" src="explore-viewer.js?v=3"></script>
</body>
</html>

127
reikhelm-web/explore.js Normal file
View file

@ -0,0 +1,127 @@
// explore.js — turn a dungeon envelope into a navigable room graph for the
// pre-baked first-person explorer.
//
// The player occupies a ROOM and a cardinal FACING. Moving forward (W) steps to
// the neighbouring room in the faced direction; A/D rotate the facing. To support
// that we need, per room: a vantage cell (where the camera stands), and which
// room lies to its N/E/S/W. Rooms connect through corridors, so we contract the
// region graph (rooms = nodes, corridors = edges to traverse through) and bin
// each room-neighbour into the cardinal of its bearing.
//
// Renderer-side, like depth.js/fpv.js: derived from regions + edges, nothing
// stored in the core. Room ids here are the region ARRAY INDEX (== the wire id;
// edges already reference regions by that index), used as the atlas frame key.
import { getStage, distanceToWall } from './render.js';
import { mostOpenCell, centroidOf } from './fpv.js';
const WALL = 0;
const DIRV = [[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']]; // y grows downward = south
// Build the room navigation graph from the ACTUAL tile openings (not centroid
// bearings, which disagree with what the views render). For each room we scan its
// boundary for real gaps, bin each gap by the cardinal it sits on, and flood the
// corridor behind it to find the room it truly leads to. nav + open then share a
// single source of truth with the rendered passages.
//
// Returns { seed, width, height, startRoom, rooms }; each room =
// { id, theme, vantage:[x,y], centroid:[x,y], bounds, nav:{N,E,S,W}, open:{N,E,S,W} }
// (nav = neighbour room id or null; open = a real gap exists that way).
export function dungeonGraph(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const dist = distanceToWall(tiles, w, h);
const isRoom = (i) => regions[i] && regions[i].kind === 'Room' && regions[i].cells && regions[i].cells.length > 0;
const inBounds = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
const open = (x, y) => inBounds(x, y) && tiles[y][x] !== WALL; // floor/door/pillar/water/lava all pass
// cell → owning room id (region index), -1 for corridors/walls.
const cellRoom = new Int32Array(w * h).fill(-1);
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
for (const [x, y] of regions[i].cells) if (inBounds(x, y)) cellRoom[y * w + x] = i;
}
// From a gap cell just outside `home`, flood through non-room corridor cells
// until we reach a different room; return its id (or null for a dead end/loop).
const destFrom = (sx, sy, home) => {
const seen = new Set([sx + ',' + sy]);
const q = [[sx, sy]];
while (q.length) {
const [x, y] = q.shift();
const rid = cellRoom[y * w + x];
if (rid !== -1 && rid !== home) return rid; // arrived at another room
for (const [dx, dy] of DIRV) {
const nx = x + dx, ny = y + dy, k = nx + ',' + ny;
if (seen.has(k) || !open(nx, ny)) continue;
if (cellRoom[ny * w + nx] === home) continue; // never wander back into home
seen.add(k); q.push([nx, ny]);
}
}
return null;
};
const rooms = [];
for (let i = 0; i < regions.length; i++) {
if (!isRoom(i)) continue;
const r = regions[i];
const centroid = centroidOf(r.cells);
const van = mostOpenCell(dist, w, r.cells);
const cellSet = new Set(r.cells.map(([x, y]) => x + ',' + y));
// Collect boundary gaps per cardinal: a room cell whose neighbour that way is
// outside the room and not a wall.
const gaps = { N: [], E: [], S: [], W: [] };
for (const [x, y] of r.cells) {
for (const [dx, dy, dir] of DIRV) {
const nx = x + dx, ny = y + dy;
if (!inBounds(nx, ny) || cellSet.has(nx + ',' + ny) || tiles[ny][nx] === WALL) continue;
gaps[dir].push([nx, ny]);
}
}
const nav = { N: null, E: null, S: null, W: null };
const openDir = { N: false, E: false, S: false, W: false };
for (const dir of ['N', 'E', 'S', 'W']) {
if (gaps[dir].length === 0) continue;
openDir[dir] = true;
for (const [gx, gy] of gaps[dir]) { // first gap that reaches a room wins
const d = destFrom(gx, gy, i);
if (d != null) { nav[dir] = d; break; }
}
}
rooms.push({
id: i,
theme: r.theme || null,
vantage: van,
centroid: [Math.round(centroid[0]), Math.round(centroid[1])],
bounds: r.bounds,
nav,
open: openDir,
});
}
// Start room: the one holding the Entrance entity, else the largest.
let startRoom = rooms.length ? rooms[0].id : null;
const ent = (env.entities || []).find((e) => e.kind === 'Entrance');
if (ent) {
const hit = rooms.find((rm) => regions[rm.id].cells.some(([x, y]) => x === ent.at[0] && y === ent.at[1]));
if (hit) startRoom = hit.id;
} else {
let bestArea = 0;
for (const rm of rooms) {
const a = regions[rm.id].cells.length;
if (a > bestArea) { bestArea = a; startRoom = rm.id; }
}
}
return { seed: env.seed, width: w, height: h, startRoom, rooms };
}
// Full manifest = the nav graph + the tile grid (for the viewer's minimap).
export function dungeonManifest(env, opts = {}) {
const g = dungeonGraph(env, opts);
const { tiles } = getStage(env, opts.stage);
return { ...g, tiles };
}

157
reikhelm-web/fpv.js Normal file
View file

@ -0,0 +1,157 @@
// fpv.js — first-person (Eye-of-the-Beholder / Daggerfall style) depth exporter.
//
// A renderer concern, like depth.js: it reads the same envelope and produces an
// 8-bit depth map for the AI-render pipeline — but viewed from *inside* the
// dungeon rather than top-down. A grid dungeon is a 2D map extruded: walls are
// full-height blocks, floor and ceiling are flat planes, the eye sits at mid
// height, and you face one of four cardinal directions. So we don't need any 3D
// data from the core — a classic Wolfenstein-style raycaster turns the grid into
// a perspective depth map directly.
//
// Per screen column we cast one ray, march the grid (DDA) to the first blocking
// cell, and fill the column: a wall slice at that distance, floor ramping toward
// the camera below it, ceiling above. Distance → gray with NEAR=white/FAR=black
// (the same convention comfy.py expects). Doorways are non-blocking, so they
// read as dark recesses leading deeper — which is exactly what we want.
import { getStage, distanceToWall } from './render.js';
import { depthToCanvas } from './depth.js';
const WALL = 0, PILLAR = 3;
// Cardinal facings as (dx, dy) on the grid (y grows downward / "south").
export const DIRS = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
const blocks = (t) => t === WALL || t === PILLAR; // line-of-sight blockers
// Centroid of a cell list as [cx, cy].
export function centroidOf(cells) {
let cx = 0, cy = 0;
for (const [x, y] of cells) { cx += x; cy += y; }
return [cx / cells.length, cy / cells.length];
}
// The *most open* cell (max distance-to-wall) among `cells`, so a camera stands
// in open space rather than against a wall (a room centroid can land near a wall
// when the room is non-convex). Ties break toward the cell nearest the centroid,
// keeping it visually centred. `dist` is a precomputed distanceToWall field.
export function mostOpenCell(dist, w, cells) {
const [cx, cy] = centroidOf(cells);
let pick = cells[0], pmax = -1, pcen = Infinity;
for (const [x, y] of cells) {
const d = dist[y * w + x];
const cen = (x - cx) ** 2 + (y - cy) ** 2;
if (d > pmax || (d === pmax && cen < pcen)) { pmax = d; pcen = cen; pick = [x, y]; }
}
return pick;
}
// Vantage cell for one room region (its most-open cell).
export function roomVantage(env, region, opts = {}) {
const { tiles } = getStage(env, opts.stage);
return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells);
}
// Default demo vantage: the most-open cell of the largest room (or, failing that,
// the most open floor cell anywhere).
export function vantage(env, opts = {}) {
const { tiles, regions } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const dist = distanceToWall(tiles, w, h);
let best = null, bestArea = 0;
for (const r of regions) {
if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue;
if (r.cells.length > bestArea) { bestArea = r.cells.length; best = r; }
}
if (best) return mostOpenCell(dist, w, best.cells);
const all = [];
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!blocks(tiles[y][x])) all.push([x, y]);
return all.length ? mostOpenCell(dist, w, all) : [w >> 1, h >> 1];
}
// Build a first-person depth field. Returns { width, height, gray, at, dir }.
// opts: { stage?, at?[x,y], dir?'N'|'E'|'S'|'W', width?, height?, fov?deg, maxView?, gamma? }
export function computeFPVDepth(env, opts = {}) {
const { tiles } = getStage(env, opts.stage);
const w = env.width, h = env.height;
const W = opts.width ?? 1280, H = opts.height ?? 720; // 16:9 — integer-scales to 1080p/1440p
const fov = (opts.fov ?? 80) * Math.PI / 180; // wider horizontal FOV to fill the widescreen frame
const planeLen = Math.tan(fov / 2);
const maxView = opts.maxView ?? 12; // how far the eye sees (cells) before it's pure dark
const gamma = opts.gamma ?? 2.0; // >1 deepens the falloff to black (matches the proven corridor look)
const at = opts.at ?? vantage(env, opts);
const dirName = opts.dir ?? 'N';
const [dirX, dirY] = DIRS[dirName];
const posX = at[0] + 0.5, posY = at[1] + 0.5;
// Camera plane ⟂ to the view direction (rotate dir 90°), scaled to the FOV.
const planeX = -dirY * planeLen, planeY = dirX * planeLen;
const gray = new Uint8ClampedArray(W * H);
const half = H / 2;
const centerX = W >> 1;
let ahead = maxView; // straight-ahead wall distance (cells) — big = an opening leads onward
for (let x = 0; x < W; x++) {
const camX = 2 * x / W - 1; // 1 … 1 across the screen
const rayX = dirX + planeX * camX;
const rayY = dirY + planeY * camX;
// DDA grid march.
let mapX = posX | 0, mapY = posY | 0;
const deltaX = Math.abs(1 / rayX), deltaY = Math.abs(1 / rayY); // Infinity when axis-aligned — fine
let stepX, stepY, sideX, sideY;
if (rayX < 0) { stepX = -1; sideX = (posX - mapX) * deltaX; } else { stepX = 1; sideX = (mapX + 1 - posX) * deltaX; }
if (rayY < 0) { stepY = -1; sideY = (posY - mapY) * deltaY; } else { stepY = 1; sideY = (mapY + 1 - posY) * deltaY; }
let hit = false, side = 0;
const maxSteps = maxView * 2 + 4;
for (let s = 0; s < maxSteps; s++) {
if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
else { sideY += deltaY; mapY += stepY; side = 1; }
if (mapX < 0 || mapY < 0 || mapX >= w || mapY >= h) break; // off-map → open/far
if (blocks(tiles[mapY][mapX])) { hit = true; break; }
}
const perp = side === 0 ? sideX - deltaX : sideY - deltaY;
const dist = hit ? clamp(perp, 0.02, maxView) : maxView;
if (x === centerX) ahead = dist;
// Project the 1-cell-tall wall: lineH = H/dist (fills the screen at dist 1).
const lineH = H / dist;
const ds = clamp(Math.floor(half - lineH / 2), 0, H);
const de = clamp(Math.floor(half + lineH / 2), 0, H);
for (let y = 0; y < H; y++) {
let dpix;
if (y >= ds && y < de) {
dpix = dist; // wall slice
} else {
// Floor (below) / ceiling (above). (0.5·H)/p is continuous with the
// wall distance exactly at the slice edge, so the column has no seam.
const p = y < half ? (half - y) : (y - half + 1);
dpix = Math.min((0.5 * H) / p, maxView);
}
const t = clamp(dpix / maxView, 0, 1);
gray[y * W + x] = Math.round(255 * Math.pow(1 - t, gamma));
}
}
return { width: W, height: H, gray, at, dir: dirName, ahead };
}
// --- export glue (mirrors depth.js) --------------------------------------
export function exportFPVDataURL(env, opts = {}) {
const field = computeFPVDepth(env, opts);
return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
}
export async function exportFPVToServer(env, name, opts = {}) {
const field = computeFPVDepth(env, opts);
const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png'));
const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob });
if (!resp.ok) throw new Error('save failed: ' + resp.status);
const info = await resp.json();
return { ...info, width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
}

View file

@ -3,7 +3,10 @@
// 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';
import { renderMap, getStage, THEMES } from './render.js';
import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js';
import { exportFPVDataURL, exportFPVToServer, vantage } from './fpv.js';
import { dungeonManifest } from './explore.js';
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
@ -21,7 +24,7 @@ const state = {
env: null, // last successful envelope
stage: 0, // snapshot index currently shown
followFinal: true,
opts: { lighting: true, outlines: false, graph: false, grid: false },
opts: { lighting: true, themes: true, entities: true, outlines: false, graph: false, grid: false },
genMs: 0,
};
@ -40,6 +43,11 @@ const SLIDERS = [
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
['pillars.room_chance', 'pillars', 0, 1, 0.05],
['pools.water_chance', '≈ water', 0, 1, 0.05],
['pools.lava_chance', '♨ lava', 0, 1, 0.05],
['entities.treasure_chance', '◆ treasure', 0, 1, 0.05],
['entities.monster_chance', '● monsters', 0, 1, 0.05],
];
function getPath(obj, path) {
@ -87,15 +95,34 @@ function countTiles(env, code) {
return n;
}
// Count themed rooms by theme id (final-stage regions carry the theme tag).
function themeCounts(env) {
const counts = {};
for (const r of env.regions) {
if (r.kind === 'Room' && r.theme) counts[r.theme] = (counts[r.theme] || 0) + 1;
}
return counts;
}
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);
const ents = env.entities || [];
const byKind = (k) => ents.filter((e) => e.kind === k).length;
const counts = themeCounts(env);
const legend = Object.keys(THEMES)
.filter((k) => counts[k])
.map((k) => `${THEMES[k].glyph} ${THEMES[k].label} <b>${counts[k]}</b>`)
.join(' · ');
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`;
`gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit` +
(legend ? `<br>${legend}` : '');
}
// --- controls UI ---------------------------------------------------------
@ -107,7 +134,8 @@ function buildControls() {
seedRow.innerHTML = `
<label>seed</label>
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
<button id="reroll"> reroll</button>`;
<button id="reroll"> reroll</button>
<button id="depth" title="export top-down depth map PNG (for the AI-render pipeline)"> depth</button>`;
panel.appendChild(seedRow);
seedRow.querySelector('#seed').addEventListener('input', (e) => {
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
@ -118,6 +146,9 @@ function buildControls() {
document.getElementById('seed').value = String(state.seed);
regenerate();
});
seedRow.querySelector('#depth').addEventListener('click', () => {
if (state.env) downloadDepth(state.env, { stage: state.stage });
});
// Sliders.
for (const [path, label, min, max, step] of SLIDERS) {
@ -141,7 +172,7 @@ function buildControls() {
// Toggles.
const togRow = document.createElement('div');
togRow.className = 'row toggles';
togRow.innerHTML = ['lighting', 'outlines', 'graph', 'grid']
togRow.innerHTML = ['lighting', 'themes', 'entities', 'outlines', 'graph', 'grid']
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
.join('');
panel.appendChild(togRow);
@ -187,8 +218,42 @@ window.reikhelm = {
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(); },
// Depth-map export (drives the AI-render pipeline). exportDepth → data URL +
// dims (for headless capture); exportDepthToServer → POST the PNG to serve.py.
exportDepth(opts) { return state.env ? exportDepthDataURL(state.env, opts) : null; },
downloadDepth(opts) { return state.env ? downloadDepth(state.env, opts) : null; },
exportDepthToServer(name, opts) { return state.env ? exportDepthToServer(state.env, name, opts) : null; },
// First-person (Eye-of-the-Beholder style) depth — raycast from a room centre.
exportFPV(opts) { return state.env ? exportFPVDataURL(state.env, opts) : null; },
exportFPVToServer(name, opts) { return state.env ? exportFPVToServer(state.env, name, opts) : null; },
vantage(opts) { return state.env ? vantage(state.env, opts) : null; },
dungeonManifest() { return state.env ? dungeonManifest(state.env) : null; },
// Save a JSON blob to the dev server (used to drop an atlas manifest to disk).
async saveJSON(name, obj) {
const r = await fetch('/save/' + name, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) });
if (!r.ok) throw new Error('saveJSON failed: ' + r.status);
return r.json();
},
// Export every room × 4 cardinal FPV depth maps + the manifest for a baked atlas.
async bakeAtlasDepths(seed) {
if (seed != null) this.setSeed(seed);
const m = dungeonManifest(state.env); // nav + open already derived from real tile openings
const sd = state.env.seed;
for (const r of m.rooms) {
for (const dir of ['N', 'E', 'S', 'W']) {
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at: r.vantage, dir });
}
}
await this.saveJSON(`atlas/${sd}/manifest.json`, m);
return { seed: sd, rooms: m.rooms.length, frames: m.rooms.length * 4, themes: m.rooms.map((r) => `${r.id}:${r.theme}`) };
},
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,
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
water: state.env ? countTiles(state.env, 4) : 0,
lava: state.env ? countTiles(state.env, 5) : 0,
entities: state.env ? state.env.entities.length : 0,
themes: state.env ? themeCounts(state.env) : {},
ok: !!state.env }),
};

View file

@ -6,7 +6,7 @@
// 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;
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
const PAL = {
@ -19,21 +19,52 @@ const PAL = {
wallRim: [120, 110, 128], // rim where stone meets carved floor
doorWarm: [212, 165, 74],
doorDark: [120, 84, 32],
pillarTop: [122, 112, 100], // lit top-left of a stone column
pillarBody: [60, 54, 54], // shadowed lower-right
waterDeep: [30, 70, 104], // deep pool
waterShallow: [66, 120, 152], // sheen
lavaCrust: [120, 32, 10], // cooled crust
lavaHot: [255, 152, 48], // molten core
entrance: [96, 214, 124], // green beacon
exit: [96, 200, 232], // cyan portal
treasure: [242, 202, 92], // gold
monster: [226, 84, 72], // crimson
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)',
};
// Room themes — renderer-owned palette keyed by the wire theme id. Each theme
// retints a room's floor (`floor`) and wall rim (`accent`) and biases its torch
// warmth (`warm`, where 0.5 reproduces the untinted baseline). All floor values
// sit in the ~90..170/channel band so the lighting model reads unchanged —
// themes shift hue/temperature, never brightness. `glyph`/`label` feed the page
// legend. Tuned by eye against the warm-stone base.
export const THEMES = {
forge: { floor: [150, 98, 70], accent: [228, 118, 50], warm: 1.0, glyph: '🔥', label: 'Forge' },
cistern: { floor: [98, 116, 134], accent: [84, 148, 178], warm: 0.12, glyph: '💧', label: 'Cistern' },
hall: { floor: [144, 136, 118], accent: [190, 162, 104], warm: 0.6, glyph: '🏛', label: 'Hall' },
throne: { floor: [120, 100, 140], accent: [202, 142, 200], warm: 0.5, glyph: '👑', label: 'Throne' },
threshold: { floor: [126, 138, 128], accent: [116, 192, 148], warm: 0.42, glyph: '🚪', label: 'Threshold' },
vault: { floor: [142, 132, 102], accent: [216, 184, 92], warm: 0.6, glyph: '💰', label: 'Vault' },
library: { floor: [138, 120, 94], accent: [120, 90, 60], warm: 0.68, glyph: '📜', label: 'Library' },
den: { floor: [120, 96, 90], accent: [186, 92, 80], warm: 0.4, glyph: '⚔', label: 'Den' },
crypt: { floor: [106, 112, 130], accent: [126, 138, 168], warm: 0.08, glyph: '💀', label: 'Crypt' },
stone: { floor: [138, 133, 127], accent: [120, 110, 128], warm: 0.5, glyph: '⛏', label: 'Stone' },
};
// 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
ambient: 0.44, // floor brightness with no room glow
corridorFloor: 0.36, // ambient for cells in no room (corridors)
glow: 0.78, // 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
lavaGlow: 0.75, // brightness lava adds to nearby floor
lavaRadius: 6, // how far (cells) lava light reaches
};
// --- small helpers -------------------------------------------------------
@ -63,8 +94,9 @@ export function getStage(env, stage) {
// --- 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) {
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion
// here, and crevice AO in the depth-map exporter (depth.js) — hence exported.
export function distanceToWall(tiles, w, h) {
const dist = new Float32Array(w * h).fill(Infinity);
const q = new Int32Array(w * h);
let tail = 0;
@ -108,6 +140,51 @@ function roomGlow(regions, w, h) {
return glow;
}
// Per-cell theme lookup: map every cell of a themed Room region to its theme
// object (from THEMES), null elsewhere (corridors, unthemed rooms, pre-themer
// snapshot stages). Rooms don't overlap, so the assignment is unambiguous.
function themeField(regions, w, h) {
const field = new Array(w * h).fill(null);
for (const r of regions) {
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
const th = THEMES[r.theme];
if (!th) continue;
for (const [x, y] of r.cells) {
if (x >= 0 && y >= 0 && x < w && y < h) field[y * w + x] = th;
}
}
return field;
}
// Multi-source BFS distance (in cells) from lava to each open cell — lava light
// pools out through open space and is blocked by walls. Returns null if there's
// no lava (so the caller can skip the warm-light contribution entirely).
function lavaLightField(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] === LAVA) { dist[y * w + x] = 0; q[tail++] = y * w + x; }
}
}
if (tail === 0) return null;
let head = 0;
while (head < tail) {
const idx = q[head++];
const d = dist[idx];
if (d >= LIGHT.lavaRadius) continue; // cap how far the glow travels
const x = idx % w, y = (idx / w) | 0;
const step = (nx, ny) => {
if (nx < 0 || ny < 0 || nx >= w || ny >= h || tiles[ny][nx] === WALL) return;
const n = ny * w + nx;
if (dist[n] > d + 1) { dist[n] = d + 1; q[tail++] = n; }
};
step(x + 1, y); step(x - 1, y); step(x, y + 1); step(x, y - 1);
}
return dist;
}
// --- main entry ----------------------------------------------------------
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
@ -130,44 +207,75 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
const lit = opts.lighting !== false;
const dist = lit ? distanceToWall(tiles, w, h) : null;
const glow = lit ? roomGlow(regions, w, h) : null;
const lavaField = lit ? lavaLightField(tiles, w, h) : null;
const tfield = opts.themes !== false ? themeField(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.
// 1) Terrain: lit stone for floor/door/pillar-underlay, plus water and lava
// pools. Lava is self-lit and also throws warm light onto nearby floor.
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;
const X = px(x), Y = py(y);
let b;
// Lava: molten rock, drawn bright regardless of ambient light.
if (t === LAVA) {
const f = hash2(x * 7 + 2, y * 5 + 9);
ctx.fillStyle = rgb(lerp3(PAL.lavaHot, PAL.lavaCrust, 0.2 + 0.6 * f));
ctx.fillRect(X, Y, cell, cell);
continue;
}
let b, g = 0, lavaB = 0;
if (lit) {
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
const g = glow[i];
g = glow[i];
if (lavaField) lavaB = Math.max(0, 1 - lavaField[i] / LIGHT.lavaRadius);
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g) * aoMul);
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g + LIGHT.lavaGlow * lavaB) * 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);
// Water: a cool, reflective pool — darker than stone with a faint sheen.
if (t === WATER) {
const f = hash2(x * 5 + 1, y * 9 + 4);
const base = lerp3(PAL.waterDeep, PAL.waterShallow, 0.35 + 0.4 * f);
ctx.fillStyle = rgb(scale3(base, 0.7 + 0.5 * b));
ctx.fillRect(X, Y, cell, cell);
continue;
}
// Floor / door / pillar underlay: lit stone, warmed by torch + lava light.
// A themed room substitutes its own floor hue (and scales the torch warmth
// by its `warm`); unthemed cells keep the cool↔warm stone lerp.
const th = tfield ? tfield[i] : null;
const coarse = hash2((x >> 2) + 11, (y >> 2) + 7);
const grain = 0.93 + 0.09 * hash2(x, y);
const stone = th ? th.floor : lerp3(PAL.stoneCool, PAL.stoneWarm, 0.5 + 0.4 * coarse);
let col = scale3(stone, b * grain);
if (lit) {
const warmScale = th ? 0.5 + th.warm : 1; // warm=0.5 ⇒ unchanged baseline
const warm = g * 58 * warmScale + lavaB * 95; // torch + lava both pool warm
col = [col[0] + warm, col[1] + warm * 0.5, col[2] + warm * 0.1];
}
ctx.fillStyle = rgb(col);
ctx.fillRect(X, 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);
const grd = ctx.createLinearGradient(0, Y, 0, 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));
ctx.fillRect(X, Y, cell, Math.ceil(cell * 0.7));
}
}
}
@ -201,12 +309,16 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
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);
// boundary reads crisply on all sides (catches the floor's glow). Each rim
// segment takes the accent of the themed room it borders (else plain rim).
const rimColor = (nx, ny) => {
const th = tfield ? tfield[ny * w + nx] : null;
return rgb(scale3(th ? th.accent : PAL.wallRim, 0.6 * grain));
};
if (isFloor(x, y - 1)) { ctx.fillStyle = rimColor(x, y - 1); ctx.fillRect(X, Y, cell, rim); }
if (isFloor(x, y + 1)) { ctx.fillStyle = rimColor(x, y + 1); ctx.fillRect(X, Y + cell - rim, cell, rim); }
if (isFloor(x - 1, y)) { ctx.fillStyle = rimColor(x - 1, y); ctx.fillRect(X, Y, rim, cell); }
if (isFloor(x + 1, y)) { ctx.fillStyle = rimColor(x + 1, y); ctx.fillRect(X + cell - rim, Y, rim, cell); }
}
}
@ -235,6 +347,29 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
}
}
// 3.5) Pillars — free-standing stone columns standing on the lit floor (the
// floor under them was already painted in pass 1).
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (tiles[y][x] !== PILLAR) continue;
const cxp = px(x) + cell / 2, cyp = py(y) + cell / 2;
const r = cell * 0.36;
// Contact shadow pooled at the base.
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.beginPath();
ctx.ellipse(cxp, cyp + cell * 0.18, r * 1.05, r * 0.62, 0, 0, Math.PI * 2);
ctx.fill();
// Column body, lit from the top-left.
const grd = ctx.createRadialGradient(cxp - r * 0.4, cyp - r * 0.45, r * 0.1, cxp, cyp, r * 1.1);
grd.addColorStop(0, rgb(PAL.pillarTop));
grd.addColorStop(1, rgb(PAL.pillarBody));
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cxp, cyp, r, 0, Math.PI * 2);
ctx.fill();
}
}
// 4) Vignette over the whole grid for mood.
if (lit && LIGHT.vignette > 0) {
const cxp = ox + gw / 2, cyp = oy + gh / 2;
@ -250,9 +385,79 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
if (opts.graph) drawGraph(ctx, regions, edges, layout);
if (opts.grid) drawGrid(ctx, layout);
// 6) Entities (entrance/exit/treasure/monsters) — only exist on the final
// stage, drawn on top of everything as the dungeon's inhabitants.
const lastStage = (env.snapshots?.length ?? 1) - 1;
const isFinal = (opts.stage ?? lastStage) >= lastStage;
if (isFinal && opts.entities !== false && env.entities) {
drawEntities(ctx, env.entities, layout);
}
return layout;
}
// Draw each entity as an iconic marker centered in its cell, with a soft glow so
// it reads over the lit floor.
function drawEntities(ctx, entities, { cell, ox, oy }) {
const r = Math.max(2.5, cell * 0.3);
const center = (e) => [ox + e.at[0] * cell + cell / 2, oy + e.at[1] * cell + cell / 2];
const glow = (cx, cy, col, rad) => {
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
g.addColorStop(0, `rgba(${col[0]},${col[1]},${col[2]},0.55)`);
g.addColorStop(1, `rgba(${col[0]},${col[1]},${col[2]},0)`);
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
ctx.fill();
};
for (const e of entities) {
const [cx, cy] = center(e);
switch (e.kind) {
case 'Entrance': {
glow(cx, cy, PAL.entrance, r * 2.4);
ctx.fillStyle = rgb(PAL.entrance);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.beginPath(); ctx.arc(cx, cy, r * 0.42, 0, Math.PI * 2); ctx.fill();
break;
}
case 'Exit': {
glow(cx, cy, PAL.exit, r * 2.6);
ctx.strokeStyle = rgb(PAL.exit);
ctx.lineWidth = Math.max(1.5, r * 0.34);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, r * 0.45, 0, Math.PI * 2); ctx.stroke();
break;
}
case 'Treasure': {
glow(cx, cy, PAL.treasure, r * 2.0);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(Math.PI / 4); // diamond
ctx.fillStyle = rgb(PAL.treasure);
const s = r * 1.25;
ctx.fillRect(-s / 2, -s / 2, s, s);
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.fillRect(-s / 2, -s / 2, s * 0.32, s * 0.32);
ctx.restore();
break;
}
case 'Monster': {
glow(cx, cy, PAL.monster, r * 1.9);
ctx.fillStyle = rgb(PAL.monster);
ctx.beginPath(); ctx.arc(cx, cy, r * 0.92, 0, Math.PI * 2); ctx.fill();
// two dark "eyes"
ctx.fillStyle = 'rgba(20,8,8,0.92)';
const eye = Math.max(0.7, r * 0.2);
ctx.beginPath(); ctx.arc(cx - r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + r * 0.34, cy - r * 0.12, eye, 0, Math.PI * 2); ctx.fill();
break;
}
}
}
}
// Trace each region's true cell-set boundary (shows polygonal room shapes).
function drawOutlines(ctx, regions, { cell, ox, oy }) {
for (const r of regions) {

View file

@ -84,7 +84,8 @@ input[type="range"] {
height: 4px;
}
.seed-row { grid-template-columns: 40px 1fr auto; }
.seed-row { grid-template-columns: 40px 1fr auto auto; }
.seed-row button { padding: 5px 7px; white-space: nowrap; }
.seed-row input[type="number"] {
background: #0c0c11;
border: 1px solid var(--panel-edge);

21
tools/.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Python venv (regenerate: python3 -m venv .venv && .venv/bin/pip install pillow numpy opencv-python-headless)
.venv/
__pycache__/
*.pyc
# macOS
.DS_Store
# All pipeline outputs live here — depth maps, renders, dither, baked atlases,
# and the local gallery of keepers. Regenerable; organized; never committed.
out/
# Legacy ad-hoc outputs (pre-tools/out/ layout). Curated keepers live in pixelart/samples/.
comfy-spike/out_*.png
comfy-spike/depth_*.png
comfy-spike/fpv_*.png
pixelart/p_*.png
pixelart/crypt_*.png
pixelart/godray_*.png
pixelart/*_x*.png
pixelart/lock/

120
tools/README.md Normal file
View file

@ -0,0 +1,120 @@
# reikhelm tools — AI-render → pixel-art pipeline
Post-processing tools that turn a depth map into atmospheric, palette-locked pixel
art via a local diffusion render. Three stages, glued by PNG files.
```
real geometry ──► depth map ──► Stage 1: ComfyUI/Z-Image ──► Stage 2: dither ──► pixel art
(reikhelm-web/ (top-down (comfy-spike/comfy.py) (pixelart/pixelate.py)
depth.js export) heightfield)
```
**Now rendering real generated dungeons** (top-down). The depth exporter lives
renderer-side in `reikhelm-web/depth.js` (derives a height field from the same
envelope render.js paints — walls raised, floors mid, pools recessed, pillars as
bumps, plus subtle per-theme relief; near=white/far=black). It honors the "map
holds no rendering info" contract: height, like color, is derived from
tiles+regions+theme, never stored in the core.
## Setup
The pixelart tools share a venv (Pillow + numpy + opencv-headless):
```
python3 -m venv tools/pixelart/.venv
tools/pixelart/.venv/bin/pip install pillow numpy opencv-python-headless
```
`comfy-spike/comfy.py` and `serve.py` are pure stdlib (no venv). LAN calls to ComfyUI need the Bash sandbox disabled.
## Stage 0 — export a depth map from a real dungeon (`serve.py` + `reikhelm-web/{depth,fpv}.js`)
Serve the playground and let it POST depth PNGs back to disk:
```
python3 tools/serve.py # http://127.0.0.1:8000, saves → tools/comfy-spike/
```
Two depth modes, both derived from the same envelope (renderer-side, core stays clean):
**Top-down** (`depth.js`) — a height field, walls raised / pools recessed. Hit the
playground's **⬇ depth** button, or headless:
```js
// in the page (e.g. via Playwright browser_evaluate):
window.reikhelm.setSeed(7);
await window.reikhelm.exportDepthToServer('depth_seed7.png'); // → tools/comfy-spike/depth_seed7.png
// window.reikhelm.exportDepth({stage,scale,bevel,ao,normalize}) → {url,width,height} for in-context capture
```
≈1024px on the long edge (16px/cell at the default 64×40 map), aspect-matched to the render.
**First-person** (`fpv.js`) — an Eye-of-the-Beholder / Daggerfall view via a Wolfenstein
DDA raycaster over the grid (wall slice + floor/ceiling ramp, near=white). Camera at a
room's most-open cell, one of 4 cardinal facings:
```js
window.reikhelm.setSeed(7);
const at = window.reikhelm.vantage(); // most-open cell of the largest room
for (const dir of ['N','E','S','W'])
await window.reikhelm.exportFPVToServer(`fpv_s7_${dir}.png`, { dir });
// exportFPV({at:[x,y],dir,width,height,fov,maxView,gamma}) → {url,width,height,at,dir}
```
1280×720 (16:9 — integer-scales to 1080p/1440p). Doorways are non-blocking, so they read as dark recesses leading deeper.
## Stage 1 — depth → diffusion render (`comfy-spike/comfy.py`)
Talks to ComfyUI over HTTP (workstation `http://192.168.1.26:8188`).
```
# real dungeon (Stage 0 export), top-down prompt, geometry-honoring strength:
python3 comfy-spike/comfy.py zrender http://192.168.1.26:8188 --depth comfy-spike/depth_seed7.png \
--width 1024 --height 640 --strength 0.85 --prefix rk_s7 \
--prompt "overhead top-down view of an ancient stone dungeon, rock-cut chambers linked by corridors, flagstone floors, molten lava pool, torchlight, dark fantasy, highly detailed, cinematic"
# synthetic test depth (no real geometry): comfy.py depth --kind room --width 1024 --height 640 --out d.png
# also: probe <url> | nodeinfo <url> <Node...> for introspection
```
Proven Z-Image depth recipe: `ModelPatchLoader` + `ZImageFunControlnet`, CLIP type `qwen_image`,
`EmptySD3LatentImage`, `res_multistep`/`simple`, 812 steps, cfg 1.0, `ModelSamplingAuraFlow` shift 3.0.
Depth strength ~0.6 for freeform; **0.800.85 is the sweet spot for real geometry** (confirmed on
seeds 7/33: <0.6 drifts off the layout, 1.0 goes rigid and flattens the pools). Set `--width/--height`
to the depth PNG's dimensions and use a **top-down/overhead** prompt to match the geometry.
## Stage 2 — render → pixel art (`pixelart/pixelate.py`)
LOCKED "Primordyn" recipe = the defaults:
```
tools/pixelart/.venv/bin/python pixelart/pixelate.py render.png --palette pixelart/primordyn_v2.json
# → 640×360 · FloydSteinberg · OKLab match · linear-light · serpentine · fill
# --size 320x180 = chunky cut; --dither atkinson|jjn|bayer4|none to experiment
```
Outputs a true indexed PNG (+ optional `--preview-scale` nearest-neighbor preview). Palette loader
auto-detects .json / .gpl / JASC .pal / hex / Paint.NET / .png / `adaptive:N`.
## Stage 3 — bake & explore a first-person dungeon (`bake_atlas.py` + `reikhelm-web/explore.html`)
Pre-render every room's 4 cardinal views into an atlas, then walk it Eye-of-the-Beholder style.
```
# 1) export the depth maps + room-graph manifest for a seed (browser, via Playwright):
# window.reikhelm.bakeAtlasDepths(7) → tools/out/atlas/7/_work/r<id>_<dir>_depth.png + manifest.json
# 2) render every view through comfy → pixelate (FIXED diffusion seed = style lock across frames):
python3 tools/bake_atlas.py --seed 7 # → tools/out/atlas/7/r<id>_<dir>.png (640x360 pixel art)
# 3) walk it: http://127.0.0.1:8000/explore.html?seed=7
```
The explorer (`explore.html` + `explore-viewer.js`) loads the manifest + frames: you occupy a room and a
cardinal facing; **W/S** step forward/back to the room in that direction, **A/D** turn. `explore.js` builds
the room graph (corridors contracted; each neighbour binned to a cardinal) and per-room vantage points.
Prompt is tank's "empty abandoned ruin" recipe — **no** *first person / POV / dungeon-crawler* trigger
words (they summon player hands; at cfg 1.0 the negative prompt is inert, so the positive must stay clean).
See `.dev/2026-06-01-fpv-prompts.md`. The baker then varies the body **per room theme** (forge→lava,
throne→throne+banners, library→shelves, crypt→niches…) and adds a **wall-vs-passage clause per facing**
(from `open[dir]` in the manifest) so rooms read distinctly and solid walls don't grow phantom doors.
Strength **0.85** honors the footprint. `--rooms 6,7,9` re-bakes a subset.
## Helpers & references
- `pixelart/montage.py` — tile labeled images into a contact sheet.
- `pixelart/samples/` — curated reference: before/after pair + the kernel/size & palette-stretch contact sheets.
- `out/gallery/` — local keepers (top-down + first-person hero shots). Everything under `out/` is git-ignored.
## Next
- **Top-down real-geometry export — done** (`reikhelm-web/depth.js`, Stage 0). Per-tile heights + subtle
per-theme relief; the BFS distance field is reused only for crevice AO (it's an openness map, not a
depth map — feeding it raw would dome the floors).
- **First-person depth — working** (`reikhelm-web/fpv.js`). A grid raycaster, NOT the heavy 3D path once
assumed. 16:9 dungeon-crawler views from a room centre; strength **0.550.70** gives the richest results.
- **Playable EoB explorer — shipped** (Stage 3). Per-room ×4-facing atlas bake + a WASD viewer. Movement is
room-to-room along the contracted corridor graph.
- **Next for the explorer:** per-*cell* baking (smooth step-by-step movement, not just room-to-room);
entity sprites (monsters/treasure) composited on frames; door cells rendered as doors vs open archways;
a "bake this seed" button in the playground. Walk-forward render sequence lives in `out/walk/`.
- **Richer relief (top-down Stage B+):** push per-theme height harder (throne dais, deeper cisterns) if
top-down renders feel uniform.
- **Rust port:** move depth export (both modes) + the locked Stage-2 dither into a crate so the engine
owns the whole chain. See project memory.

155
tools/bake_atlas.py Normal file
View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Bake a first-person dungeon atlas: every room's 4 cardinal views → pixel art.
Input (written by the browser, see explore-bake hook):
tools/out/atlas/<seed>/_work/r<id>_<dir>_depth.png FPV depth maps (1280x720)
tools/out/atlas/<seed>/manifest.json room graph (nav + vantages + tiles)
For each depth map this runs the proven chain comfy.py zrender (Z-Image depth
ControlNet) pixelate.py (locked Primordyn dither) and writes the explorer frame:
tools/out/atlas/<seed>/r<id>_<dir>.png 640x360 indexed pixel art
The prompt is tank's "empty abandoned ruin" recipe: NO first-person/POV/crawler
trigger words (they summon player hands), a fixed style-anchor block, and a FIXED
seed across every frame so the palette + lighting stay coherent across the atlas.
python3 tools/bake_atlas.py --seed 7 [--url http://192.168.1.26:8188]
[--strength 0.65] [--size 640x360] [--limit N]
"""
import argparse
import glob
import json
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COMFY = os.path.join(REPO, "tools", "comfy-spike", "comfy.py")
PIXELATE = os.path.join(REPO, "tools", "pixelart", "pixelate.py")
VENV_PY = os.path.join(REPO, "tools", "pixelart", ".venv", "bin", "python")
PALETTE = os.path.join(REPO, "tools", "pixelart", "primordyn_v2.json")
RUN = os.getpid() # unique per invocation → ComfyUI never reuses a SaveImage prefix (re-bakes stay clean)
# Tank's anti-hands framing ("empty abandoned ruin, no people, architectural
# photography" — never "first person / POV / dungeon crawler"), but the room's
# CHARACTER now comes from its procgen theme and the view's actual openings, so
# the atlas stops being a mashup of one generic hall. The style anchor stays
# byte-identical across frames (consistency lever); the theme body + opening
# clause vary per (room, facing).
STYLE = ("dark fantasy, ancient torchlit stone, warm orange torchlight, deep black shadows, "
"wet stone, volumetric haze, architectural photography of an empty ruin, "
"empty, deserted, no people, uninhabited, atmospheric, highly detailed, ")
THEME_BODY = {
"forge": "molten forge chamber, channels of glowing orange lava, iron anvils and hanging chains, soot-blackened granite, fiery glow",
"cistern": "flooded stone cistern, still dark water with mirror reflections, dripping wet walls, cold teal light",
"crypt": "ancient crypt, carved stone sarcophagi and bone-filled wall niches, cobwebs, cold pale light",
"library": "ruined library, tall stone shelves of rotting books and scrolls, drifting dust, warm dim light",
"throne": "vast throne hall, a raised dais with a great stone throne, tattered banners, towering carved columns",
"vault": "treasure vault, iron-bound chests and stone strongboxes, scattered gold, heavy locked stone",
"hall": "grand pillared hall, rows of carved stone columns, high vaulted ceiling",
"den": "foul beast den, gnawed bones and filth, claw-scratched walls, a feral lair, dim red light",
"threshold": "dungeon gatehouse, a great iron portcullis and worn stone steps, torchlit entrance",
"stone": "rough-hewn stone chamber, bare granite walls, plain and ancient",
}
DEFAULT_BODY = THEME_BODY["stone"]
# These reinforce the depth (a deep recess vs a near wall straight ahead) so the
# model paints a passage only where one actually exists — killing phantom doors.
AHEAD_OPEN = "a dark arched passage leads onward into shadow ahead"
AHEAD_WALL = "a solid carved stone wall closes the way ahead"
NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, "
"holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern")
def prompt_for(theme, is_open):
body = THEME_BODY.get(theme or "stone", DEFAULT_BODY)
return f"{STYLE}{body}, {AHEAD_OPEN if is_open else AHEAD_WALL}"
NAME_RE = re.compile(r"^r(\d+)_([NESW])_depth\.png$")
def run(cmd):
p = subprocess.run(cmd, capture_output=True, text=True)
if p.returncode != 0:
sys.stderr.write(p.stdout + p.stderr + "\n")
return p.returncode == 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seed", type=int, required=True, help="dungeon seed (selects the atlas dir)")
ap.add_argument("--url", default="http://192.168.1.26:8188")
ap.add_argument("--strength", type=float, default=0.85, help="depth control strength (0.85 honors the room footprint)")
ap.add_argument("--render-seed", type=int, default=7, help="FIXED diffusion seed for every frame (style lock)")
ap.add_argument("--size", default="640x360", help="pixel-art frame size (16:9; 320x180 for chunky)")
ap.add_argument("--width", type=int, default=1280)
ap.add_argument("--height", type=int, default=720)
ap.add_argument("--rooms", default="", help="comma-separated room ids to bake (default all)")
ap.add_argument("--limit", type=int, default=0, help="bake only the first N frames (smoke test)")
a = ap.parse_args()
atlas = os.path.join(REPO, "tools", "out", "atlas", str(a.seed))
work = os.path.join(atlas, "_work")
# Per-room theme + per-facing openness come from the manifest the browser wrote.
rooms = {}
mpath = os.path.join(atlas, "manifest.json")
if os.path.exists(mpath):
with open(mpath) as f:
for r in json.load(f).get("rooms", []):
rooms[int(r["id"])] = r
only = {int(x) for x in a.rooms.split(",") if x.strip()} if a.rooms else None
def order(p):
m = NAME_RE.match(os.path.basename(p))
return (int(m.group(1)), "NESW".index(m.group(2))) if m else (1 << 30, 0)
depths = sorted(glob.glob(os.path.join(work, "r*_*_depth.png")), key=order)
if only is not None:
depths = [p for p in depths if NAME_RE.match(os.path.basename(p)) and int(NAME_RE.match(os.path.basename(p)).group(1)) in only]
if not depths:
sys.exit(f"!! no depth maps to bake in {work} — run the browser export first")
if a.limit:
depths = depths[:a.limit]
print(f"baking {len(depths)} frames → {os.path.relpath(atlas, REPO)} "
f"(strength {a.strength}, render-seed {a.render_seed}, {a.size}, per-theme prompts)")
ok = 0
for i, depth in enumerate(depths, 1):
m = NAME_RE.match(os.path.basename(depth))
if not m:
continue
rid, d = m.group(1), m.group(2)
room = rooms.get(int(rid), {})
theme = room.get("theme")
is_open = bool(room.get("open", {}).get(d, False))
prompt = prompt_for(theme, is_open)
prefix = f"r{rid}_{d}_{RUN}" # per-run unique; avoids ComfyUI prefix-counter collisions on re-bake
frame = os.path.join(atlas, f"r{rid}_{d}.png")
tag = f"[{i}/{len(depths)}] r{rid} {d} {theme or '?'}{'·open' if is_open else '·wall'}"
if not run(["python3", COMFY, "zrender", a.url, "--depth", depth,
"--width", str(a.width), "--height", str(a.height),
"--strength", str(a.strength), "--seed", str(a.render_seed),
"--prompt", prompt, "--negative", NEGATIVE,
"--prefix", prefix, "--wait", "300"]):
print(f" {tag} RENDER FAILED"); continue
renders = glob.glob(os.path.join(work, f"out_{prefix}_*.png"))
if not renders:
print(f" {tag} no render output"); continue
render = renders[0]
if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE,
"--size", a.size, "--preview-scale", "0", "--out", frame]):
print(f" {tag} DITHER FAILED"); continue
os.remove(render) # keep the depth, drop the big intermediate render
ok += 1
print(f" {tag}{os.path.relpath(frame, REPO)}")
print(f"done: {ok}/{len(depths)} frames baked into {os.path.relpath(atlas, REPO)}")
if __name__ == "__main__":
main()

416
tools/comfy-spike/comfy.py Normal file
View file

@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Throwaway spike: prove reikhelm (Mac) -> ComfyUI (LAN workstation) depth-render works.
Pure stdlib. No pip installs. Three subcommands:
depth generate a synthetic depth map PNG (no network) -- our test input
probe ask a ComfyUI server what models / controlnets / nodes it has
render upload a depth PNG, run a depth-ControlNet graph, pull the image back
This is a validation tool, not the real integration. Once the pipe is proven we
port the proven flow into Rust (reikhelm renders depth -> POST /prompt -> /view).
"""
import argparse
import json
import os
import struct
import sys
import time
import urllib.error
import urllib.request
import zlib
# --------------------------------------------------------------------------- #
# Grayscale PNG writer (stdlib only -- avoids a Pillow dependency)
# --------------------------------------------------------------------------- #
def write_gray_png(path, width, height, pixels):
"""pixels: a bytes/bytearray of length width*height, one 8-bit gray value each."""
def chunk(tag, data):
body = tag + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0) # 8-bit, color type 0 (gray)
raw = bytearray()
for y in range(height):
raw.append(0) # filter byte 0 (None) per scanline
raw.extend(pixels[y * width:(y + 1) * width])
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n")
f.write(chunk(b"IHDR", ihdr))
f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 9)))
f.write(chunk(b"IEND", b""))
# --------------------------------------------------------------------------- #
# Synthetic depth maps. ControlNet convention: NEAR = white(255), FAR = black(0).
# --------------------------------------------------------------------------- #
def corridor_depth(w, h, bands=0, falloff=2.2, floor_bias=0.0):
"""One-point-perspective stone corridor: bright near the viewer (frame edge),
dark at the vanishing point, SMOOTH by default so no hard ring seams appear.
`falloff` > 1 deepens the tunnel / thins the near rim. `bands` > 0 quantizes
into concentric rings (causes visible stone ridges -- leave 0 for smooth).
`floor_bias` (0..1) lowers the vanishing point so the floor occupies more of
the lower frame, reading as a hall you stand in rather than a symmetric portal."""
px = bytearray(w * h)
cx = w / 2.0
cy = h * (0.5 + 0.5 * floor_bias) # push vanishing point upward
for y in range(h):
for x in range(w):
# Chebyshev distance to the (possibly shifted) center -> rectangular tunnel.
dx = abs(x - cx) / cx
dy = abs(y - cy) / max(cy, h - cy)
d = max(dx, dy)
v = d ** falloff # steeper => deeper tunnel, thin near rim
if bands > 0:
v = round(v * bands) / bands # quantize into rings (visible ridges)
px[y * w + x] = max(0, min(255, int(v * 255)))
return px
def room_depth(w, h):
"""Top-down 'dollhouse' room: tall walls near the camera (bright border),
floor farther away (mid), recessed pool in the middle (dark)."""
px = bytearray(w * h)
bx, by = w // 7, h // 7
px0, px1, py0, py1 = w // 3, 2 * w // 3, h // 3, 2 * h // 3
for y in range(h):
for x in range(w):
if x < bx or x >= w - bx or y < by or y >= h - by:
v = 235 # walls: near/bright
elif px0 <= x < px1 and py0 <= y < py1:
v = 40 # recessed pool: far/dark
else:
v = 120 # floor: mid
px[y * w + x] = v
return px
def cmd_depth(args):
w = args.width or args.size
h = args.height or args.size
if args.kind == "corridor":
px = corridor_depth(w, h, bands=args.bands, falloff=args.falloff, floor_bias=args.floor_bias)
else:
px = room_depth(w, h)
write_gray_png(args.out, w, h, px)
print(f"wrote {args.out} ({w}x{h}, kind={args.kind}, bands={args.bands}, falloff={args.falloff})")
# --------------------------------------------------------------------------- #
# ComfyUI HTTP helpers
# --------------------------------------------------------------------------- #
def _get(base, path, timeout=15):
with urllib.request.urlopen(base + path, timeout=timeout) as r:
return r.read()
def _get_json(base, path, timeout=15):
return json.loads(_get(base, path, timeout))
def _node_input_options(info, class_type, input_name):
"""Pull the dropdown option list (e.g. available checkpoints) for one node input."""
node = info.get(class_type)
if not node:
return None
spec = node.get("input", {})
for group in ("required", "optional"):
if input_name in spec.get(group, {}):
opt = spec[group][input_name]
# ComfyUI encodes a dropdown as [ [list_of_values], {meta} ] or [list_of_values]
if isinstance(opt, list) and opt and isinstance(opt[0], list):
return opt[0]
return None
def cmd_probe(args):
base = args.url.rstrip("/")
try:
stats = _get_json(base, "/system_stats")
except urllib.error.URLError as e:
print(f"!! cannot reach {base} -- {e}")
print(" ComfyUI must be started with --listen 0.0.0.0 to accept LAN connections,")
print(" and the firewall must allow the port (default 8188).")
sys.exit(2)
print(f"== reachable: {base} ==")
sysinfo = stats.get("system", {})
print(f" comfyui: {sysinfo.get('comfyui_version', '?')} python: {sysinfo.get('python_version', '?')[:7]}")
for d in stats.get("devices", []):
free = d.get("vram_free", 0) / 1e9
total = d.get("vram_total", 0) / 1e9
print(f" device: {d.get('name', '?')} VRAM {free:.1f}/{total:.1f} GB free")
info = _get_json(base, "/object_info", timeout=60)
print(f"\n== installed node classes: {len(info)} ==")
def show(label, class_type, input_name, filt=None):
opts = _node_input_options(info, class_type, input_name)
if opts is None:
print(f" [{label}] node '{class_type}' NOT present")
return
if filt:
hits = [o for o in opts if any(f in o.lower() for f in filt)]
extra = f" (filtered {len(hits)}/{len(opts)})" if hits != opts else ""
opts = hits or opts
print(f" [{label}]{extra}: {opts}")
else:
print(f" [{label}] ({len(opts)}): {opts}")
show("checkpoints", "CheckpointLoaderSimple", "ckpt_name")
show("controlnets", "ControlNetLoader", "control_net_name")
show("unet/diffusion (flux/z-image)", "UNETLoader", "unet_name")
show("vae", "VAELoader", "vae_name")
show("clip", "CLIPLoader", "clip_name")
show("dualclip (flux)", "DualCLIPLoader", "clip_name1")
print("\n== relevant nodes present? ==")
for n in ["ControlNetApplyAdvanced", "ControlNetApply", "LoadImage", "KSampler",
"UNETLoader", "DualCLIPLoader", "FluxGuidance", "VAELoader"]:
print(f" {'yes' if n in info else ' no'} {n}")
# surface any Z-Image-specific nodes by name
zi = sorted(k for k in info if "image" in k.lower() and "z" in k.lower().split("image")[0][-2:])
flux = sorted(k for k in info if "flux" in k.lower())
if flux:
print(f" flux nodes: {flux}")
print("\nNext: tell me the checkpoint + controlnet names above and I'll wire the render graph.")
# --------------------------------------------------------------------------- #
# Render: upload depth, run an SDXL depth-ControlNet graph, pull the result.
# (SDXL first -- simplest, most-likely-installed. Flux/Z-Image added after probe.)
# --------------------------------------------------------------------------- #
def upload_image(base, path):
boundary = "----comfyspikeboundary"
fn = os.path.basename(path)
with open(path, "rb") as f:
data = f.read()
parts = [
f"--{boundary}\r\n".encode(),
f'Content-Disposition: form-data; name="image"; filename="{fn}"\r\n'.encode(),
b"Content-Type: image/png\r\n\r\n", data, b"\r\n",
f"--{boundary}\r\n".encode(),
b'Content-Disposition: form-data; name="overwrite"\r\n\r\ntrue\r\n',
f"--{boundary}--\r\n".encode(),
]
body = b"".join(parts)
req = urllib.request.Request(
base + "/upload/image", data=body, method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
return json.loads(urllib.request.urlopen(req, timeout=30).read())
def sdxl_depth_graph(a, depth_filename):
return {
"ckpt": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": a.ckpt}},
"pos": {"class_type": "CLIPTextEncode", "inputs": {"text": a.prompt, "clip": ["ckpt", 1]}},
"neg": {"class_type": "CLIPTextEncode", "inputs": {"text": a.negative, "clip": ["ckpt", 1]}},
"img": {"class_type": "LoadImage", "inputs": {"image": depth_filename}},
"cnet": {"class_type": "ControlNetLoader", "inputs": {"control_net_name": a.controlnet}},
"apply": {"class_type": "ControlNetApplyAdvanced", "inputs": {
"positive": ["pos", 0], "negative": ["neg", 0], "control_net": ["cnet", 0],
"image": ["img", 0], "strength": a.strength, "start_percent": 0.0, "end_percent": 1.0}},
"latent": {"class_type": "EmptyLatentImage", "inputs": {"width": a.size, "height": a.size, "batch_size": 1}},
"ks": {"class_type": "KSampler", "inputs": {
"seed": a.seed, "steps": a.steps, "cfg": a.cfg, "sampler_name": a.sampler,
"scheduler": a.scheduler, "denoise": 1.0, "model": ["ckpt", 0],
"positive": ["apply", 0], "negative": ["apply", 1], "latent_image": ["latent", 0]}},
"vae": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["ckpt", 2]}},
"save": {"class_type": "SaveImage", "inputs": {"images": ["vae", 0], "filename_prefix": "comfyspike"}},
}
def _run_graph(base, graph, out_dir, wait):
"""Queue a graph, poll history, download any output images. Returns saved paths."""
try:
resp = urllib.request.urlopen(urllib.request.Request(
base + "/prompt", data=json.dumps({"prompt": graph}).encode(),
headers={"Content-Type": "application/json"}, method="POST"), timeout=30)
except urllib.error.HTTPError as e:
print("!! /prompt rejected the graph (validation error):")
print(e.read().decode()[:3000])
sys.exit(1)
pid = json.loads(resp.read())["prompt_id"]
print(f"queued prompt {pid} ... waiting (up to {wait}s)")
deadline = time.time() + wait
while time.time() < deadline:
hist = _get_json(base, "/history/" + pid)
if pid in hist:
entry = hist[pid]
status = entry.get("status", {})
if status.get("status_str") == "error":
print("!! execution error:")
print(json.dumps(status.get("messages", status), indent=2)[:3000])
saved = []
for node_out in entry.get("outputs", {}).values():
for im in node_out.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"],
"subfolder": im.get("subfolder", ""),
"type": im.get("type", "output")})
blob = _get(base, "/view?" + q, timeout=120)
out = os.path.join(out_dir or ".", "out_" + im["filename"])
with open(out, "wb") as f:
f.write(blob)
saved.append(out)
print("rendered:", *saved) if saved else print("finished but no images -- check the graph")
return saved
time.sleep(2)
print(f"!! timed out after {wait}s")
return []
def cmd_render(args):
base = args.url.rstrip("/")
up = upload_image(base, args.depth)
depth_fn = up["name"] + (f" [{up['subfolder']}]" if up.get("subfolder") else "")
print(f"uploaded depth -> {depth_fn}")
_run_graph(base, sdxl_depth_graph(args, depth_fn), os.path.dirname(args.depth), args.wait)
# --------------------------------------------------------------------------- #
# Z-Image-Turbo + DiffSynth Union depth patch (the recommended path).
# Uses model_patches (NOT controlnet/) + the native ZImageFunControlnet node.
# --------------------------------------------------------------------------- #
def zimage_depth_graph(a, depth_filename):
g = {
"unet": {"class_type": "UNETLoader", "inputs": {"unet_name": a.unet, "weight_dtype": "default"}},
"clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": a.clip, "type": a.clip_type}},
"vae": {"class_type": "VAELoader", "inputs": {"vae_name": a.vae}},
"patch": {"class_type": "ModelPatchLoader", "inputs": {"name": a.patch}},
"depth": {"class_type": "LoadImage", "inputs": {"image": depth_filename}},
"shift": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["unet", 0], "shift": a.shift}},
"cnet": {"class_type": "ZImageFunControlnet", "inputs": {
"model": ["shift", 0], "model_patch": ["patch", 0], "vae": ["vae", 0],
"strength": a.strength, "image": ["depth", 0]}},
"pos": {"class_type": "CLIPTextEncode", "inputs": {"text": a.prompt, "clip": ["clip", 0]}},
"neg": {"class_type": "CLIPTextEncode", "inputs": {"text": a.negative, "clip": ["clip", 0]}},
"latent": {"class_type": "EmptySD3LatentImage", "inputs": {"width": a.width or a.size, "height": a.height or a.size, "batch_size": 1}},
"ks": {"class_type": "KSampler", "inputs": {
"seed": a.seed, "steps": a.steps, "cfg": a.cfg, "sampler_name": a.sampler,
"scheduler": a.scheduler, "denoise": 1.0, "model": ["cnet", 0],
"positive": ["pos", 0], "negative": ["neg", 0], "latent_image": ["latent", 0]}},
"dec": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["vae", 0]}},
"save": {"class_type": "SaveImage", "inputs": {"images": ["dec", 0], "filename_prefix": a.prefix}},
}
return g
def cmd_zrender(args):
base = args.url.rstrip("/")
up = upload_image(base, args.depth)
depth_fn = up["name"] + (f" [{up['subfolder']}]" if up.get("subfolder") else "")
print(f"uploaded depth -> {depth_fn}")
_run_graph(base, zimage_depth_graph(args, depth_fn), os.path.dirname(args.depth), args.wait)
# --------------------------------------------------------------------------- #
import urllib.parse # noqa: E402 (used in cmd_render)
def cmd_nodeinfo(args):
"""Dump the exact input/output signature of specific node classes from a live server."""
base = args.url.rstrip("/")
info = _get_json(base, "/object_info", timeout=60)
for n in args.nodes:
node = info.get(n)
if not node:
print(f"\n## {n} -- NOT PRESENT")
continue
print(f"\n## {n}")
spec = node.get("input", {})
for group in ("required", "optional"):
for name, val in spec.get(group, {}).items():
meta = {}
t = val
if isinstance(val, list) and val:
t = val[0]
if len(val) > 1 and isinstance(val[1], dict):
meta = val[1]
if isinstance(t, list): # combo / dropdown
shown = t if len(t) <= 20 else t[:20] + [f"...(+{len(t) - 20})"]
t = f"COMBO{shown}"
hint = {k: meta[k] for k in ("default",) if k in meta}
print(f" {group:8} {name}: {t}" + (f" {hint}" if hint else ""))
outs = node.get("output_name") or node.get("output")
print(f" -> outputs: {outs}")
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
d = sub.add_parser("depth", help="generate a synthetic depth map PNG")
d.add_argument("--kind", choices=["corridor", "room"], default="corridor")
d.add_argument("--size", type=int, default=1024, help="square size if --width/--height omitted")
d.add_argument("--width", type=int, default=0)
d.add_argument("--height", type=int, default=0)
d.add_argument("--bands", type=int, default=0, help="corridor: >0 quantizes into rings (visible ridges); 0=smooth")
d.add_argument("--falloff", type=float, default=2.2, help="corridor: >1 deepens the tunnel / thins the near rim")
d.add_argument("--floor-bias", dest="floor_bias", type=float, default=0.0,
help="corridor: 0..1 lowers the vanishing point (more floor in lower frame)")
d.add_argument("--out", default="sample_depth.png")
d.set_defaults(func=cmd_depth)
pr = sub.add_parser("probe", help="inspect a ComfyUI server's models/nodes")
pr.add_argument("url", help="e.g. http://192.168.1.50:8188")
pr.set_defaults(func=cmd_probe)
r = sub.add_parser("render", help="upload depth + run an SDXL depth-ControlNet render")
r.add_argument("url")
r.add_argument("--depth", default="sample_depth.png")
r.add_argument("--ckpt", required=True, help="checkpoint filename from `probe`")
r.add_argument("--controlnet", required=True, help="depth controlnet filename from `probe`")
r.add_argument("--prompt", default="inside an ancient stone dungeon corridor, wet flagstone, "
"flickering torchlight, volumetric fog, moss, dramatic shadows, "
"dark fantasy, highly detailed, cinematic")
r.add_argument("--negative", default="blurry, text, watermark, modern, people, cartoon, flat lighting")
r.add_argument("--strength", type=float, default=0.6)
r.add_argument("--size", type=int, default=1024)
r.add_argument("--steps", type=int, default=28)
r.add_argument("--cfg", type=float, default=6.0)
r.add_argument("--seed", type=int, default=42)
r.add_argument("--sampler", default="dpmpp_2m")
r.add_argument("--scheduler", default="karras")
r.add_argument("--wait", type=int, default=300)
r.set_defaults(func=cmd_render)
ni = sub.add_parser("nodeinfo", help="dump exact input/output signatures of node classes")
ni.add_argument("url")
ni.add_argument("nodes", nargs="+")
ni.set_defaults(func=cmd_nodeinfo)
z = sub.add_parser("zrender", help="Z-Image-Turbo depth render via the model_patches union patch")
z.add_argument("url")
z.add_argument("--depth", default="sample_depth.png")
z.add_argument("--unet", default="z_image_turbo_bf16.safetensors")
z.add_argument("--clip", default="qwen_3_4b.safetensors")
z.add_argument("--clip-type", dest="clip_type", default="qwen_image",
help="CLIPLoader type for the Qwen3-4B encoder")
z.add_argument("--vae", default="ae.safetensors")
z.add_argument("--patch", default="Z-Image-Turbo-Fun-Controlnet-Union-2.1-lite-2602-8steps.safetensors")
z.add_argument("--prompt", default="inside an ancient stone dungeon corridor, wet mossy flagstone walls, "
"flickering torchlight, volumetric fog, deep shadows receding into "
"darkness, dark fantasy, cinematic, highly detailed")
z.add_argument("--negative", default="")
z.add_argument("--strength", type=float, default=0.65)
z.add_argument("--size", type=int, default=1024, help="square size if --width/--height omitted")
z.add_argument("--width", type=int, default=0)
z.add_argument("--height", type=int, default=0)
z.add_argument("--steps", type=int, default=8)
z.add_argument("--cfg", type=float, default=1.0)
z.add_argument("--shift", type=float, default=3.0)
z.add_argument("--seed", type=int, default=42)
z.add_argument("--prefix", default="zspike", help="SaveImage filename prefix (names the output)")
z.add_argument("--sampler", default="res_multistep")
z.add_argument("--scheduler", default="simple")
z.add_argument("--wait", type=int, default=300)
z.set_defaults(func=cmd_zrender)
args = p.parse_args()
args.func(args)
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

56
tools/pixelart/montage.py Normal file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Tile labeled images into a contact sheet for side-by-side comparison.
Usage: montage.py --out sheet.png --cols 3 [--scale 0.5] path:label path:label ...
"""
import argparse
from PIL import Image, ImageDraw, ImageFont
def load_font(size):
for p in ("/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/Library/Fonts/Arial.ttf"):
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--out", required=True)
ap.add_argument("--cols", type=int, default=3)
ap.add_argument("--scale", type=float, default=1.0)
ap.add_argument("--pad", type=int, default=8)
ap.add_argument("--label-h", type=int, default=24, dest="label_h")
ap.add_argument("items", nargs="+", help="each 'path:label'")
a = ap.parse_args()
imgs, labels = [], []
for it in a.items:
path, _, lab = it.partition(":")
imgs.append(Image.open(path).convert("RGB"))
labels.append(lab)
cw = int(max(i.width for i in imgs) * a.scale)
ch = int(max(i.height for i in imgs) * a.scale)
font = load_font(max(12, int(a.label_h * 0.7)))
cols = a.cols
rows = (len(imgs) + cols - 1) // cols
cellw, cellh = cw + a.pad, ch + a.label_h + a.pad
sheet = Image.new("RGB", (cols * cellw + a.pad, rows * cellh + a.pad), (18, 18, 20))
d = ImageDraw.Draw(sheet)
for k, (im, lab) in enumerate(zip(imgs, labels)):
r, c = divmod(k, cols)
x, y = a.pad + c * cellw, a.pad + r * cellh
d.text((x + 2, y + 4), lab, font=font, fill=(232, 232, 238))
sheet.paste(im.resize((cw, ch), Image.LANCZOS), (x, y + a.label_h))
sheet.save(a.out)
print(f"wrote {a.out} ({sheet.width}x{sheet.height})")
if __name__ == "__main__":
main()

319
tools/pixelart/pixelate.py Normal file
View file

@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""Crunch an image into a limited-palette pixel-art render.
Pipeline: load -> resize-to-target (any dims/aspect, fill/fit/pad + focus)
-> palette-constrained dither (OKLab-perceptual match, linear-light
error diffusion) -> indexed PNG + upscaled preview.
Run with the sibling venv: .venv/bin/python pixelate.py <image> [opts]
LOCKED Primordyn recipe (2026-06-01): the DEFAULTS are the look. Running
pixelate.py render.png --palette primordyn_v2.json
is exactly --size 640x360 --dither floyd --space oklab --mode fill (serpentine,
strength 1.0). Override --size 320x180 for the chunkier cut.
The quality knobs that matter: perceptual matching in OKLab (right color, not
just RGB-nearest), error diffusion done in LINEAR light (gamma-correct, no
muddy darkening), and a choice of diffusion kernels (Floyd-Steinberg, Atkinson,
Jarvis, Stucki, Sierra) or ordered Bayer.
Palettes auto-detected: JSON ({"colors":[[r,g,b],...]} or bare list), GIMP .gpl,
JASC/Aseprite .pal, hex lists (.hex/.txt), Paint.NET, .png swatch, or adaptive:N.
"""
import argparse
import json
import os
import re
import sys
import numpy as np
from PIL import Image
# --------------------------------------------------------------------------- #
# Palette loading (format-agnostic)
# --------------------------------------------------------------------------- #
def _hex_to_rgb(h):
h = h.lstrip("#")
if h.lower().startswith("0x"):
h = h[2:]
if len(h) == 8: # 8 digits: Paint.NET AARRGGBB -> drop alpha
h = h[2:]
return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
def _parse_text_palette(text):
lines = text.splitlines()
first = next((ln.strip() for ln in lines if ln.strip()), "")
if first.lower().startswith("gimp palette"): # GIMP .gpl
out = []
for ln in lines[1:]:
s = ln.strip()
if not s or s.startswith("#") or s.lower().startswith(("name:", "columns:")):
continue
parts = s.split()
if len(parts) >= 3 and all(p.isdigit() for p in parts[:3]):
out.append(tuple(int(p) for p in parts[:3]))
return out
if first.upper().startswith("JASC-PAL"): # JASC / Aseprite .pal
return [tuple(int(p) for p in ln.split()[:3])
for ln in lines[3:] if len(ln.split()) >= 3 and ln.split()[0].isdigit()]
hexes = re.findall(r"(?:#|0x)?([0-9a-fA-F]{8}|[0-9a-fA-F]{6})\b", text)
if hexes and ("#" in text or "0x" in text.lower() or any(re.search("[a-fA-F]", h) for h in hexes)):
return [_hex_to_rgb(h) for h in hexes]
out = [] # decimal triples
for ln in lines:
s = ln.strip()
if not s or s.startswith((";", "//")):
continue
nums = [p for p in re.split(r"[\s,]+", s) if p.isdigit()]
if len(nums) >= 3:
out.append(tuple(int(p) for p in nums[:3]))
return out
def load_palette(spec, ref_image=None):
if spec.startswith("adaptive:"):
n = int(spec.split(":", 1)[1])
q = ref_image.convert("RGB").quantize(colors=n, method=Image.MEDIANCUT)
return np.array(q.getpalette()[: n * 3], dtype=np.float32).reshape(-1, 3)
raw = open(spec, "rb").read()
if spec.lower().endswith(".json") or raw.lstrip()[:1] in (b"{", b"["):
try:
doc = json.loads(raw.decode("utf-8"))
colors = doc.get("colors", doc) if isinstance(doc, dict) else doc
triples = [tuple(c[:3]) for c in colors if isinstance(c, (list, tuple)) and len(c) >= 3]
if triples:
return np.array(triples, dtype=np.float32)
except (ValueError, UnicodeDecodeError):
pass
if raw[:8] == b"\x89PNG\r\n\x1a\n":
arr = np.array(Image.open(spec).convert("RGB")).reshape(-1, 3)
return np.array(list(dict.fromkeys(map(tuple, arr.tolist()))), dtype=np.float32)
colors = _parse_text_palette(raw.decode("utf-8", "replace"))
if not colors:
sys.exit(f"!! could not parse any colors from {spec}")
return np.array(colors, dtype=np.float32)
# --------------------------------------------------------------------------- #
# Color spaces. Matching happens in a perceptual space; diffusion in linear.
# --------------------------------------------------------------------------- #
def srgb_to_linear(c):
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def linear_to_srgb(c):
return np.where(c <= 0.0031308, c * 12.92, 1.055 * np.clip(c, 0, None) ** (1 / 2.4) - 0.055)
def linear_to_oklab(c):
r, g, b = c[..., 0], c[..., 1], c[..., 2]
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
l_, m_, s_ = np.cbrt(l), np.cbrt(m), np.cbrt(s)
return np.stack([
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
], -1)
def to_match_fn(space):
return {"oklab": linear_to_oklab, "linear": lambda c: c, "srgb": linear_to_srgb}[space]
# --------------------------------------------------------------------------- #
# Nearest-color LUT over the linear cube (built via the perceptual metric).
# Lets the sequential error-diffusion loop do O(1) lookups instead of an
# argmin-over-256 per pixel.
# --------------------------------------------------------------------------- #
def build_lut(palette_match, to_match, grid=64):
axis = np.linspace(0.0, 1.0, grid, dtype=np.float32)
gx, gy, gz = np.meshgrid(axis, axis, axis, indexing="ij")
pts_lin = np.stack([gx, gy, gz], -1).reshape(-1, 3)
pts = to_match(pts_lin)
out = np.empty(pts.shape[0], dtype=np.int32)
step = 4096
for s in range(0, pts.shape[0], step):
d = ((pts[s:s + step, None, :] - palette_match[None, :, :]) ** 2).sum(2)
out[s:s + step] = d.argmin(1)
return out.reshape(grid, grid, grid)
# kernel = (divisor, [(dx, dy, weight), ...]); error spread to *future* pixels.
KERNELS = {
"floyd": (16, [(1, 0, 7), (-1, 1, 3), (0, 1, 5), (1, 1, 1)]),
"atkinson": (8, [(1, 0, 1), (2, 0, 1), (-1, 1, 1), (0, 1, 1), (1, 1, 1), (0, 2, 1)]),
"jjn": (48, [(1, 0, 7), (2, 0, 5), (-2, 1, 3), (-1, 1, 5), (0, 1, 7), (1, 1, 5),
(2, 1, 3), (-2, 2, 1), (-1, 2, 3), (0, 2, 5), (1, 2, 3), (2, 2, 1)]),
"stucki": (42, [(1, 0, 8), (2, 0, 4), (-2, 1, 2), (-1, 1, 4), (0, 1, 8), (1, 1, 4),
(2, 1, 2), (-2, 2, 1), (-1, 2, 2), (0, 2, 4), (1, 2, 2), (2, 2, 1)]),
"sierra": (32, [(1, 0, 5), (2, 0, 3), (-2, 1, 2), (-1, 1, 4), (0, 1, 5), (1, 1, 4),
(2, 1, 2), (-1, 2, 2), (0, 2, 3), (1, 2, 2)]),
}
BAYER = {
4: np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]], np.float32) / 16.0 - 0.5,
8: (np.array([[0, 32, 8, 40, 2, 34, 10, 42], [48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38], [60, 28, 52, 20, 62, 30, 54, 22],
[3, 35, 11, 43, 1, 33, 9, 41], [51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37], [63, 31, 55, 23, 61, 29, 53, 21]], np.float32) / 64.0 - 0.5),
}
def _lut_lookup(lin_vals, lut, gscale):
gi = np.clip((lin_vals * gscale + 0.5).astype(np.int32), 0, gscale)
return lut[gi[..., 0], gi[..., 1], gi[..., 2]]
def error_diffuse(lin, lut, palette_lin, kernel, serpentine, strength):
h, w, _ = lin.shape
work = lin.copy()
idxmap = np.zeros((h, w), np.int32)
div, taps = kernel
gscale = lut.shape[0] - 1
for y in range(h):
rev = serpentine and (y & 1)
xr = range(w - 1, -1, -1) if rev else range(w)
for x in xr:
v = work[y, x]
gi = np.clip((v * gscale + 0.5).astype(np.int32), 0, gscale)
i = int(lut[gi[0], gi[1], gi[2]])
idxmap[y, x] = i
err = (v - palette_lin[i]) * strength
for dx, dy, wt in taps:
nx, ny = x + (-dx if rev else dx), y + dy
if 0 <= nx < w and 0 <= ny < h:
work[ny, nx] += err * (wt / div)
return idxmap
def ordered(lin, lut, bayer, strength):
h, w, _ = lin.shape
tile = np.tile(bayer, (h // bayer.shape[0] + 1, w // bayer.shape[1] + 1))[:h, :w]
v = np.clip(lin + tile[..., None] * strength, 0.0, 1.0)
return _lut_lookup(v, lut, lut.shape[0] - 1)
# --------------------------------------------------------------------------- #
# Resize to target dimensions (ported from convert_image.py's crop logic)
# --------------------------------------------------------------------------- #
def resize_to_target(img, tw, th, mode, fx, fy, resample):
sw, sh = img.size
sa, ta = sw / sh, tw / th
if mode == "pad":
scale = min(tw / sw, th / sh)
nw, nh = max(1, round(sw * scale)), max(1, round(sh * scale))
canvas = Image.new("RGB", (tw, th), (0, 0, 0))
canvas.paste(img.resize((nw, nh), resample), ((tw - nw) // 2, (th - nh) // 2))
return canvas
if mode == "fit": # scale to cover, crop minimal
scale = max(tw / sw, th / sh)
nw, nh = max(tw, round(sw * scale)), max(th, round(sh * scale))
scaled = img.resize((nw, nh), resample)
x, y = round((nw - tw) * fx), round((nh - th) * fy)
return scaled.crop((x, y, x + tw, y + th))
# fill (default): crop to target aspect, then scale
if sa > ta:
cw, ch = round(sh * ta), sh
else:
cw, ch = sw, round(sw / ta)
x, y = round((sw - cw) * fx), round((sh - ch) * fy)
return img.crop((x, y, x + cw, y + ch)).resize((tw, th), resample)
FOCI = {"center": (.5, .5), "top": (.5, 0), "bottom": (.5, 1), "left": (0, .5), "right": (1, .5),
"top-left": (0, 0), "top-right": (1, 0), "bottom-left": (0, 1), "bottom-right": (1, 1)}
PRESETS = {"primordyn": (640, 360), "640p": (640, 360), "small": (320, 180), "320p": (320, 180),
"vic20": (176, 184), "c64": (320, 200)}
def parse_size(s):
if s.lower() in PRESETS:
return PRESETS[s.lower()]
m = re.fullmatch(r"(\d+)\s*[xX]\s*(\d+)", s.strip())
if not m:
sys.exit(f"!! --size must be WxH or one of {list(PRESETS)}; got '{s}'")
return int(m.group(1)), int(m.group(2))
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("image")
p.add_argument("--palette", default="primordyn_v2.json", help="palette file or adaptive:N")
p.add_argument("--size", default="640x360", help=f"WxH or preset {list(PRESETS)}")
p.add_argument("--mode", choices=["fill", "fit", "pad"], default="fill", help="aspect handling")
p.add_argument("--focus", default="center", help=f"crop focus: {list(FOCI)} or 'fx,fy' (0..1)")
p.add_argument("--dither", choices=list(KERNELS) + ["bayer4", "bayer8", "none"], default="floyd")
p.add_argument("--space", choices=["oklab", "linear", "srgb"], default="oklab", help="color-match space")
p.add_argument("--strength", type=float, default=1.0, help="dither amount (error-diffusion fraction / bayer scale)")
p.add_argument("--no-serpentine", action="store_true")
p.add_argument("--resample", choices=["lanczos", "box", "bilinear", "hamming", "nearest"], default="lanczos")
p.add_argument("--lut", type=int, default=64, help="nearest-color LUT resolution per axis")
p.add_argument("--preview-scale", type=int, default=2, help="nearest-neighbor upscale for the preview (0=off)")
p.add_argument("--out", default=None)
args = p.parse_args()
tw, th = parse_size(args.size)
fx, fy = FOCI.get(args.focus.lower(), None) or tuple(float(v) for v in args.focus.split(","))
rfilt = {"lanczos": Image.LANCZOS, "box": Image.BOX, "bilinear": Image.BILINEAR,
"hamming": Image.HAMMING, "nearest": Image.NEAREST}[args.resample]
src = Image.open(args.image).convert("RGB")
small = resize_to_target(src, tw, th, args.mode, fx, fy, rfilt)
palette_srgb = load_palette(args.palette, ref_image=small)
pal01 = palette_srgb / 255.0
palette_lin = srgb_to_linear(pal01).astype(np.float32)
to_match = to_match_fn(args.space)
palette_match = to_match(palette_lin).astype(np.float32)
lin = srgb_to_linear(np.asarray(small, np.float32) / 255.0)
if args.dither in KERNELS:
lut = build_lut(palette_match, to_match, args.lut)
idxmap = error_diffuse(lin, lut, palette_lin, KERNELS[args.dither],
not args.no_serpentine, args.strength)
elif args.dither.startswith("bayer"):
lut = build_lut(palette_match, to_match, args.lut)
idxmap = ordered(lin, lut, BAYER[int(args.dither[5:])], args.strength)
else: # none -> hard nearest, vectorized
flat = to_match(lin).reshape(-1, 3)
out = np.empty(flat.shape[0], np.int32)
for s in range(0, flat.shape[0], 65536):
d = ((flat[s:s + 65536, None, :] - palette_match[None, :, :]) ** 2).sum(2)
out[s:s + 65536] = d.argmin(1)
idxmap = out.reshape(th, tw)
# --- outputs: true indexed PNG (target res) + upscaled RGB preview ---
base = os.path.splitext(args.out or args.image)[0]
lo_path = args.out or f"{base}_{tw}x{th}_{args.dither}.png"
idx_img = Image.frombytes("P", (tw, th), idxmap.astype(np.uint8).tobytes())
flat_pal = palette_srgb.astype(np.uint8).reshape(-1).tolist()
idx_img.putpalette(flat_pal + [0] * (768 - len(flat_pal)))
idx_img.save(lo_path)
used = len(np.unique(idxmap))
print(f"palette: {len(palette_srgb)} colors ({args.palette}) | used: {used}")
print(f"target: {tw}x{th} via {args.mode}/{args.resample} focus={args.focus}")
print(f"dither: {args.dither} | match-space: {args.space} | strength: {args.strength}"
f"{'' if args.no_serpentine or args.dither in ('none',) or args.dither.startswith('bayer') else ' | serpentine'}")
print(f"wrote {lo_path} (indexed PNG)")
if args.preview_scale > 1:
up_path = os.path.splitext(lo_path)[0] + f"_x{args.preview_scale}.png"
idx_img.convert("RGB").resize((tw * args.preview_scale, th * args.preview_scale),
Image.NEAREST).save(up_path)
print(f"wrote {up_path} (preview)")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

105
tools/serve.py Normal file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Dev server for the reikhelm playground, headless depth/FPV export, and the
pre-baked first-person explorer. Pure stdlib no deps.
python3 tools/serve.py # serve reikhelm-web on :8000
Three things, one server:
GET /<path> static file from the web root (reikhelm-web/)
GET /out/<path> static file from the output tree (tools/out/) lets the
explorer load baked atlas frames + manifests
POST /save/<path>.png write the raw request body (a PNG) under tools/out/<path>;
sub-dirs allowed and created (e.g. atlas/7/r3_N.png)
{"ok": true, "path": "...", "bytes": N}
All generated images live under tools/out/ (git-ignored, organized):
out/depth/ top-down + one-off depth maps out/fpv/ first-person stills
out/atlas/<seed>/ a baked explorable dungeon (frames + manifest.json)
"""
import argparse
import json
import os
import sys
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def safe_join(base, rel):
"""Join rel under base, collapsing any '..' so it can't escape base."""
rel = os.path.normpath("/" + rel).lstrip("/")
return os.path.join(base, rel)
class Handler(SimpleHTTPRequestHandler):
out_dir = "."
extensions_map = {
**SimpleHTTPRequestHandler.extensions_map,
".js": "text/javascript",
".mjs": "text/javascript",
".wasm": "application/wasm",
".json": "application/json",
}
# Route /out/<path> to the output tree; everything else from the web root (cwd).
def translate_path(self, path):
p = unquote(path.split("?", 1)[0].split("#", 1)[0])
if p == "/out" or p.startswith("/out/"):
return safe_join(self.out_dir, p[len("/out/"):]) if p != "/out" else self.out_dir
return super().translate_path(path)
def end_headers(self):
self.send_header("Cache-Control", "no-store") # dev server: always serve fresh modules
super().end_headers()
def do_POST(self):
if not self.path.startswith("/save/"):
self.send_error(404, "only /save/<path>.png is supported")
return
rel = unquote(self.path[len("/save/"):])
if not (rel.endswith(".png") or rel.endswith(".json")):
self.send_error(400, "path must end in .png or .json")
return
dest = safe_join(self.out_dir, rel)
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(body)
payload = json.dumps({"ok": True, "path": dest, "bytes": len(body)}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
sys.stderr.write(f"saved {os.path.relpath(dest, REPO)} ({len(body)} bytes)\n")
def log_message(self, fmt, *args): # quiet GETs; POSTs/errors print via do_POST/send_error
pass
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", type=int, default=8000)
ap.add_argument("--root", default=os.path.join(REPO, "reikhelm-web"), help="static web root")
ap.add_argument("--out", default=os.path.join(REPO, "tools", "out"), help="output tree (served at /out, target of /save)")
args = ap.parse_args()
Handler.out_dir = os.path.abspath(args.out)
os.makedirs(Handler.out_dir, exist_ok=True)
root = os.path.abspath(args.root)
os.chdir(root)
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
print(f"serving {os.path.relpath(root, REPO)} at http://127.0.0.1:{args.port}")
print(f" /out/ → {os.path.relpath(Handler.out_dir, REPO)} POST /save/<path>.png writes there")
try:
httpd.serve_forever()
except KeyboardInterrupt:
httpd.shutdown()
if __name__ == "__main__":
main()