// 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 = `
${f.name}lvl ${f.level}
`; 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('DAZED'); for (const s of f.statuses) chips.push(`${s.replace('_', ' ')}`); 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 = `${i + 1}${ab.name}${cost}
`; 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]) => `
${v}
${k}
`) .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 = `
boot failed: ${e}\n${e.stack || ''}
`; });