// 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'; 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} ${counts[k]}`) .join(' · '); readout.querySelector('#stats').innerHTML = `seed ${env.seed} · ${env.width}×${env.height} · ` + `${rooms} rooms · ${corrs} corridors · ${doors} doors · ` + `gen ${state.genMs.toFixed(1)} ms
` + `◆ ${byKind('Treasure')} treasure · ● ${byKind('Monster')} monsters · ` + `${byKind('Entrance')} entrance · ${byKind('Exit')} exit` + (legend ? `
${legend}` : ''); } // --- controls UI --------------------------------------------------------- function buildControls() { // Seed row. const seedRow = document.createElement('div'); seedRow.className = 'row seed-row'; seedRow.innerHTML = ` `; 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 = ` ${fmt(val, step)}`; 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) => ``) .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, 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); });