reikhelm/reikhelm-web/main.js
Parley Hatch e7e8bb9187 feat(core): water & lava pools (PoolDecorator) + lava lighting
Seventh pass (before pillars): floods an organic blob of Water or Lava into
some larger rooms via randomized BFS growth.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:41:48 -06:00

215 lines
8.3 KiB
JavaScript
Raw 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 } from './render.js';
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const panel = document.getElementById('controls');
const readout = document.getElementById('readout');
const stageWrap = document.getElementById('stage-wrap');
const stageSlider = document.getElementById('stage');
const stageLabel = document.getElementById('stage-label');
// --- app state -----------------------------------------------------------
const state = {
seed: 1,
config: null, // parsed DungeonConfig (nested object)
env: null, // last successful envelope
stage: 0, // snapshot index currently shown
followFinal: true,
opts: { lighting: true, outlines: false, graph: false, grid: false },
genMs: 0,
};
// Slider definitions: [config path, label, min, max, step].
const SLIDERS = [
['width', 'map width', 32, 160, 1],
['height', 'map height', 24, 100, 1],
['bsp.max_depth', 'BSP depth', 1, 7, 1],
['bsp.min_leaf', 'min leaf', 6, 24, 1],
['rooms.min_size', 'room min', 3, 16, 1],
['rooms.max_size', 'room max', 4, 22, 1],
['rooms.margin', 'margin', 0, 3, 1],
['connect.extra_edge_ratio', 'loop edges', 0, 0.8, 0.02],
['doors.door_chance', 'door chance', 0, 1, 0.05],
['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],
];
function getPath(obj, path) {
return path.split('.').reduce((o, k) => o[k], obj);
}
function setPath(obj, path, val) {
const keys = path.split('.');
const last = keys.pop();
keys.reduce((o, k) => o[k], obj)[last] = val;
}
// --- generation + draw ---------------------------------------------------
function regenerate() {
const t0 = performance.now();
const json = generate(state.seed, JSON.stringify(state.config));
state.genMs = performance.now() - t0;
const env = JSON.parse(json);
if (!env.ok) {
readout.querySelector('#err').textContent = '⚠ ' + env.error;
return; // keep last good render
}
readout.querySelector('#err').textContent = '';
state.env = env;
const lastStage = (env.snapshots?.length ?? 1) - 1;
if (state.followFinal) state.stage = lastStage;
stageSlider.max = String(lastStage);
stageSlider.value = String(state.stage);
draw();
updateReadout();
}
function draw() {
if (!state.env) return;
const rect = canvas.getBoundingClientRect();
renderMap(ctx, rect.width, rect.height, state.env, { ...state.opts, stage: state.stage });
const st = getStage(state.env, state.stage);
const last = (state.env.snapshots?.length ?? 1) - 1;
stageLabel.textContent = `${state.stage + 1}/${last + 1} · ${st.label}${state.stage === last ? ' (final)' : ''}`;
}
function countTiles(env, code) {
let n = 0;
for (const row of env.tiles) for (const t of row) if (t === code) n++;
return n;
}
function updateReadout() {
const env = state.env;
const rooms = env.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length;
const corrs = env.regions.filter((r) => r.kind === 'Corridor').length;
const doors = countTiles(env, 2);
readout.querySelector('#stats').innerHTML =
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
`${env.edges.length} edges · gen <b>${state.genMs.toFixed(1)}</b> ms`;
}
// --- controls UI ---------------------------------------------------------
function buildControls() {
// Seed row.
const seedRow = document.createElement('div');
seedRow.className = 'row seed-row';
seedRow.innerHTML = `
<label>seed</label>
<input type="number" id="seed" min="0" step="1" value="${state.seed}">
<button id="reroll">⟳ reroll</button>`;
panel.appendChild(seedRow);
seedRow.querySelector('#seed').addEventListener('input', (e) => {
state.seed = Math.max(0, parseInt(e.target.value || '0', 10));
regenerate();
});
seedRow.querySelector('#reroll').addEventListener('click', () => {
state.seed = Math.floor(Math.random() * 1e9);
document.getElementById('seed').value = String(state.seed);
regenerate();
});
// Sliders.
for (const [path, label, min, max, step] of SLIDERS) {
const row = document.createElement('div');
row.className = 'row';
const val = getPath(state.config, path);
row.innerHTML = `
<label>${label}</label>
<input type="range" data-path="${path}" min="${min}" max="${max}" step="${step}" value="${val}">
<span class="val" data-for="${path}">${fmt(val, step)}</span>`;
panel.appendChild(row);
const slider = row.querySelector('input');
slider.addEventListener('input', (e) => {
const v = step < 1 ? parseFloat(e.target.value) : parseInt(e.target.value, 10);
setPath(state.config, path, v);
row.querySelector('.val').textContent = fmt(v, step);
regenerate();
});
}
// Toggles.
const togRow = document.createElement('div');
togRow.className = 'row toggles';
togRow.innerHTML = ['lighting', 'outlines', 'graph', 'grid']
.map((k) => `<label class="tog"><input type="checkbox" data-opt="${k}" ${state.opts[k] ? 'checked' : ''}>${k}</label>`)
.join('');
panel.appendChild(togRow);
togRow.querySelectorAll('input').forEach((cb) => {
cb.addEventListener('change', (e) => {
state.opts[e.target.dataset.opt] = e.target.checked;
draw();
});
});
}
function fmt(v, step) { return step < 1 ? v.toFixed(2) : String(v); }
// Stage scrubber.
stageSlider.addEventListener('input', (e) => {
state.stage = parseInt(e.target.value, 10);
const last = (state.env?.snapshots?.length ?? 1) - 1;
state.followFinal = state.stage === last;
draw();
});
// Keyboard: R reroll, ←/→ scrub stages.
window.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'r' || e.key === 'R') document.getElementById('reroll').click();
if (e.key === 'ArrowLeft') { stageSlider.value = String(Math.max(0, state.stage - 1)); stageSlider.dispatchEvent(new Event('input')); }
if (e.key === 'ArrowRight') { const last = (state.env?.snapshots?.length ?? 1) - 1; stageSlider.value = String(Math.min(last, state.stage + 1)); stageSlider.dispatchEvent(new Event('input')); }
});
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.floor(rect.width * dpr);
canvas.height = Math.floor(rect.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
window.addEventListener('resize', resize);
// --- automation hooks (used by Playwright-driven iteration) --------------
window.reikhelm = {
setSeed(s) { state.seed = s | 0; const el = document.getElementById('seed'); if (el) el.value = String(s); regenerate(); },
setStage(i) { stageSlider.value = String(i); stageSlider.dispatchEvent(new Event('input')); },
setOpt(k, v) { state.opts[k] = v; const cb = document.querySelector(`[data-opt="${k}"]`); if (cb) cb.checked = v; draw(); },
setConfig(path, v) { setPath(state.config, path, v); regenerate(); },
state: () => ({ seed: state.seed, stage: state.stage, genMs: state.genMs, config: state.config,
rooms: state.env?.regions.filter((r) => r.kind === 'Room' && r.cells.length > 0).length,
pillarTiles: state.env ? countTiles(state.env, 3) : 0,
water: state.env ? countTiles(state.env, 4) : 0,
lava: state.env ? countTiles(state.env, 5) : 0,
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);
});