Compare commits
No commits in common. "ed00e310090b5952cfd399ec79a516b9b038f404" and "a1d2a63937ba0f90c9784b95cf636cac720116d9" have entirely different histories.
ed00e31009
...
a1d2a63937
42 changed files with 49 additions and 5575 deletions
|
|
@ -1,118 +0,0 @@
|
||||||
# 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.2–1.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.3–1.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.6–0.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 (80–250 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
|
|
||||||
|
|
@ -24,9 +24,6 @@ const fn glyph(tile: Tile) -> char {
|
||||||
Tile::Wall => '#',
|
Tile::Wall => '#',
|
||||||
Tile::Floor => '.',
|
Tile::Floor => '.',
|
||||||
Tile::Door => '+',
|
Tile::Door => '+',
|
||||||
Tile::Pillar => 'o',
|
|
||||||
Tile::Water => '~',
|
|
||||||
Tile::Lava => '!',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +82,6 @@ mod tests {
|
||||||
tiles,
|
tiles,
|
||||||
regions: Vec::new(),
|
regions: Vec::new(),
|
||||||
graph: ConnGraph::new(),
|
graph: ConnGraph::new(),
|
||||||
entities: Vec::new(),
|
|
||||||
seed: 0,
|
seed: 0,
|
||||||
width: 3,
|
width: 3,
|
||||||
height: 3,
|
height: 3,
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
//! 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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
pub mod ascii;
|
pub mod ascii;
|
||||||
pub mod blackboard;
|
pub mod blackboard;
|
||||||
pub mod entity;
|
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod grid;
|
pub mod grid;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::entity::Entity;
|
|
||||||
use crate::grid::Grid;
|
use crate::grid::Grid;
|
||||||
use crate::region::{ConnGraph, Region};
|
use crate::region::{ConnGraph, Region};
|
||||||
|
|
||||||
|
|
@ -30,15 +29,6 @@ pub enum Tile {
|
||||||
Floor,
|
Floor,
|
||||||
/// A doorway between regions (walkable, but semantically a threshold).
|
/// A doorway between regions (walkable, but semantically a threshold).
|
||||||
Door,
|
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
|
/// The generator's output: what occupies each cell, the semantic regions, and
|
||||||
|
|
@ -60,9 +50,6 @@ pub struct Map {
|
||||||
pub regions: Vec<Region>,
|
pub regions: Vec<Region>,
|
||||||
/// How regions connect (each edge is a door/corridor link).
|
/// How regions connect (each edge is a door/corridor link).
|
||||||
pub graph: ConnGraph,
|
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.
|
/// The seed that produced this map, for reproducibility.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Map width in cells.
|
/// Map width in cells.
|
||||||
|
|
@ -74,9 +61,8 @@ pub struct Map {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::entity::{Entity, EntityKind};
|
|
||||||
use crate::geometry::{Point, Rect};
|
use crate::geometry::{Point, Rect};
|
||||||
use crate::region::{RegionId, RegionKind, RegionTheme};
|
use crate::region::{RegionId, RegionKind};
|
||||||
|
|
||||||
/// Builds a tiny but structurally complete `Map` for serde exercises.
|
/// Builds a tiny but structurally complete `Map` for serde exercises.
|
||||||
fn sample_map() -> Map {
|
fn sample_map() -> Map {
|
||||||
|
|
@ -89,7 +75,6 @@ mod tests {
|
||||||
kind: RegionKind::Room,
|
kind: RegionKind::Room,
|
||||||
bounds: Rect::new(1, 0, 1, 2),
|
bounds: Rect::new(1, 0, 1, 2),
|
||||||
cells: vec![Point::new(1, 0), Point::new(1, 1)],
|
cells: vec![Point::new(1, 0), Point::new(1, 1)],
|
||||||
theme: Some(RegionTheme::Vault),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut graph = ConnGraph::new();
|
let mut graph = ConnGraph::new();
|
||||||
|
|
@ -100,7 +85,6 @@ mod tests {
|
||||||
tiles,
|
tiles,
|
||||||
regions: vec![region],
|
regions: vec![region],
|
||||||
graph,
|
graph,
|
||||||
entities: vec![Entity::new(EntityKind::Treasure, Point::new(1, 0))],
|
|
||||||
seed: 0xDEAD_BEEF,
|
seed: 0xDEAD_BEEF,
|
||||||
width: 3,
|
width: 3,
|
||||||
height: 2,
|
height: 2,
|
||||||
|
|
@ -123,7 +107,6 @@ mod tests {
|
||||||
// Spot-check the semantic layer, which does implement `Eq`, directly.
|
// Spot-check the semantic layer, which does implement `Eq`, directly.
|
||||||
assert_eq!(original.regions, restored.regions);
|
assert_eq!(original.regions, restored.regions);
|
||||||
assert_eq!(original.graph, restored.graph);
|
assert_eq!(original.graph, restored.graph);
|
||||||
assert_eq!(original.entities, restored.entities);
|
|
||||||
assert_eq!(original.seed, restored.seed);
|
assert_eq!(original.seed, restored.seed);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(original.width, original.height),
|
(original.width, original.height),
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@
|
||||||
//! resulting [`Map`] is identical for a given seed with or without snapshots.
|
//! resulting [`Map`] is identical for a given seed with or without snapshots.
|
||||||
|
|
||||||
use crate::blackboard::Blackboard;
|
use crate::blackboard::Blackboard;
|
||||||
use crate::entity::Entity;
|
|
||||||
use crate::geometry::{Point, Rect};
|
use crate::geometry::{Point, Rect};
|
||||||
use crate::grid::Grid;
|
use crate::grid::Grid;
|
||||||
use crate::map::{Map, Tile};
|
use crate::map::{Map, Tile};
|
||||||
|
|
@ -75,7 +74,6 @@ impl GenContext {
|
||||||
kind,
|
kind,
|
||||||
bounds,
|
bounds,
|
||||||
cells,
|
cells,
|
||||||
theme: None,
|
|
||||||
});
|
});
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
@ -233,18 +231,10 @@ 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 {
|
let map = Map {
|
||||||
tiles: ctx.tiles,
|
tiles: ctx.tiles,
|
||||||
regions: ctx.regions,
|
regions: ctx.regions,
|
||||||
graph: ctx.graph,
|
graph: ctx.graph,
|
||||||
entities,
|
|
||||||
seed,
|
seed,
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
|
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -455,7 +455,6 @@ mod tests {
|
||||||
kind: RegionKind::Corridor,
|
kind: RegionKind::Corridor,
|
||||||
bounds: Rect::new(30, 0, 4, 1),
|
bounds: Rect::new(30, 0, 4, 1),
|
||||||
cells: vec![Point::new(30, 0)],
|
cells: vec![Point::new(30, 0)],
|
||||||
theme: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
run(&mut ctx, ConnectConfig::default(), 0xBEEF);
|
run(&mut ctx, ConnectConfig::default(), 0xBEEF);
|
||||||
|
|
|
||||||
|
|
@ -121,31 +121,15 @@ impl Pass for CorridorCarver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a corridor anchor for the `Room` region with id `index`, or [`None`]
|
/// Returns the center of the `Room` region with id `index`, or [`None`] if the
|
||||||
/// if the id is out of range or names a non-Room region.
|
/// id is out of range or names a non-Room region.
|
||||||
///
|
///
|
||||||
/// The anchor is the room's carved cell **nearest its bounding-box center**. For
|
/// Endpoint ids come from `MstConnect`, which only ever links real rooms, so in
|
||||||
/// a convex, centered room (rectangle, octagon, ellipse, plus) the center cell is
|
/// the assembled recipe this always resolves; the guard simply keeps the pass
|
||||||
/// itself carved, so this is exactly `bounds.center()` — i.e. output-preserving.
|
/// total against a hand-built or malformed graph.
|
||||||
/// 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> {
|
fn room_center(ctx: &GenContext, index: crate::region::RegionId) -> Option<Point> {
|
||||||
let region = ctx.regions.get(index.0)?;
|
let region = ctx.regions.get(index.0)?;
|
||||||
if region.kind != RegionKind::Room {
|
(region.kind == RegionKind::Room).then(|| region.bounds.center())
|
||||||
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)
|
/// Carves [`Floor`](Tile::Floor) at `p` (a bounds-safe no-op if `p` is off-grid)
|
||||||
|
|
|
||||||
|
|
@ -325,7 +325,6 @@ mod tests {
|
||||||
kind,
|
kind,
|
||||||
bounds,
|
bounds,
|
||||||
cells,
|
cells,
|
||||||
theme: None,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
//! 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(¢ers) {
|
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -29,9 +29,6 @@ pub use bsp::{BspConfig, BspPartition};
|
||||||
pub mod room;
|
pub mod room;
|
||||||
pub use room::{RoomCarver, RoomConfig, ShapeWeights};
|
pub use room::{RoomCarver, RoomConfig, ShapeWeights};
|
||||||
|
|
||||||
pub mod cave;
|
|
||||||
pub use cave::{CaveConfig, CaveShaper};
|
|
||||||
|
|
||||||
pub mod connect;
|
pub mod connect;
|
||||||
pub use connect::{ConnectConfig, MstConnect};
|
pub use connect::{ConnectConfig, MstConnect};
|
||||||
|
|
||||||
|
|
@ -40,15 +37,3 @@ pub use corridor::CorridorCarver;
|
||||||
|
|
||||||
pub mod door;
|
pub mod door;
|
||||||
pub use door::{DoorConfig, DoorPlacer};
|
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};
|
|
||||||
|
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -616,7 +616,6 @@ mod tests {
|
||||||
kind: RegionKind::Corridor,
|
kind: RegionKind::Corridor,
|
||||||
bounds: corridor_bounds,
|
bounds: corridor_bounds,
|
||||||
cells: vec![Point::new(16, 0)],
|
cells: vec![Point::new(16, 0)],
|
||||||
theme: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
run(&mut ctx, RoomConfig::default(), 99);
|
run(&mut ctx, RoomConfig::default(), 99);
|
||||||
|
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
//! 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 1–5 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(¢ers) {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,29 +6,17 @@
|
||||||
//! order:
|
//! order:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! BspPartition → RoomCarver → CaveShaper → MstConnect → CorridorCarver
|
//! BspPartition → RoomCarver → MstConnect → CorridorCarver → DoorPlacer
|
||||||
//! → DoorPlacer → PoolDecorator → PillarPlacer → RoomThemer → EntityPlacer
|
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
//! 1. [`BspPartition`] cuts the canvas into leaf rectangles (one placeholder
|
||||||
//! Room region per leaf).
|
//! Room region per leaf).
|
||||||
//! 2. [`RoomCarver`] carves an actual room inside each leaf, after which
|
//! 2. [`RoomCarver`] carves an actual room inside each leaf.
|
||||||
//! [`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
|
//! 3. [`MstConnect`] plans which rooms link up (a minimum spanning tree, plus
|
||||||
//! optional loop edges).
|
//! optional loop edges).
|
||||||
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
|
//! 4. [`CorridorCarver`] carves an L-shaped floor corridor per planned edge.
|
||||||
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
|
//! 5. [`DoorPlacer`] marks a fraction of the room↔corridor pierce points as
|
||||||
//! doors (purely cosmetic — connectivity is already established by the floor).
|
//! 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)
|
//! ## Why validation matters (spec §8)
|
||||||
//!
|
//!
|
||||||
|
|
@ -50,9 +38,8 @@ use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::pass::Pipeline;
|
use crate::pass::Pipeline;
|
||||||
use crate::passes::{
|
use crate::passes::{
|
||||||
BspConfig, BspPartition, CaveConfig, CaveShaper, ConnectConfig, CorridorCarver, DoorConfig,
|
BspConfig, BspPartition, ConnectConfig, CorridorCarver, DoorConfig, DoorPlacer, MstConnect,
|
||||||
DoorPlacer, EntityConfig, EntityPlacer, MstConnect, PillarConfig, PillarPlacer, PoolConfig,
|
RoomCarver, RoomConfig, ShapeWeights,
|
||||||
PoolDecorator, RoomCarver, RoomConfig, RoomThemer, ShapeWeights, ThemeConfig,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Configuration for the [`dungeon`] recipe.
|
/// Configuration for the [`dungeon`] recipe.
|
||||||
|
|
@ -71,21 +58,10 @@ pub struct DungeonConfig {
|
||||||
pub bsp: BspConfig,
|
pub bsp: BspConfig,
|
||||||
/// How a room is carved inside each leaf.
|
/// How a room is carved inside each leaf.
|
||||||
pub rooms: RoomConfig,
|
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.
|
/// How rooms are linked into a connectivity graph.
|
||||||
pub connect: ConnectConfig,
|
pub connect: ConnectConfig,
|
||||||
/// How room↔corridor pierce points are marked as doors.
|
/// How room↔corridor pierce points are marked as doors.
|
||||||
pub doors: DoorConfig,
|
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 {
|
impl Default for DungeonConfig {
|
||||||
|
|
@ -116,15 +92,10 @@ impl Default for DungeonConfig {
|
||||||
margin: 1,
|
margin: 1,
|
||||||
shapes: ShapeWeights::varied(),
|
shapes: ShapeWeights::varied(),
|
||||||
},
|
},
|
||||||
caves: CaveConfig::default(),
|
|
||||||
connect: ConnectConfig {
|
connect: ConnectConfig {
|
||||||
extra_edge_ratio: 0.30,
|
extra_edge_ratio: 0.30,
|
||||||
},
|
},
|
||||||
doors: DoorConfig::default(),
|
doors: DoorConfig::default(),
|
||||||
pools: PoolConfig::default(),
|
|
||||||
pillars: PillarConfig::default(),
|
|
||||||
themes: ThemeConfig::default(),
|
|
||||||
entities: EntityConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,14 +204,9 @@ pub fn dungeon(cfg: DungeonConfig) -> Result<Pipeline, ConfigError> {
|
||||||
let pipeline = Pipeline::new(cfg.width, cfg.height)
|
let pipeline = Pipeline::new(cfg.width, cfg.height)
|
||||||
.then(BspPartition::new(cfg.bsp))
|
.then(BspPartition::new(cfg.bsp))
|
||||||
.then(RoomCarver::new(cfg.rooms))
|
.then(RoomCarver::new(cfg.rooms))
|
||||||
.then(CaveShaper::new(cfg.caves))
|
|
||||||
.then(MstConnect::new(cfg.connect))
|
.then(MstConnect::new(cfg.connect))
|
||||||
.then(CorridorCarver::new())
|
.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)
|
Ok(pipeline)
|
||||||
}
|
}
|
||||||
|
|
@ -253,18 +219,13 @@ mod tests {
|
||||||
use crate::region::RegionKind;
|
use crate::region::RegionKind;
|
||||||
use std::collections::{BTreeSet, VecDeque};
|
use std::collections::{BTreeSet, VecDeque};
|
||||||
|
|
||||||
/// The pass names in pipeline order, used to check snapshot labels.
|
/// The five pass names in pipeline order, used to check snapshot labels.
|
||||||
const PASS_NAMES: [&str; 10] = [
|
const PASS_NAMES: [&str; 5] = [
|
||||||
"bsp_partition",
|
"bsp_partition",
|
||||||
"room_carver",
|
"room_carver",
|
||||||
"cave_shaper",
|
|
||||||
"mst_connect",
|
"mst_connect",
|
||||||
"corridor_carver",
|
"corridor_carver",
|
||||||
"door_placer",
|
"door_placer",
|
||||||
"pool_decorator",
|
|
||||||
"pillar_placer",
|
|
||||||
"room_themer",
|
|
||||||
"entity_placer",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)?
|
/// Is `p` a walkable cell (Floor or Door are both walkable; Wall is not)?
|
||||||
|
|
@ -330,13 +291,13 @@ mod tests {
|
||||||
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
|
let (snapped, snapshots) = dungeon(cfg).unwrap().run_with_snapshots(7);
|
||||||
|
|
||||||
assert_eq!(plain, snapped, "snapshotting must not change the map");
|
assert_eq!(plain, snapped, "snapshotting must not change the map");
|
||||||
assert_eq!(snapshots.len(), 10, "one snapshot per pass (ten passes)");
|
assert_eq!(snapshots.len(), 5, "one snapshot per pass (five passes)");
|
||||||
|
|
||||||
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
|
let labels: Vec<&str> = snapshots.iter().map(|s| s.label.as_str()).collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
labels,
|
labels,
|
||||||
PASS_NAMES.to_vec(),
|
PASS_NAMES.to_vec(),
|
||||||
"snapshot labels must be the ten pass names in pipeline order"
|
"snapshot labels must be the five pass names in pipeline order"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -622,93 +583,7 @@ mod tests {
|
||||||
assert!(total_full > 0, "door_chance=1.0 must place doors");
|
assert!(total_full > 0, "door_chance=1.0 must place doors");
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Test 7: entities ---------------------------------------------------
|
// --- Test 7: eyeball render ---------------------------------------------
|
||||||
|
|
||||||
/// 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
|
/// Prints one dungeon for eyeball confirmation under `--nocapture`. Not an
|
||||||
/// assertion (beyond non-empty output); it documents what the recipe makes.
|
/// assertion (beyond non-empty output); it documents what the recipe makes.
|
||||||
|
|
|
||||||
|
|
@ -40,65 +40,6 @@ pub enum RegionKind {
|
||||||
Corridor,
|
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.
|
/// 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
|
/// `bounds` is the axis-aligned bounding box; `cells` is the exact set of
|
||||||
|
|
@ -115,10 +56,6 @@ pub struct Region {
|
||||||
pub bounds: Rect,
|
pub bounds: Rect,
|
||||||
/// The exact cells this region occupies, in the order they were added.
|
/// The exact cells this region occupies, in the order they were added.
|
||||||
pub cells: Vec<Point>,
|
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`].
|
/// A connection between two regions in the [`ConnGraph`].
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,6 @@ fn color_of(tile: Tile) -> Color {
|
||||||
Tile::Wall => Color::new(0.07, 0.07, 0.09, 1.0),
|
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::Floor => Color::new(0.80, 0.76, 0.66, 1.0),
|
||||||
Tile::Door => GOLD,
|
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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,12 @@
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use wasm_bindgen::prelude::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
use reikhelm_core::entity::{Entity, EntityKind};
|
|
||||||
use reikhelm_core::geometry::Point;
|
use reikhelm_core::geometry::Point;
|
||||||
use reikhelm_core::grid::Grid;
|
use reikhelm_core::grid::Grid;
|
||||||
use reikhelm_core::map::{Map, Tile};
|
use reikhelm_core::map::{Map, Tile};
|
||||||
use reikhelm_core::pass::Snapshot;
|
use reikhelm_core::pass::Snapshot;
|
||||||
use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig};
|
use reikhelm_core::recipes::dungeon::{dungeon, DungeonConfig};
|
||||||
use reikhelm_core::region::{Edge, Region, RegionKind, RegionTheme};
|
use reikhelm_core::region::{Edge, Region, RegionKind};
|
||||||
|
|
||||||
/// The integer tile code sent on the wire. Renderer concern; not a core type.
|
/// The integer tile code sent on the wire. Renderer concern; not a core type.
|
||||||
fn tile_code(tile: Tile) -> u8 {
|
fn tile_code(tile: Tile) -> u8 {
|
||||||
|
|
@ -37,9 +36,6 @@ fn tile_code(tile: Tile) -> u8 {
|
||||||
Tile::Wall => 0,
|
Tile::Wall => 0,
|
||||||
Tile::Floor => 1,
|
Tile::Floor => 1,
|
||||||
Tile::Door => 2,
|
Tile::Door => 2,
|
||||||
Tile::Pillar => 3,
|
|
||||||
Tile::Water => 4,
|
|
||||||
Tile::Lava => 5,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,23 +47,6 @@ 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.
|
/// A tile grid flattened to row-major rows of integer codes.
|
||||||
fn tiles_to_rows(tiles: &Grid<Tile>) -> Vec<Vec<u8>> {
|
fn tiles_to_rows(tiles: &Grid<Tile>) -> Vec<Vec<u8>> {
|
||||||
let w = tiles.width() as i32;
|
let w = tiles.width() as i32;
|
||||||
|
|
@ -90,17 +69,13 @@ struct RectDto {
|
||||||
h: i32,
|
h: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A semantic region on the wire: id, kind tag, bounds, `[x, y]` cells, and an
|
/// A semantic region on the wire: id, kind tag, bounds, and `[x, y]` cells.
|
||||||
/// optional theme tag (present once a themer has run — `None` for corridors,
|
|
||||||
/// unthemed rooms, and the pre-themer snapshot stages).
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct RegionDto {
|
struct RegionDto {
|
||||||
id: usize,
|
id: usize,
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
bounds: RectDto,
|
bounds: RectDto,
|
||||||
cells: Vec<[i32; 2]>,
|
cells: Vec<[i32; 2]>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
theme: Option<&'static str>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Region> for RegionDto {
|
impl From<&Region> for RegionDto {
|
||||||
|
|
@ -115,33 +90,6 @@ impl From<&Region> for RegionDto {
|
||||||
h: r.bounds.h,
|
h: r.bounds.h,
|
||||||
},
|
},
|
||||||
cells: r.cells.iter().map(|p| [p.x, p.y]).collect(),
|
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],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +142,6 @@ struct Envelope {
|
||||||
tiles: Vec<Vec<u8>>,
|
tiles: Vec<Vec<u8>>,
|
||||||
regions: Vec<RegionDto>,
|
regions: Vec<RegionDto>,
|
||||||
edges: Vec<EdgeDto>,
|
edges: Vec<EdgeDto>,
|
||||||
entities: Vec<EntityDto>,
|
|
||||||
snapshots: Vec<SnapshotDto>,
|
snapshots: Vec<SnapshotDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,7 +172,6 @@ fn build_envelope(seed: u64, map: &Map, snapshots: &[Snapshot]) -> Envelope {
|
||||||
tiles: tiles_to_rows(&map.tiles),
|
tiles: tiles_to_rows(&map.tiles),
|
||||||
regions: map.regions.iter().map(RegionDto::from).collect(),
|
regions: map.regions.iter().map(RegionDto::from).collect(),
|
||||||
edges: map.graph.edges().iter().map(EdgeDto::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(),
|
snapshots: snapshots.iter().map(SnapshotDto::from).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
// 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
|
|
||||||
// ≈30–45 — 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
// 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();
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,7 @@
|
||||||
// a dungeon in-browser, and hands the envelope to the renderer.
|
// a dungeon in-browser, and hands the envelope to the renderer.
|
||||||
|
|
||||||
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
|
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
|
||||||
import { renderMap, getStage, THEMES } from './render.js';
|
import { renderMap, getStage } 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 canvas = document.getElementById('view');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
@ -24,7 +21,7 @@ const state = {
|
||||||
env: null, // last successful envelope
|
env: null, // last successful envelope
|
||||||
stage: 0, // snapshot index currently shown
|
stage: 0, // snapshot index currently shown
|
||||||
followFinal: true,
|
followFinal: true,
|
||||||
opts: { lighting: true, themes: true, entities: true, outlines: false, graph: false, grid: false },
|
opts: { lighting: true, outlines: false, graph: false, grid: false },
|
||||||
genMs: 0,
|
genMs: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -43,11 +40,6 @@ const SLIDERS = [
|
||||||
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
|
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
|
||||||
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
|
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
|
||||||
['rooms.shapes.plus', '✚ plus', 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) {
|
function getPath(obj, path) {
|
||||||
|
|
@ -95,34 +87,15 @@ function countTiles(env, code) {
|
||||||
return n;
|
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() {
|
function updateReadout() {
|
||||||
const env = state.env;
|
const env = state.env;
|
||||||
const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length;
|
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 corrs = env.regions.filter((r) => r.kind === 'Corridor').length;
|
||||||
const doors = countTiles(env, 2);
|
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 =
|
readout.querySelector('#stats').innerHTML =
|
||||||
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
|
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
|
||||||
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
|
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
|
||||||
`gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
|
`${env.edges.length} edges · gen <b>${state.genMs.toFixed(1)}</b> ms`;
|
||||||
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
|
|
||||||
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit` +
|
|
||||||
(legend ? `<br>${legend}` : '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- controls UI ---------------------------------------------------------
|
// --- controls UI ---------------------------------------------------------
|
||||||
|
|
@ -134,8 +107,7 @@ function buildControls() {
|
||||||
seedRow.innerHTML = `
|
seedRow.innerHTML = `
|
||||||
<label>seed</label>
|
<label>seed</label>
|
||||||
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
|
<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);
|
panel.appendChild(seedRow);
|
||||||
seedRow.querySelector('#seed').addEventListener('input', (e) => {
|
seedRow.querySelector('#seed').addEventListener('input', (e) => {
|
||||||
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
|
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
|
||||||
|
|
@ -146,9 +118,6 @@ function buildControls() {
|
||||||
document.getElementById('seed').value = String(state.seed);
|
document.getElementById('seed').value = String(state.seed);
|
||||||
regenerate();
|
regenerate();
|
||||||
});
|
});
|
||||||
seedRow.querySelector('#depth').addEventListener('click', () => {
|
|
||||||
if (state.env) downloadDepth(state.env, { stage: state.stage });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sliders.
|
// Sliders.
|
||||||
for (const [path, label, min, max, step] of SLIDERS) {
|
for (const [path, label, min, max, step] of SLIDERS) {
|
||||||
|
|
@ -172,7 +141,7 @@ function buildControls() {
|
||||||
// Toggles.
|
// Toggles.
|
||||||
const togRow = document.createElement('div');
|
const togRow = document.createElement('div');
|
||||||
togRow.className = 'row toggles';
|
togRow.className = 'row toggles';
|
||||||
togRow.innerHTML = ['lighting', 'themes', 'entities', 'outlines', 'graph', 'grid']
|
togRow.innerHTML = ['lighting', 'outlines', 'graph', 'grid']
|
||||||
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
|
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
|
||||||
.join('');
|
.join('');
|
||||||
panel.appendChild(togRow);
|
panel.appendChild(togRow);
|
||||||
|
|
@ -218,42 +187,8 @@ window.reikhelm = {
|
||||||
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
|
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(); },
|
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(); },
|
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,
|
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,
|
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 }),
|
ok: !!state.env }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// same contract the Rust renderers honor. All art (color, light, depth) lives
|
// same contract the Rust renderers honor. All art (color, light, depth) lives
|
||||||
// here; the core stores none of it.
|
// here; the core stores none of it.
|
||||||
|
|
||||||
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
|
const WALL = 0, FLOOR = 1, DOOR = 2;
|
||||||
|
|
||||||
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
|
// Palette — renderer-owned. Warm stone on near-black, amber thresholds.
|
||||||
const PAL = {
|
const PAL = {
|
||||||
|
|
@ -19,52 +19,21 @@ const PAL = {
|
||||||
wallRim: [120, 110, 128], // rim where stone meets carved floor
|
wallRim: [120, 110, 128], // rim where stone meets carved floor
|
||||||
doorWarm: [212, 165, 74],
|
doorWarm: [212, 165, 74],
|
||||||
doorDark: [120, 84, 32],
|
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)',
|
outlineRoom: 'rgba(122, 184, 240, 0.85)',
|
||||||
outlineCorr: 'rgba(150, 150, 170, 0.40)',
|
outlineCorr: 'rgba(150, 150, 170, 0.40)',
|
||||||
edge: 'rgba(232, 96, 84, 0.85)',
|
edge: 'rgba(232, 96, 84, 0.85)',
|
||||||
grid: 'rgba(255,255,255,0.045)',
|
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).
|
// Lighting tunables (tweaked by eye via screenshots).
|
||||||
const LIGHT = {
|
const LIGHT = {
|
||||||
ambient: 0.44, // floor brightness with no room glow
|
ambient: 0.46, // floor brightness with no room glow
|
||||||
corridorFloor: 0.36, // ambient for cells in no room (corridors)
|
corridorFloor: 0.40, // ambient for cells in no room (corridors)
|
||||||
glow: 0.78, // peak extra brightness at a room's lit core
|
glow: 0.70, // peak extra brightness at a room's lit core
|
||||||
aoFloor: 0.64, // brightness multiplier right next to a wall
|
aoFloor: 0.64, // brightness multiplier right next to a wall
|
||||||
aoDepth: 2.6, // cells from wall at which AO is fully open
|
aoDepth: 2.6, // cells from wall at which AO is fully open
|
||||||
maxB: 1.2, // clamp so highlights don't blow out
|
maxB: 1.2, // clamp so highlights don't blow out
|
||||||
vignette: 0.30, // strength of the screen-edge darkening
|
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 -------------------------------------------------------
|
// --- small helpers -------------------------------------------------------
|
||||||
|
|
@ -94,9 +63,8 @@ export function getStage(env, stage) {
|
||||||
// --- precomputed fields --------------------------------------------------
|
// --- precomputed fields --------------------------------------------------
|
||||||
|
|
||||||
// Multi-source BFS distance (in cells) from every floor cell to the nearest
|
// 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
|
// 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.
|
function distanceToWall(tiles, w, h) {
|
||||||
export function distanceToWall(tiles, w, h) {
|
|
||||||
const dist = new Float32Array(w * h).fill(Infinity);
|
const dist = new Float32Array(w * h).fill(Infinity);
|
||||||
const q = new Int32Array(w * h);
|
const q = new Int32Array(w * h);
|
||||||
let tail = 0;
|
let tail = 0;
|
||||||
|
|
@ -140,51 +108,6 @@ function roomGlow(regions, w, h) {
|
||||||
return glow;
|
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 ----------------------------------------------------------
|
// --- main entry ----------------------------------------------------------
|
||||||
|
|
||||||
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
|
// Draw `env` at stage `opts.stage` into a `vw`×`vh` viewport (CSS pixels).
|
||||||
|
|
@ -207,75 +130,44 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
|
||||||
const lit = opts.lighting !== false;
|
const lit = opts.lighting !== false;
|
||||||
const dist = lit ? distanceToWall(tiles, w, h) : null;
|
const dist = lit ? distanceToWall(tiles, w, h) : null;
|
||||||
const glow = lit ? roomGlow(regions, 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 px = (x) => ox + x * cell;
|
||||||
const py = (y) => oy + y * cell;
|
const py = (y) => oy + y * cell;
|
||||||
const isFloor = (x, y) => x >= 0 && y >= 0 && x < w && y < h && tiles[y][x] !== WALL;
|
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;
|
const isWall = (x, y) => x < 0 || y < 0 || x >= w || y >= h || tiles[y][x] === WALL;
|
||||||
|
|
||||||
// 1) Terrain: lit stone for floor/door/pillar-underlay, plus water and lava
|
// 1) Floor + doors, lit.
|
||||||
// pools. Lava is self-lit and also throws warm light onto nearby floor.
|
|
||||||
for (let y = 0; y < h; y++) {
|
for (let y = 0; y < h; y++) {
|
||||||
for (let x = 0; x < w; x++) {
|
for (let x = 0; x < w; x++) {
|
||||||
const t = tiles[y][x];
|
const t = tiles[y][x];
|
||||||
if (t === WALL) continue;
|
if (t === WALL) continue;
|
||||||
const i = y * w + x;
|
const i = y * w + x;
|
||||||
const X = px(x), Y = py(y);
|
|
||||||
|
|
||||||
// Lava: molten rock, drawn bright regardless of ambient light.
|
let b;
|
||||||
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) {
|
if (lit) {
|
||||||
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
|
const ao = Math.min(1, dist[i] / LIGHT.aoDepth);
|
||||||
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
|
const aoMul = lerp(LIGHT.aoFloor, 1, ao);
|
||||||
g = glow[i];
|
const g = glow[i];
|
||||||
if (lavaField) lavaB = Math.max(0, 1 - lavaField[i] / LIGHT.lavaRadius);
|
|
||||||
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
|
const ambient = g > 0 ? LIGHT.ambient : LIGHT.corridorFloor;
|
||||||
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g + LIGHT.lavaGlow * lavaB) * aoMul);
|
b = Math.min(LIGHT.maxB, (ambient + LIGHT.glow * g) * aoMul);
|
||||||
} else {
|
} else {
|
||||||
b = 0.85;
|
b = 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Water: a cool, reflective pool — darker than stone with a faint sheen.
|
// Warm/cool stone variation + fine grain, from a stable hash.
|
||||||
if (t === WATER) {
|
const tint = 0.45 + 0.55 * hash2(x * 3 + 1, y * 7 + 2);
|
||||||
const f = hash2(x * 5 + 1, y * 9 + 4);
|
const grain = 0.9 + 0.2 * hash2(x, y);
|
||||||
const base = lerp3(PAL.waterDeep, PAL.waterShallow, 0.35 + 0.4 * f);
|
const stone = lerp3(PAL.stoneCool, PAL.stoneWarm, tint);
|
||||||
ctx.fillStyle = rgb(scale3(base, 0.7 + 0.5 * b));
|
ctx.fillStyle = rgb(scale3(stone, b * grain));
|
||||||
ctx.fillRect(X, Y, cell, cell);
|
ctx.fillRect(px(x), py(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.
|
// Shadow cast by a wall to the north, falling onto this floor cell.
|
||||||
if (lit && isWall(x, y - 1)) {
|
if (lit && isWall(x, y - 1)) {
|
||||||
const grd = ctx.createLinearGradient(0, Y, 0, Y + cell * 0.7);
|
const grd = ctx.createLinearGradient(0, py(y), 0, py(y) + cell * 0.7);
|
||||||
grd.addColorStop(0, 'rgba(0,0,0,0.45)');
|
grd.addColorStop(0, 'rgba(0,0,0,0.45)');
|
||||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
ctx.fillStyle = grd;
|
ctx.fillStyle = grd;
|
||||||
ctx.fillRect(X, Y, cell, Math.ceil(cell * 0.7));
|
ctx.fillRect(px(x), py(y), cell, Math.ceil(cell * 0.7));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,16 +201,12 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
|
||||||
ctx.fillRect(X, Y, cell, cell);
|
ctx.fillRect(X, Y, cell, cell);
|
||||||
|
|
||||||
// Rim-light every edge where this stone meets carved floor, so the room
|
// Rim-light every edge where this stone meets carved floor, so the room
|
||||||
// boundary reads crisply on all sides (catches the floor's glow). Each rim
|
// boundary reads crisply on all sides (catches the floor's glow).
|
||||||
// segment takes the accent of the themed room it borders (else plain rim).
|
ctx.fillStyle = rgb(scale3(PAL.wallRim, 0.6 * grain));
|
||||||
const rimColor = (nx, ny) => {
|
if (isFloor(x, y - 1)) ctx.fillRect(X, Y, cell, rim);
|
||||||
const th = tfield ? tfield[ny * w + nx] : null;
|
if (isFloor(x, y + 1)) ctx.fillRect(X, Y + cell - rim, cell, rim);
|
||||||
return rgb(scale3(th ? th.accent : PAL.wallRim, 0.6 * grain));
|
if (isFloor(x - 1, y)) ctx.fillRect(X, Y, rim, cell);
|
||||||
};
|
if (isFloor(x + 1, y)) ctx.fillRect(X + cell - rim, Y, rim, cell);
|
||||||
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); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,29 +235,6 @@ 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.
|
// 4) Vignette over the whole grid for mood.
|
||||||
if (lit && LIGHT.vignette > 0) {
|
if (lit && LIGHT.vignette > 0) {
|
||||||
const cxp = ox + gw / 2, cyp = oy + gh / 2;
|
const cxp = ox + gw / 2, cyp = oy + gh / 2;
|
||||||
|
|
@ -385,79 +250,9 @@ export function renderMap(ctx, vw, vh, env, opts = {}) {
|
||||||
if (opts.graph) drawGraph(ctx, regions, edges, layout);
|
if (opts.graph) drawGraph(ctx, regions, edges, layout);
|
||||||
if (opts.grid) drawGrid(ctx, 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;
|
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).
|
// Trace each region's true cell-set boundary (shows polygonal room shapes).
|
||||||
function drawOutlines(ctx, regions, { cell, ox, oy }) {
|
function drawOutlines(ctx, regions, { cell, ox, oy }) {
|
||||||
for (const r of regions) {
|
for (const r of regions) {
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,7 @@ input[type="range"] {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seed-row { grid-template-columns: 40px 1fr auto auto; }
|
.seed-row { grid-template-columns: 40px 1fr auto; }
|
||||||
.seed-row button { padding: 5px 7px; white-space: nowrap; }
|
|
||||||
.seed-row input[type="number"] {
|
.seed-row input[type="number"] {
|
||||||
background: #0c0c11;
|
background: #0c0c11;
|
||||||
border: 1px solid var(--panel-edge);
|
border: 1px solid var(--panel-edge);
|
||||||
|
|
|
||||||
21
tools/.gitignore
vendored
21
tools/.gitignore
vendored
|
|
@ -1,21 +0,0 @@
|
||||||
# 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
120
tools/README.md
|
|
@ -1,120 +0,0 @@
|
||||||
# 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`, 8–12 steps, cfg 1.0, `ModelSamplingAuraFlow` shift 3.0.
|
|
||||||
Depth strength ~0.6 for freeform; **0.80–0.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 · Floyd–Steinberg · 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.55–0.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.
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
#!/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.
|
Before Width: | Height: | Size: 6.8 KiB |
|
|
@ -1,56 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,319 +0,0 @@
|
||||||
#!/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.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 724 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2 MiB |
105
tools/serve.py
105
tools/serve.py
|
|
@ -1,105 +0,0 @@
|
||||||
#!/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()
|
|
||||||
Loading…
Reference in a new issue