DPS meter under the combat log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-25 20:30:15 +00:00
parent 1bf3f3b62f
commit 4f2d113abd
3 changed files with 48 additions and 1 deletions

View file

@ -6073,6 +6073,7 @@ PATCH_NOTES = [
("new", "Enemy casts that can't be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses."), ("new", "Enemy casts that can't be interrupted show a 🔒 and a grey bar. Stuns still stop them, except on bosses."),
("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still " ("fix", "Resting in a group: everyone heals at their own pace, so good food tops you up first. The pull still "
"waits for the slowest, and Pull now sends each in as healed as they really are."), "waits for the slowest, and Pull now sends each in as healed as they really are."),
("new", "DPS meter under the combat log: damage per second and total for each fighter, live. Shows the last fight while resting."),
]}, ]},
{"date": "2026-10-24", "title": "Dungeon loot", "notes": [ {"date": "2026-10-24", "title": "Dungeon loot", "notes": [
("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, " ("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, "

View file

@ -943,7 +943,8 @@ function combatFrame() {
foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span><b>${esc(f.enemy)}</b></div> foe = `<div class="foe-head"><span class="foe-ico">${encounter?.icon || ""}</span><b>${esc(f.enemy)}</b></div>
<div class="pack">${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` <span class="muted">· rare</span>` : ""}`, true, <div class="pack">${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` <span class="muted">· rare</span>` : ""}`, true,
` <span class="traits-inline">${traits(v)}</span>`)).join("")}</div> ` <span class="traits-inline">${traits(v)}</span>`)).join("")}</div>
<ul class="fight-log" data-f="log"></ul>`; <ul class="fight-log" data-f="log"></ul>
<div class="dps" data-f="dps"></div>`;
} }
return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}> return `<div class="panel fight combat-frame" ${f ? `data-fight="${a.start}"` : ""}>
<div class="you-side">${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}</div> <div class="you-side">${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}</div>
@ -1071,6 +1072,28 @@ function fightState(f, t) {
return { st, shown }; return { st, shown };
} }
// The damage meter: what each fighter has dealt so far, and per second since they joined (game seconds,
// so dev speed doesn't inflate it). Best first, bars scaled to the top DPS.
const DAMAGE = new Set(["hit", "crit", "tick"]);
let lastMeter = null; // the fight just finished, shown dimmed while resting for the next one
function dpsMeter(f, t) {
const rows = f.members.map((m) => {
let dmg = 0, heal = 0;
for (const e of f.events) {
if (e[0] > t) break;
if (e[1] !== m.id) continue;
if (DAMAGE.has(e[3])) dmg += e[4] || 0;
else if (e[3] === "heal" && e[6] !== "potion") heal += e[4] || 0;
}
const secs = Math.max(1, Math.min(t, f.end) - (m.at + f.rest) / f.pace) * f.pace;
return { m, dmg, heal, dps: dmg / secs };
}).sort((x, y) => y.dmg - x.dmg);
const top = Math.max(...rows.map((r) => r.dps)) || 1;
return rows.map(({ m, dmg, heal, dps }) => `<li class="${m.id === S.uid ? "mine" : ""}"
title="${esc(`${fmt(dmg)} damage${heal ? ` · ${fmt(heal)} healing` : ""}`)}"><span class="dps-fill" style="width:${dps / top * 100}%"></span>
<b>${esc(playerName(m.id))}</b><span>${dps < 10 ? dps.toFixed(1) : fmt(Math.round(dps))} <span class="muted">· ${fmt(dmg)}</span></span></li>`).join("");
}
function animateFight(now) { function animateFight(now) {
const a = S.me?.action, f = a?.fight; const a = S.me?.action, f = a?.fight;
if (!f) return; if (!f) return;
@ -1166,6 +1189,20 @@ function animateFight(now) {
: lines.join("") || `<li class="muted">Squaring up…</li>`; : lines.join("") || `<li class="muted">Squaring up…</li>`;
} }
} }
// Refreshed on every swing and once a second, since DPS falls between hits.
const meter = panel.querySelector('[data-f="dps"]');
if (meter) {
const stale = resting && lastMeter?.start !== a.start ? lastMeter : null;
const key = resting ? `rest:${stale?.start}` : `${a.start}:${shown}:${Math.floor(Math.min(t, f.end))}`;
if (meter.dataset.key !== key) {
meter.dataset.key = key;
if (!resting) lastMeter = { start: a.start, rows: dpsMeter(f, t) };
const rows = resting ? stale?.rows : lastMeter.rows;
meter.classList.toggle("stale", resting);
meter.innerHTML = rows ? `<div class="dps-head small muted"><span>${resting ? "Last fight" : "Damage"}</span><span title="Damage per second · total">DPS · total</span></div>
<ul>${rows}</ul>` : "";
}
}
} }
// A piece of jewellery's bonus, e.g. "+8% accuracy". // A piece of jewellery's bonus, e.g. "+8% accuracy".

View file

@ -545,6 +545,15 @@ button.sheet-skill:hover { background: var(--panel-2); }
.pill.yours { box-shadow: 0 0 0 2px var(--gold); } .pill.yours { box-shadow: 0 0 0 2px var(--gold); }
/* Fixed at 7 lines (newest first) so the frame doesn't grow and shrink with the log; older lines drop off the bottom */ /* Fixed at 7 lines (newest first) so the frame doesn't grow and shrink with the log; older lines drop off the bottom */
.combat-frame .fight-log { margin: 0; line-height: 1.35; height: calc(7 * 1.35em + 12px); min-height: 0; overflow: hidden; } .combat-frame .fight-log { margin: 0; line-height: 1.35; height: calc(7 * 1.35em + 12px); min-height: 0; overflow: hidden; }
/* Damage meter under the log: a row per fighter, the bar behind it scaled to the top DPS */
.dps { display: grid; gap: 6px; font-size: 13px; }
.dps.stale { opacity: .55; }
.dps-head { display: flex; justify-content: space-between; }
.dps ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 3px; }
.dps li { position: relative; display: flex; justify-content: space-between; gap: 8px; padding: 3px 8px; border-radius: 6px; background: var(--bg); overflow: hidden; }
.dps li > :not(.dps-fill) { position: relative; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dps-fill { position: absolute; inset: 0 auto 0 0; background: rgba(152, 161, 180, .18); transition: width .3s ease-out; }
.dps li.mine .dps-fill { background: rgba(224, 176, 74, .3); }
.prayer-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; } .prayer-bar { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.prayer-bar .pp { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; margin-right: 4px; } .prayer-bar .pp { display: inline-flex; align-items: center; gap: 5px; font-weight: 600; margin-right: 4px; }
.pray-btn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; padding: 0; cursor: pointer; .pray-btn { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; padding: 0; cursor: pointer;