Per feedback ("not every room needs pillars"): default PillarConfig drops to
room_chance 0.30 / min_room 7 (was 0.55/7). A typical dungeon now has a couple
of distinctive pillared halls rather than pillars in most rooms — measured ~14
of 24 default seeds carry any pillars at all, usually just one room's worth.
The pillars slider still goes to 1.0 for full colonnades.
Also expose pillarTiles in the web state() hook (iteration/measurement aid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
8.1 KiB
JavaScript
211 lines
8.1 KiB
JavaScript
// 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],
|
||
];
|
||
|
||
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,
|
||
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);
|
||
});
|