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