reikhelm/game-web/game.js
Parley Hatch 5813ee3b5a feat(game): REIKHELM: DESCENT — playable first-person roguelike
The pieces finally meet: procgen dungeons + the baked EoB atlas pipeline +
the Vigor combat engine become one game. Three fixed pre-baked floors
(seeds 7 → 69 → 59, 208 frames), monsters/loot/encounters rerolled per run
and composited over the empty scenes; the Ember Sovereign waits at the
bottom.

- game-wasm: run state machine in Rust (manifest nav graph as dungeon
  truth, seeded spawning by theme/depth, embedded combat-core Encounter,
  vigor/fatigue persistence between fights, rest/potions/relics/flee,
  permadeath + victory). 8 native tests incl. a whole-run bot and a
  ScriptedPlayer boss-winnability probe used for tuning.
- game-web: no-framework front-end — title/death/victory screens, baked
  frames with screen-blend monster portraits, combat overlay (dual vigor
  bars, cast telegraphs, cooldown sweeps, floaters), minimap with fog,
  all SFX synthesized in WebAudio.
- balance: floor-scaled levels with a hero's edge over trash, no packs on
  floor 1, boss tuned by sim sweep (~35%/55% win at skill 50/80).
- campaign atlases now versioned (tools/.gitignore exception); boss
  portrait added to the Z-Image roster.

Verified end-to-end in-browser: an autopilot beat the full game through
the real UI (10 fights, 2 flees, Sovereign down), and died plenty before
that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:30:50 -06:00

677 lines
23 KiB
JavaScript

