Fight together: tag into anyone's fight
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0e0a824a9
commit
2d2cb16aff
6 changed files with 574 additions and 102 deletions
|
|
@ -4,6 +4,7 @@
|
|||
// between polls. The server is the source of truth for everything.
|
||||
|
||||
const POLL_MS = 10000;
|
||||
const FIGHT_POLL_MS = 2000; // in a fight someone may tag in: keep everyone's view of it close
|
||||
const SUMMARY_MODAL_AT = 25;
|
||||
|
||||
let C = null; // static content tables
|
||||
|
|
@ -21,6 +22,8 @@ let pickedClass = null; // registration
|
|||
const tabs = {}; // skill -> selected recipe tab
|
||||
let refreshing = false;
|
||||
let refreshedFor = null; // action (start and length) a completion refresh was sent for
|
||||
let polledAt = 0;
|
||||
let tagging = false; // this page just tagged into someone's fight
|
||||
let retriedAt = 0; // last time a fight past its kill asked again for the next one
|
||||
let version = null; // server build this page was loaded from
|
||||
let failures = 0; // polls in a row that didn't reach the server
|
||||
|
|
@ -77,6 +80,7 @@ async function act(path, body) {
|
|||
async function refresh() {
|
||||
if (refreshing) return;
|
||||
refreshing = true;
|
||||
polledAt = Date.now();
|
||||
try {
|
||||
accept(await api("/api/state"));
|
||||
failures = 0;
|
||||
|
|
@ -95,8 +99,14 @@ function accept(state) {
|
|||
location.reload(); // a new build was deployed
|
||||
return;
|
||||
}
|
||||
// Someone tagging into your fight (not you tagging into theirs) gets a toast.
|
||||
const fightingWith = S?.me && !tagging ? S.me.action?.with || [] : null;
|
||||
tagging = false;
|
||||
S = state;
|
||||
skew = state.now - Date.now() / 1000;
|
||||
for (const uid of fightingWith ? state.me?.action?.with || [] : []) {
|
||||
if (!fightingWith.includes(uid)) toast(`⚔️ ${playerName(uid)} joined the fight`, "good");
|
||||
}
|
||||
if (state.summary) showSummary(state.summary);
|
||||
orderNews(state);
|
||||
const ev = state.upcoming;
|
||||
|
|
@ -180,6 +190,7 @@ function awayModal(sum) {
|
|||
openModal(`
|
||||
<h2>While you were away</h2>
|
||||
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
|
||||
${Object.keys(sum.with || {}).length ? `<p class="muted small">⚔️ ${Object.entries(sum.with).map(([name, n]) => `${fmt(n)} with ${esc(name)}`).join(" · ")}</p>` : ""}
|
||||
${Object.keys(sum.eaten || {}).length ? `<p class="muted small">Took ${fmt(sum.damage)} damage and ate ${Object.entries(sum.eaten).map(([id, q]) => `${fmt(q)} ${esc(item(id).name.toLowerCase())}`).join(", ")}.</p>` : ""}
|
||||
${items ? `<ul>${items}</ul>` : ""}
|
||||
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
|
||||
|
|
@ -832,12 +843,16 @@ function variantRows(e, o) {
|
|||
function combatFrame() {
|
||||
const me = S.me, a = me.action, f = a?.fight;
|
||||
const encounter = f && C.actions.find((x) => x.id === a.id);
|
||||
const allies = f ? f.members.filter((m) => m.id !== S.uid) : [];
|
||||
const bars = (who) => `<div class="bar hp ${who}"><span data-f="${who}-bar"${who === "you" ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
|
||||
<div class="bar swing"><span data-f="${who}-swing"></span></div>`;
|
||||
const you = `<div class="fighter">
|
||||
<div class="fighter-name"><b>${esc(me.name)}</b><span class="small muted">${HEART} <span data-f="you-hp">${fmt(me.hp)} / ${me.max_hp}</span></span></div>
|
||||
${bars("you")}
|
||||
</div>`;
|
||||
</div>${allies.map((m, i) => `<div class="fighter ally">
|
||||
<div class="fighter-name"><b>${esc(m.name)}</b><span class="small muted">${HEART} <span data-f="ally${i}-hp"></span></span></div>
|
||||
<div class="bar hp you"><span data-f="ally${i}-bar"></span></div><div class="bar swing"><span data-f="ally${i}-swing"></span></div>
|
||||
</div>`).join("")}`;
|
||||
let foe = `<div class="foe-idle muted">Not fighting.</div>`;
|
||||
if (f) {
|
||||
const mine = C.styles[(me.equip.weapon && item(me.equip.weapon).style) || "sword"].damage;
|
||||
|
|
@ -864,15 +879,18 @@ function prayerBar(me) {
|
|||
return `<div class="prayer-bar"><span class="pp ${me.prayers.length && !me.blessing.cost ? "warn" : ""}" title="Prayer points">${skillOf("prayer").icon} ${fmt(me.pp)}</span>${buttons}</div>`;
|
||||
}
|
||||
|
||||
// A line of the fight: yours as "You", everyone else's by name.
|
||||
function fightLine(e, enemy) {
|
||||
const [, who, kind, amount, , , food] = e;
|
||||
const [, who, kind, amount, , , food, target] = e;
|
||||
const type = kind === "hit" || kind === "crit" ? (C.damage_types[food] ? ` ${dmgIcon(food)}` : "") : "";
|
||||
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 (kind === "block") return `<li class="good">You block ${foe}'s blow</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>${type}</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>${type}</li>`;
|
||||
const subject = who === "foe" ? target : who, mine = subject === S.uid;
|
||||
const name = mine ? "You" : esc(playerName(subject)), s = mine ? "" : "s";
|
||||
if (kind === "eat") return `<li class="${mine ? "good" : "muted"}">${name} eat${s} ${esc(item(food).name.toLowerCase())} (+${amount})</li>`;
|
||||
if (kind === "wind") return `<li class="good">Second wind! ${mine ? "Back on your feet" : `${name} is back up`}</li>`;
|
||||
if (kind === "block") return `<li class="${mine ? "good" : "muted"}">${name} block${s} ${foe}'s blow</li>`;
|
||||
if (who !== "foe") return kind === "miss" ? `<li class="muted">${name} miss${mine ? "" : "es"}</li>` : `<li${mine ? "" : ` class="ally"`}>${name} ${kind === "crit" ? `<b>crit${s}</b>` : `hit${s}`} for <b>${amount}</b>${type}</li>`;
|
||||
return kind === "miss" ? `<li class="muted">${foe} misses</li>` : `<li class="${mine ? "bad" : "muted"}">${foe} ${kind === "crit" ? "<b>crits</b>" : "hits"} ${mine ? "you" : name} for <b>${amount}</b>${type}</li>`;
|
||||
}
|
||||
|
||||
function animateFight(now) {
|
||||
|
|
@ -880,37 +898,50 @@ function animateFight(now) {
|
|||
if (!f) return;
|
||||
const t = now - a.start;
|
||||
const over = t >= f.end;
|
||||
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
|
||||
// From the kill until the next enemy turns up (the next fight's start, once the server sends it),
|
||||
// the panel is the same quiet countdown: no enemy, both swing timers empty.
|
||||
const waiting = t < 0 || (over && f.result === "won");
|
||||
// the panel is the same quiet countdown: no enemy, every swing timer empty.
|
||||
const waiting = t < 0 || (over && f.result === "won" && mine.result === "won");
|
||||
const respawnIn = t < 0 ? -t : f.end + (a.respawn || 1) - t;
|
||||
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 = waiting ? 0 : last ? last[5] : f.enemy_hp;
|
||||
// Each fighter's HP is in their own swings and bites, and in the enemy's blows at them.
|
||||
const about = (e, m) => e[1] === m.id || (e[1] === "foe" && e[7] === m.id);
|
||||
const hpOf = (m) => { for (let i = shown; i >= 0; i--) if (about(f.events[i], m)) return f.events[i][4]; return m.start_hp; };
|
||||
const swungAt = (id) => { for (let i = shown; i >= 0; i--) if (f.events[i][1] === id && f.events[i][2] !== "eat" && f.events[i][2] !== "wind") return f.events[i][0]; return null; };
|
||||
const hp = hpOf(mine), foeHp = waiting ? 0 : last ? last[5] : f.enemy_hp;
|
||||
let fell = Infinity; // beaten or out of food while the others fight on
|
||||
if (mine.result === "lost" || mine.result === "fled") for (let i = f.events.length - 1; i >= 0; i--) if (about(f.events[i], mine)) { fell = f.events[i][0]; break; }
|
||||
for (const el of document.querySelectorAll("[data-progress]")) el.style.width = `${foeHp / f.enemy_hp * 100}%`;
|
||||
const panel = $("[data-fight]");
|
||||
if (!panel) return;
|
||||
panel.classList.toggle("waiting", waiting);
|
||||
// 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)}%`;
|
||||
for (const el of document.querySelectorAll("[data-live-hpbar]")) el.style.width = `${Math.max(0, hp / mine.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("you-bar", (el) => { el.style.width = `${Math.max(0, hp / mine.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("you-hp", (el) => { el.textContent = `${fmt(hp)} / ${fmt(mine.top)}`; });
|
||||
set("foe-hp", (el) => { el.textContent = waiting ? "" : `${fmt(foeHp)} / ${fmt(f.enemy_hp)}`; });
|
||||
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 || waiting ? "0%" : `${Math.min(100, (t - swung) / every * 100)}%`; });
|
||||
}
|
||||
const swing = (key, swung, every, from = 0, gone = false) => set(`${key}-swing`, (el) => {
|
||||
el.style.width = over || waiting || gone || t < from ? "0%" : `${Math.min(100, (t - (swung ?? from)) / every * 100)}%`;
|
||||
});
|
||||
swing("you", swungAt(mine.id), mine.interval, mine.at / f.pace, t >= fell);
|
||||
swing("foe", swungAt("foe"), f.enemy_interval);
|
||||
f.members.filter((m) => m.id !== S.uid).forEach((m, i) => {
|
||||
const ally = hpOf(m), left = m.left != null && t >= m.left / f.pace;
|
||||
set(`ally${i}-bar`, (el) => { el.style.width = `${Math.max(0, ally / m.top * 100)}%`; });
|
||||
set(`ally${i}-hp`, (el) => { el.textContent = t < m.at / f.pace ? "joining…" : left ? "left" : `${fmt(ally)} / ${fmt(m.top)}`; });
|
||||
swing(`ally${i}`, swungAt(m.id), m.interval, m.at / f.pace, left);
|
||||
});
|
||||
set("log", (el) => {
|
||||
const key = `${a.start}:${shown}:${over}:${waiting ? Math.ceil(respawnIn * 10) : ""}`;
|
||||
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 && !waiting) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
|
||||
if ((over && !waiting) || t >= fell) lines.unshift(`<li class="bad"><b>You fall back.</b></li>`);
|
||||
el.innerHTML = waiting ? `<li class="good"><b>Victory!</b> ${respawnIn > 0.05 ? `Next enemy in ${respawnIn.toFixed(1)}s` : "Next enemy coming…"}</li>`
|
||||
: lines.join("") || `<li class="muted">Squaring up…</li>`;
|
||||
});
|
||||
|
|
@ -1014,7 +1045,7 @@ function actionCard(a) {
|
|||
let extra = "";
|
||||
if (a.enemy) {
|
||||
const o = me.fight_odds[a.id], e = a.enemy;
|
||||
extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}`;
|
||||
extra = `${e.boss ? `<div><span class="tag warn">Boss</span></div>` : ""}${variantRows(e, o)}${fightingHere(a, why)}`;
|
||||
if (a.gold) io += `<span class="item-chip" title="Gold: ${a.gold[0]}–${a.gold[1]} a kill">${COIN} ${a.gold[0]}–${a.gold[1]}</span>`;
|
||||
if (a.drops) io += a.drops.map(chance).join("");
|
||||
if (a.special === "sabotage") extra += `<div class="small muted">-3% raid strength per win (max 30%)</div>`;
|
||||
|
|
@ -1052,7 +1083,7 @@ function actionCard(a) {
|
|||
${extra}
|
||||
${me.action?.plot2 && (active || second) ? `<div class="small muted">Plot ${active ? 1 : 2}${growingNow ? " · growing now" : " · next"}</div>` : ""}
|
||||
${growingNow && !a.enemy ? `<div class="bar thick"><span data-progress></span></div>` : ""}
|
||||
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting</span></div>` : ""}
|
||||
${active && a.enemy ? `<div><span class="tag gold">⚔️ Fighting${me.action.with?.length ? ` with ${me.action.with.map((uid) => esc(playerName(uid))).join(", ")}` : ""}</span></div>` : ""}
|
||||
${active && me.action.left ? `<div class="small muted">Just one${me.action.resume ? `, then back to ${esc(C.actions.find((x) => x.id === me.action.resume).name.toLowerCase())}` : ""}</div>` : ""}
|
||||
<div class="foot">
|
||||
<span class="why">${!active && !second && why ? esc(why) : ""}</span>
|
||||
|
|
@ -1065,6 +1096,15 @@ function actionCard(a) {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// Who else is fighting this encounter, with a button to tag in: one per group fighting together.
|
||||
function fightingHere(a, why) {
|
||||
const mine = S.me.action?.party;
|
||||
const here = S.players.filter((p) => p.id !== S.uid && p.fight === a.id && !(mine && p.party === mine));
|
||||
const groups = [...new Map(here.map((p) => [p.party || p.id, here.filter((q) => (q.party || q.id) === (p.party || p.id))])).values()];
|
||||
return groups.map((group) => `<div class="fighting-here"><span class="small">⚔️ ${group.map((p) => esc(p.name)).join(", ")}</span>
|
||||
<button class="btn sm" data-join="${esc(group[0].id)}" ${why ? `disabled title="${esc(why)}"` : ""}>Join</button></div>`).join("");
|
||||
}
|
||||
|
||||
// --- bank -----------------------------------------------------------------
|
||||
|
||||
// A strongbox's extra slots or a bag's bigger stack.
|
||||
|
|
@ -1652,6 +1692,12 @@ function board() {
|
|||
</div>`;
|
||||
}
|
||||
|
||||
function joinButton(p) {
|
||||
if (!p.fight || p.id === S.uid || (p.party && p.party === S.me?.action?.party)) return "";
|
||||
const why = S.me?.blocked[p.fight];
|
||||
return ` <button class="btn sm primary" data-join="${esc(p.id)}" ${why ? `disabled title="${esc(why)}"` : ""}>⚔️ Join</button>`;
|
||||
}
|
||||
|
||||
function roster() {
|
||||
return `<div class="grid wide">${S.players.map((p) => {
|
||||
const cls = C.classes[p.cls];
|
||||
|
|
@ -1661,7 +1707,7 @@ function roster() {
|
|||
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
|
||||
<div class="small favour">${FAVOUR} ${fmt(p.donated)} given to the city</div></div></div>
|
||||
<div class="levels">${levels}</div>
|
||||
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button></p></div>`;
|
||||
<p><button class="btn sm" data-nav="character:${esc(p.id)}">Character sheet</button>${joinButton(p)}</p></div>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
|
|
@ -1955,6 +2001,7 @@ document.addEventListener("click", (e) => {
|
|||
if (d.start) return act("/api/action", { id: d.start });
|
||||
if (d.once) return act("/api/action", { id: d.once, once: true });
|
||||
if ("stop" in d) return act("/api/stop", {});
|
||||
if (d.join) { tagging = true; return act("/api/join", { id: d.join }); }
|
||||
if ("orderNew" in d) {
|
||||
draft = draft ? null : { kind: "craft", item: "", qty: 1, provide: [], reward: 0 };
|
||||
return render();
|
||||
|
|
@ -2173,6 +2220,8 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr
|
|||
$("#main").innerHTML = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;
|
||||
return;
|
||||
}
|
||||
setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS);
|
||||
setInterval(() => {
|
||||
if (!document.hidden && Date.now() - polledAt >= (S?.me?.action?.fight ? FIGHT_POLL_MS : POLL_MS)) refresh();
|
||||
}, 500);
|
||||
requestAnimationFrame(loop);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -257,6 +257,10 @@ select { background: var(--bg); border: 1px solid var(--line); border-radius: 8p
|
|||
.fight .bar.hp { height: 12px; }
|
||||
.fight .bar.hp.you > span { background: var(--good); }
|
||||
.fight .bar.hp.foe > span { background: var(--bad); }
|
||||
.fighter.ally { opacity: 0.85; }
|
||||
.fighter.ally .bar.hp { height: 6px; }
|
||||
.fight-log .ally { color: var(--muted); }
|
||||
.fighting-here { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; }
|
||||
.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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue