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