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>
677 lines
23 KiB
JavaScript
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>`;
|
|
});
|