reikhelm/reikhelm-web/main.js
Parley Hatch f849d82d31 feat(wasm+web): live browser dungeon playground (WASM core + atmospheric renderer)
Phase 0+1 of the browser viz effort:
- core: drop unused `rand` dep (removes getrandom/libc → clean wasm32 build);
  derive serde on DungeonConfig + 5 sub-configs so the bridge can (de)serialize
- reikhelm-wasm: thin wasm-bindgen cdylib exposing generate(seed, config_json)
  -> JSON envelope (tiles as int codes, regions, edges, per-pass snapshots) and
  default_config_json(); holds no generation logic
- workspace: add reikhelm-wasm member; default-members keeps host cargo
  test/clippy on core+viz only
- reikhelm-web: vanilla-JS + Canvas 2D renderer running the real core live in
  the browser. Atmospheric look — lit stone floor, raised stone walls with
  rim-lit carved edges + top bevel, room light-pooling, amber door thresholds,
  vignette. Controls: seed/reroll, 9 config sliders, stage scrubber, semantic
  overlays (region outlines / graph / grid). window.reikhelm hooks for
  Playwright-driven iteration.

Core stays 115 tests green. Driven + screenshotted via Playwright MCP.

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

205 lines
7.8 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],
];
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,
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);
});