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:
parent
2ca90c43c0
commit
19ccab4ac3
7 changed files with 395 additions and 115 deletions
|
|
@ -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) + "%";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue