Damage Meter auto-refresh, NPC toggle, and iconized breakdown
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
const MODULE_ID = "gowlers-tracking-ledger";
|
const MODULE_ID = "gowlers-tracking-ledger";
|
||||||
const MODULE_VERSION = "1.1.0";
|
const MODULE_VERSION = "1.1.1";
|
||||||
const TRACK_SETTING = "actorSettings";
|
const TRACK_SETTING = "actorSettings";
|
||||||
const FLAG_SCOPE = "world";
|
const FLAG_SCOPE = "world";
|
||||||
const MAX_HISTORY_ROWS = 100;
|
const MAX_HISTORY_ROWS = 100;
|
||||||
@@ -83,6 +83,8 @@ const ledgerState = {
|
|||||||
recentMessages: [], // Array of recent message metadata: { message, timestamp, source }
|
recentMessages: [], // Array of recent message metadata: { message, timestamp, source }
|
||||||
historyPageState: new Map(), // actorId -> { [tabId]: { page, pageSize } }
|
historyPageState: new Map(), // actorId -> { [tabId]: { page, pageSize } }
|
||||||
historyTabState: new Map(), // actorId -> active tab id
|
historyTabState: new Map(), // actorId -> active tab id
|
||||||
|
damageOverlay: null,
|
||||||
|
damageMeterIncludeNPCs: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHistoryPageState(actorId, tabId) {
|
function getHistoryPageState(actorId, tabId) {
|
||||||
@@ -267,6 +269,12 @@ Hooks.once("init", () => {
|
|||||||
Hooks.once("ready", async () => {
|
Hooks.once("ready", async () => {
|
||||||
if (game.system.id !== "pf1") return;
|
if (game.system.id !== "pf1") return;
|
||||||
await initializeModule();
|
await initializeModule();
|
||||||
|
registerSceneControls();
|
||||||
|
// Expose NPC toggle helper for damage meter checkbox
|
||||||
|
window.GowlersTrackingDamageMeterToggleNPCs = (checked) => {
|
||||||
|
ledgerState.damageMeterIncludeNPCs = !!checked;
|
||||||
|
refreshDamageMeterOverlay();
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initializeModule() {
|
async function initializeModule() {
|
||||||
@@ -538,6 +546,7 @@ async function initializeModule() {
|
|||||||
openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId),
|
openConfigForActor: (actorId) => TrackingLedgerConfig.openForActor(actorId),
|
||||||
setActorTracking: setActorTracking,
|
setActorTracking: setActorTracking,
|
||||||
getActorTracking,
|
getActorTracking,
|
||||||
|
openDamageMeterOverlay: () => openDamageMeterOverlay(),
|
||||||
};
|
};
|
||||||
game.modules.get(MODULE_ID).api = api;
|
game.modules.get(MODULE_ID).api = api;
|
||||||
globalThis.GowlersTrackingLedger = 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) {
|
async function handleCreateActor(actor) {
|
||||||
await ensureActorConfig(actor);
|
await ensureActorConfig(actor);
|
||||||
primeActor(actor);
|
primeActor(actor);
|
||||||
@@ -933,6 +993,9 @@ function buildHistoryContent(actor, tabArg) {
|
|||||||
{
|
{
|
||||||
label: "Details",
|
label: "Details",
|
||||||
render: (entry) => {
|
render: (entry) => {
|
||||||
|
if (entry.damageDetails?.parts?.length) {
|
||||||
|
return formatDamagePartsWithIcons(entry.damageDetails.parts);
|
||||||
|
}
|
||||||
if (entry.breakdown) return entry.breakdown;
|
if (entry.breakdown) return entry.breakdown;
|
||||||
if (entry.amount != null) return `${entry.amount} damage`;
|
if (entry.amount != null) return `${entry.amount} damage`;
|
||||||
return "";
|
return "";
|
||||||
@@ -1228,6 +1291,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
|
|||||||
source: source,
|
source: source,
|
||||||
breakdown: damageBreakdown,
|
breakdown: damageBreakdown,
|
||||||
encounterId: encounterId,
|
encounterId: encounterId,
|
||||||
|
damageDetails: matchedMessage.damageDetails ?? null,
|
||||||
};
|
};
|
||||||
recordDamageDealt(attacker, dealtEntry);
|
recordDamageDealt(attacker, dealtEntry);
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1353,7 @@ async function recordHistoryEntry(actor, statId, previous, nextValue, userId, op
|
|||||||
|
|
||||||
// Refresh any open dialogs for this actor
|
// Refresh any open dialogs for this actor
|
||||||
refreshOpenDialogs(actor.id);
|
refreshOpenDialogs(actor.id);
|
||||||
|
refreshDamageMeterOverlay();
|
||||||
|
|
||||||
if (shouldSendChat(actor.id, statId)) {
|
if (shouldSendChat(actor.id, statId)) {
|
||||||
sendChatNotification(statId, actor, previous, nextValue, entry);
|
sendChatNotification(statId, actor, previous, nextValue, entry);
|
||||||
@@ -1446,6 +1511,35 @@ function isNonlethalType(type) {
|
|||||||
return String(type ?? "").toLowerCase() === "nonlethal";
|
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) {
|
function resolveParticipantName(participant) {
|
||||||
if (!participant) return "Unknown";
|
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) {
|
function checkboxValue(value, fallback = false) {
|
||||||
if (value === undefined || value === null) return fallback;
|
if (value === undefined || value === null) return fallback;
|
||||||
if (Array.isArray(value)) value = value[value.length - 1];
|
if (Array.isArray(value)) value = value[value.length - 1];
|
||||||
|
|||||||
Reference in New Issue
Block a user