reikhelm/reikhelm-web/main.js
Parley Hatch 00502153f3 feat(core): items & creatures — entity layer + EntityPlacer (9th pass)
The roadmap capstone: the dungeon is now populated.

- New entity layer: src/entity.rs (Entity { kind, at }, EntityKind {Entrance,
  Exit, Treasure, Monster}); Map gains an `entities: Vec<Entity>` overlay field
  (serde round-tripped). Entities aren't terrain, so no new Tile and the frozen
  viz is untouched.
- EntityPlacer/EntityConfig: places one Entrance (random room), one Exit (the
  room farthest from it — a traversal goal), per-room Treasure (treasure_chance)
  and Monsters (monster_chance, 1..=max, never in the entrance room). All on
  plain Floor only, never two per cell, fully deterministic.
- Handoff via the Blackboard (the designed side channel for non-tile/region/edge
  data) under entity::BLACKBOARD_KEY, harvested by Pipeline into Map.entities —
  so GenContext's 25 literals stay untouched.
- Renderer draws iconic markers on the final stage: green beacon (entrance),
  cyan portal (exit), gold diamond (treasure), crimson eyed-blob (monster), each
  with a soft glow. New entities toggle + treasure/monster sliders + readout.

Core: 135 tests green (6 new: one entrance/one exit on distinct floor, entrance
room monster-free, determinism, empty-map, recipe populated-check). clippy
clean. Pipeline is now 9 passes.

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

222 lines
8.7 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, entities: true },
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;
}
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;
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`;
}
// --- 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', '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,
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);
});