reikhelm/combat/combat-web/game.js
Parley Hatch 57dba5e149 feat(combat): order-of-magnitude slowdown — fights now ~20-30s
Fights were ~3-5s; the user wanted 20-30s. Tuned the table (and added one
mechanic) so they land there, re-validated against the sim throughout.

- Engine: a `global_cooldown` (the genre swing timer, §10 "attack delay") — an
  actor can't start a new action until it elapses, so no button-masher can
  out-DPS the intended tempo. Config-driven (`global_cooldown` ticks; 0 = off).
  Actor gains `next_action_at`; engine gates decisions on it.
- Controller: ScriptedPlayer now pressures with its heavy proactively when
  healthy (not only on the exact overrun frame), so the sim measures what a real
  player experiences instead of an unrealistically patient bound.
- Config rebalance: damage cut + flattened, fatigue/regen/recovery co-tuned, and
  the kit made roughly **DPS-neutral** with auto-attack (each ability's cooldown
  matched to auto's damage-per-second). Under the global cooldown that's the key
  insight: abilities win through stagger leverage + timing, not raw throughput —
  otherwise strong abilities trivialize the pace and flat ones aren't worth their
  fill (the guardrail kept inverting until this landed). Auto-attacks (difficulty
  0) also no longer fizzle.
- Front-end: cache-bust the config fetch so edits are always picked up; widened
  the sim's archetype sweep to all six monsters.

Result (sim, skilled play ≈ real play): monster fights 16-38s (centered ~24s);
guardrail steep and green (even-con auto 0% → skill20 2% → skill50 12% →
skill80 44% → skill100 78%); all six monsters winnable. Verified live in-browser:
realistic play lands ~15-30s and a clean mid-fight reads perfectly on the dual bar.

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

329 lines
12 KiB
JavaScript
Raw Permalink 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();
// 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 = `
<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();