Combat engine: swing-by-swing fights, Frontier and expeditions, trophies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-19 23:32:07 +01:00
parent 2ca90c43c0
commit 19ccab4ac3
7 changed files with 395 additions and 115 deletions

View file

@ -229,7 +229,7 @@ function sidebar() {
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}</div>
<div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold: from fights and selling. Fund buildings with it or give it to the treasury">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by giving to the city. Spend it on the Council page, or on extra bank slots">${FAVOUR} ${fmt(me.favour)}</span></div>
</div>
<div class="navhead">City</div>
@ -339,7 +339,7 @@ function skillPage(id) {
</div>
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`;
if (id === "combat") html += combatPanel();
if (id === "combat") html += fightPanel() + combatPanel();
if (id === "prayer") html += prayerPanel();
if (id === "construction") html += S.city.construction ? siteBanner()
: `<div class="panel muted">Nothing is being built right now. Fund a building on the Town square and its site opens here.</div>`;
@ -358,7 +358,7 @@ function skillPage(id) {
const open = (g) => actions.some((a) => a.group === g && !isLocked(a));
let current = tabs[id] || remember(`tab.${id}`);
// Tier ladders open on the highest tier you can work; categories open on the first.
const ladder = ["smithing", "leatherworking", "herblore"].includes(id);
const ladder = ["smithing", "leatherworking", "herblore", "combat"].includes(id);
if (!groups.includes(current)) current = (ladder && [...groups].reverse().find(open)) || groups[0];
const bar = groups.map((g) => `<button class="tab ${g === current ? "on" : ""} ${open(g) ? "" : "dim"}" data-tab="${id}" data-group="${esc(g)}">${esc(g)}</button>`).join("");
// Ungrouped actions (the building site) stay above the tabs.
@ -441,6 +441,63 @@ function toolPanel(skill) {
: `<span class="muted">No tool. A smith can make you one that speeds this up.</span>`}</div>`;
}
// The fight in progress, replayed from the server's swing-by-swing record.
function fightPanel() {
const f = S.me.action?.fight;
if (!f) return "";
const side = (who, name) => `<div class="fighter">
<div class="fighter-name"><b>${esc(name)}</b><span class="small muted" data-f="${who}-hp"></span></div>
<div class="bar hp ${who}"><span data-f="${who}-bar"></span></div>
<div class="bar swing"><span data-f="${who}-swing"></span></div>
</div>`;
return `<div class="panel fight" data-fight="${S.me.action.start}">
<div class="fighters">${side("you", S.me.name)}<span class="vs">⚔️</span>${side("foe", f.enemy)}</div>
<ul class="fight-log" data-f="log"></ul>
</div>`;
}
function fightLine(e, enemy) {
const [, who, kind, amount, , , food] = e;
const foe = esc(enemy);
if (kind === "eat") return `<li class="good">You eat ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
if (kind === "wind") return `<li class="good">Second wind! Back on your feet</li>`;
if (who === "you") return kind === "miss" ? `<li class="muted">You miss</li>` : `<li>You ${kind === "crit" ? "<b>crit</b>" : "hit"} for <b>${amount}</b></li>`;
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="bad">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} you for <b>${amount}</b></li>`;
}
function animateFight(now) {
const panel = $("[data-fight]"), a = S.me?.action, f = a?.fight;
if (!panel || !f) return;
const t = now - a.start;
let shown = -1;
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) shown++;
const last = f.events[shown];
const hp = last ? last[4] : f.start_hp, foeHp = last ? last[5] : f.enemy_hp;
// Your HP everywhere follows the fight as it plays out.
for (const el of document.querySelectorAll("[data-live-hp]")) el.textContent = fmt(hp);
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / f.top * 100)}%`;
const set = (key, fn) => { const el = panel.querySelector(`[data-f="${key}"]`); if (el) fn(el); };
set("you-bar", (el) => { el.style.width = `${Math.max(0, hp / f.top * 100)}%`; });
set("foe-bar", (el) => { el.style.width = `${Math.max(0, foeHp / f.enemy_hp * 100)}%`; });
set("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(f.top)}`; });
set("foe-hp", (el) => { el.textContent = `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
const over = t >= f.end;
for (const [who, every] of [["you", f.interval], ["foe", f.enemy_interval]]) {
let swung = 0;
for (let i = shown; i >= 0; i--) if (f.events[i][1] === who && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") { swung = f.events[i][0]; break; }
set(`${who}-swing`, (el) => { el.style.width = over ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
}
set("log", (el) => {
const key = `${a.start}:${shown}:${over}`;
if (el.dataset.key === key) return;
el.dataset.key = key;
const lines = f.events.slice(Math.max(0, shown - 5), shown + 1).reverse().map((e) => fightLine(e, f.enemy));
if (over) lines.unshift(f.result === "won" ? `<li class="good"><b>${esc(f.enemy)} is beaten.</b> The next one is coming…</li>`
: `<li class="bad"><b>You fall back.</b></li>`);
el.innerHTML = lines.join("") || `<li class="muted">Squaring up…</li>`;
});
}
function combatPanel() {
const me = S.me;
const stat = (id) => [item(id).power && `+${item(id).power} power`, item(id).armour && `+${item(id).armour} armour`].filter(Boolean).join(", ");
@ -454,8 +511,8 @@ function combatPanel() {
const hpPct = me.hp / me.max_hp * 100;
return `<div class="cols">
<div class="panel"><h2>Health</h2>
<div class="hp-line"><b>${HEART} ${fmt(me.hp)} / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span style="width:${hpPct}%"></span></div>
<div class="hp-line"><b>${HEART} <span data-live-hp>${fmt(me.hp)}</span> / ${me.max_hp}</b><span class="muted small">recovers ${Math.round(C.regen_per_hour * 100)}% an hour</span></div>
<div class="bar thick hp"><span data-live-hpbar style="width:${hpPct}%"></span></div>
<div class="row" style="margin-top:12px"><label class="small muted">Food in fights
<select data-food-pick>${`<option value="">Cheapest first</option>` + foods}</select></label></div>
<p class="small">${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` <b class="warn">(not enough points: prayers are off)</b>`}` : `<span class="muted">No prayers active.</span>`}
@ -481,6 +538,7 @@ function itemStats(id) {
if (it.tool) parts.push(`${Math.round(it.speed * 100)}% faster ${skillOf(it.tool).name.toLowerCase()}`);
if (it.req && it.req[1] > 1) parts.push(`needs ${skillOf(it.req[0]).name.toLowerCase()} ${it.req[1]}`);
if (it.potion) parts.push(`${it.potion.text}, ${C.potion_charges} uses`);
if (it.trophy) parts.push(`An expedition trophy: stronger weapons will need these`);
if (it.fitting) {
const f = C.fittings.find((x) => x.id === id), done = S.city.buildings[f.building].fittings.includes(id);
parts.push(`${C.buildings[f.building].icon} For the ${C.buildings[f.building].name.toLowerCase()} (Lv ${f.level}): ${f.effect}${done ? " · already fitted" : ""}`);
@ -507,9 +565,11 @@ function actionCard(a) {
}
io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join("");
let extra = "";
if ("difficulty" in a) {
const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04));
extra = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b> · about <b>${me.fight_damage[a.id]}</b> damage a fight${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.enemy) {
const o = me.fight_odds[a.id], e = a.enemy, pct = (x) => `${Math.round(x * 100)}%`;
extra = `<div class="small">${HEART} ${fmt(e.hp)} HP · hits up to <b>${o.their_max}</b> every ${e.interval}s${e.boss ? ` · <span class="tag warn">Boss</span>` : ""}</div>
<div class="small">You land <b>${pct(o.hit)}</b> of swings (up to ${o.max_hit}) · it lands <b>${pct(o.their_hit)}</b></div>
<div class="small">About <b>${dur(o.kill_time)}</b> a kill, <b>${Math.round(o.damage)}</b> damage taken${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${esc(item(id).name)}: ${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
}
@ -549,7 +609,7 @@ function actionCard(a) {
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
${second ? `<button class="btn" data-plot="">Clear plot 2</button>`
: active ? `<button class="btn danger" data-stop>Stop</button>`
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${"difficulty" in a ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
: `<span class="btns"><button class="btn" data-once="${a.id}" ${why ? "disabled" : ""} title="Do this once, then go back to what you were doing">${a.enemy ? "Fight once" : Object.keys(a.outputs).length ? "Make one" : "Once"}</button>
${canPlot ? `<button class="btn" data-plot="${a.id}" ${why ? "disabled" : ""} title="Grow this in your second plot as well">Plot 2</button>` : ""}
<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button></span>`}
</div>
@ -1063,6 +1123,7 @@ function register() {
function animate() {
const now = serverNow();
const a = S?.me?.action;
animateFight(now);
if (a) {
const pct = Math.min(1, (now - a.start) / a.duration);
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%";

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before After
Before After

View file

@ -229,6 +229,21 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p
.building.damaged { border-color: rgba(226, 100, 92, .6); }
.cost { display: flex; flex-wrap: wrap; gap: 6px; }
.fund { display: flex; flex-direction: column; gap: 6px; }
.fight .fighters { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; }
.fight .vs { font-size: 22px; }
.fighter { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.fighter-name { display: flex; justify-content: space-between; gap: 8px; }
.fighter-name b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fighter-name span { white-space: nowrap; }
@media (max-width: 500px) { .fight .fighters { gap: 8px; } .fighter-name { flex-direction: column; gap: 0; } }
.fight .bar.hp { height: 12px; }
.fight .bar.hp.you > span { background: var(--good); }
.fight .bar.hp.foe > span { background: var(--bad); }
.fight .bar.swing { height: 4px; }
.fight .bar.swing > span { background: var(--gold); transition: none; }
.fight-log { list-style: none; margin: 12px 0 0; padding: 0; font-size: 13px; display: flex; flex-direction: column; gap: 2px; min-height: 7.5em; }
.fight-log .good { color: var(--good); }
.fight-log .bad { color: var(--bad); }
.fund-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.fittings { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--line); }
.fitting-row { display: flex; align-items: center; gap: 8px; }