Adds RegionTheme + RoomThemer: a pure classifier (no tile changes) that labels each room (terrain-driven Forge/Cistern/Hall, special Throne/ Threshold, weighted Crypt/Den/Library/Vault/Stone). Renderer tints themed rooms; EntityPlacer biases contents by theme. Entrance/exit now chosen RNG-free from geometry so themer & placer agree. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
238 lines
9.3 KiB
JavaScript
238 lines
9.3 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, 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} <b>${counts[k]}</b>`)
|
||
.join(' · ');
|
||
readout.querySelector('#stats').innerHTML =
|
||
`seed <b>${env.seed}</b> · ${env.width}×${env.height} · ` +
|
||
`<b>${rooms}</b> rooms · ${corrs} corridors · ${doors} doors · ` +
|
||
`gen <b>${state.genMs.toFixed(1)}</b> ms<br>` +
|
||
`◆ <b>${byKind('Treasure')}</b> treasure · ● <b>${byKind('Monster')}</b> monsters · ` +
|
||
`${byKind('Entrance')} entrance · ${byKind('Exit')} exit` +
|
||
(legend ? `<br>${legend}` : '');
|
||
}
|
||
|
||
// --- 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', 'themes', 'entities', '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,
|
||
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);
|
||
});
|