reikhelm/combat/combat-web/game.js
Parley Hatch 902a233229 feat(combat): playable browser front-end + AI monster portraits
A real-time consumer of combat-core (the front-end the spec designs for, §14):
pick a foe, manage your single Vigor pool, try not to spend your survival.

- combat-core: a HumanController (input-driven, spec §5) behind a shared intent
  cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can
  drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g.
  auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green.
- combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the
  player's ability list, and a flavorful MonsterAI that uses each monster's
  signature kit (the wraith curses your ceiling) on a coin-flip so big hits space
  into a readable duel. Own workspace member, kept out of the default host build.
- combat-web: an atmospheric battler. The signature dual Vigor bar shows fill,
  the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue
  zone in one bar — the squeeze made legible. Floating damage, cast telegraphs,
  combat log, result overlay. Verified end-to-end in a real browser.
- tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image
  stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed.
- Added skeleton / troll / wraith monster stat blocks; bumped the duelist's
  durability so fights are readable (even-con ~30s) and active play beats every
  monster while passive auto-attacking loses the hard ones. Re-validated the sim:
  the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34%
  at even con). README findings updated to match.

Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a
3s active-play victory over the Skeleton Knight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:46:47 -06:00

327 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();
cfgText = await (await fetch("../configs/default.json")).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 = `
<img src="./portraits/${foe.id}.png" alt="${foe.name}" loading="lazy" />
<div class="fmeta"><div class="fname">${foe.name}</div>
<div class="fblurb">${foe.blurb}</div></div>`;
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 = `
<div class="vbar">
<div class="vbar-cap"></div>
<div class="vbar-fill"></div>
<div class="vbar-ceiling"></div>
<div class="vbar-num"></div>
</div>`;
}
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]) => `<span class="chip ${c}">${t}</span>`).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 = `<span class="akey">${i + 1}</span>
<span class="aname">${ab.name}</span>
<span class="acost">${cost}${cast}</span>
<span class="acd hidden"></span>`;
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();