// 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 = `
`;
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();