// Vigor — browser front-end driving combat-core (via combat-wasm) one tick per // wall-clock frame. The engine is authoritative; this file is pure presentation: // build a Game, push the player's intent, render the state it hands back. import init, { Game } from "./pkg/combat_wasm.js"; // Monster roster for the start screen. ids match the config stat_blocks. const ROSTER = [ { id: "rat", name: "Dire Rat", blurb: "Fast and fragile — a flurry of filthy bites." }, { id: "skeleton", name: "Skeleton Knight", blurb: "Brittle bone, a wicked notched blade." }, { id: "battlemage", name: "Dark Sorcerer", blurb: "Fireballs with a long, punishable wind-up." }, { id: "golem", name: "Stone Golem", blurb: "A slow wall of stone. Immense pool, heavy blows." }, { id: "troll", name: "Cave Troll", blurb: "Huge, regenerates, hits like a landslide." }, { id: "wraith", name: "Wraith", blurb: "Curses your ceiling — it drains your maximum away." }, ]; const PLAYER = "duelist"; let cfgText = null; // raw config JSON string (handed to the engine) let abilityIndex = {}; // id -> { name, cast_time } from the config (for any caster) let tickMs = 100; // ms per tick (1000 / tick_rate) let game = null; let loopId = null; let over = false; let enemyId = 1; let curFoe = null; let playerAbilities = []; let prevDown = {}; // id -> was-down, to fire the KO shake once let casts = {}; // actorId -> { id, total } to drive the telegraph bar const $ = (sel) => document.querySelector(sel); const round = (x) => Math.round(x); async function boot() { await init(); // Cache-bust so config edits are always picked up (the browser otherwise // serves a stale fetch and the game runs old numbers). cfgText = await (await fetch("../configs/default.json?v=" + Date.now())).text(); const cfg = JSON.parse(cfgText); tickMs = Math.max(20, Math.round(1000 / (cfg.tick_rate || 10))); for (const a of cfg.abilities || []) { abilityIndex[a.id] = { name: a.name, cast_time: a.cast_time || 0 }; } buildRoster(); $("#again").onclick = () => startBattle(curFoe); $("#choose").onclick = () => { $("#result").classList.add("hidden"); $("#battle").classList.add("hidden"); $("#start").classList.remove("hidden"); }; $("#auto-toggle").onclick = toggleAuto; window.addEventListener("keydown", onKey); } function buildRoster() { const r = $("#roster"); r.innerHTML = ""; for (const foe of ROSTER) { const el = document.createElement("div"); el.className = "foe"; el.innerHTML = ` ${foe.name}
${foe.name}
${foe.blurb}
`; el.onclick = () => startBattle(foe); r.appendChild(el); } } function startBattle(foe) { curFoe = foe; over = false; prevDown = {}; casts = {}; if (loopId) clearInterval(loopId); const seed = Math.floor(Math.random() * 1e9); try { game = new Game(cfgText, PLAYER, foe.id, BigInt(seed)); } catch (e) { alert("Could not start battle: " + e); return; } enemyId = 1; playerAbilities = JSON.parse(game.abilities()); game.setTarget(enemyId); // Scene chrome. $("#start").classList.add("hidden"); $("#result").classList.add("hidden"); $("#battle").classList.remove("hidden"); $("#enemy-portrait").src = `./portraits/${foe.id}.png`; $("#enemy-name").textContent = foe.name; $("#player-name").textContent = "Duelist"; $("#floaters").innerHTML = ""; $("#log").innerHTML = ""; mountVbar("#enemy-vbar"); mountVbar("#player-vbar"); buildActionBar(); applyState(JSON.parse(game.state())); loopId = setInterval(step, tickMs); } function step() { if (over) return; const s = JSON.parse(game.tick()); applyState(s); for (const ev of s.events) handleEvent(ev, s); if (s.outcome) endBattle(s.outcome); } // ── rendering ────────────────────────────────────────────────────────── function mountVbar(sel) { $(sel).innerHTML = `
`; } function updateVbar(sel, a) { const bar = $(sel).querySelector(".vbar"); const pct = (v) => `${Math.max(0, Math.min(100, (v / a.max_vigor) * 100))}%`; bar.querySelector(".vbar-cap").style.width = pct(a.cap); bar.querySelector(".vbar-fill").style.width = pct(a.vigor); bar.querySelector(".vbar-ceiling").style.left = pct(a.cap); bar.querySelector(".vbar-num").textContent = `${round(a.vigor)} · cap ${round(a.cap)} / ${round(a.max_vigor)}`; bar.classList.toggle("dazed", a.stagger === "dazed"); bar.classList.toggle("down", a.stagger === "unconscious" || a.stagger === "dead"); } function applyState(s) { for (const a of s.actors) { const isEnemy = a.team !== 0; const card = isEnemy ? "#enemy-card" : "#player-card"; updateVbar(isEnemy ? "#enemy-vbar" : "#player-vbar", a); $(isEnemy ? "#enemy-level" : "#player-level").textContent = `Lv ${a.level}`; renderStatuses(isEnemy ? "#enemy-status" : "#player-status", a); $(card).classList.toggle("down", a.stagger === "unconscious" || a.stagger === "dead"); // Enemy cast telegraph. if (isEnemy) renderCast(a); if (!isEnemy) updateActionBar(a); } } function renderStatuses(sel, a) { const row = $(sel); const chips = []; if (a.stagger === "dazed") chips.push(["dazed", "DAZED"]); for (const st of a.statuses) chips.push([st, label(st)]); row.innerHTML = chips.map(([c, t]) => `${t}`).join(""); } function label(st) { return ({ dot: "poison", hot: "mending", regen_sabotage: "enervated", fatigue_amp: "weary", mitigation: "warded", })[st] || st; } function renderCast(a) { const wrap = $("#enemy-cast"); const card = $("#enemy-card"); if (a.casting) { const info = abilityIndex[a.casting.ability] || { name: a.casting.ability, cast_time: a.casting.remaining }; if (!casts[a.id]) casts[a.id] = { id: a.casting.ability, total: Math.max(1, a.casting.remaining) }; const total = abilityIndex[a.casting.ability]?.cast_time || casts[a.id].total; const frac = Math.max(0, Math.min(1, (total - a.casting.remaining) / total)); wrap.classList.remove("hidden"); wrap.querySelector(".cast-label").textContent = `casting ${info.name}…`; wrap.querySelector(".cast-fill").style.width = `${frac * 100}%`; card.classList.add("casting"); } else { delete casts[a.id]; wrap.classList.add("hidden"); card.classList.remove("casting"); } } // ── action bar ───────────────────────────────────────────────────────── function buildActionBar() { const bar = $("#actionbar"); bar.innerHTML = ""; playerAbilities.forEach((ab, i) => { const b = document.createElement("button"); b.className = "ability"; b.dataset.kind = ab.kind; b.dataset.id = ab.id; const cost = ab.fill_cost > 0 ? `${round(ab.fill_cost)} vigor` : "free"; const cast = ab.cast_time > 0 ? ` · ${(ab.cast_time / 10).toFixed(1)}s cast` : ""; b.innerHTML = `${i + 1} ${ab.name} ${cost}${cast} `; b.onclick = () => useAbility(ab.id); bar.appendChild(b); }); } function updateActionBar(player) { const cdMap = {}; for (const c of player.cooldowns) cdMap[c.id] = c.remaining; for (const b of $("#actionbar").children) { const ab = playerAbilities.find((x) => x.id === b.dataset.id); const cd = cdMap[ab.id] || 0; const unaffordable = ab.fill_cost > player.vigor; const cdEl = b.querySelector(".acd"); if (cd > 0) { cdEl.classList.remove("hidden"); cdEl.textContent = (cd / 10).toFixed(1); } else { cdEl.classList.add("hidden"); } b.disabled = cd > 0 || unaffordable || player.stagger !== "normal"; } } function useAbility(id) { if (!game || over) return; game.useAbility(id, enemyId); } function toggleAuto() { const btn = $("#auto-toggle"); const on = !btn.classList.contains("on"); btn.classList.toggle("on", on); if (game) game.setAutoAttack(on); } function onKey(e) { if ($("#battle").classList.contains("hidden")) return; if (e.key === " ") { e.preventDefault(); toggleAuto(); return; } const n = parseInt(e.key, 10); if (n >= 1 && n <= playerAbilities.length) { useAbility(playerAbilities[n - 1].id); } } // ── events → juice ───────────────────────────────────────────────────── function nameOf(s, id) { const a = s.actors.find((x) => x.id === id); return a ? a.name : `#${id}`; } function handleEvent(ev, s) { switch (ev.t) { case "hit": { const toPlayer = ev.tgt === 0; const big = ev.outcome === "dazed" || ev.outcome === "unconscious" || ev.outcome === "killed"; const tag = ev.outcome === "dazed" ? " DAZE!" : ev.outcome === "unconscious" ? " KO!" : ev.outcome === "killed" ? " ✶" : ""; floater(`${round(ev.dmg)}${tag}`, toPlayer ? "down" : "up", "hit", big); if (ev.dmg > 0) shake(toPlayer ? "#player-card" : "#enemy-card"); logLine(`${nameOf(s, ev.src)} hits ${nameOf(s, ev.tgt)} for ${round(ev.dmg)}${tag}`, big ? "lbig" : "lhit"); break; } case "ceiling": { floater(`-${round(ev.fatigue)} cap`, ev.tgt === 0 ? "down" : "up", "curse", true); logLine(`${nameOf(s, ev.src)} curses ${nameOf(s, ev.tgt)}: ceiling −${round(ev.fatigue)}`, "lcurse"); break; } case "recovery": { if (ev.heal > 0 || ev.revived) floater(ev.revived ? "REVIVE" : `+${round(ev.heal)} cap`, ev.tgt === 0 ? "down" : "up", "heal", ev.revived); logLine(`${nameOf(s, ev.tgt)} recovers ${round(ev.heal)} ceiling${ev.revived ? " (revived!)" : ""}`, "lheal"); break; } case "cast": logLine(`${nameOf(s, ev.actor)} begins ${abilityIndex[ev.ability]?.name || ev.ability}…`, "lcast"); break; case "fizzle": floater("fizzle", ev.actor === 0 ? "down" : "up", "curse", false); logLine(`${nameOf(s, ev.actor)}'s ${abilityIndex[ev.ability]?.name || ev.ability} fizzles`, ""); break; case "interrupt": logLine(`${nameOf(s, ev.actor)}'s ${abilityIndex[ev.ability]?.name || ev.ability} is interrupted!`, "lbig"); break; case "death": logLine(`${nameOf(s, ev.actor)} falls.`, "lbig"); break; } } let floatSeq = 0; function floater(text, dir, kind, big) { const f = document.createElement("div"); f.className = `floater ${dir} ${kind}${big ? " crit" : ""}`; f.textContent = text; f.style.left = `${50 + (floatSeq++ % 5 - 2) * 12}%`; $("#floaters").appendChild(f); setTimeout(() => f.remove(), 1200); } function shake(sel) { const el = $(sel); el.classList.remove("hurt"); void el.offsetWidth; // reflow to restart the animation el.classList.add("hurt"); } function logLine(text, cls) { const log = $("#log"); const line = document.createElement("div"); if (cls) line.className = cls; line.textContent = text; log.appendChild(line); while (log.children.length > 40) log.removeChild(log.firstChild); log.scrollTop = log.scrollHeight; } function endBattle(outcome) { over = true; if (loopId) clearInterval(loopId); const win = outcome === "win_player"; const card = $("#result .result-card"); card.classList.toggle("win", win); card.classList.toggle("lose", !win); $("#result-title").textContent = win ? "VICTORY" : outcome === "draw" ? "MUTUAL RUIN" : "DEFEAT"; $("#result-sub").textContent = win ? `${curFoe.name} falls before you.` : outcome === "draw" ? "You fell together." : `${curFoe.name} has bested you.`; $("#result").classList.remove("hidden"); } boot();