diff --git a/game.py b/game.py index 0665c35..69ce729 100644 --- a/game.py +++ b/game.py @@ -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."), ("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."), + ("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": [ ("new", "Masterwork patterns: every dungeon boss can drop one of 7 (15% from a mini-boss, 35% from the last boss, " diff --git a/static/app.js b/static/app.js index baca614..5eda0ca 100644 --- a/static/app.js +++ b/static/app.js @@ -943,7 +943,8 @@ function combatFrame() { foe = `
${encounter?.icon || ""}${esc(f.enemy)}
${f.pack.map((v, i) => unit(`e${i}`, `${esc(v.name)}${v.archetype === "elite" && !v.boss ? ` · rare` : ""}`, true, ` ${traits(v)}`)).join("")}
- `; + +
`; } return `
${you}${abilityBar(me)}${prayerBar(me)}${paperDoll(me, true)}
@@ -1071,6 +1072,28 @@ function fightState(f, t) { 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 }) => `
  • + ${esc(playerName(m.id))}${dps < 10 ? dps.toFixed(1) : fmt(Math.round(dps))} · ${fmt(dmg)}
  • `).join(""); +} + function animateFight(now) { const a = S.me?.action, f = a?.fight; if (!f) return; @@ -1166,6 +1189,20 @@ function animateFight(now) { : lines.join("") || `
  • Squaring up…
  • `; } } + // 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 ? `
    ${resting ? "Last fight" : "Damage"}DPS · total
    + ` : ""; + } + } } // A piece of jewellery's bonus, e.g. "+8% accuracy". diff --git a/static/styles.css b/static/styles.css index 6f73a16..89ff817 100644 --- a/static/styles.css +++ b/static/styles.css @@ -545,6 +545,15 @@ button.sheet-skill:hover { background: var(--panel-2); } .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 */ .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 .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;