reikhelm/reikhelm-web/main.js
Parley Hatch acc8137f4a fix(pipeline): kill dither speckle + atlas geometry lies (post-playtest)
Parley's review: dither full of green spots, baked rooms don't match the
nav graph (doors painted on closed walls, real openings off-center).
Both root-caused:

- pixelate.py: the nearest-color LUT was spaced in LINEAR RGB, cramming
  the whole shadow range into ~4 cells — dark picks were effectively
  arbitrary (green/teal confetti in warm near-blacks). LUT is now
  sRGB-spaced, diffusion error is gamut-clamped (no more blue blobs at
  torch highlights), and a new --overshoot penalty forbids choosing a
  color more saturated than the source. Green-dominant dark pixels on
  the test frame: 5.2% -> 0.02%, while palette usage went UP (107 -> 135
  colors). Recipe rev 2: append --overshoot 6.

- fpv.js: new directedVantage(env, region, dir) — per-(room,facing)
  camera instead of one room-wide vantage. Open ahead: stand in a cell
  whose straight-ahead ray escapes through the actual gap (the passage
  you walk is centered, 2-7 cells deep). Closed ahead: stand ~2 cells
  from the wall, deliberately OFF the room's axis — a symmetric stage
  begs the model to paint a centered focal door; an off-axis dead-end
  corner doesn't. Walls also render under their own fixed seed and an
  emphatic sealed-masonry prompt clause (cfg 1.0 negatives are inert).

Validated on seed 7 room 1: the closed-N phantom door is gone; the open-S
passage is dead-center. Full campaign re-bake follows as its own commit.

Also: rest-overlay no longer sticks without a keyup, in-combat drink
message, feathered sprite mask, no monster packs on floor 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:06:46 -06:00

