Damage Meter auto-refresh, NPC toggle, and iconized breakdown

This commit is contained in:
centron\schwoerer
2025-11-22 22:25:28 +01:00
parent 1019af2219
commit 0a076ae5ee

View File

@@ -1,6 +1,6 @@
const MODULE_ID = "gowlers-tracking-ledger";
const MODULE_VERSION = "1.1.0";
const MODULE_VERSION = "1.1.1";
const TRACK_SETTING = "actorSettings";
const FLAG_SCOPE = "world";
const MAX_HISTORY_ROWS = 100;
@@ -83,6 +83,8 @@ const ledgerState = {
recentMessages: [], // Array of recent message metadata: { message, timestamp, source }
historyPageState: new Map(), // actorId -> { [tabId]: { page, pageSize } }
historyTabState: new Map(), // actorId -> active tab id
damageOverlay: null,
damageMeterIncludeNPCs: true,
};
function getHistoryPageState(actorId, tabId) {
@@ -267,6 +269,12 @@ Hooks.once("init", () => {
Hooks.once("ready", async () => {
if (game.system.id !== "pf1") return;
await initializeModule();
registerSceneControls();
// Expose NPC toggle helper for damage meter checkbox
window.GowlersTrackingDamageMeterToggleNPCs = (checked) => {
ledgerState.damageMeterIncludeNPCs = !!checked;
refreshDamageMeterOverlay();
};
});
async function initializeModule() {
@@ -538,6 +546,7 @@ async function initializeModule() {
openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId),
setActorTracking: setActorTracking,
getActorTracking,
openDamageMeterOverlay: () => openDamageMeterOverlay(),
};
game.modules.get(MODULE_ID).api = api;
globalThis.GowlersTrackingLedger = api;
@@ -563,6 +572,57 @@ function registerSettingsMenu() {
});
}
function registerSceneControls() {
Hooks.on("getSceneControlButtons", (controls) => {
const tokenControls = controls.find((c) => c.name === "token");
if (!tokenControls) return;
const existing = tokenControls.tools.find((t) => t.name === "damage-meter");
if (existing) return;
tokenControls.tools.push({
name: "damage-meter",
title: "Damage Meter",
icon: "fas fa-bolt",
button: true,
onClick: () => openDamageMeterOverlay(),
});
});
}
function openDamageMeterOverlay() {
try {
if (ledgerState.damageOverlay?.rendered) {
const content = buildDamageMeterContent();
ledgerState.damageOverlay.data.content = content;
ledgerState.damageOverlay.render(false);
ledgerState.damageOverlay.bringToTop();
return;
}
const content = buildDamageMeterContent();
const dlg = new Dialog({
title: "Damage Meter",
content,
buttons: {},
close: () => {
ledgerState.damageOverlay = null;
},
}, {
id: `${MODULE_ID}-damage-meter`,
width: 300,
height: "auto",
resizable: true,
});
ledgerState.damageOverlay = dlg;
dlg.render(true);
} catch (err) {
console.error("[GowlersTracking] Failed to open Damage Meter overlay:", err);
}
}
async function handleCreateActor(actor) {
await ensureActorConfig(actor);
primeActor(actor);
@@ -933,6 +993,9 @@ function buildHistoryContent(actor, tabArg) {
{
label: "Details",
render: (entry) => {
if (entry.damageDetails?.parts?.length) {
return formatDamagePartsWithIcons(entry.damageDetails.parts);
}
if (entry.breakdown) return entry.breakdown;
if (entry.amount != null) return `${entry.amount} damage`;
return "";
@@ -1228,6 +1291,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
source: source,
breakdown: damageBreakdown,
encounterId: encounterId,
damageDetails: matchedMessage.damageDetails ?? null,
};
recordDamageDealt(attacker, dealtEntry);
}
@@ -1289,6 +1353,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
// Refresh any open dialogs for this actor
refreshOpenDialogs(actor.id);
refreshDamageMeterOverlay();
if (shouldSendChat(actor.id, statId)) {
sendChatNotification(statId, actor, previous, nextValue, entry);
@@ -1446,6 +1511,35 @@ function isNonlethalType(type) {
return String(type ?? "").toLowerCase() === "nonlethal";
}
function formatDamagePartsWithIcons(parts) {
if (!Array.isArray(parts) || !parts.length) return "";
const iconMap = {
slashing: { icon: "ra ra-sword", color: "#e3c000" },
piercing: { icon: "ra ra-spear-head", color: "#2c7be5" },
bludgeoning: { icon: "ra ra-large-hammer", color: "#e03131" },
fire: { icon: "ra ra-fire", color: "#f76707" },
cold: { icon: "ra ra-snowflake", color: "#3bc9db" },
electricity: { icon: "ra ra-lightning-bolt", color: "#f0c419" },
acid: { icon: "ra ra-round-bottom-flask", color: "#2f9e44" },
sonic: { icon: "ra ra-megaphone", color: "#22b8cf" },
force: { icon: "ra ra-crystal-ball", color: "#845ef7" },
negative: { icon: "ra ra-skull", color: "#7950f2" },
positive: { icon: "ra ra-sun", color: "#fab005" },
precision: { icon: "ra ra-target-arrows", color: "#000" },
nonlethal: { icon: "ra ra-hand", color: "#000" },
untyped: { icon: "ra ra-uncertainty", color: "#666" },
};
return parts
.map((p) => {
const baseType = (p.types && p.types[0]) || p.customTypes?.[0] || p.materials?.[0] || "untyped";
const mapEntry = iconMap[baseType?.toLowerCase?.()] ?? iconMap.untyped;
const icon = `<i class="${mapEntry.icon}" style="color:${mapEntry.color};"></i>`;
const amt = Number.isFinite(p.total) ? p.total : p.formula ?? "?";
return `<span style="display:inline-flex; align-items:center; gap:4px; margin-right:6px;">${icon}<span>${amt}</span></span>`;
})
.join(" ");
}
function resolveParticipantName(participant) {
if (!participant) return "Unknown";
@@ -1549,6 +1643,114 @@ function buildEncounterXpTooltip(encounter) {
}
}
function refreshDamageMeterOverlay() {
const dlg = ledgerState.damageOverlay;
if (!dlg?.rendered) return;
try {
const content = buildDamageMeterContent();
dlg.data.content = content;
dlg.render(false);
} catch (err) {
console.warn("[GowlersTracking] Failed to refresh Damage Meter overlay:", err);
}
}
function computeDamageMeterData() {
const currentEncounterId = game.combat?.id ?? ledgerState.lastCombatId ?? null;
const actors = game.actors.contents ?? [];
const totals = [];
const includeNPCs = ledgerState.damageMeterIncludeNPCs;
for (const actor of actors) {
const isNPC = actor.type !== "character" && !actor.hasPlayerOwner;
if (!includeNPCs && isNPC) continue;
const entries = actor.getFlag(FLAG_SCOPE, DAMAGE_DEALT_FLAG) ?? [];
if (!entries.length) continue;
const filtered = currentEncounterId ? entries.filter((e) => e.encounterId === currentEncounterId) : entries;
if (!filtered.length) continue;
const total = filtered.reduce((sum, e) => sum + (Number(e.amount) || 0), 0);
totals.push({
actorId: actor.id,
name: actor.name,
total,
hits: filtered.length,
last: filtered[0],
});
}
totals.sort((a, b) => b.total - a.total);
const grandTotal = totals.reduce((s, t) => s + t.total, 0);
return { totals, grandTotal, encounterId: currentEncounterId };
}
function buildDamageMeterContent() {
const { totals, grandTotal, encounterId } = computeDamageMeterData();
const encounterLabel = encounterId ? `Encounter: ${encounterId.slice(0, 8)}` : "All history";
const includeNPCs = ledgerState.damageMeterIncludeNPCs;
const rows = totals.length
? totals
.map(
(t, idx) => {
const max = totals[0]?.total || 1;
const pct = Math.max(1, Math.round((t.total / max) * 100));
return `
<tr>
<td style="padding:4px 6px;">${idx + 1}</td>
<td style="padding:4px 6px;">${t.name}</td>
<td style="padding:4px 6px; text-align:right;">${t.total}</td>
<td style="padding:4px 6px; text-align:right;">${t.hits}</td>
<td style="padding:4px 6px; max-width: 220px;">
<div style="background:#e0e0e0; border-radius:4px; overflow:hidden; height:10px; margin-bottom:4px;">
<div style="width:${pct}%; background:#ff9800; height:10px;"></div>
</div>
${t.last?.breakdown ?? ""}
</td>
</tr>`;
}
)
.join("")
: `<tr><td colspan="5" style="padding:6px; text-align:center; opacity:0.7;">No damage recorded${encounterId ? " for this encounter" : ""}.</td></tr>`;
const npcToggle = `<label style="font-size:12px; display:inline-flex; align-items:center; gap:4px; margin-left:6px;">
<input type="checkbox" ${includeNPCs ? "checked" : ""} onchange="window.GowlersTrackingDamageMeterToggleNPCs?.(this.checked)">
Include NPCs
</label>`;
return `
<div style="padding:8px; font-size: 13px; line-height: 1.4;">
<div style="font-weight:bold; margin-bottom:4px;">Damage Meter</div>
<div style="margin-bottom:6px;">${encounterLabel}${npcToggle} <span style="float:right;">v${MODULE_VERSION}</span></div>
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr>
<th style="text-align:left; padding:4px 6px;">#</th>
<th style="text-align:left; padding:4px 6px;">Actor</th>
<th style="text-align:right; padding:4px 6px;">Total</th>
<th style="text-align:right; padding:4px 6px;">Hits</th>
<th style="text-align:left; padding:4px 6px;">Last Breakdown</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot>
<tr>
<td></td>
<td style="padding:4px 6px; font-weight:bold;">Total</td>
<td style="padding:4px 6px; text-align:right; font-weight:bold;">${grandTotal}</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
`;
}
function checkboxValue(value, fallback = false) {
if (value === undefined || value === null) return fallback;
if (Array.isArray(value)) value = value[value.length - 1];