// game.js — REIKHELM: DESCENT front-end. A thin view over game-wasm: every
// input calls into the Game and re-renders from the returned state payload;
// the only client-side state is presentation (timers, animations, the log).
import init, { Game } from './pkg/game_wasm.js';
import { sfx } from './audio.js';
const CAMPAIGN = [
{ seed: 7, name: 'The Hollow Threshold' },
{ seed: 69, name: 'The Drowned Halls' },
{ seed: 59, name: 'The Burning Deep' },
];
const ROMAN = ['I', 'II', 'III'];
const TICK_MS = 100;
// ------------------------------------------------------------------ //
// DOM
// ------------------------------------------------------------------ //
const $ = (id) => document.getElementById(id);
const screens = { title: $('title'), game: $('game'), death: $('death'), victory: $('victory') };
const frame = $('frame');
const frameFade = $('frame-fade');
const sprites = $('sprites');
const floaters = $('floaters');
const banner = $('banner');
const enemiesBox = $('enemies');
const logBox = $('log');
const actionbar = $('actionbar');
const minimap = $('minimap');
const mctx = minimap.getContext('2d');
// ------------------------------------------------------------------ //
// Session
// ------------------------------------------------------------------ //
let configText = null;
let manifests = []; // parsed, in floor order (minimap needs tiles)
let manifestsText = null; // raw JSON array string for the wasm constructor
let game = null; // wasm Game
let st = null; // latest state payload
let abilities = []; // player loadout views
let seed = roll();
let lastArch = 'duelist';
let ticker = null;
let target = 1;
let castMax = {}; // actor id -> longest cast seen (telegraph scale)
let lastGrowl = 0;
function roll() { return 1 + Math.floor(Math.random() * 999_999); }
function show(name) {
for (const k in screens) screens[k].classList.toggle('show', k === name);
}
// ------------------------------------------------------------------ //
// Boot
// ------------------------------------------------------------------ //
async function boot() {
await init();
configText = await (await fetch('config.json')).text();
manifests = await Promise.all(
CAMPAIGN.map(async (f) => (await fetch(`/out/atlas/${f.seed}/manifest.json`)).json()),
);
manifestsText = JSON.stringify(manifests);
$('seed-label').textContent = String(seed).padStart(6, '0');
$('reroll').onclick = () => {
seed = roll();
$('seed-label').textContent = String(seed).padStart(6, '0');
sfx.unlock(); sfx.turn();
};
document.querySelectorAll('.arch-card').forEach((card) => {
card.onclick = () => { sfx.unlock(); startRun(card.dataset.arch); };
});
$('btn-retry').onclick = () => { seed = roll(); startRun(lastArch); };
$('btn-again').onclick = () => { seed = roll(); startRun(lastArch); };
$('btn-auto').onclick = () => toggleAuto();
$('btn-flee').onclick = () => flee();
document.body.dataset.ready = '1';
}
function startRun(arch) {
lastArch = arch;
stopTicker();
game = new Game(configText, manifestsText, arch, BigInt(seed));
abilities = JSON.parse(game.abilities());
buildActionbar();
logBox.innerHTML = '';
show('game');
sfx.ambientOn();
floorCard(0);
log(`the descent begins — fate ${String(seed).padStart(6, '0')}`);
apply(game.state());
}
// ------------------------------------------------------------------ //
// State pump: every interaction funnels through apply()
// ------------------------------------------------------------------ //
function apply(json) {
st = JSON.parse(json);
for (const ev of st.events) onEvent(ev);
render();
}
function onEvent(ev) {
switch (ev.t) {
case 'moved': sfx.step(); dip(110); break;
case 'bump': sfx.bump(); break;
case 'rested': sfx.rest(); showRest(); break;
case 'loot': {
sfx.loot();
flash('belt-gold');
let s = `you find ${ev.gold} gold`;
if (ev.item === 'draught') { s += ' and a vigor draught'; flash('belt-draught'); }
if (ev.item === 'tonic') { s += ' and a stamina tonic'; flash('belt-tonic'); }
if (ev.item === 'relic') s += '…';
log(s, 'good');
break;
}
case 'relic':
sfx.relic();
flash('belt-relic');
log(`☥ a RELIC of the old kings — your pool widens (${ev.total})`, 'good');
break;
case 'item_used':
if (ev.ok) { sfx.potion(); log(ev.kind === 'draught' ? 'the draught burns going down' : 'the tonic steadies your limbs', 'good'); }
else if (st.mode === 'combat') log('no drinking while something wants you dead', 'bad');
else log(`no ${ev.kind} left on your belt`, 'bad');
break;
case 'encounter': {
sfx.encounter();
bannerShow(ev.monsters.join(' & '));
log(`${ev.monsters.join(' and ')} bars the way!`, 'bad');
// let the lunge land before the swing timers start
setTimeout(() => { if (st && st.mode === 'combat') startTicker(); }, 750);
break;
}
case 'win_fight': {
sfx.monsterDeath();
let s = `the room falls silent — ${ev.gold} gold`;
if (ev.drops.length) s += `, dropped: ${ev.drops.join(', ')}`;
log(s, 'good');
flash('belt-gold');
break;
}
case 'fled':
sfx.fled();
log('you break and run — the effort marks you', 'bad');
break;
case 'descend':
sfx.descend();
floorCard(ev.floor);
break;
case 'player_died':
sfx.death();
setTimeout(() => endDeath(ev.by), 1700);
break;
case 'victory':
sfx.victory();
setTimeout(endVictory, 1900);
break;
}
}
// ------------------------------------------------------------------ //
// Render
// ------------------------------------------------------------------ //
function render() {
const floorMeta = CAMPAIGN[st.floor];
$('floor-label').innerHTML = `floor <b>${ROMAN[st.floor]}</b> · ${floorMeta.name}`;
$('compass').textContent = `${st.facing}`;
setFrame(`/out/atlas/${st.floor_seed}/r${st.room}_${st.facing}.png`);
preloadAround();
renderPlayer();
renderBelt();
drawMinimap();
const inCombat = st.mode === 'combat' || (st.combat && st.combat.outcome);
renderSprites(inCombat);
renderEnemies(inCombat);
actionbar.classList.toggle('on', inCombat);
$('combat-controls').classList.toggle('on', inCombat);
if (st.combat) renderCombat();
$('descend-prompt').classList.toggle(
'show',
st.mode === 'explore' && st.at_exit && st.floor + 1 < st.floors_total,
);
$('danger-vignette').classList.toggle('on', st.mode === 'explore' && st.adjacent_danger);
if (st.mode === 'explore' && st.adjacent_danger && Date.now() - lastGrowl > 6000) {
lastGrowl = Date.now();
sfx.growl();
}
}
let frameUrl = null;
function setFrame(url) {
if (frameUrl === url) return;
frameUrl = url;
frame.src = url;
}
function dip(ms) {
frameFade.classList.add('dip');
setTimeout(() => frameFade.classList.remove('dip'), ms);
}
const preloaded = new Set();
function preloadAround() {
const m = manifests[st.floor];
const here = m.rooms.find((r) => r.id === st.room);
const want = [st.room];
if (here) for (const d of ['N', 'E', 'S', 'W']) if (here.nav[d] != null) want.push(here.nav[d]);
for (const id of want) {
for (const d of ['N', 'E', 'S', 'W']) {
const u = `/out/atlas/${st.floor_seed}/r${id}_${d}.png`;
if (!preloaded.has(u)) { preloaded.add(u); new Image().src = u; }
}
}
}
// ---------------- player HUD ---------------- //
function setVbar(el, a) {
const capPct = (a.cap / a.max_vigor) * 100;
el.querySelector('.vbar-cap').style.width = `${capPct}%`;
el.querySelector('.vbar-fill').style.width = `${(a.vigor / a.max_vigor) * 100}%`;
el.querySelector('.vbar-ceiling').style.left = `calc(${capPct}% - 1px)`;
el.querySelector('.vbar-num').textContent =
`${Math.round(a.vigor)} · cap ${Math.round(a.cap)} / ${Math.round(a.max_vigor)}`;
}
function renderPlayer() {
const me = st.combat ? st.combat.actors.find((a) => a.id === 0) : st.player;
setVbar($('player-vbar'), me);
const cast = st.combat && st.combat.actors[0].casting;
const bar = $('player-cast');
bar.classList.toggle('on', !!cast);
bar.classList.add('player');
if (cast) {
castMax[0] = Math.max(castMax[0] || 1, cast.remaining);
bar.firstElementChild.style.width = `${(1 - cast.remaining / castMax[0]) * 100}%`;
} else castMax[0] = 1;
}
function renderBelt() {
$('belt-gold').querySelector('b').textContent = st.player.gold;
$('belt-draught').querySelector('b').textContent = st.player.draughts;
$('belt-tonic').querySelector('b').textContent = st.player.tonics;
$('belt-relic').querySelector('b').textContent = st.player.relics;
}
function flash(id) {
const el = $(id);
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
// ---------------- monsters ---------------- //
let spriteIds = null; // signature of current sprite set
function renderSprites(inCombat) {
if (!inCombat) {
if (sprites.childElementCount) { sprites.innerHTML = ''; spriteIds = null; }
return;
}
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
const sig = foes.map((f) => f.id).join(',');
if (sig !== spriteIds) {
spriteIds = sig;
sprites.innerHTML = '';
const lanes = foes.length === 1 ? [50] : [37, 63];
foes.forEach((f, i) => {
const div = document.createElement('div');
div.className = `sprite enter slot-${i}`;
div.style.left = `${lanes[i]}%`;
div.dataset.actor = f.id;
div.innerHTML = `<img src="portraits/${f.archetype || 'skeleton'}.png" alt=""><span class="tgt">▼</span>`;
div.onclick = () => setTarget(f.id);
sprites.appendChild(div);
setTimeout(() => div.classList.remove('enter'), 500);
});
}
for (const f of foes) {
const div = sprites.querySelector(`[data-actor="${f.id}"]`);
if (!div) continue;
div.classList.toggle('dazed', f.stagger === 'dazed');
if ((f.stagger === 'dead' || f.stagger === 'unconscious') && !div.classList.contains('dead')) {
div.classList.add('dead');
}
div.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
}
}
let panelIds = null;
function renderEnemies(inCombat) {
if (!inCombat) {
if (enemiesBox.childElementCount) { enemiesBox.innerHTML = ''; panelIds = null; }
return;
}
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1) : [];
const sig = foes.map((f) => f.id).join(',');
if (sig !== panelIds) {
panelIds = sig;
enemiesBox.innerHTML = '';
for (const f of foes) {
const div = document.createElement('div');
div.className = 'epanel';
div.dataset.actor = f.id;
div.innerHTML = `
<div class="ehead"><span class="ename">${f.name}</span><span class="elvl">lvl ${f.level}</span></div>
<div class="vbar"><div class="vbar-cap"></div><div class="vbar-fill"></div><div class="vbar-ceiling"></div><div class="vbar-num"></div></div>
<div class="castbar"><div></div></div>
<div class="estatus"></div>`;
div.onclick = () => setTarget(f.id);
enemiesBox.appendChild(div);
}
}
for (const f of foes) {
const panel = enemiesBox.querySelector(`[data-actor="${f.id}"]`);
if (!panel) continue;
setVbar(panel.querySelector('.vbar'), f);
panel.classList.toggle('dead', f.stagger === 'dead' || f.stagger === 'unconscious');
panel.classList.toggle('targeted', f.id === target && f.stagger !== 'dead');
const cb = panel.querySelector('.castbar div');
if (f.casting) {
castMax[f.id] = Math.max(castMax[f.id] || 1, f.casting.remaining);
cb.style.width = `${(1 - f.casting.remaining / castMax[f.id]) * 100}%`;
} else { cb.style.width = '0%'; castMax[f.id] = 1; }
const chips = [];
if (f.stagger === 'dazed') chips.push('<span class="chip daze">DAZED</span>');
for (const s of f.statuses) chips.push(`<span class="chip">${s.replace('_', ' ')}</span>`);
panel.querySelector('.estatus').innerHTML = chips.join('');
}
}
// ---------------- combat ---------------- //
function startTicker() {
if (ticker) return;
ticker = setInterval(() => {
if (!game) return;
apply(game.combatTick());
}, TICK_MS);
}
function stopTicker() {
if (ticker) { clearInterval(ticker); ticker = null; }
}
function renderCombat() {
// retarget if the focus dropped
const foes = st.combat.actors.filter((a) => a.team === 1);
const focus = foes.find((f) => f.id === target);
if (!focus || focus.stagger === 'dead' || focus.stagger === 'unconscious') {
const alive = foes.find((f) => f.stagger === 'normal' || f.stagger === 'dazed');
if (alive) setTarget(alive.id, true);
}
for (const ev of st.combat.events) onCombatEvent(ev);
updateActionbar();
$('btn-auto').classList.toggle('lit', st.player.auto_attack);
if (st.combat.outcome) stopTicker();
}
function onCombatEvent(ev) {
switch (ev.t) {
case 'hit': {
const big = ev.dmg >= 18;
if (ev.tgt === 0) { sfx.playerHit(); floatAt('player', `-${ev.dmg.toFixed(0)}`, 'ouch'); }
else {
(big ? sfx.heavyHit : sfx.hit)();
floatAt(ev.tgt, `${ev.dmg.toFixed(0)}`, big ? 'big' : 'dmg');
hurt(ev.tgt);
}
if (ev.outcome === 'dazed') { sfx.daze(); floatAt(ev.tgt, 'DAZED', 'curse'); }
if (ev.tgt === 0) flashBar();
break;
}
case 'ceiling':
floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `${ev.fatigue.toFixed(0)}`, 'curse');
break;
case 'recovery':
sfx.recovery();
floatAt(ev.tgt === 0 ? 'player' : ev.tgt, `+${ev.heal.toFixed(0)}`, 'heal');
break;
case 'cast': {
sfx.cast();
const name = (abilities.find((a) => a.id === ev.ability) || {}).name || ev.ability.replace(/_/g, ' ');
if (ev.actor !== 0) floatAt(ev.actor, name, 'info');
break;
}
case 'fizzle':
sfx.fizzle();
floatAt(ev.actor === 0 ? 'player' : ev.actor, 'fizzle!', 'info');
break;
case 'interrupt':
floatAt(ev.actor === 0 ? 'player' : ev.actor, 'interrupted!', 'info');
break;
case 'death':
if (ev.actor !== 0) sfx.monsterDeath();
break;
}
}
function hurt(actorId) {
const div = sprites.querySelector(`[data-actor="${actorId}"]`);
if (!div || div.classList.contains('dead')) return;
div.classList.remove('hurt');
void div.offsetWidth;
div.classList.add('hurt');
}
function flashBar() {
const bar = $('player-vbar');
bar.classList.remove('hurtflash');
void bar.offsetWidth;
bar.classList.add('hurtflash');
}
function floatAt(where, text, cls) {
const f = document.createElement('div');
f.className = `floater ${cls}`;
f.textContent = text;
if (where === 'player') {
f.style.left = `${44 + Math.random() * 12}%`;
f.style.bottom = '24%';
} else {
const div = sprites.querySelector(`[data-actor="${where}"]`);
const lane = div ? parseFloat(div.style.left) : 50;
f.style.left = `${lane - 4 + Math.random() * 8}%`;
f.style.bottom = `${46 + Math.random() * 8}%`;
}
floaters.appendChild(f);
setTimeout(() => f.remove(), 1150);
}
function setTarget(id, silent) {
target = id;
if (game) game.setTarget(id);
if (!silent) sfx.turn();
renderSprites(true);
if (st && st.combat) renderEnemies(true);
}
// ---------------- action bar ---------------- //
function buildActionbar() {
actionbar.innerHTML = '';
abilities.forEach((ab, i) => {
const b = document.createElement('button');
b.className = 'slot';
b.dataset.kind = ab.kind;
b.dataset.id = ab.id;
const cost = ab.fill_cost > 0 ? `${ab.fill_cost}` : ab.fatigue_cost > 0 ? `${ab.fatigue_cost}` : 'free';
b.innerHTML = `<span class="num">${i + 1}</span><span class="nm">${ab.name}</span><span class="cost">${cost}</span><div class="cd"></div>`;
b.onclick = () => useAbility(ab.id);
actionbar.appendChild(b);
});
}
function updateActionbar() {
const me = st.combat.actors[0];
const cds = Object.fromEntries(me.cooldowns.map((c) => [c.id, c.remaining]));
for (const slot of actionbar.children) {
const ab = abilities.find((a) => a.id === slot.dataset.id);
const remaining = cds[ab.id] || 0;
slot.querySelector('.cd').style.transform = `scaleY(${ab.cooldown ? Math.min(1, remaining / ab.cooldown) : 0})`;
slot.classList.toggle('locked', me.vigor < ab.fill_cost || me.stagger !== 'normal');
}
}
function useAbility(id) {
if (!game || !st || st.mode !== 'combat') return;
game.useAbility(id, target);
}
function toggleAuto() {
if (!game) return;
game.setAutoAttack(!st.player.auto_attack);
st.player.auto_attack = !st.player.auto_attack;
$('btn-auto').classList.toggle('lit', st.player.auto_attack);
}
function flee() {
if (!game || !st || st.mode !== 'combat') return;
stopTicker();
apply(game.flee());
}
// ---------------- banner / floor card / log ---------------- //
function bannerShow(text) {
banner.textContent = text;
banner.classList.remove('show');
void banner.offsetWidth;
banner.classList.add('show');
}
function floorCard(idx) {
$('floorcard-depth').textContent = `· floor ${ROMAN[idx]} ·`;
$('floorcard-name').textContent = CAMPAIGN[idx].name;
const fc = $('floorcard');
fc.classList.remove('show');
void fc.offsetWidth;
fc.classList.add('show');
log(`floor ${ROMAN[idx]}${CAMPAIGN[idx].name}`);
}
function log(text, cls = '') {
const line = document.createElement('div');
line.className = `line ${cls}`;
line.textContent = text;
logBox.appendChild(line);
while (logBox.childElementCount > 4) logBox.firstElementChild.remove();
}
// ---------------- minimap ---------------- //
function drawMinimap() {
const m = manifests[st.floor];
const { tiles, width, height } = m;
const s = Math.max(2, Math.floor(Math.min(196 / width, 124 / height)));
minimap.width = width * s;
minimap.height = height * s;
const visited = new Set(st.visited);
const danger = new Set(st.known_monsters);
// ghost layer: the bones of the floor, barely visible
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (tiles[y][x] !== 0) {
mctx.fillStyle = 'rgba(70, 64, 78, 0.16)';
mctx.fillRect(x * s, y * s, s, s);
}
}
}
// visited rooms: lit
for (const r of m.rooms) {
if (!visited.has(r.id)) continue;
for (let y = r.bounds.y; y < r.bounds.y + r.bounds.h; y++) {
for (let x = r.bounds.x; x < r.bounds.x + r.bounds.w; x++) {
const t = tiles[y] && tiles[y][x];
if (t == null || t === 0) continue;
mctx.fillStyle =
t === 2 ? 'rgba(202, 162, 74, 0.85)' :
t === 4 ? 'rgba(70, 120, 140, 0.8)' :
t === 5 ? 'rgba(190, 90, 40, 0.85)' :
'rgba(120, 112, 100, 0.55)';
mctx.fillRect(x * s, y * s, s, s);
}
}
}
// markers
for (const r of m.rooms) {
if (!visited.has(r.id)) continue;
const [vx, vy] = r.vantage;
if (r.id === st.exit_room) {
mctx.fillStyle = '#d4a54a';
mctx.font = `${s * 3}px monospace`;
mctx.fillText('▼', vx * s - s, vy * s + s * 1.5);
}
if (danger.has(r.id) && r.id !== st.room) {
mctx.fillStyle = '#b03a2e';
mctx.fillRect(vx * s - 1, vy * s - 1, s + 2, s + 2);
}
}
// the player
const here = m.rooms.find((r) => r.id === st.room);
if (here) {
const [vx, vy] = here.vantage;
const cx = vx * s + s / 2, cy = vy * s + s / 2;
mctx.fillStyle = '#e8dcc8';
mctx.beginPath();
mctx.arc(cx, cy, Math.max(2, s * 0.8), 0, Math.PI * 2);
mctx.fill();
const vec = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }[st.facing];
mctx.strokeStyle = '#d4a54a';
mctx.lineWidth = Math.max(1.5, s * 0.5);
mctx.beginPath();
mctx.moveTo(cx, cy);
mctx.lineTo(cx + vec[0] * s * 2.2, cy + vec[1] * s * 2.2);
mctx.stroke();
}
}
// ---------------- end screens ---------------- //
function statsHTML() {
const s = st.stats;
const rows = [
[s.kills, 'slain'],
[s.gold_looted, 'gold'],
[st.floor + 1, 'floors'],
[s.rooms_explored, 'rooms'],
[s.fights, 'battles'],
[`${Math.round(s.combat_ticks / 10)}s`, 'in combat'],
];
return rows
.map(([v, k]) => `<div class="stat"><div class="v">${v}</div><div class="k">${k}</div></div>`)
.join('');
}
function endDeath(by) {
stopTicker();
sfx.ambientOff();
$('death-by').textContent = by ? `${by} stands over your body on floor ${ROMAN[st.floor]}` : `floor ${ROMAN[st.floor]} keeps you`;
$('death-stats').innerHTML = statsHTML();
show('death');
}
function endVictory() {
stopTicker();
sfx.ambientOff();
$('victory-stats').innerHTML = statsHTML();
show('victory');
}
// ------------------------------------------------------------------ //
// Input
// ------------------------------------------------------------------ //
let lastRest = 0;
let restHide = null;
const restOverlay = $('rest-overlay');
// Shown by the rested event, self-hiding — robust even without a keyup.
function showRest() {
restOverlay.classList.add('show');
clearTimeout(restHide);
restHide = setTimeout(() => restOverlay.classList.remove('show'), 400);
}
window.addEventListener('keydown', (e) => {
if (!game || !st) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
if (st.mode === 'explore') {
if (k === 'w' || k === 'arrowup') apply(game.step(true));
else if (k === 's' || k === 'arrowdown') apply(game.step(false));
else if (k === 'a' || k === 'arrowleft') { sfx.turn(); apply(game.turn(-1)); }
else if (k === 'd' || k === 'arrowright') { sfx.turn(); apply(game.turn(1)); }
else if (k === 'r') {
const now = Date.now();
if (now - lastRest > 130) { lastRest = now; apply(game.rest()); }
} else if (k === 'g') apply(game.useItem('draught'));
else if (k === 't') apply(game.useItem('tonic'));
else if (k === 'enter') apply(game.descend());
else return;
e.preventDefault();
return;
}
if (st.mode === 'combat') {
const n = parseInt(k, 10);
if (n >= 1 && n <= abilities.length) useAbility(abilities[n - 1].id);
else if (k === ' ') toggleAuto();
else if (k === 'f') flee();
else if (k === 'tab') {
const foes = st.combat ? st.combat.actors.filter((a) => a.team === 1 && a.stagger !== 'dead') : [];
if (foes.length > 1) {
const i = foes.findIndex((f) => f.id === target);
setTarget(foes[(i + 1) % foes.length].id);
}
} else return;
e.preventDefault();
}
});
window.addEventListener('keyup', (e) => {
if (e.key.toLowerCase() === 'r') restOverlay.classList.remove('show');
});
// Scripting/debug handle (same spirit as the playground's window.reikhelm).
window.descent = {
get st() { return st; },
cmd(name, ...args) { if (game) apply(game[name](...args)); },
start(arch, s) { if (s != null) seed = s; startRun(arch); },
};
boot().catch((e) => {
document.body.innerHTML = `<pre style="color:#b03a2e;padding:40px">boot failed: ${e}\n${e.stack || ''}</pre>`;
});