277 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// main.js — app state, controls, and the WASM bridge wiring for the reikhelm
// browser playground. Loads the real reikhelm-core compiled to WASM, generates
// a dungeon in-browser, and hands the envelope to the renderer.
import init, { generate, default_config_json } from './pkg/reikhelm_wasm.js';
import { renderMap, getStage, THEMES } from './render.js';
import { downloadDepth, exportDepthDataURL, exportDepthToServer } from './depth.js';
import { exportFPVDataURL, exportFPVToServer, vantage, directedVantage } from './fpv.js';
import { dungeonManifest } from './explore.js';
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const panel = document.getElementById('controls');
const readout = document.getElementById('readout');
const stageWrap = document.getElementById('stage-wrap');
const stageSlider = document.getElementById('stage');
const stageLabel = document.getElementById('stage-label');
// --- app state -----------------------------------------------------------
const state = {
seed: 1,
config: null, // parsed DungeonConfig (nested object)
env: null, // last successful envelope
stage: 0, // snapshot index currently shown
followFinal: true,
opts: { lighting: true, themes: true, entities: true, outlines: false, graph: false, grid: false },
genMs: 0,
};
// Slider definitions: [config path, label, min, max, step].
const SLIDERS = [
['width', 'map width', 32, 160, 1],
['height', 'map height', 24, 100, 1],
['bsp.max_depth', 'BSP depth', 1, 7, 1],
['bsp.min_leaf', 'min leaf', 6, 24, 1],
['rooms.min_size', 'room min', 3, 16, 1],
['rooms.max_size', 'room max', 4, 22, 1],
['rooms.margin', 'margin', 0, 3, 1],
['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02],
['doors.door_chance', 'door chance', 0, 1, 0.05],
['rooms.shapes.rect', '▢ rect', 0, 3, 0.1],
['rooms.shapes.octagon', '⬡ octagon', 0, 3, 0.1],
['rooms.shapes.ellipse', '◯ ellipse', 0, 3, 0.1],
['rooms.shapes.plus', '✚ plus', 0, 3, 0.1],
['pillars.room_chance', 'pillars', 0, 1, 0.05],
['pools.water_chance', '≈ water', 0, 1, 0.05],
['pools.lava_chance', '♨ lava', 0, 1, 0.05],
['entities.treasure_chance', '◆ treasure', 0, 1, 0.05],
['entities.monster_chance', '● monsters', 0, 1, 0.05],
];
function getPath(obj, path) {
return path.split('.').reduce((o, k) => o[k], obj);
}
function setPath(obj, path, val) {
const keys = path.split('.');
const last = keys.pop();
keys.reduce((o, k) => o[k], obj)[last] = val;
}
// --- generation + draw ---------------------------------------------------
function regenerate() {
const t0 = performance.now();
const json = generate(state.seed, JSON.stringify(state.config));
state.genMs = performance.now() - t0;
const env = JSON.parse(json);
if (!env.ok) {
readout.querySelector('#err').textContent = '⚠ ' + env.error;
return; // keep last good render
}
readout.querySelector('#err').textContent = '';
state.env = env;
const lastStage = (env.snapshots?.length ?? 1) - 1;
if (state.followFinal) state.stage = lastStage;
stageSlider.max = String(lastStage);
stageSlider.value = String(state.stage);
draw();
updateReadout();
}
function draw() {
if (!state.env) return;
const rect = canvas.getBoundingClientRect();
renderMap(ctx, rect.width, rect.height, state.env, { ...state.opts, stage: state.stage });
const st = getStage(state.env, state.stage);
const last = (state.env.snapshots?.length ?? 1) - 1;
stageLabel.textContent = `${state.stage + 1}/${last + 1} · ${st.label}${state.stage === last ? ' (final)' : ''}`;
}
function countTiles(env, code) {
let n = 0;
for (const row of env.tiles) for (const t of row) if (t === code) n++;
return n;
}
// Count themed rooms by theme id (final-stage regions carry the theme tag).
function themeCounts(env) {
const counts = {};
for (const r of env.regions) {
if (r.kind === 'Room' && r.theme) counts[r.theme] = (counts[r.theme] || 0) + 1;
}
return counts;
}
function updateReadout() {
const env = state.env;
const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length;
const corrs = env.regions.filter((r) => r.kind === 'Corridor').length;
const doors = countTiles(env, 2);
const ents = env.entities || [];
const byKind = (k) => ents.filter((e) => e.kind === k).length;
const counts = themeCounts(env);
const legend = Object.keys(THEMES)
.filter((k) => counts[k])
.map((k) => `${THEMES[k].glyph} ${THEMES[k].label} <b>${counts[k]}</b>`)
.join(' · ');
readout.querySelector('#stats').innerHTML =
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
`gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit` +
(legend ? `<br>${legend}` : '');
}
// --- controls UI ---------------------------------------------------------
function buildControls() {
// Seed row.
const seedRow = document.createElement('div');
seedRow.className = 'row seed-row';
seedRow.innerHTML = `
<label>seed</label>
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
<button id="reroll">⟳ reroll</button>
<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));
regenerate();
});
seedRow.querySelector('#reroll').addEventListener('click', () => {
state.seed = Math.floor(Math.random() * 1e9);
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) {
const row = document.createElement('div');
row.className = 'row';
const val = getPath(state.config, path);
row.innerHTML = `
<label>${label}</label>
<input type="range" data-path="${path}" min="${min}" max="${max}" step="${step}" value="${val}">
<span class="val" data-for="${path}">${fmt(val, step)}</span>`;
panel.appendChild(row);
const slider = row.querySelector('input');
slider.addEventListener('input', (e) => {
const v = step < 1 ? parseFloat(e.target.value) : parseInt(e.target.value, 10);
setPath(state.config, path, v);
row.querySelector('.val').textContent = fmt(v, step);
regenerate();
});
}
// Toggles.
const togRow = document.createElement('div');
togRow.className = 'row toggles';
togRow.innerHTML = ['lighting', 'themes', 'entities', 'outlines', 'graph', 'grid']
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
.join('');
panel.appendChild(togRow);
togRow.querySelectorAll('input').forEach((cb) => {
cb.addEventListener('change', (e) => {
state.opts[e.target.dataset.opt] = e.target.checked;
draw();
});
});
}
function fmt(v, step) { return step < 1 ? v.toFixed(2) : String(v); }
// Stage scrubber.
stageSlider.addEventListener('input', (e) => {
state.stage = parseInt(e.target.value, 10);
const last = (state.env?.snapshots?.length ?? 1) - 1;
state.followFinal = state.stage === last;
draw();
});
// Keyboard: R reroll, ←/→ scrub stages.
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'r' || e.key === 'R') document.getElementById('reroll').click();
if (e.key === 'ArrowLeft') { stageSlider.value = String(Math.max(0, state.stage - 1)); stageSlider.dispatchEvent(new Event('input')); }
if (e.key === 'ArrowRight') { const last = (state.env?.snapshots?.length ?? 1) - 1; stageSlider.value = String(Math.min(last, state.stage + 1)); stageSlider.dispatchEvent(new Event('input')); }
});
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
window.addEventListener('resize', resize);
// --- automation hooks (used by Playwright-driven iteration) --------------
window.reikhelm = {
setSeed(s) { state.seed = s | 0; const el = document.getElementById('seed'); if (el) el.value = String(s); regenerate(); },
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
setOpt(k, v) { state.opts[k] = v; const cb = document.querySelector(`[data-opt="${k}"]`); if (cb) cb.checked = v; draw(); },
setConfig(path, v) { setPath(state.config, path, v); regenerate(); },
// 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']) {
// Per-direction camera: gap-aligned when the way is open, near-wall
// when it's closed — so the depth map can't be misread by the model.
const at = directedVantage(state.env, state.env.regions[r.id], dir);
await exportFPVToServer(state.env, `atlas/${sd}/_work/r${r.id}_${dir}_depth.png`, { at, dir });
}
}
await this.saveJSON(`atlas/${sd}/manifest.json`, m);
return { seed: sd, rooms: m.rooms.length, frames: m.rooms.length * 4, themes: m.rooms.map((r) => `${r.id}:${r.theme}`) };
},
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
water: state.env ? countTiles(state.env, 4) : 0,
lava: state.env ? countTiles(state.env, 5) : 0,
entities: state.env ? state.env.entities.length : 0,
themes: state.env ? themeCounts(state.env) : {},
ok: !!state.env }),
};
// --- boot ----------------------------------------------------------------
async function boot() {
await init();
state.config = JSON.parse(default_config_json());
buildControls();
resize(); // sizes the canvas and triggers the first draw via regenerate below
regenerate();
document.body.dataset.ready = '1'; // a flag Playwright can wait on
}
boot().catch((e) => {
readout.querySelector('#err').textContent = 'boot failed: ' + e;
console.error(e);
});