feat(render): real-geometry depth export + first-person explorable atlas
Render actual generated dungeons through the AI pipeline — both top-down and first-person (Eye-of-the-Beholder style) — instead of synthetic test depth. All renderer-side: derived from the envelope's tiles+regions+theme, the core stores none of it. - depth.js: top-down height-field depth exporter (walls raised, pools recessed, pillars as bumps, subtle per-theme relief), calibrated to the proven room_depth levels so one tall room can't crush every floor dark. - fpv.js: first-person depth via a Wolfenstein-style grid raycaster (16:9), reports straight-ahead distance for opening detection. - explore.js: room->room nav graph derived from REAL tile openings (scan boundary gaps, bin by cardinal, flood the corridor to the destination room) — nav + open share one source of truth with the rendered passages. - bake_atlas.py: bakes per-room x4-facing pixel-art frames; per-theme + per-facing (wall vs passage) prompts, fixed diffusion seed for cross-frame style coherence, per-run unique prefixes so re-bakes don't silently skip. - explore.html + explore-viewer.js: WASD first-person dungeon explorer (room-to-room movement, turning, minimap), integer-pixel fullscreen. - serve.py: stdlib dev server — static web root + /out tree + POST /save, no-store. - main.js/render.js/style.css: export hooks, the depth button, distanceToWall export. - tools/README.md: Stage 0/1/2/3 docs; .dev/2026-06-01-fpv-prompts.md: prompt research (cfg-1 negatives are inert; trigger words summon hands; empty-ruin reframe; fixed seed = consistency). Proven end-to-end on seed 7 (17 rooms). Outputs organized under git-ignored tools/out/. Control strength: 0.80-0.85 top-down, 0.85 first-person. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3a30f6962
commit
ed00e31009
13 changed files with 1217 additions and 17 deletions
118
.dev/2026-06-01-fpv-prompts.md
Normal file
118
.dev/2026-06-01-fpv-prompts.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# First-Person Dungeon FPV Prompts (Z-Image-Turbo + Depth Fun-ControlNet)
|
||||
|
||||
**Researched:** 2026-06-01
|
||||
**Pipeline:** Z-Image-Turbo (Qwen3-4B encoder), 8 steps, cfg 1.0, Fun-ControlNet Union depth patch, strength ~0.65, res_multistep/simple. Output downsampled to 256-color pixel art.
|
||||
|
||||
## TL;DR
|
||||
- At **cfg 1.0 the negative prompt is mechanically dead** (classifier-free guidance is off). Hands/figures must be killed by the POSITIVE prompt + avoiding trigger words, NOT by negatives.
|
||||
- Top triggers that summon a player character: **"first person", "POV", "dungeon crawler", "Eye of the Beholder", "adventurer", "hero", "you"**. Drop them all. Frame as **architectural photography of an empty/abandoned place** instead.
|
||||
- Let the depth map own the geometry; the prompt owns material + light. Don't fight the controlnet by over-describing structure.
|
||||
|
||||
---
|
||||
|
||||
## 1. Positive-prompt templates (copy-paste)
|
||||
|
||||
Frame every shot as a deserted architectural photograph. The model never adds a person to "an empty abandoned stone hall, no people."
|
||||
|
||||
### Corridor
|
||||
```
|
||||
empty abandoned stone dungeon corridor, deserted, no people, uninhabited,
|
||||
narrow ancient stonework passage receding into darkness, wet mossy flagstone
|
||||
floor, rough granite block walls, iron sconces with flickering torchlight,
|
||||
warm orange firelight and deep black shadows, volumetric haze, damp cold air,
|
||||
dark fantasy, architectural photography of an empty ruin, atmospheric, detailed stone
|
||||
```
|
||||
|
||||
### Chamber / room
|
||||
```
|
||||
empty abandoned stone dungeon chamber, deserted, no people, uninhabited,
|
||||
vast vaulted hall of ancient masonry, weathered granite pillars, cracked
|
||||
flagstone floor, iron braziers with flickering torchlight, warm firelight
|
||||
pooling in deep shadow, volumetric haze, dripping damp walls, dark fantasy,
|
||||
architectural photography of an empty ruin, atmospheric, detailed stone
|
||||
```
|
||||
|
||||
### Tighter variant (if long prompts drift)
|
||||
```
|
||||
empty stone dungeon corridor, no people, abandoned ancient ruin, mossy
|
||||
flagstone, granite walls, torchlight, deep shadows, volumetric fog, dark
|
||||
fantasy, architectural photo
|
||||
```
|
||||
|
||||
### Style anchor block (PREPEND identically to EVERY frame in an atlas)
|
||||
```
|
||||
dark fantasy dungeon, ancient torchlit stone, warm orange torchlight, deep
|
||||
black shadows, wet mossy granite masonry, volumetric haze,
|
||||
```
|
||||
Then append the per-room body (corridor vs chamber, any theme word like "crypt"/"flooded"/"library").
|
||||
|
||||
### Words to AVOID (they summon a character / break the empty frame)
|
||||
| Avoid | Why | Use instead |
|
||||
|---|---|---|
|
||||
| first person, POV, first-person view | model fills the bottom edge with hands/weapon | (nothing — say "corridor receding into darkness") |
|
||||
| dungeon crawler, Eye of the Beholder, Daggerfall | strong "RPG with a player" prior → arms/HUD | "dark fantasy", "old-school RPG art style" |
|
||||
| adventurer, hero, explorer, party, you, your | literal person | "abandoned", "deserted" |
|
||||
| holding, wielding, torch in hand | summons a hand+weapon | "iron sconce", "wall torch", "brazier" |
|
||||
| game screenshot, HUD, interface, UI | summons fake UI overlay | "architectural photograph" |
|
||||
|
||||
Note "torch" alone is fine (wall torches); only **"holding/carrying a torch"** triggers a hand. Anchor light to fixtures: **sconce, brazier, wall torch.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Negative prompt — and why it barely matters here
|
||||
|
||||
**Caveat first:** Z-Image-Turbo is a distilled model running at **cfg/guidance 1.0**, where classifier-free guidance is effectively off, so the negative branch is **ignored or near-inert.** Two independent sources confirm the Qwen-family text encoder + distillation means "the model does not use negative prompts at all." Do NOT rely on it to remove hands. The positive prompt and trigger-word avoidance do ~95% of the work.
|
||||
|
||||
Still set one (cheap insurance; matters only if you ever nudge cfg to ~1.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
|
||||
236
reikhelm-web/depth.js
Normal file
236
reikhelm-web/depth.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// depth.js — top-down depth-map exporter for reikhelm map envelopes.
|
||||
//
|
||||
// A *renderer* concern, exactly like render.js: it reads the same JSON envelope
|
||||
// (integer tile codes, regions, themes) and derives an 8-bit grayscale depth map
|
||||
// for the diffusion control pipeline (tools/comfy-spike → ComfyUI Z-Image). It
|
||||
// holds NO generation logic and the core stores none of this — height, like
|
||||
// color, lives renderer-side and is derived from tiles + regions + theme.
|
||||
//
|
||||
// Convention (matches tools/comfy-spike/comfy.py): a top-down depth map where
|
||||
// NEAR the camera = white(255), FAR = black(0). Looking straight down, the tops
|
||||
// of walls are closest to the camera (white), the floor sits mid-gray, and the
|
||||
// bottoms of recessed pools are farthest (black). So depth here is a *height
|
||||
// field*: brightness ∝ height.
|
||||
//
|
||||
// Note on the BFS field: render.js's `distanceToWall` is an openness/cavity map,
|
||||
// not a depth map — feeding it raw would dome the floors toward the camera (room
|
||||
// centres bulging), which is wrong. We build a height model instead, and reuse
|
||||
// the distance field only for a subtle ambient-occlusion darkening in the
|
||||
// crevices where stone meets floor.
|
||||
|
||||
import { getStage, distanceToWall } from './render.js';
|
||||
|
||||
const WALL = 0, FLOOR = 1, DOOR = 2, PILLAR = 3, WATER = 4, LAVA = 5;
|
||||
|
||||
// Per-tile base height in [0,1] (1 = tallest = nearest the top-down camera =
|
||||
// whitest). The wall plateau is the high reference; pools recess below the
|
||||
// floor; a door notches just under floor level so passages read as openings;
|
||||
// a pillar stands as a tall column on the floor. Renderer-owned, like PAL.
|
||||
const HEIGHT = {
|
||||
[WALL]: 1.00, // flat wall-top plateau (uniform: from directly above, all wall tops sit level)
|
||||
[FLOOR]: 0.50, // mid reference plane
|
||||
[DOOR]: 0.45, // threshold, a hair below floor → reads as a gap in the wall line
|
||||
[PILLAR]: 0.84, // free-standing column rising off the floor
|
||||
[WATER]: 0.15, // recessed pool
|
||||
[LAVA]: 0.10, // recessed molten channel (lowest)
|
||||
};
|
||||
|
||||
// Per-theme relief (Stage-B layer, kept subtle so it modulates the room without
|
||||
// swamping the wall↔pool contrast that carries the structure). Floor bias raises
|
||||
// or sinks a themed room's floor; wall bias makes its walls stand a touch taller.
|
||||
// Keyed by the wire theme id (render.js THEMES). Absent themes → flat (0,0).
|
||||
const THEME_RELIEF = {
|
||||
throne: { floor: 0.07, wall: 0.06 }, // a raised dais under taller walls
|
||||
vault: { floor: 0.04, wall: 0.03 }, // built-up stone room
|
||||
hall: { floor: 0.025, wall: 0.02 },
|
||||
library: { floor: 0.02, wall: 0.01 },
|
||||
forge: { floor: 0.0, wall: 0.01 }, // lava already carries the relief
|
||||
threshold: { floor: 0.0, wall: 0.0 },
|
||||
stone: { floor: 0.0, wall: 0.0 },
|
||||
den: { floor: -0.02, wall: 0.0 },
|
||||
cistern: { floor: -0.06, wall: 0.0 }, // sunken water room
|
||||
crypt: { floor: -0.05, wall: 0.0 }, // sunken vault
|
||||
};
|
||||
|
||||
const AO = { depth: 2.4, strength: 0.07 }; // crevice darkening near walls (reuses distanceToWall)
|
||||
|
||||
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
|
||||
|
||||
// Per-cell theme relief: for every cell of a themed Room, its {floor,wall} bias;
|
||||
// {0,0} elsewhere. Rooms don't overlap, so the assignment is unambiguous.
|
||||
function reliefField(regions, w, h) {
|
||||
const fl = new Float32Array(w * h);
|
||||
const wl = new Float32Array(w * h);
|
||||
for (const r of regions) {
|
||||
if (r.kind !== 'Room' || !r.theme || !r.cells) continue;
|
||||
const rel = THEME_RELIEF[r.theme];
|
||||
if (!rel) continue;
|
||||
for (const [x, y] of r.cells) {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) continue;
|
||||
fl[y * w + x] = rel.floor;
|
||||
wl[y * w + x] = rel.wall;
|
||||
}
|
||||
}
|
||||
return { fl, wl };
|
||||
}
|
||||
|
||||
// In-place separable box blur over a Float32 field (two passes ≈ Gaussian).
|
||||
// Used as a small bevel so wall edges read as sloped faces rather than 1px
|
||||
// cliffs, without melting the flat plateaus. radius in pixels; clamps at edges.
|
||||
function boxBlur(src, w, h, radius, passes = 2) {
|
||||
if (radius < 1) return src;
|
||||
let buf = src;
|
||||
const tmp = new Float32Array(w * h);
|
||||
const norm = 1 / (radius * 2 + 1);
|
||||
for (let p = 0; p < passes; p++) {
|
||||
// horizontal
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
let acc = 0;
|
||||
for (let k = -radius; k <= radius; k++) acc += buf[row + clamp(k, 0, w - 1)];
|
||||
for (let x = 0; x < w; x++) {
|
||||
tmp[row + x] = acc * norm;
|
||||
const out = row + clamp(x - radius, 0, w - 1);
|
||||
const inc = row + clamp(x + radius + 1, 0, w - 1);
|
||||
acc += buf[inc] - buf[out];
|
||||
}
|
||||
}
|
||||
// vertical
|
||||
for (let x = 0; x < w; x++) {
|
||||
let acc = 0;
|
||||
for (let k = -radius; k <= radius; k++) acc += tmp[clamp(k, 0, h - 1) * w + x];
|
||||
for (let y = 0; y < h; y++) {
|
||||
buf[y * w + x] = acc * norm;
|
||||
const out = clamp(y - radius, 0, h - 1) * w + x;
|
||||
const inc = clamp(y + radius + 1, 0, h - 1) * w + x;
|
||||
acc += tmp[inc] - tmp[out];
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- main entry ----------------------------------------------------------
|
||||
|
||||
// Derive a depth height field from an envelope. Returns { width, height, gray }
|
||||
// where gray is a Uint8ClampedArray (row-major, one 8-bit value per pixel),
|
||||
// full-range normalized with near(tall)=255.
|
||||
//
|
||||
// opts: { stage?, scale?, bevel?, ao? }
|
||||
// stage — snapshot to read (default final)
|
||||
// scale — pixels per cell (default ≈ 1024 / longest map edge, clamped 6..48)
|
||||
// bevel — edge-softening as a fraction of a cell (default 0.16; 0 = hard steps)
|
||||
// ao — crevice AO strength (default AO.strength; 0 = off)
|
||||
export function computeDepthField(env, opts = {}) {
|
||||
const { tiles, regions } = getStage(env, opts.stage);
|
||||
const w = env.width, h = env.height;
|
||||
const scale = opts.scale ?? clamp(Math.round(1024 / Math.max(w, h)), 6, 48);
|
||||
const aoStrength = opts.ao ?? AO.strength;
|
||||
|
||||
// 1) Per-cell height = tile base + theme relief − crevice AO.
|
||||
const dist = distanceToWall(tiles, w, h);
|
||||
const { fl, wl } = reliefField(regions, w, h);
|
||||
const cell = new Float32Array(w * h);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = y * w + x;
|
||||
const t = tiles[y][x];
|
||||
let v = HEIGHT[t] ?? HEIGHT[FLOOR];
|
||||
if (t === WALL) {
|
||||
v += wl[i]; // taller themed walls
|
||||
} else {
|
||||
v += fl[i]; // raised/sunken themed floors
|
||||
// Crevice AO: open cells right against stone sit slightly lower. `dist`
|
||||
// is 0 in walls and grows outward; openness = min(dist/aoDepth, 1).
|
||||
const openness = Math.min(1, dist[i] / AO.depth);
|
||||
v -= (1 - openness) * aoStrength;
|
||||
}
|
||||
cell[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Upscale to pixels as flat blocks (nearest), preserving plateaus/pools —
|
||||
// hard depth steps at wall tops are physically correct top-down.
|
||||
const W = w * scale, H = h * scale;
|
||||
const px = new Float32Array(W * H);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const cy = (y / scale) | 0;
|
||||
for (let x = 0; x < W; x++) {
|
||||
px[y * W + x] = cell[cy * w + ((x / scale) | 0)];
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Light bevel: a small blur turns the 1px block edges into sloped wall
|
||||
// faces the depth ControlNet can read, without rounding off the flat tops.
|
||||
const bevel = opts.bevel ?? 0.16;
|
||||
const radius = Math.max(0, Math.round(scale * bevel));
|
||||
boxBlur(px, W, H, radius);
|
||||
|
||||
// 4) Height → 8-bit gray, near(tall)=white. Default ('levels') maps heights
|
||||
// onto the *proven* room_depth levels (floor≈120, wall≈235, recessed pools
|
||||
// ≈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 };
|
||||
}
|
||||
145
reikhelm-web/explore-viewer.js
Normal file
145
reikhelm-web/explore-viewer.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// explore-viewer.js — walk a pre-baked first-person dungeon.
|
||||
//
|
||||
// Loads an atlas manifest (room graph + tiles) and the baked frames from
|
||||
// /out/atlas/<seed>/, and lets you move through it Eye-of-the-Beholder style:
|
||||
// you occupy a room and a cardinal facing; W/S step forward/back to the room in
|
||||
// that direction, A/D turn. Each frame is the pre-rendered pixel-art view for the
|
||||
// current (room, facing). Movement is just swapping the image — the dungeon was
|
||||
// "burned off" ahead of time by tools/bake_atlas.py.
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const SEED = params.get('seed') || '7';
|
||||
const BASE = `/out/atlas/${SEED}`;
|
||||
const DIRS = ['N', 'E', 'S', 'W'];
|
||||
const VEC = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
|
||||
|
||||
const img = document.getElementById('frame');
|
||||
const hud = document.getElementById('hud');
|
||||
const miss = document.getElementById('miss');
|
||||
const missDetail = document.getElementById('miss-detail');
|
||||
const mini = document.getElementById('minimap');
|
||||
const mctx = mini.getContext('2d');
|
||||
|
||||
let manifest, byId;
|
||||
const state = { room: null, facing: 'N' };
|
||||
|
||||
// One cache-bust token per page load: re-bakes show fresh art on reload, but
|
||||
// turning/stepping within a session still hits the browser cache (snappy).
|
||||
const BUST = `?t=${Date.now()}`;
|
||||
const frameURL = (id, dir) => `${BASE}/r${id}_${dir}.png${BUST}`;
|
||||
const room = () => byId.get(state.room);
|
||||
const opposite = (d) => DIRS[(DIRS.indexOf(d) + 2) % 4];
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
manifest = await (await fetch(`${BASE}/manifest.json`)).json();
|
||||
} catch {
|
||||
hud.innerHTML = `no atlas for seed <b>${SEED}</b> — bake one with <span class="dim">tools/bake_atlas.py --seed ${SEED}</span>`;
|
||||
return;
|
||||
}
|
||||
byId = new Map(manifest.rooms.map((r) => [r.id, r]));
|
||||
// ?room=&facing= jump straight to a room (demo/debug); else start at the entrance.
|
||||
const wantRoom = Number(params.get('room'));
|
||||
state.room = (params.has('room') && byId.has(wantRoom)) ? wantRoom : manifest.startRoom;
|
||||
const wantFacing = (params.get('facing') || '').toUpperCase();
|
||||
state.facing = DIRS.includes(wantFacing) ? wantFacing : (firstOpenFacing(state.room) || 'N');
|
||||
show();
|
||||
preloadAround();
|
||||
}
|
||||
|
||||
function firstOpenFacing(id) {
|
||||
const nav = byId.get(id).nav;
|
||||
return DIRS.find((d) => nav[d] != null);
|
||||
}
|
||||
|
||||
function show() {
|
||||
miss.classList.remove('show');
|
||||
img.src = frameURL(state.room, state.facing);
|
||||
updateHud();
|
||||
drawMinimap();
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
const r = room();
|
||||
const exits = DIRS.filter((d) => r.nav[d] != null);
|
||||
const ahead = r.nav[state.facing] != null ? `→ room ${r.nav[state.facing]}` : 'wall';
|
||||
hud.innerHTML =
|
||||
`seed <b>${manifest.seed}</b> · room <b>${r.id}</b>${r.theme ? ` · ${r.theme}` : ''} · ` +
|
||||
`facing <b>${state.facing}</b> <span class="dim">(${ahead})</span><br>` +
|
||||
`<span class="dim">exits</span> ${exits.join(' ') || '—'}`;
|
||||
}
|
||||
|
||||
function turn(delta) {
|
||||
state.facing = DIRS[(DIRS.indexOf(state.facing) + delta + 4) % 4];
|
||||
show();
|
||||
}
|
||||
|
||||
function step(forward) {
|
||||
const dir = forward ? state.facing : opposite(state.facing);
|
||||
const target = room().nav[dir];
|
||||
if (target != null) {
|
||||
state.room = target;
|
||||
show();
|
||||
preloadAround();
|
||||
} else {
|
||||
img.animate(
|
||||
[{ filter: 'brightness(1)' }, { filter: 'brightness(0.55)' }, { filter: 'brightness(1)' }],
|
||||
{ duration: 150 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'w' || k === 'arrowup') step(true);
|
||||
else if (k === 's' || k === 'arrowdown') step(false);
|
||||
else if (k === 'a' || k === 'arrowleft') turn(-1);
|
||||
else if (k === 'd' || k === 'arrowright') turn(1);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// A frame that 404s just hasn't been baked yet — show a hint instead of a broken image.
|
||||
img.addEventListener('error', () => {
|
||||
if (!img.getAttribute('src')) return;
|
||||
miss.classList.add('show');
|
||||
missDetail.textContent = `room ${state.room}, facing ${state.facing}`;
|
||||
});
|
||||
|
||||
// Warm the browser cache for instant turning/stepping.
|
||||
function preloadAround() {
|
||||
const seen = new Set();
|
||||
const pre = (id) => DIRS.forEach((d) => {
|
||||
const u = frameURL(id, d);
|
||||
if (!seen.has(u)) { seen.add(u); new Image().src = u; }
|
||||
});
|
||||
pre(state.room);
|
||||
const nav = room().nav;
|
||||
DIRS.forEach((d) => { if (nav[d] != null) pre(nav[d]); });
|
||||
}
|
||||
|
||||
function drawMinimap() {
|
||||
const { tiles, width, height } = manifest;
|
||||
const s = Math.max(2, Math.floor(170 / Math.max(width, height)));
|
||||
mini.width = width * s; mini.height = height * s;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = tiles[y][x];
|
||||
mctx.fillStyle = t === 0 ? '#15151c' : t === 2 ? '#caa24a' : '#34343f';
|
||||
mctx.fillRect(x * s, y * s, s, s);
|
||||
}
|
||||
}
|
||||
for (const r of manifest.rooms) { // every room as a faint node
|
||||
mctx.fillStyle = 'rgba(122,160,220,0.45)';
|
||||
mctx.fillRect(r.vantage[0] * s, r.vantage[1] * s, s, s);
|
||||
}
|
||||
const [vx, vy] = room().vantage; // the player + facing arrow
|
||||
const cx = vx * s + s / 2, cy = vy * s + s / 2;
|
||||
mctx.fillStyle = '#e8dcc8';
|
||||
mctx.beginPath(); mctx.arc(cx, cy, Math.max(2, s * 0.7), 0, Math.PI * 2); mctx.fill();
|
||||
const [dx, dy] = VEC[state.facing];
|
||||
mctx.strokeStyle = '#d4a54a'; mctx.lineWidth = Math.max(1.5, s * 0.5);
|
||||
mctx.beginPath(); mctx.moveTo(cx, cy); mctx.lineTo(cx + dx * s * 2.4, cy + dy * s * 2.4); mctx.stroke();
|
||||
}
|
||||
|
||||
boot();
|
||||
42
reikhelm-web/explore.html
Normal file
42
reikhelm-web/explore.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>reikhelm · dungeon explorer</title>
|
||||
<style>
|
||||
:root { --accent: #d4a54a; --ink: #e0d4cc; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #000; color: var(--ink);
|
||||
font: 13px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; overflow: hidden; }
|
||||
/* The pre-rendered frame, integer-pixel scaled to fill the screen. */
|
||||
#frame { position: fixed; inset: 0; width: 100%; height: 100%;
|
||||
object-fit: contain; image-rendering: pixelated; background: #000;
|
||||
transition: filter .12s; }
|
||||
.vignette { position: fixed; inset: 0; pointer-events: none;
|
||||
box-shadow: inset 0 0 220px 60px rgba(0,0,0,0.7); }
|
||||
#hud { position: fixed; top: 14px; left: 16px; padding: 8px 12px;
|
||||
background: rgba(8,8,11,0.62); border: 1px solid rgba(212,165,74,0.35);
|
||||
border-radius: 6px; backdrop-filter: blur(2px); }
|
||||
#hud b { color: var(--accent); }
|
||||
#hud .dim { color: #8a8a98; }
|
||||
#minimap { position: fixed; bottom: 14px; left: 16px; image-rendering: pixelated;
|
||||
background: rgba(8,8,11,0.55); border: 1px solid rgba(212,165,74,0.30);
|
||||
border-radius: 4px; padding: 6px; }
|
||||
#miss { position: fixed; inset: 0; display: none; place-items: center; text-align: center;
|
||||
color: #8a8a98; }
|
||||
#miss.show { display: grid; }
|
||||
.keys { position: fixed; bottom: 16px; right: 18px; color: #6a6a76; text-align: right; }
|
||||
.keys b { color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img id="frame" alt="dungeon view">
|
||||
<div class="vignette"></div>
|
||||
<div id="hud">loading atlas…</div>
|
||||
<div id="miss"><div>⛏ this view isn't baked yet<br><span id="miss-detail"></span></div></div>
|
||||
<canvas id="minimap" width="180" height="120"></canvas>
|
||||
<p class="keys"><b>W</b>/<b>S</b> forward·back · <b>A</b>/<b>D</b> turn</p>
|
||||
<script type="module" src="explore-viewer.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
127
reikhelm-web/explore.js
Normal file
127
reikhelm-web/explore.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// explore.js — turn a dungeon envelope into a navigable room graph for the
|
||||
// pre-baked first-person explorer.
|
||||
//
|
||||
// The player occupies a ROOM and a cardinal FACING. Moving forward (W) steps to
|
||||
// the neighbouring room in the faced direction; A/D rotate the facing. To support
|
||||
// that we need, per room: a vantage cell (where the camera stands), and which
|
||||
// room lies to its N/E/S/W. Rooms connect through corridors, so we contract the
|
||||
// region graph (rooms = nodes, corridors = edges to traverse through) and bin
|
||||
// each room-neighbour into the cardinal of its bearing.
|
||||
//
|
||||
// Renderer-side, like depth.js/fpv.js: derived from regions + edges, nothing
|
||||
// stored in the core. Room ids here are the region ARRAY INDEX (== the wire id;
|
||||
// edges already reference regions by that index), used as the atlas frame key.
|
||||
|
||||
import { getStage, distanceToWall } from './render.js';
|
||||
import { mostOpenCell, centroidOf } from './fpv.js';
|
||||
|
||||
const WALL = 0;
|
||||
const DIRV = [[0, -1, 'N'], [1, 0, 'E'], [0, 1, 'S'], [-1, 0, 'W']]; // y grows downward = south
|
||||
|
||||
// Build the room navigation graph from the ACTUAL tile openings (not centroid
|
||||
// bearings, which disagree with what the views render). For each room we scan its
|
||||
// boundary for real gaps, bin each gap by the cardinal it sits on, and flood the
|
||||
// corridor behind it to find the room it truly leads to. nav + open then share a
|
||||
// single source of truth with the rendered passages.
|
||||
//
|
||||
// Returns { seed, width, height, startRoom, rooms }; each room =
|
||||
// { id, theme, vantage:[x,y], centroid:[x,y], bounds, nav:{N,E,S,W}, open:{N,E,S,W} }
|
||||
// (nav = neighbour room id or null; open = a real gap exists that way).
|
||||
export function dungeonGraph(env, opts = {}) {
|
||||
const { tiles, regions } = getStage(env, opts.stage);
|
||||
const w = env.width, h = env.height;
|
||||
const dist = distanceToWall(tiles, w, h);
|
||||
const isRoom = (i) => regions[i] && regions[i].kind === 'Room' && regions[i].cells && regions[i].cells.length > 0;
|
||||
const inBounds = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
|
||||
const open = (x, y) => inBounds(x, y) && tiles[y][x] !== WALL; // floor/door/pillar/water/lava all pass
|
||||
|
||||
// cell → owning room id (region index), -1 for corridors/walls.
|
||||
const cellRoom = new Int32Array(w * h).fill(-1);
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
if (!isRoom(i)) continue;
|
||||
for (const [x, y] of regions[i].cells) if (inBounds(x, y)) cellRoom[y * w + x] = i;
|
||||
}
|
||||
|
||||
// From a gap cell just outside `home`, flood through non-room corridor cells
|
||||
// until we reach a different room; return its id (or null for a dead end/loop).
|
||||
const destFrom = (sx, sy, home) => {
|
||||
const seen = new Set([sx + ',' + sy]);
|
||||
const q = [[sx, sy]];
|
||||
while (q.length) {
|
||||
const [x, y] = q.shift();
|
||||
const rid = cellRoom[y * w + x];
|
||||
if (rid !== -1 && rid !== home) return rid; // arrived at another room
|
||||
for (const [dx, dy] of DIRV) {
|
||||
const nx = x + dx, ny = y + dy, k = nx + ',' + ny;
|
||||
if (seen.has(k) || !open(nx, ny)) continue;
|
||||
if (cellRoom[ny * w + nx] === home) continue; // never wander back into home
|
||||
seen.add(k); q.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const rooms = [];
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
if (!isRoom(i)) continue;
|
||||
const r = regions[i];
|
||||
const centroid = centroidOf(r.cells);
|
||||
const van = mostOpenCell(dist, w, r.cells);
|
||||
const cellSet = new Set(r.cells.map(([x, y]) => x + ',' + y));
|
||||
|
||||
// Collect boundary gaps per cardinal: a room cell whose neighbour that way is
|
||||
// outside the room and not a wall.
|
||||
const gaps = { N: [], E: [], S: [], W: [] };
|
||||
for (const [x, y] of r.cells) {
|
||||
for (const [dx, dy, dir] of DIRV) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (!inBounds(nx, ny) || cellSet.has(nx + ',' + ny) || tiles[ny][nx] === WALL) continue;
|
||||
gaps[dir].push([nx, ny]);
|
||||
}
|
||||
}
|
||||
|
||||
const nav = { N: null, E: null, S: null, W: null };
|
||||
const openDir = { N: false, E: false, S: false, W: false };
|
||||
for (const dir of ['N', 'E', 'S', 'W']) {
|
||||
if (gaps[dir].length === 0) continue;
|
||||
openDir[dir] = true;
|
||||
for (const [gx, gy] of gaps[dir]) { // first gap that reaches a room wins
|
||||
const d = destFrom(gx, gy, i);
|
||||
if (d != null) { nav[dir] = d; break; }
|
||||
}
|
||||
}
|
||||
|
||||
rooms.push({
|
||||
id: i,
|
||||
theme: r.theme || null,
|
||||
vantage: van,
|
||||
centroid: [Math.round(centroid[0]), Math.round(centroid[1])],
|
||||
bounds: r.bounds,
|
||||
nav,
|
||||
open: openDir,
|
||||
});
|
||||
}
|
||||
|
||||
// Start room: the one holding the Entrance entity, else the largest.
|
||||
let startRoom = rooms.length ? rooms[0].id : null;
|
||||
const ent = (env.entities || []).find((e) => e.kind === 'Entrance');
|
||||
if (ent) {
|
||||
const hit = rooms.find((rm) => regions[rm.id].cells.some(([x, y]) => x === ent.at[0] && y === ent.at[1]));
|
||||
if (hit) startRoom = hit.id;
|
||||
} else {
|
||||
let bestArea = 0;
|
||||
for (const rm of rooms) {
|
||||
const a = regions[rm.id].cells.length;
|
||||
if (a > bestArea) { bestArea = a; startRoom = rm.id; }
|
||||
}
|
||||
}
|
||||
|
||||
return { seed: env.seed, width: w, height: h, startRoom, rooms };
|
||||
}
|
||||
|
||||
// Full manifest = the nav graph + the tile grid (for the viewer's minimap).
|
||||
export function dungeonManifest(env, opts = {}) {
|
||||
const g = dungeonGraph(env, opts);
|
||||
const { tiles } = getStage(env, opts.stage);
|
||||
return { ...g, tiles };
|
||||
}
|
||||
157
reikhelm-web/fpv.js
Normal file
157
reikhelm-web/fpv.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// fpv.js — first-person (Eye-of-the-Beholder / Daggerfall style) depth exporter.
|
||||
//
|
||||
// A renderer concern, like depth.js: it reads the same envelope and produces an
|
||||
// 8-bit depth map for the AI-render pipeline — but viewed from *inside* the
|
||||
// dungeon rather than top-down. A grid dungeon is a 2D map extruded: walls are
|
||||
// full-height blocks, floor and ceiling are flat planes, the eye sits at mid
|
||||
// height, and you face one of four cardinal directions. So we don't need any 3D
|
||||
// data from the core — a classic Wolfenstein-style raycaster turns the grid into
|
||||
// a perspective depth map directly.
|
||||
//
|
||||
// Per screen column we cast one ray, march the grid (DDA) to the first blocking
|
||||
// cell, and fill the column: a wall slice at that distance, floor ramping toward
|
||||
// the camera below it, ceiling above. Distance → gray with NEAR=white/FAR=black
|
||||
// (the same convention comfy.py expects). Doorways are non-blocking, so they
|
||||
// read as dark recesses leading deeper — which is exactly what we want.
|
||||
|
||||
import { getStage, distanceToWall } from './render.js';
|
||||
import { depthToCanvas } from './depth.js';
|
||||
|
||||
const WALL = 0, PILLAR = 3;
|
||||
|
||||
// Cardinal facings as (dx, dy) on the grid (y grows downward / "south").
|
||||
export const DIRS = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] };
|
||||
|
||||
const clamp = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
|
||||
const blocks = (t) => t === WALL || t === PILLAR; // line-of-sight blockers
|
||||
|
||||
// Centroid of a cell list as [cx, cy].
|
||||
export function centroidOf(cells) {
|
||||
let cx = 0, cy = 0;
|
||||
for (const [x, y] of cells) { cx += x; cy += y; }
|
||||
return [cx / cells.length, cy / cells.length];
|
||||
}
|
||||
|
||||
// The *most open* cell (max distance-to-wall) among `cells`, so a camera stands
|
||||
// in open space rather than against a wall (a room centroid can land near a wall
|
||||
// when the room is non-convex). Ties break toward the cell nearest the centroid,
|
||||
// keeping it visually centred. `dist` is a precomputed distanceToWall field.
|
||||
export function mostOpenCell(dist, w, cells) {
|
||||
const [cx, cy] = centroidOf(cells);
|
||||
let pick = cells[0], pmax = -1, pcen = Infinity;
|
||||
for (const [x, y] of cells) {
|
||||
const d = dist[y * w + x];
|
||||
const cen = (x - cx) ** 2 + (y - cy) ** 2;
|
||||
if (d > pmax || (d === pmax && cen < pcen)) { pmax = d; pcen = cen; pick = [x, y]; }
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
|
||||
// Vantage cell for one room region (its most-open cell).
|
||||
export function roomVantage(env, region, opts = {}) {
|
||||
const { tiles } = getStage(env, opts.stage);
|
||||
return mostOpenCell(distanceToWall(tiles, env.width, env.height), env.width, region.cells);
|
||||
}
|
||||
|
||||
// Default demo vantage: the most-open cell of the largest room (or, failing that,
|
||||
// the most open floor cell anywhere).
|
||||
export function vantage(env, opts = {}) {
|
||||
const { tiles, regions } = getStage(env, opts.stage);
|
||||
const w = env.width, h = env.height;
|
||||
const dist = distanceToWall(tiles, w, h);
|
||||
let best = null, bestArea = 0;
|
||||
for (const r of regions) {
|
||||
if (r.kind !== 'Room' || !r.cells || r.cells.length === 0) continue;
|
||||
if (r.cells.length > bestArea) { bestArea = r.cells.length; best = r; }
|
||||
}
|
||||
if (best) return mostOpenCell(dist, w, best.cells);
|
||||
const all = [];
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!blocks(tiles[y][x])) all.push([x, y]);
|
||||
return all.length ? mostOpenCell(dist, w, all) : [w >> 1, h >> 1];
|
||||
}
|
||||
|
||||
// Build a first-person depth field. Returns { width, height, gray, at, dir }.
|
||||
// opts: { stage?, at?[x,y], dir?'N'|'E'|'S'|'W', width?, height?, fov?deg, maxView?, gamma? }
|
||||
export function computeFPVDepth(env, opts = {}) {
|
||||
const { tiles } = getStage(env, opts.stage);
|
||||
const w = env.width, h = env.height;
|
||||
const W = opts.width ?? 1280, H = opts.height ?? 720; // 16:9 — integer-scales to 1080p/1440p
|
||||
const fov = (opts.fov ?? 80) * Math.PI / 180; // wider horizontal FOV to fill the widescreen frame
|
||||
const planeLen = Math.tan(fov / 2);
|
||||
const maxView = opts.maxView ?? 12; // how far the eye sees (cells) before it's pure dark
|
||||
const gamma = opts.gamma ?? 2.0; // >1 deepens the falloff to black (matches the proven corridor look)
|
||||
|
||||
const at = opts.at ?? vantage(env, opts);
|
||||
const dirName = opts.dir ?? 'N';
|
||||
const [dirX, dirY] = DIRS[dirName];
|
||||
const posX = at[0] + 0.5, posY = at[1] + 0.5;
|
||||
// Camera plane ⟂ to the view direction (rotate dir 90°), scaled to the FOV.
|
||||
const planeX = -dirY * planeLen, planeY = dirX * planeLen;
|
||||
|
||||
const gray = new Uint8ClampedArray(W * H);
|
||||
const half = H / 2;
|
||||
const centerX = W >> 1;
|
||||
let ahead = maxView; // straight-ahead wall distance (cells) — big = an opening leads onward
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const camX = 2 * x / W - 1; // −1 … 1 across the screen
|
||||
const rayX = dirX + planeX * camX;
|
||||
const rayY = dirY + planeY * camX;
|
||||
|
||||
// DDA grid march.
|
||||
let mapX = posX | 0, mapY = posY | 0;
|
||||
const deltaX = Math.abs(1 / rayX), deltaY = Math.abs(1 / rayY); // Infinity when axis-aligned — fine
|
||||
let stepX, stepY, sideX, sideY;
|
||||
if (rayX < 0) { stepX = -1; sideX = (posX - mapX) * deltaX; } else { stepX = 1; sideX = (mapX + 1 - posX) * deltaX; }
|
||||
if (rayY < 0) { stepY = -1; sideY = (posY - mapY) * deltaY; } else { stepY = 1; sideY = (mapY + 1 - posY) * deltaY; }
|
||||
|
||||
let hit = false, side = 0;
|
||||
const maxSteps = maxView * 2 + 4;
|
||||
for (let s = 0; s < maxSteps; s++) {
|
||||
if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
|
||||
else { sideY += deltaY; mapY += stepY; side = 1; }
|
||||
if (mapX < 0 || mapY < 0 || mapX >= w || mapY >= h) break; // off-map → open/far
|
||||
if (blocks(tiles[mapY][mapX])) { hit = true; break; }
|
||||
}
|
||||
const perp = side === 0 ? sideX - deltaX : sideY - deltaY;
|
||||
const dist = hit ? clamp(perp, 0.02, maxView) : maxView;
|
||||
if (x === centerX) ahead = dist;
|
||||
|
||||
// Project the 1-cell-tall wall: lineH = H/dist (fills the screen at dist 1).
|
||||
const lineH = H / dist;
|
||||
const ds = clamp(Math.floor(half - lineH / 2), 0, H);
|
||||
const de = clamp(Math.floor(half + lineH / 2), 0, H);
|
||||
|
||||
for (let y = 0; y < H; y++) {
|
||||
let dpix;
|
||||
if (y >= ds && y < de) {
|
||||
dpix = dist; // wall slice
|
||||
} else {
|
||||
// Floor (below) / ceiling (above). (0.5·H)/p is continuous with the
|
||||
// wall distance exactly at the slice edge, so the column has no seam.
|
||||
const p = y < half ? (half - y) : (y - half + 1);
|
||||
dpix = Math.min((0.5 * H) / p, maxView);
|
||||
}
|
||||
const t = clamp(dpix / maxView, 0, 1);
|
||||
gray[y * W + x] = Math.round(255 * Math.pow(1 - t, gamma));
|
||||
}
|
||||
}
|
||||
|
||||
return { width: W, height: H, gray, at, dir: dirName, ahead };
|
||||
}
|
||||
|
||||
// --- export glue (mirrors depth.js) --------------------------------------
|
||||
|
||||
export function exportFPVDataURL(env, opts = {}) {
|
||||
const field = computeFPVDepth(env, opts);
|
||||
return { url: depthToCanvas(field).toDataURL('image/png'), width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
|
||||
}
|
||||
|
||||
export async function exportFPVToServer(env, name, opts = {}) {
|
||||
const field = computeFPVDepth(env, opts);
|
||||
const blob = await new Promise((res) => depthToCanvas(field).toBlob(res, 'image/png'));
|
||||
const resp = await fetch('/save/' + encodeURIComponent(name), { method: 'POST', body: blob });
|
||||
if (!resp.ok) throw new Error('save failed: ' + resp.status);
|
||||
const info = await resp.json();
|
||||
return { ...info, width: field.width, height: field.height, at: field.at, dir: field.dir, ahead: field.ahead };
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
|
||||
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
|
||||
import { renderMap, getStage, THEMES } from './render.js';
|
||||
import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js';
|
||||
import { exportFPVDataURL, exportFPVToServer, vantage } from './fpv.js';
|
||||
import { dungeonManifest } from './explore.js';
|
||||
|
||||
const canvas = document.getElementById('view');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
|
@ -131,7 +134,8 @@ function buildControls() {
|
|||
seedRow.innerHTML = `
|
||||
<label>seed</label>
|
||||
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
|
||||
<button id="reroll">⟳ reroll</button>`;
|
||||
<button id="reroll">⟳ reroll</button>
|
||||
<button id="depth" title="export top-down depth map PNG (for the AI-render pipeline)">⬇ depth</button>`;
|
||||
panel.appendChild(seedRow);
|
||||
seedRow.querySelector('#seed').addEventListener('input', (e) => {
|
||||
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
|
||||
|
|
@ -142,6 +146,9 @@ function buildControls() {
|
|||
document.getElementById('seed').value = String(state.seed);
|
||||
regenerate();
|
||||
});
|
||||
seedRow.querySelector('#depth').addEventListener('click', () => {
|
||||
if (state.env) downloadDepth(state.env, { stage: state.stage });
|
||||
});
|
||||
|
||||
// Sliders.
|
||||
for (const [path, label, min, max, step] of SLIDERS) {
|
||||
|
|
@ -211,6 +218,35 @@ window.reikhelm = {
|
|||
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
|
||||
setOpt(k, v) { state.opts[k] = v; const cb = document.querySelector(`[data-opt="${k}"]`); if (cb) cb.checked = v; draw(); },
|
||||
setConfig(path, v) { setPath(state.config, path, v); regenerate(); },
|
||||
// Depth-map export (drives the AI-render pipeline). exportDepth → data URL +
|
||||
// dims (for headless capture); exportDepthToServer → POST the PNG to serve.py.
|
||||
exportDepth(opts) { return state.env ? exportDepthDataURL(state.env, opts) : null; },
|
||||
downloadDepth(opts) { return state.env ? downloadDepth(state.env, opts) : null; },
|
||||
exportDepthToServer(name, opts) { return state.env ? exportDepthToServer(state.env, name, opts) : null; },
|
||||
// First-person (Eye-of-the-Beholder style) depth — raycast from a room centre.
|
||||
exportFPV(opts) { return state.env ? exportFPVDataURL(state.env, opts) : null; },
|
||||
exportFPVToServer(name, opts) { return state.env ? exportFPVToServer(state.env, name, opts) : null; },
|
||||
vantage(opts) { return state.env ? vantage(state.env, opts) : null; },
|
||||
dungeonManifest() { return state.env ? dungeonManifest(state.env) : null; },
|
||||
// Save a JSON blob to the dev server (used to drop an atlas manifest to disk).
|
||||
async saveJSON(name, obj) {
|
||||
const r = await fetch('/save/' + name, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) });
|
||||
if (!r.ok) throw new Error('saveJSON failed: ' + r.status);
|
||||
return r.json();
|
||||
},
|
||||
// Export every room × 4 cardinal FPV depth maps + the manifest for a baked atlas.
|
||||
async bakeAtlasDepths(seed) {
|
||||
if (seed != null) this.setSeed(seed);
|
||||
const m = dungeonManifest(state.env); // nav + open already derived from real tile openings
|
||||
const sd = state.env.seed;
|
||||
for (const r of m.rooms) {
|
||||
for (const dir of ['N', 'E', 'S', 'W']) {
|
||||
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at: r.vantage, dir });
|
||||
}
|
||||
}
|
||||
await this.saveJSON(`atlas/${sd}/manifest.json`, m);
|
||||
return { seed: sd, rooms: m.rooms.length, frames: m.rooms.length * 4, themes: m.rooms.map((r) => `${r.id}:${r.theme}`) };
|
||||
},
|
||||
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
|
||||
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
|
||||
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
|
||||
|
|
|
|||
|
|
@ -94,8 +94,9 @@ export function getStage(env, stage) {
|
|||
// --- precomputed fields --------------------------------------------------
|
||||
|
||||
// Multi-source BFS distance (in cells) from every floor cell to the nearest
|
||||
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion.
|
||||
function distanceToWall(tiles, w, h) {
|
||||
// wall. Wall cells are 0; open cells grow outward. Drives ambient occlusion
|
||||
// here, and crevice AO in the depth-map exporter (depth.js) — hence exported.
|
||||
export function distanceToWall(tiles, w, h) {
|
||||
const dist = new Float32Array(w * h).fill(Infinity);
|
||||
const q = new Int32Array(w * h);
|
||||
let tail = 0;
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ input[type="range"] {
|
|||
height: 4px;
|
||||
}
|
||||
|
||||
.seed-row { grid-template-columns: 40px 1fr auto; }
|
||||
.seed-row { grid-template-columns: 40px 1fr auto auto; }
|
||||
.seed-row button { padding: 5px 7px; white-space: nowrap; }
|
||||
.seed-row input[type="number"] {
|
||||
background: #0c0c11;
|
||||
border: 1px solid var(--panel-edge);
|
||||
|
|
|
|||
8
tools/.gitignore
vendored
8
tools/.gitignore
vendored
|
|
@ -6,8 +6,14 @@ __pycache__/
|
|||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Ad-hoc generated render / dither outputs (regenerable). Curated keepers live in pixelart/samples/.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,33 +1,73 @@
|
|||
# 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. Two stages, glued by PNG files.
|
||||
art via a local diffusion render. Three stages, glued by PNG files.
|
||||
|
||||
```
|
||||
depth map ──► Stage 1: ComfyUI/Z-Image render ──► Stage 2: dither ──► pixel art
|
||||
(synthetic now; (comfy-spike/comfy.py) (pixelart/pixelate.py)
|
||||
real reikhelm
|
||||
geometry next)
|
||||
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` is pure stdlib (no venv). LAN calls to ComfyUI need the Bash sandbox disabled.
|
||||
`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`).
|
||||
```
|
||||
python3 comfy-spike/comfy.py depth --kind corridor --width 1536 --height 864 --out d.png # synthetic test depth
|
||||
python3 comfy-spike/comfy.py zrender http://192.168.1.26:8188 --depth d.png --prompt "..." --prefix scene
|
||||
# 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.65–0.85 to honor real geometry**.
|
||||
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:
|
||||
|
|
@ -39,11 +79,42 @@ tools/pixelart/.venv/bin/python pixelart/pixelate.py render.png --palette pixela
|
|||
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
|
||||
Wire **real reikhelm geometry** → depth-map export (top-down from the renderer's distance field, then a
|
||||
height layer) into Stage 1, so we render actual generated dungeons. Later: port the locked Stage-2 dither
|
||||
into a Rust crate so the engine owns the whole chain. See project memory.
|
||||
- **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.
|
||||
|
|
|
|||
155
tools/bake_atlas.py
Normal file
155
tools/bake_atlas.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bake a first-person dungeon atlas: every room's 4 cardinal views → pixel art.
|
||||
|
||||
Input (written by the browser, see explore-bake hook):
|
||||
tools/out/atlas/<seed>/_work/r<id>_<dir>_depth.png FPV depth maps (1280x720)
|
||||
tools/out/atlas/<seed>/manifest.json room graph (nav + vantages + tiles)
|
||||
|
||||
For each depth map this runs the proven chain — comfy.py zrender (Z-Image depth
|
||||
ControlNet) → pixelate.py (locked Primordyn dither) — and writes the explorer frame:
|
||||
tools/out/atlas/<seed>/r<id>_<dir>.png 640x360 indexed pixel art
|
||||
|
||||
The prompt is tank's "empty abandoned ruin" recipe: NO first-person/POV/crawler
|
||||
trigger words (they summon player hands), a fixed style-anchor block, and a FIXED
|
||||
seed across every frame so the palette + lighting stay coherent across the atlas.
|
||||
|
||||
python3 tools/bake_atlas.py --seed 7 [--url http://192.168.1.26:8188]
|
||||
[--strength 0.65] [--size 640x360] [--limit N]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
COMFY = os.path.join(REPO, "tools", "comfy-spike", "comfy.py")
|
||||
PIXELATE = os.path.join(REPO, "tools", "pixelart", "pixelate.py")
|
||||
VENV_PY = os.path.join(REPO, "tools", "pixelart", ".venv", "bin", "python")
|
||||
PALETTE = os.path.join(REPO, "tools", "pixelart", "primordyn_v2.json")
|
||||
RUN = os.getpid() # unique per invocation → ComfyUI never reuses a SaveImage prefix (re-bakes stay clean)
|
||||
|
||||
# Tank's anti-hands framing ("empty abandoned ruin, no people, architectural
|
||||
# photography" — never "first person / POV / dungeon crawler"), but the room's
|
||||
# CHARACTER now comes from its procgen theme and the view's actual openings, so
|
||||
# the atlas stops being a mashup of one generic hall. The style anchor stays
|
||||
# byte-identical across frames (consistency lever); the theme body + opening
|
||||
# clause vary per (room, facing).
|
||||
STYLE = ("dark fantasy, ancient torchlit stone, warm orange torchlight, deep black shadows, "
|
||||
"wet stone, volumetric haze, architectural photography of an empty ruin, "
|
||||
"empty, deserted, no people, uninhabited, atmospheric, highly detailed, ")
|
||||
THEME_BODY = {
|
||||
"forge": "molten forge chamber, channels of glowing orange lava, iron anvils and hanging chains, soot-blackened granite, fiery glow",
|
||||
"cistern": "flooded stone cistern, still dark water with mirror reflections, dripping wet walls, cold teal light",
|
||||
"crypt": "ancient crypt, carved stone sarcophagi and bone-filled wall niches, cobwebs, cold pale light",
|
||||
"library": "ruined library, tall stone shelves of rotting books and scrolls, drifting dust, warm dim light",
|
||||
"throne": "vast throne hall, a raised dais with a great stone throne, tattered banners, towering carved columns",
|
||||
"vault": "treasure vault, iron-bound chests and stone strongboxes, scattered gold, heavy locked stone",
|
||||
"hall": "grand pillared hall, rows of carved stone columns, high vaulted ceiling",
|
||||
"den": "foul beast den, gnawed bones and filth, claw-scratched walls, a feral lair, dim red light",
|
||||
"threshold": "dungeon gatehouse, a great iron portcullis and worn stone steps, torchlit entrance",
|
||||
"stone": "rough-hewn stone chamber, bare granite walls, plain and ancient",
|
||||
}
|
||||
DEFAULT_BODY = THEME_BODY["stone"]
|
||||
# These reinforce the depth (a deep recess vs a near wall straight ahead) so the
|
||||
# model paints a passage only where one actually exists — killing phantom doors.
|
||||
AHEAD_OPEN = "a dark arched passage leads onward into shadow ahead"
|
||||
AHEAD_WALL = "a solid carved stone wall closes the way ahead"
|
||||
NEGATIVE = ("person, people, human, figure, character, hands, fingers, hand, arm, arms, "
|
||||
"holding weapon, sword, feet, legs, body, silhouette, UI, HUD, text, watermark, modern")
|
||||
|
||||
|
||||
def prompt_for(theme, is_open):
|
||||
body = THEME_BODY.get(theme or "stone", DEFAULT_BODY)
|
||||
return f"{STYLE}{body}, {AHEAD_OPEN if is_open else AHEAD_WALL}"
|
||||
|
||||
NAME_RE = re.compile(r"^r(\d+)_([NESW])_depth\.png$")
|
||||
|
||||
|
||||
def run(cmd):
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
sys.stderr.write(p.stdout + p.stderr + "\n")
|
||||
return p.returncode == 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--seed", type=int, required=True, help="dungeon seed (selects the atlas dir)")
|
||||
ap.add_argument("--url", default="http://192.168.1.26:8188")
|
||||
ap.add_argument("--strength", type=float, default=0.85, help="depth control strength (0.85 honors the room footprint)")
|
||||
ap.add_argument("--render-seed", type=int, default=7, help="FIXED diffusion seed for every frame (style lock)")
|
||||
ap.add_argument("--size", default="640x360", help="pixel-art frame size (16:9; 320x180 for chunky)")
|
||||
ap.add_argument("--width", type=int, default=1280)
|
||||
ap.add_argument("--height", type=int, default=720)
|
||||
ap.add_argument("--rooms", default="", help="comma-separated room ids to bake (default all)")
|
||||
ap.add_argument("--limit", type=int, default=0, help="bake only the first N frames (smoke test)")
|
||||
a = ap.parse_args()
|
||||
|
||||
atlas = os.path.join(REPO, "tools", "out", "atlas", str(a.seed))
|
||||
work = os.path.join(atlas, "_work")
|
||||
|
||||
# Per-room theme + per-facing openness come from the manifest the browser wrote.
|
||||
rooms = {}
|
||||
mpath = os.path.join(atlas, "manifest.json")
|
||||
if os.path.exists(mpath):
|
||||
with open(mpath) as f:
|
||||
for r in json.load(f).get("rooms", []):
|
||||
rooms[int(r["id"])] = r
|
||||
|
||||
only = {int(x) for x in a.rooms.split(",") if x.strip()} if a.rooms else None
|
||||
|
||||
def order(p):
|
||||
m = NAME_RE.match(os.path.basename(p))
|
||||
return (int(m.group(1)), "NESW".index(m.group(2))) if m else (1 << 30, 0)
|
||||
depths = sorted(glob.glob(os.path.join(work, "r*_*_depth.png")), key=order)
|
||||
if only is not None:
|
||||
depths = [p for p in depths if NAME_RE.match(os.path.basename(p)) and int(NAME_RE.match(os.path.basename(p)).group(1)) in only]
|
||||
if not depths:
|
||||
sys.exit(f"!! no depth maps to bake in {work} — run the browser export first")
|
||||
if a.limit:
|
||||
depths = depths[:a.limit]
|
||||
|
||||
print(f"baking {len(depths)} frames → {os.path.relpath(atlas, REPO)} "
|
||||
f"(strength {a.strength}, render-seed {a.render_seed}, {a.size}, per-theme prompts)")
|
||||
ok = 0
|
||||
for i, depth in enumerate(depths, 1):
|
||||
m = NAME_RE.match(os.path.basename(depth))
|
||||
if not m:
|
||||
continue
|
||||
rid, d = m.group(1), m.group(2)
|
||||
room = rooms.get(int(rid), {})
|
||||
theme = room.get("theme")
|
||||
is_open = bool(room.get("open", {}).get(d, False))
|
||||
prompt = prompt_for(theme, is_open)
|
||||
prefix = f"r{rid}_{d}_{RUN}" # per-run unique; avoids ComfyUI prefix-counter collisions on re-bake
|
||||
frame = os.path.join(atlas, f"r{rid}_{d}.png")
|
||||
tag = f"[{i}/{len(depths)}] r{rid} {d} {theme or '?'}{'·open' if is_open else '·wall'}"
|
||||
|
||||
if not run(["python3", COMFY, "zrender", a.url, "--depth", depth,
|
||||
"--width", str(a.width), "--height", str(a.height),
|
||||
"--strength", str(a.strength), "--seed", str(a.render_seed),
|
||||
"--prompt", prompt, "--negative", NEGATIVE,
|
||||
"--prefix", prefix, "--wait", "300"]):
|
||||
print(f" {tag} RENDER FAILED"); continue
|
||||
|
||||
renders = glob.glob(os.path.join(work, f"out_{prefix}_*.png"))
|
||||
if not renders:
|
||||
print(f" {tag} no render output"); continue
|
||||
render = renders[0]
|
||||
|
||||
if not run([VENV_PY, PIXELATE, render, "--palette", PALETTE,
|
||||
"--size", a.size, "--preview-scale", "0", "--out", frame]):
|
||||
print(f" {tag} DITHER FAILED"); continue
|
||||
|
||||
os.remove(render) # keep the depth, drop the big intermediate render
|
||||
ok += 1
|
||||
print(f" {tag} → {os.path.relpath(frame, REPO)}")
|
||||
|
||||
print(f"done: {ok}/{len(depths)} frames baked into {os.path.relpath(atlas, REPO)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
105
tools/serve.py
Normal file
105
tools/serve.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Dev server for the reikhelm playground, headless depth/FPV export, and the
|
||||
pre-baked first-person explorer. Pure stdlib — no deps.
|
||||
|
||||
python3 tools/serve.py # serve reikhelm-web on :8000
|
||||
|
||||
Three things, one server:
|
||||
GET /<path> static file from the web root (reikhelm-web/)
|
||||
GET /out/<path> static file from the output tree (tools/out/) — lets the
|
||||
explorer load baked atlas frames + manifests
|
||||
POST /save/<path>.png write the raw request body (a PNG) under tools/out/<path>;
|
||||
sub-dirs allowed and created (e.g. atlas/7/r3_N.png)
|
||||
→ {"ok": true, "path": "...", "bytes": N}
|
||||
|
||||
All generated images live under tools/out/ (git-ignored, organized):
|
||||
out/depth/ top-down + one-off depth maps out/fpv/ first-person stills
|
||||
out/atlas/<seed>/ a baked explorable dungeon (frames + manifest.json)
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def safe_join(base, rel):
|
||||
"""Join rel under base, collapsing any '..' so it can't escape base."""
|
||||
rel = os.path.normpath("/" + rel).lstrip("/")
|
||||
return os.path.join(base, rel)
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
out_dir = "."
|
||||
|
||||
extensions_map = {
|
||||
**SimpleHTTPRequestHandler.extensions_map,
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".wasm": "application/wasm",
|
||||
".json": "application/json",
|
||||
}
|
||||
|
||||
# Route /out/<path> to the output tree; everything else from the web root (cwd).
|
||||
def translate_path(self, path):
|
||||
p = unquote(path.split("?", 1)[0].split("#", 1)[0])
|
||||
if p == "/out" or p.startswith("/out/"):
|
||||
return safe_join(self.out_dir, p[len("/out/"):]) if p != "/out" else self.out_dir
|
||||
return super().translate_path(path)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cache-Control", "no-store") # dev server: always serve fresh modules
|
||||
super().end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if not self.path.startswith("/save/"):
|
||||
self.send_error(404, "only /save/<path>.png is supported")
|
||||
return
|
||||
rel = unquote(self.path[len("/save/"):])
|
||||
if not (rel.endswith(".png") or rel.endswith(".json")):
|
||||
self.send_error(400, "path must end in .png or .json")
|
||||
return
|
||||
dest = safe_join(self.out_dir, rel)
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
f.write(body)
|
||||
payload = json.dumps({"ok": True, "path": dest, "bytes": len(body)}).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
sys.stderr.write(f"saved {os.path.relpath(dest, REPO)} ({len(body)} bytes)\n")
|
||||
|
||||
def log_message(self, fmt, *args): # quiet GETs; POSTs/errors print via do_POST/send_error
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--port", type=int, default=8000)
|
||||
ap.add_argument("--root", default=os.path.join(REPO, "reikhelm-web"), help="static web root")
|
||||
ap.add_argument("--out", default=os.path.join(REPO, "tools", "out"), help="output tree (served at /out, target of /save)")
|
||||
args = ap.parse_args()
|
||||
|
||||
Handler.out_dir = os.path.abspath(args.out)
|
||||
os.makedirs(Handler.out_dir, exist_ok=True)
|
||||
root = os.path.abspath(args.root)
|
||||
os.chdir(root)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
print(f"serving {os.path.relpath(root, REPO)} at http://127.0.0.1:{args.port}")
|
||||
print(f" /out/ → {os.path.relpath(Handler.out_dir, REPO)} POST /save/<path>.png writes there")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in a new issue