reikhelm/reikhelm-web/main.js
Parley Hatch a1d2a63937 feat(core): polygonal room shapes (rect/octagon/ellipse/plus)
RoomCarver now rolls a per-room shape within its chosen rectangle, weighted by
a new RoomConfig.shapes (ShapeWeights). The rect is still picked first and
unchanged, so room centers — and the corridors wired between them — are
identical to before; only the carved interior differs.

- Shapes built from per-row contiguous spans → guaranteed 4-connected and
  convex, so flood-fill never strands a cell.
- Hard invariant: every shape contains rect.center() (the corridor target),
  with a fallback to a full rect for tiny rooms or any non-covering shape —
  keeps MstConnect/CorridorCarver correct.
- ShapeWeights::default() is rect-only (preserves old behavior + existing
  tests); the dungeon recipe opts into ShapeWeights::varied().
- Configs lose Eq (now carry f64 weights); serde unaffected.

Core: 116 tests green (new forced-shape invariants test: in-bounds, center
carved, non-rectangular, 4-connected); connectivity + determinism + door tests
all pass with varied shapes active. Surfaced as 4 shape-weight sliders in the
web playground.

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

209 lines
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],
['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],
];
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);
});