Bug: timeSinceStart only updated when an envelope event fired (combatStart, attack-roll, etc.). If the combat was idle — a player thinking about their turn, between turns — the timer froze. Fix: 1Hz self-rescheduling setTimeout in the constructor that bumps timeSinceStart and triggers a throttled render while _state.isActive is true. Stopped on unwireHooks (combat end). TDD: Section O (5 assertions) added BEFORE the fix. - O.1 initial timeSinceStart reflects _combatStartedAt - O.2 advances without an envelope event - O.3 tick interval is ~1s - O.4 timer does not tick when combat is inactive - O.5 timer resumes when combat becomes active again Tests: 65/65 passing in ~2s. Playwright 31/31.
689 lines
25 KiB
JavaScript
689 lines
25 KiB
JavaScript
// scripts/hud.js — Combat HUD Hub's ApplicationV2 instance.
|
|
//
|
|
// A floating HUD that aggregates registered sections. Consumer
|
|
// modules register sections via combat-hud-hub.api.addSection();
|
|
// the hub ships built-in core sections (core-header, core-combatants,
|
|
// core-dice-streak) that light up when battle-focus is present.
|
|
//
|
|
// Lifecycle: created as a singleton in main.js's getHud(). Subscribes
|
|
// to foundry-hooks-lib envelope stream at ready (when hooks-lib is
|
|
// active). Reads the active encounter from battle-focus.api.
|
|
// Throttled to once per second.
|
|
//
|
|
// API surface (game.modules.get("combat-hud-hub").api):
|
|
// getHud() -> CombatHudHubApp instance
|
|
// openHud() / closeHud() -> proxies to instance.open() / .close()
|
|
// Sections are managed via the same api object (addSection,
|
|
// removeSection, listSections, pushFeedEntry — see main.js).
|
|
|
|
import {
|
|
onCombatStart,
|
|
onCombatEnd,
|
|
onCombatRound,
|
|
onCombatTurn,
|
|
onCreateCombatant,
|
|
onDeleteCombatant,
|
|
onDnd5eRollAttack,
|
|
onDnd5eRollDamage,
|
|
} from "./event-translation.js";
|
|
|
|
const MODULE_ID = "combat-hud-hub";
|
|
|
|
// Foundry v14: ApplicationV2 + HandlebarsApplicationMixin live under
|
|
// foundry.applications.api. Resolve with safe fallbacks.
|
|
const _APP = foundry?.applications?.api ?? globalThis;
|
|
const ApplicationV2 = _APP.ApplicationV2 ?? globalThis.ApplicationV2;
|
|
const HandlebarsApplicationMixin =
|
|
_APP.HandlebarsApplicationMixin ?? globalThis.HandlebarsApplicationMixin;
|
|
|
|
// Re-render at most once per this many milliseconds.
|
|
const RENDER_THROTTLE_MS = 1000;
|
|
|
|
// Dice-streak dedup window.
|
|
const DICE_STREAK_MAX_GAP_MS = 8000;
|
|
|
|
const POSITION_CLASSES = {
|
|
top: "chh-hud--top",
|
|
bottom: "chh-hud--bottom",
|
|
left: "chh-hud--left",
|
|
right: "chh-hud--right",
|
|
};
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
function formatDuration(ms) {
|
|
if (!Number.isFinite(ms) || ms < 0) return "0:00";
|
|
const total = Math.floor(ms / 1000);
|
|
const m = Math.floor(total / 60);
|
|
const s = total % 60;
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function resolvePortrait(tokenDoc, actorDoc) {
|
|
try { if (tokenDoc?.texture?.src) return tokenDoc.texture.src; }
|
|
catch (_) { /* no texture */ }
|
|
try { if (actorDoc?.img) return actorDoc.img; }
|
|
catch (_) { /* no img */ }
|
|
return null;
|
|
}
|
|
|
|
function hpPercent(actorDoc) {
|
|
try {
|
|
const hp = actorDoc?.system?.attributes?.hp;
|
|
const max = hp?.max ?? null;
|
|
const value = hp?.value ?? null;
|
|
if (max == null || max <= 0 || value == null) return null;
|
|
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
function getPlayerCharacterId() {
|
|
try { return game?.user?.character?.id ?? null; }
|
|
catch (_) { return null; }
|
|
}
|
|
|
|
function getHudPosition() {
|
|
try {
|
|
const v = game.settings.get(MODULE_ID, "hudPosition");
|
|
if (v === "top" || v === "bottom" || v === "left" || v === "right") return v;
|
|
} catch (_) { /* setting not registered yet */ }
|
|
return "top";
|
|
}
|
|
|
|
function extractD20FromEvent(ev) {
|
|
if (!ev) return null;
|
|
if (typeof ev.d20 === "number") return ev.d20;
|
|
const rolls = ev.rolls ?? ev.rawRolls ?? null;
|
|
if (Array.isArray(rolls) && rolls[0]) {
|
|
const r0 = rolls[0];
|
|
const die = r0.terms?.find?.((t) => t.constructor?.name === "Die");
|
|
const result = die?.results?.[0]?.result;
|
|
if (typeof result === "number") return result;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Build the per-section render context ───────────────────────────────
|
|
// The HUD runs every registered section's render() with a context
|
|
// derived from the current state + encounter. We give each section
|
|
// the same shared context shape so consumers can compose freely.
|
|
|
|
function buildRenderContext(state, encounter, event) {
|
|
const isGM = (() => {
|
|
try { return !!game?.user?.isGM; }
|
|
catch (_) { return true; }
|
|
})();
|
|
const playerCharId = getPlayerCharacterId();
|
|
const viewMode = isGM ? "gm" : "player";
|
|
|
|
// Per-PC combatant rows.
|
|
const allCombatants = [];
|
|
if (encounter) {
|
|
for (const c of encounter.combatants?.values?.() ?? []) {
|
|
const tok = (() => {
|
|
try { return canvas?.tokens?.get(c.tokenId)?.document ?? null; }
|
|
catch (_) { return null; }
|
|
})();
|
|
const actor = c.actorId ? game.actors?.get(c.actorId) : null;
|
|
let damageDealt = 0, damageTaken = 0, hits = 0, crits = 0;
|
|
const perRound = encounter.statsByRound?.get(c.tokenId);
|
|
if (perRound instanceof Map) {
|
|
for (const stat of perRound.values()) {
|
|
damageDealt += stat.damageDealt ?? 0;
|
|
damageTaken += stat.damageTaken ?? 0;
|
|
hits += stat.hits ?? 0;
|
|
crits += stat.crits ?? 0;
|
|
}
|
|
}
|
|
allCombatants.push({
|
|
tokenId: c.tokenId,
|
|
actorId: c.actorId ?? c.id ?? c.tokenId,
|
|
name: c.name,
|
|
isPlayer: !!c.isPlayer,
|
|
side: c.isPlayer ? "party" : "foe",
|
|
status: c.status ?? "standing",
|
|
damageDealt, damageTaken, hits, crits,
|
|
portrait: resolvePortrait(tok, actor),
|
|
hpPct: hpPercent(actor),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Player view filters to the player's own character.
|
|
const combatants = (!isGM && playerCharId)
|
|
? allCombatants.filter(c => !c.isPlayer || c.actorId === playerCharId)
|
|
: allCombatants;
|
|
|
|
// Current turn: prefer the encounter's current combatant (if it has
|
|
// one), fall back to game.combat for legacy callers. The encounter's
|
|
// authoritative source — battle-focus owns the encounter singleton.
|
|
// The HUD also tracks _latestTurn (set on combatTurn events) as a
|
|
// tiebreaker when the encounter's currentTurn is a stale number.
|
|
// If we have a _latestTurn that doesn't match the encounter's
|
|
// current combatant, the encounter is stale — use _latestTurn.
|
|
let currentTurn = null;
|
|
const _lt = state?._latestTurn;
|
|
const _encStale = _lt?.combatantId
|
|
&& encounter?.combatantId
|
|
&& _lt.combatantId !== encounter.combatantId;
|
|
if (encounter?.currentTurn && typeof encounter.currentTurn === "object" && !_encStale) {
|
|
const ec = encounter.currentTurn;
|
|
currentTurn = { name: ec.name, tokenId: ec.tokenId ?? null, portrait: ec.portrait ?? null };
|
|
} else if (encounter?.combatantId && encounter.combatants?.get && !_encStale) {
|
|
// Some encounter shapes store the current combatant id and a
|
|
// combatants map. The map may be keyed by tokenId, actorId,
|
|
// or combatantId — try each in turn.
|
|
const cid = encounter.combatantId;
|
|
let cc = encounter.combatants.get(cid);
|
|
if (!cc) {
|
|
for (const c of encounter.combatants.values()) {
|
|
if (c.combatantId === cid || c.tokenId === cid || c.actorId === cid) {
|
|
cc = c; break;
|
|
}
|
|
}
|
|
}
|
|
if (cc) {
|
|
const tok = (() => {
|
|
try { return canvas?.tokens?.get(cc.tokenId)?.document ?? null; }
|
|
catch (_) { return null; }
|
|
})();
|
|
const actor = cc.actorId ? game.actors?.get(cc.actorId) : null;
|
|
currentTurn = {
|
|
name: cc.name ?? cc.actor?.name ?? "(unknown)",
|
|
tokenId: cc.tokenId ?? null,
|
|
portrait: resolvePortrait(tok, actor),
|
|
};
|
|
}
|
|
} else if (_lt?.combatantId) {
|
|
// HUD's cached latest turn (from the most recent combatTurn
|
|
// event). Use it to resolve the new combatant via the turn
|
|
// order or the encounter's combatants map.
|
|
const cached = state._latestTurn;
|
|
let cc = encounter?.combatants?.get?.(cached.combatantTokenId ?? cached.combatantId);
|
|
if (!cc && encounter?.combatants) {
|
|
// Look up by combatantId (combatant document id).
|
|
for (const c of encounter.combatants.values()) {
|
|
if (c.combatantId === cached.combatantId) { cc = c; break; }
|
|
}
|
|
}
|
|
if (cc) {
|
|
const tok = (() => {
|
|
try { return canvas?.tokens?.get(cc.tokenId)?.document ?? null; }
|
|
catch (_) { return null; }
|
|
})();
|
|
const actor = cc.actorId ? game.actors?.get(cc.actorId) : null;
|
|
currentTurn = {
|
|
name: cc.name ?? cc.actor?.name ?? cached.combatantName ?? "(unknown)",
|
|
tokenId: cc.tokenId ?? null,
|
|
portrait: resolvePortrait(tok, actor),
|
|
};
|
|
} else {
|
|
// No combatant resolution — emit a minimal stub from the
|
|
// cached fields so the header at least shows the right name.
|
|
currentTurn = {
|
|
name: cached.combatantName ?? "(unknown)",
|
|
tokenId: cached.combatantTokenId ?? null,
|
|
portrait: null,
|
|
};
|
|
}
|
|
} else {
|
|
// Legacy fallback: game.combat global.
|
|
try {
|
|
const cc = game.combat?.combatant;
|
|
const tokDoc = cc?.token ?? (cc?.tokenId ? canvas?.tokens?.get(cc.tokenId)?.document : null);
|
|
if (cc) {
|
|
currentTurn = {
|
|
name: cc.name ?? cc.actor?.name ?? "(unknown)",
|
|
tokenId: cc.tokenId ?? null,
|
|
portrait: resolvePortrait(tokDoc, cc.actor),
|
|
};
|
|
}
|
|
} catch (_) { /* no combat */ }
|
|
}
|
|
|
|
return {
|
|
round: encounter?.currentRound ?? state.round ?? 0,
|
|
turn: encounter?.currentTurn ?? state.turn ?? 0,
|
|
currentTurn,
|
|
timeSinceStart: state.timeSinceStart ?? 0,
|
|
timeSinceStartFormatted: formatDuration(state.timeSinceStart ?? 0),
|
|
position: state.position ?? getHudPosition(),
|
|
viewMode,
|
|
combatants,
|
|
diceStreak: state.diceStreak ?? 0,
|
|
lastDiceValue: state.lastDiceValue ?? null,
|
|
isActive: state.isActive ?? false,
|
|
event: event ?? null,
|
|
};
|
|
}
|
|
|
|
// ── Built-in core section render fns ──────────────────────────────────
|
|
|
|
function renderCoreHeader(ctx) {
|
|
const r = ctx.round ?? 0;
|
|
const t = ctx.turn ?? 0;
|
|
const ct = ctx.currentTurn;
|
|
const round = `<span class="chh-section-header-round" title="Current round">⚔️ Round ${r}</span>`;
|
|
const turn = ct
|
|
? `<span class="chh-section-header-turn" title="Current turn">
|
|
<img class="chh-section-header-portrait" src="${ct.portrait ?? ""}" alt="${ct.name ?? ""}" />
|
|
<span class="chh-section-header-turn-name">▶ ${ct.name ?? ""}</span>
|
|
</span>`
|
|
: `<span class="chh-section-header-turn" title="Current turn">Turn ${t}</span>`;
|
|
return `<div class="chh-section-header-content">${round}${turn}</div>`;
|
|
}
|
|
|
|
function renderCoreCombatants(ctx) {
|
|
const currentTokenId = ctx.currentTurn?.tokenId ?? null;
|
|
const rows = (ctx.combatants ?? []).map(c => {
|
|
const isCurrent = currentTokenId != null && c.tokenId === currentTokenId;
|
|
return `
|
|
<li class="chh-section-combatants-row chh-section-combatants-row--${c.side}${isCurrent ? " chh-section-combatants-row--current" : ""}"
|
|
data-token-id="${c.tokenId ?? ""}"
|
|
${isCurrent ? 'data-chh-current-turn="true"' : ""}>
|
|
${c.portrait ? `<img class="chh-section-combatants-portrait${isCurrent ? " chh-section-combatants-portrait--current" : ""}" src="${c.portrait}" alt="${c.name ?? ""}" />` : ""}
|
|
<div class="chh-section-combatants-body">
|
|
<div class="chh-section-combatants-name">
|
|
${isCurrent ? '<span class="chh-section-combatants-current-marker" title="Current turn">▶</span>' : ""}
|
|
${c.name ?? "(unnamed)"}
|
|
${c.isPlayer
|
|
? `<span class="chh-section-combatants-tag">PC</span>`
|
|
: `<span class="chh-section-combatants-tag chh-section-combatants-tag--foe">NPC</span>`}
|
|
${c.status === "down"
|
|
? `<span class="chh-section-combatants-tag chh-section-combatants-tag--down">DOWN</span>`
|
|
: ""}
|
|
</div>
|
|
<div class="chh-section-combatants-stats">
|
|
<span class="chh-section-combatants-stat" title="Damage dealt">🗡 ${c.damageDealt ?? 0}</span>
|
|
<span class="chh-section-combatants-stat" title="Damage taken">💢 ${c.damageTaken ?? 0}</span>
|
|
<span class="chh-section-combatants-stat" title="Hits / crits">🎯 ${c.hits ?? 0} / 💥 ${c.crits ?? 0}</span>
|
|
${c.hpPct != null
|
|
? `<span class="chh-section-combatants-stat chh-section-combatants-stat--hp" title="HP remaining">❤️ ${c.hpPct}%</span>`
|
|
: ""}
|
|
</div>
|
|
</div>
|
|
</li>`;
|
|
}).join("");
|
|
return rows
|
|
? `<ul class="chh-section-combatants-list">${rows}</ul>`
|
|
: `<p class="chh-hud-empty">No combatants yet.</p>`;
|
|
}
|
|
|
|
function renderCoreDiceStreak(ctx) {
|
|
const streak = ctx.diceStreak ?? 0;
|
|
const last = ctx.lastDiceValue;
|
|
return `<div class="chh-section-dice-streak" data-streak="${streak}">
|
|
<span class="chh-section-combatants-stat" title="Consecutive matching d20s">Dice Streak: <strong class="chh-section-dice-streak-value">${streak}</strong></span>
|
|
${last != null ? `<span class="chh-section-combatants-stat" style="font-style:italic">(last: ${last})</span>` : ""}
|
|
</div>`;
|
|
}
|
|
|
|
// ── The ApplicationV2 class ────────────────────────────────────────────
|
|
|
|
export class CombatHudHubApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
|
constructor(options = {}) {
|
|
super(options);
|
|
this._state = {
|
|
round: 0,
|
|
turn: 0,
|
|
currentTurn: null,
|
|
timeSinceStart: 0,
|
|
diceStreak: 0,
|
|
lastDiceValue: null,
|
|
lastDiceAt: null,
|
|
position: "top",
|
|
isActive: false,
|
|
};
|
|
this._lastRenderedAt = 0;
|
|
this._combatStartedAt = null;
|
|
this._hooksRegistered = false;
|
|
this._unsubscribers = null;
|
|
// Section registry — kept in sync with main.js's _sections Map.
|
|
// main.js owns the canonical store; this is a private mirror so
|
|
// we can iterate during render without coupling to main.js.
|
|
this._sectionsMirror = new Map();
|
|
this._feedMirror = new Map();
|
|
// 1Hz tick that bumps _state.timeSinceStart and re-renders the
|
|
// HUD while combat is active. Without this, the timer freezes
|
|
// when no envelope event fires (e.g., a player is just thinking
|
|
// about their turn). The interval is owned by the singleton and
|
|
// set up at construction; it does work only when
|
|
// _state.isActive is true. The interval is NOT a Foundry Hook
|
|
// and does not depend on a Foundry lifecycle event.
|
|
this._tickHandle = null;
|
|
this._startTick();
|
|
}
|
|
|
|
_startTick() {
|
|
if (this._tickHandle != null) return;
|
|
// Use a self-rescheduling setTimeout rather than setInterval so
|
|
// we can stop it on close and restart on open without leaking.
|
|
const TICK_MS = 1000;
|
|
const tick = () => {
|
|
if (this._state.isActive && this._combatStartedAt != null) {
|
|
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
|
|
// Re-render to update the displayed timer. Throttled render
|
|
// will coalesce if multiple ticks fire in quick succession
|
|
// (they won't, but the safety is there).
|
|
this.render(false).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] tick render failed:`, e)
|
|
);
|
|
}
|
|
this._tickHandle = setTimeout(tick, TICK_MS);
|
|
};
|
|
this._tickHandle = setTimeout(tick, TICK_MS);
|
|
}
|
|
|
|
_stopTick() {
|
|
if (this._tickHandle != null) {
|
|
clearTimeout(this._tickHandle);
|
|
this._tickHandle = null;
|
|
}
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Foundry ApplicationV2 plumbing
|
|
* =========================================================== */
|
|
|
|
static DEFAULT_OPTIONS = {
|
|
id: "combat-hud-hud",
|
|
classes: ["combat-hud-hub", "chh-app"],
|
|
tag: "div",
|
|
window: {
|
|
title: "Combat HUD",
|
|
frame: false,
|
|
positioned: false,
|
|
minimizable: false,
|
|
resizable: false,
|
|
},
|
|
position: {
|
|
width: 320,
|
|
height: "auto",
|
|
},
|
|
};
|
|
|
|
static PARTS = {
|
|
body: {
|
|
template: "modules/combat-hud-hub/templates/hud.html",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Sync the section + feed mirrors from main.js. Called by main.js
|
|
* after addSection/removeSection/pushFeedEntry.
|
|
*/
|
|
_syncSections(sections, feedBySection) {
|
|
this._sectionsMirror = new Map(sections.map(s => [s.id, s]));
|
|
this._feedMirror = feedBySection instanceof Map
|
|
? new Map(feedBySection)
|
|
: new Map(Object.entries(feedBySection ?? {}));
|
|
}
|
|
|
|
_prepareContext(_options) {
|
|
this._state.position = getHudPosition();
|
|
if (this._combatStartedAt != null) {
|
|
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
|
|
}
|
|
const ctx = buildRenderContext(this._state, this._encounter, null);
|
|
// Build the rendered HTML for each registered section. Each
|
|
// section's render fn receives the full context + its own feed.
|
|
const sections = [];
|
|
for (const [, section] of this._sectionsMirror) {
|
|
const feed = this._feedMirror.get(section.id) ?? [];
|
|
try {
|
|
sections.push({
|
|
id: section.id,
|
|
label: section.label,
|
|
html: section.render({ ...ctx, feed, section }),
|
|
});
|
|
} catch (e) {
|
|
console.warn(`[${MODULE_ID}] section ${section.id} render threw:`, e);
|
|
}
|
|
}
|
|
return {
|
|
timeSinceStart: formatDuration(this._state.timeSinceStart ?? 0),
|
|
position: this._state.position,
|
|
viewMode: ctx.viewMode,
|
|
sections,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Render with throttle. The ApplicationV2 base render() is called
|
|
* only if `force` or the throttle window has elapsed.
|
|
*/
|
|
async render(force = false, options = {}) {
|
|
if (!force) {
|
|
const now = Date.now();
|
|
const elapsed = now - this._lastRenderedAt;
|
|
if (elapsed < RENDER_THROTTLE_MS) {
|
|
if (this._pendingRender) return;
|
|
const wait = RENDER_THROTTLE_MS - elapsed;
|
|
this._pendingRender = setTimeout(() => {
|
|
this._pendingRender = null;
|
|
this.render(true, options).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] throttled render failed:`, e)
|
|
);
|
|
}, wait);
|
|
return;
|
|
}
|
|
}
|
|
this._lastRenderedAt = Date.now();
|
|
return super.render(force, options);
|
|
}
|
|
|
|
_onRender(context, options) {
|
|
const root = this.element?.[0] ?? this.element;
|
|
if (!root) return;
|
|
const closeBtn = root.querySelector?.('[data-chh-action="close"]');
|
|
if (closeBtn && !closeBtn.dataset.chhWired) {
|
|
closeBtn.dataset.chhWired = "true";
|
|
closeBtn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
this.close();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Public API
|
|
* =========================================================== */
|
|
|
|
isOpen() {
|
|
try { return !!this.rendered; }
|
|
catch (_) { return false; }
|
|
}
|
|
|
|
open() {
|
|
return this.render(true, { force: true });
|
|
}
|
|
|
|
forceRender() {
|
|
this._lastRenderedAt = 0;
|
|
return this.render(true, { force: true });
|
|
}
|
|
|
|
async close(options = {}) {
|
|
this._state.isActive = false;
|
|
this._combatStartedAt = null;
|
|
return super.close(options);
|
|
}
|
|
|
|
getState() {
|
|
return { ...this._state };
|
|
}
|
|
|
|
getView(opts = {}) {
|
|
const isGM = opts.isGM ?? (() => {
|
|
try { return !!game?.user?.isGM; } catch (_) { return true; }
|
|
})();
|
|
const playerCharId = opts.character?.id ?? getPlayerCharacterId();
|
|
const viewMode = isGM ? "gm" : "player";
|
|
const ctx = buildRenderContext(this._state, this._encounter, null);
|
|
const combatants = (!isGM && playerCharId)
|
|
? ctx.combatants.filter(c => !c.isPlayer || c.actorId === playerCharId)
|
|
: ctx.combatants;
|
|
return { ...this._state, viewMode, combatants };
|
|
}
|
|
|
|
getDiceStreak() {
|
|
return this._state.diceStreak;
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Hook wiring
|
|
* =========================================================== */
|
|
|
|
wireHooks(hooksLib) {
|
|
if (this._hooksRegistered) return;
|
|
if (!hooksLib?.subscribeMany) {
|
|
console.warn(
|
|
`[${MODULE_ID}] wireHooks called without a hooksLib API; HUD will not receive live updates.`
|
|
);
|
|
return;
|
|
}
|
|
this._hooksRegistered = true;
|
|
const handlers = {
|
|
combatStart: (envelope) => this._handleHudEvent(onCombatStart(...(envelope.args ?? []))),
|
|
combatEnd: (envelope) => this._handleHudEvent(onCombatEnd(...(envelope.args ?? []))),
|
|
combatRound: (envelope) => this._handleHudEvent(onCombatRound(...(envelope.args ?? []))),
|
|
combatTurn: (envelope) => this._handleHudEvent(onCombatTurn(...(envelope.args ?? []))),
|
|
createCombatant: (envelope) => this._handleHudEvent(onCreateCombatant(...(envelope.args ?? []))),
|
|
deleteCombatant: (envelope) => this._handleHudEvent(onDeleteCombatant(...(envelope.args ?? []))),
|
|
"dnd5e.rollAttackV2": (envelope) => this._handleHudEvent(onDnd5eRollAttack(...(envelope.args ?? []))),
|
|
"dnd5e.rollDamageV2": (envelope) => this._handleHudEvent(onDnd5eRollDamage(...(envelope.args ?? []))),
|
|
};
|
|
this._unsubscribers = hooksLib.subscribeMany(handlers);
|
|
}
|
|
|
|
unwireHooks() {
|
|
if (!this._hooksRegistered) return;
|
|
this._hooksRegistered = false;
|
|
if (Array.isArray(this._unsubscribers)) {
|
|
for (const unsub of this._unsubscribers) {
|
|
try { unsub?.(); } catch (e) { /* ignore */ }
|
|
}
|
|
}
|
|
this._unsubscribers = null;
|
|
// Stop the 1Hz tick — combat is over, no need to keep counting.
|
|
this._stopTick();
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Event handling
|
|
* =========================================================== */
|
|
|
|
_handleHudEvent(event) {
|
|
if (!event) return;
|
|
if (event.kind === "combat-start") {
|
|
this._state.diceStreak = 0;
|
|
this._state.lastDiceValue = null;
|
|
this._state.lastDiceAt = null;
|
|
this._combatStartedAt = Date.now();
|
|
this._state.isActive = true;
|
|
if (!this.rendered) {
|
|
this.open().catch((e) =>
|
|
console.warn(`[${MODULE_ID}] auto-open failed:`, e)
|
|
);
|
|
}
|
|
} else if (event.kind === "combat-end") {
|
|
this._state.isActive = false;
|
|
this._combatStartedAt = null;
|
|
} else if (event.kind === "turn") {
|
|
// Foundry's combatTurn hook fires BEFORE the new state is
|
|
// committed, so game.combat.combatant is still the OLD
|
|
// combatant at this point. The event-translation layer
|
|
// resolves the new combatant from combat.turns[newTurn] and
|
|
// passes it through. Stash the latest turn info on the state
|
|
// so buildRenderContext can use it as a tiebreaker when the
|
|
// encounter's currentTurn is a stale number.
|
|
if (typeof event.turn === "number") this._state.turn = event.turn;
|
|
if (typeof event.round === "number") this._state.round = event.round;
|
|
this._state._latestTurn = {
|
|
combatantId: event.combatantId ?? null,
|
|
combatantName: event.combatantName ?? null,
|
|
combatantTokenId: event.combatantTokenId ?? null,
|
|
turn: event.turn ?? null,
|
|
round: event.round ?? null,
|
|
};
|
|
}
|
|
// Resolve the active encounter from battle-focus's public API.
|
|
const bfMod = game?.modules?.get?.("battle-focus");
|
|
this._encounter = (bfMod?.active && bfMod.api?.getActiveEncounter)
|
|
? bfMod.api.getActiveEncounter()
|
|
: null;
|
|
if (!this._encounter) {
|
|
this.render(false).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] render failed (no encounter):`, e)
|
|
);
|
|
return;
|
|
}
|
|
if (typeof this._encounter.currentRound === "number" && event?.kind !== "turn") {
|
|
this._state.round = this._encounter.currentRound;
|
|
}
|
|
if (typeof this._encounter.currentTurn === "number" && event?.kind !== "turn") {
|
|
this._state.turn = this._encounter.currentTurn;
|
|
}
|
|
if (this._encounter.startedAt && this._combatStartedAt == null) {
|
|
this._combatStartedAt = this._encounter.startedAt;
|
|
}
|
|
if (this._combatStartedAt != null) {
|
|
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
|
|
}
|
|
// Dice-streak logic on attack-roll events.
|
|
if (event.kind === "attack-roll") {
|
|
const d20 = extractD20FromEvent(event);
|
|
if (d20 != null) this._updateDiceStreak(d20, event.ts ?? Date.now());
|
|
}
|
|
this.render(false).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] render failed:`, e)
|
|
);
|
|
}
|
|
|
|
_updateDiceStreak(d20, ts) {
|
|
const lastVal = this._state.lastDiceValue;
|
|
const lastAt = this._state.lastDiceAt;
|
|
let nextStreak;
|
|
if (lastVal === d20 && lastAt != null && (ts - lastAt) <= DICE_STREAK_MAX_GAP_MS) {
|
|
nextStreak = (this._state.diceStreak ?? 0) + 1;
|
|
} else if (lastVal == null) {
|
|
nextStreak = 1;
|
|
} else {
|
|
nextStreak = 0;
|
|
}
|
|
this._state.diceStreak = nextStreak;
|
|
this._state.lastDiceValue = d20;
|
|
this._state.lastDiceAt = ts;
|
|
}
|
|
}
|
|
|
|
// ── Singleton accessor ────────────────────────────────────────────────
|
|
|
|
let _singleton = null;
|
|
|
|
export function getHud() {
|
|
if (!_singleton) {
|
|
_singleton = new CombatHudHubApp();
|
|
}
|
|
return _singleton;
|
|
}
|
|
|
|
// Built-in core sections. main.js calls this when battle-focus is
|
|
// present at ready.
|
|
export function registerCoreSections(api) {
|
|
api.addSection({
|
|
id: "core-header",
|
|
label: "",
|
|
render: renderCoreHeader,
|
|
});
|
|
api.addSection({
|
|
id: "core-combatants",
|
|
label: "Combatants",
|
|
render: renderCoreCombatants,
|
|
});
|
|
api.addSection({
|
|
id: "core-dice-streak",
|
|
label: "",
|
|
render: renderCoreDiceStreak,
|
|
});
|
|
} |