// 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 ${ROMAN[st.floor]} · ${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 = `
▼`;
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 = `
boot failed: ${e}\n${e.stack || ''}`;
});