Instant targeting, damage on bars, interrupted casts, no arrows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc99a1e0c5
commit
049e2c771a
6 changed files with 85 additions and 41 deletions
|
|
@ -24,6 +24,9 @@ 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
|
||||
// What you last tapped in this fight (an enemy to fight, an ally to heal): shown at once and sent with
|
||||
// every press, so a tap and a press straight after it don't wait on each other.
|
||||
let aim = { fight: null, foe: null, ally: null };
|
||||
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
|
||||
|
|
@ -906,8 +909,8 @@ function combatFrame() {
|
|||
const others = f ? f.members.filter((m) => m.id !== S.uid) : [];
|
||||
const unit = (id, name, foe = false, extra = "") => `<div class="fighter ${foe ? "foe-unit" : id === S.uid ? "" : "ally"} ${f ? "aimable" : ""}"
|
||||
data-u="${esc(id)}" ${f ? `data-aim="${esc(id)}" title="${foe ? "Tap to fight this one" : "Tap to aim your heals here"}"` : ""}>
|
||||
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
|
||||
<div class="bar hp ${foe ? "foe" : "you"}"><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
|
||||
<div class="fighter-name"><b>${name}${extra}</b><span class="small muted"><span class="hitnum" data-u-hitnum></span><span data-u-status></span> ${foe ? "" : HEART} <span data-u-hp>${id === S.uid ? `${fmt(me.hp)} / ${me.max_hp}` : ""}</span></span></div>
|
||||
<div class="bar hp ${foe ? "foe" : "you"}"><span class="lag" data-u-lag></span><span data-u-bar${id === S.uid ? ` data-live-hpbar style="width:${me.hp / me.max_hp * 100}%"` : ""}></span></div>
|
||||
<div class="bar swing"><span data-u-swing></span></div>
|
||||
<div class="cast small" data-u-cast></div>
|
||||
</div>`;
|
||||
|
|
@ -973,6 +976,8 @@ function prayerBar(me) {
|
|||
}
|
||||
|
||||
const SWINGS = new Set(["hit", "crit", "miss", "block", "absorb"]);
|
||||
const LAG = 0.6; // seconds the "just lost" part of an HP bar trails behind
|
||||
const STOPPED = 1.2; // seconds an interrupted cast stays on its bar
|
||||
|
||||
// A line of the fight, terse: who, to whom, how much, with what.
|
||||
function fightLine(e, f) {
|
||||
|
|
@ -1023,7 +1028,7 @@ function fightState(f, t) {
|
|||
f.pack.forEach((foe, i) => { st[`e${i}`] = { hp: foe.hp, top: foe.hp, from: rest, every: foe.interval / f.pace, swung: null, cast: null, out: null, held: false, enraged: false, hidden: !!foe.dormant }; });
|
||||
let shown = -1;
|
||||
while (shown + 1 < f.events.length && f.events[shown + 1][0] <= t) {
|
||||
const [time, src, tgt, kind, , hp, label] = f.events[++shown];
|
||||
const [time, src, tgt, kind, n, hp, label] = f.events[++shown];
|
||||
const who = st[src], them = st[tgt];
|
||||
if (them && hp != null) them.hp = hp;
|
||||
if (who && SWINGS.has(kind) && (label === "" || C.damage_types[label])) who.swung = time;
|
||||
|
|
@ -1039,7 +1044,9 @@ function fightState(f, t) {
|
|||
}
|
||||
if (who?.cds && tgt?.[0] === "e" && (SWINGS.has(kind) || kind === "cast" || kind === "use")) who.aim = tgt;
|
||||
if (who && kind === "heal" && label === "potion") who.drank = true;
|
||||
if (them && kind === "stop" && them.cast) them.stopped = { label: them.cast.label, at: time };
|
||||
if (them && (kind === "stop" || kind === "cc")) them.cast = null;
|
||||
if (them && ["hit", "crit", "tick", "heal", "mend"].includes(kind) && n) them.hit = { at: time, n, kind };
|
||||
if (them && kind === "cc") them.held = true;
|
||||
if (them && (kind === "free" || kind === "die")) them.held = false;
|
||||
if (them && kind === "die") them.out = "dead";
|
||||
|
|
@ -1057,6 +1064,7 @@ function animateFight(now) {
|
|||
const over = t >= f.end;
|
||||
const resting = t < rest;
|
||||
const { st, shown } = fightState(f, t);
|
||||
const before = fightState(f, t - LAG).st; // the HP a moment ago, trailing the bar
|
||||
const mine = f.members.find((m) => m.id === S.uid) || f.members[0];
|
||||
const me = st[mine.id];
|
||||
// Resting: everyone's HP climbs back to full while they eat.
|
||||
|
|
@ -1072,15 +1080,25 @@ function animateFight(now) {
|
|||
const pull = panel.querySelector("[data-pull-btn]");
|
||||
if (pull) pull.hidden = !resting;
|
||||
const pick = [...(mine.inputs || [])].reverse().find((i) => i[1] === "target" && (f.rest + i[0]) / f.pace <= t && i[2][0] === "e");
|
||||
const aim = pick && st[pick[2]] && !st[pick[2]].out ? pick[2] : me.aim && st[me.aim] && !st[me.aim].out ? me.aim : null;
|
||||
const live = (id) => id && st[id] && !st[id].out && !st[id].hidden;
|
||||
const tapped = aim.fight === a.start ? aim : {};
|
||||
const aimed = live(tapped.foe) ? tapped.foe : live(pick?.[2]) ? pick[2] : live(me.aim) ? me.aim : null;
|
||||
const healing = live(tapped.ally) ? tapped.ally : null;
|
||||
for (const el of panel.querySelectorAll("[data-u]")) {
|
||||
const u = st[el.dataset.u];
|
||||
if (!u) continue;
|
||||
el.classList.toggle("aimed", el.dataset.u === aim && !over);
|
||||
el.classList.toggle("aimed", (el.dataset.u === aimed || el.dataset.u === healing) && !over);
|
||||
const gone = !!u.out || over;
|
||||
el.classList.toggle("out", !!u.out);
|
||||
el.hidden = !!u.hidden;
|
||||
el.querySelector("[data-u-bar]").style.width = `${Math.max(0, u.hp / u.top * 100)}%`;
|
||||
const was = before[el.dataset.u];
|
||||
el.querySelector("[data-u-lag]").style.width = `${Math.max(0, Math.max(u.hp, resting ? 0 : was?.hp ?? u.hp) / u.top * 100)}%`;
|
||||
const hit = u.hit && t - u.hit.at < 1 ? u.hit : null, num = el.querySelector("[data-u-hitnum]");
|
||||
const healed = hit && (hit.kind === "heal" || hit.kind === "mend");
|
||||
num.textContent = hit ? `${healed ? "+" : "−"}${hit.n}` : "";
|
||||
num.className = `hitnum ${healed ? "good" : "bad"} ${hit?.kind === "crit" ? "crit" : ""}`;
|
||||
num.style.opacity = hit ? `${1 - (t - hit.at)}` : "0";
|
||||
el.querySelector("[data-u-hp]").textContent = u.out === "left" ? "left" : t < u.from && !resting ? "joining…" : `${fmt(u.hp)} / ${fmt(u.top)}`;
|
||||
const status = u.out === "dead" ? "" : u.out === "down" ? "concuss" : u.held ? "thornbind" : u.enraged ? "unstoppable" : "";
|
||||
const statusEl = el.querySelector("[data-u-status]");
|
||||
|
|
@ -1090,12 +1108,17 @@ function animateFight(now) {
|
|||
}
|
||||
el.querySelector("[data-u-swing]").style.width = gone || t < u.from ? "0%" : `${Math.min(100, (t - (u.swung ?? u.from)) / u.every * 100)}%`;
|
||||
const cast = !gone && u.cast && t < u.cast.until ? u.cast : null;
|
||||
const spec = cast && (el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities)[cast.label];
|
||||
// A cast someone stopped stays up a moment, struck through.
|
||||
const stopped = !cast && u.stopped && t - u.stopped.at < STOPPED ? u.stopped : null;
|
||||
const table = el.dataset.u[0] === "e" ? C.enemy_abilities : C.abilities;
|
||||
const spec = cast ? table[cast.label] : stopped ? table[stopped.label] : null;
|
||||
const castEl = el.querySelector("[data-u-cast]");
|
||||
const key = cast ? `${cast.label}:${cast.from}` : "";
|
||||
const key = cast ? `${cast.label}:${cast.from}` : stopped ? `stopped:${stopped.label}:${stopped.at}` : "";
|
||||
if (castEl.dataset.key !== key) {
|
||||
castEl.dataset.key = key;
|
||||
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>` : "";
|
||||
castEl.classList.toggle("stopped", !!stopped);
|
||||
castEl.innerHTML = cast ? `<span>${spec.icon} ${esc(spec.name)}</span><span class="bar"><span></span></span>`
|
||||
: stopped ? `<span>✋ <s>${spec.icon} ${esc(spec.name)}</s></span><span class="small">interrupted</span>` : "";
|
||||
}
|
||||
if (cast) castEl.querySelector(".bar > span").style.width = `${Math.min(100, (t - cast.from) / (cast.until - cast.from) * 100)}%`;
|
||||
}
|
||||
|
|
@ -2300,13 +2323,22 @@ document.addEventListener("click", (e) => {
|
|||
if (d.press) {
|
||||
if (!S.me.manual.includes(d.press)) return toast("On auto: tap AUTO to take it yourself");
|
||||
t.closest(".ab")?.classList.add("queued");
|
||||
return act("/api/press", { id: d.press });
|
||||
const kind = C.abilities[d.press].target, mine = aim.fight === S.me.action?.start;
|
||||
const target = !mine ? null : kind === "enemy" ? aim.foe : kind === "ally" ? aim.ally : null;
|
||||
return act("/api/press", { id: d.press, ...(target ? { target } : {}) });
|
||||
}
|
||||
if (d.mode) return act("/api/manual", { id: d.mode, on: !S.me.manual.includes(d.mode) });
|
||||
if ("pull" in d) return act("/api/pull", {});
|
||||
if (d.learn) return act("/api/learn", { item: d.learn });
|
||||
if (d.eat !== undefined) return act("/api/eat", { below: Number(d.eat) });
|
||||
if (d.aim) return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
|
||||
if (d.aim) {
|
||||
const fight = S.me.action?.start;
|
||||
if (aim.fight !== fight) aim = { fight, foe: null, ally: null };
|
||||
if (d.aim[0] === "e") aim.foe = d.aim;
|
||||
else aim.ally = d.aim;
|
||||
animateFight(serverNow()); // the outline moves now; the server hears about it in the background
|
||||
return d.aim === S.uid ? undefined : act("/api/target", { id: d.aim });
|
||||
}
|
||||
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", {});
|
||||
|
|
|
|||
|
|
@ -587,6 +587,16 @@ button.sheet-skill:hover { background: var(--panel-2); }
|
|||
.ab-lv .gi { width: 10px; height: 10px; }
|
||||
.ab.boosted .ab-press::after { content: "✦"; position: absolute; top: 1px; right: 3px; font-size: 10px; color: var(--gold); }
|
||||
.unique-text { color: var(--gold); margin-top: 4px; }
|
||||
/* Damage on the bars: what was just lost trails behind, the last hit or heal by the HP */
|
||||
.fight .bar.hp { position: relative; }
|
||||
.fight .bar.hp > .lag { position: absolute; inset: 0 auto 0 0; background: rgba(255, 236, 200, .55); transition: width .25s ease-out; }
|
||||
.fight .bar.hp > span:not(.lag) { position: relative; }
|
||||
.hitnum { font-weight: 700; margin-right: 6px; font-variant-numeric: tabular-nums; }
|
||||
.hitnum.bad { color: var(--bad); }
|
||||
.hitnum.good { color: var(--good); }
|
||||
.hitnum.crit { font-size: 1.15em; }
|
||||
.cast.stopped { color: var(--muted); }
|
||||
.cast.stopped s { text-decoration-thickness: 2px; }
|
||||
.eat-below { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.eat-below .btn.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold); }
|
||||
.ab.locked { opacity: .35; filter: grayscale(1); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue