Stage 2 of the Hax's Tools split. its-achievable ships as a standalone module that subscribes to hax-hooks-lib's envelope stream and provides achievements + custom rules + rewards + achievement wall + combat HUD. ## What's new scripts/ — moved from battle-focus/scripts/, MODULE_ID retagged battle-focus → its-achievable: - achievement-rules.js (323 lines) — rule engine: OPERATORS, TRIGGER_TYPES, evaluateCondition(s), testRule, evaluateRulesFor* - achievements.js (1150 lines) — 24-entry catalog + award path, per-event evaluators, encounter-end + career-update evaluation - achievement-wall.js (333 lines) — renderAchievementWall, getAchievementWallProgress, renderAchievementPopover - custom-achievements-app.js (270 lines) — GM FormApplication for editing custom rules - hud.js (624 lines) — combat HUD (ApplicationV2 + HandlebarsApplicationMixin); removed dead import of battle-focus's encounter.js (it was unused even in the original) scripts/main.js — Foundry entry point. Registers settings at its-achievable.* namespace; exposes the public API on mod.api; registers chatBubble popover listener + HUD singleton on ready. templates/ + styles/ — moved verbatim. tests/PLAN.md — per-project test plan (sections A-F). tests/test-helpers.mjs — Foundry stub. tests/verify-achievable-v1.mjs — smoke test, 75 assertions covering rule engine, catalog, awards, hooks-lib wiring, HUD payload derivation, and wall/popover rendering. Runs in <2s. ## Architecture - **Settings namespace**: its-achievable.* (was battle-focus.*). No migration (per Kaysser's decision); users with existing worlds re-create their custom rules. Documented in README. - **HUD derives its own state from hooks-lib envelopes.** Stage 2 keeps the legacy battle-focus:hud-update broadcast subscription for now (battle-focus still emits it); Stage 3 will switch the HUD to subscribe to hooks-lib directly and remove the battle-focus broadcasts. - **Encounter singleton**: accessed via battle-focus's public api.getActiveEncounter() — no direct import of battle-focus's encounter.js. ## Dependencies - hax-hooks-lib ^0.2.0 (declared in module.json relationships). - battle-focus (soft, runtime) — provides the encounter singleton. ## Tests - 75/75 smoke assertions pass in 0.07s. - Module manifest validates: 0 errors, 1 warning (no icon — Stage 2+ work). Push: Gitea only.
629 lines
21 KiB
JavaScript
629 lines
21 KiB
JavaScript
// Active Combat HUD (slice C).
|
|
//
|
|
// A floating ApplicationV2 that shows live combat stats during an
|
|
// active combat. Subscribes to the event pipeline via Foundry's
|
|
// `Hooks.on('battle-focus:hud-update', ...)` and
|
|
// `Hooks.on('battle-focus:hud-achievement', ...)` events. Renders
|
|
// throttled to once per second to keep the DOM cheap.
|
|
//
|
|
// Layout:
|
|
// - top header: round, current turn portrait + name, time-since-start, close
|
|
// - combatants list: per-PC damage dealt / taken, hits, crits, HP%
|
|
// - dice streak: count of consecutive matching d20s
|
|
// - pinned achievements: feed of unlocks during the fight
|
|
//
|
|
// GM view shows all combatants; player view filters to the
|
|
// player's own character (per game.user.character).
|
|
//
|
|
// API exposed on `game.modules.get('battle-focus').api.hud`:
|
|
// - isOpen(): boolean
|
|
// - open(): void
|
|
// - close(): void
|
|
// - getState(): { round, turn, currentTurn, timeSinceStart, combatants,
|
|
// diceStreak, lastDiceValue, pinnedAchievements, viewMode,
|
|
// position }
|
|
// - getDiceStreak(): number
|
|
// - getPinnedAchievements(): array
|
|
// - getView(opts?): same as getState() but allows caller to specify
|
|
// a fake user for the player-view test
|
|
// - element: HTMLElement | null (the .bf-hud root)
|
|
//
|
|
// The HUD deliberately does NOT own the event pipeline — it just
|
|
// listens. main.js is responsible for broadcasting
|
|
// `battle-focus:hud-update` after each event. The HUD itself is
|
|
// passive: it stores a snapshot of state and re-renders when state
|
|
// changes.
|
|
//
|
|
// Stage 2 note: the encounter singleton is reachable via
|
|
// battle-focus.api.getActiveEncounter(). No direct ./encounter.js
|
|
// import here (encounter.js stays in battle-focus). The original
|
|
// `import { getActive as getActiveEncounter } from "./encounter.js"`
|
|
// was unused — removed to avoid a missing-module load failure.
|
|
|
|
const MODULE_ID = "its-achievable";
|
|
|
|
// Foundry v14: ApplicationV2 + HandlebarsApplicationMixin live under
|
|
// foundry.applications.api (not the global scope). Resolve them at
|
|
// import time with safe fallbacks so the module can also load on
|
|
// older Foundry versions where they may still be globals.
|
|
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. Even a busy
|
|
// combat fires <10 events/sec; 1000ms is a safe upper bound.
|
|
const RENDER_THROTTLE_MS = 1000;
|
|
|
|
// Max entries to keep in the pinned-achievements feed. Older entries
|
|
// fall off the bottom.
|
|
const PINNED_MAX = 8;
|
|
|
|
// Dice-streak dedup window. Two attack-rolls that look like they're
|
|
// "consecutive" but are 10 seconds apart probably aren't a streak
|
|
// — we reset if more than this time elapses between matching rolls.
|
|
const DICE_STREAK_MAX_GAP_MS = 8000;
|
|
|
|
const POSITION_CLASSES = {
|
|
top: "bf-hud--top",
|
|
bottom: "bf-hud--bottom",
|
|
left: "bf-hud--left",
|
|
right: "bf-hud--right",
|
|
};
|
|
|
|
/**
|
|
* Format a duration in ms as a short M:SS string for the header.
|
|
*/
|
|
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")}`;
|
|
}
|
|
|
|
/**
|
|
* Resolve the portrait URL for a token/actor. Returns null if
|
|
* neither has a texture.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Compute HP percentage for a combatant. Returns null if the
|
|
* underlying actor has no max-HP (not applicable, e.g. an object).
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the player's own character ID, or null if there isn't one
|
|
* (e.g. for GMs without a character set).
|
|
*/
|
|
function getPlayerCharacterId() {
|
|
try {
|
|
return game?.user?.character?.id ?? null;
|
|
} catch (_) { return null; }
|
|
}
|
|
|
|
/**
|
|
* Read the hudPosition setting, falling back to "top".
|
|
*/
|
|
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";
|
|
}
|
|
|
|
/**
|
|
* The HUD application. ApplicationV2 with HandlebarsApplicationMixin
|
|
* so we can use a static template path.
|
|
*/
|
|
export class BattleFocusHUD extends HandlebarsApplicationMixin(ApplicationV2) {
|
|
constructor(options = {}) {
|
|
super(options);
|
|
// The HUD's internal state. Updated on every event; rendered
|
|
// throttled. Always set to a plain object so getState() is
|
|
// safe before any events fire.
|
|
this._state = {
|
|
round: 0,
|
|
turn: 0,
|
|
currentTurn: null,
|
|
timeSinceStart: 0,
|
|
combatants: [],
|
|
diceStreak: 0,
|
|
lastDiceValue: null,
|
|
pinnedAchievements: [],
|
|
viewMode: "gm",
|
|
position: "top",
|
|
isActive: false, // tracks whether a combat is currently in progress
|
|
};
|
|
// Last rendered timestamp. The throttle uses this.
|
|
this._lastRenderedAt = 0;
|
|
// The current combat startedAt (for the timer). Set on combatStart,
|
|
// cleared on combatEnd. Resets on Foundry world reload.
|
|
this._combatStartedAt = null;
|
|
// Pendin pinned-achievement feed (so we can show toasts).
|
|
// We store the full achievement object so the template can
|
|
// render the icon + name + description.
|
|
this._pinnedQueue = [];
|
|
// Dedupe: don't re-pin the same achievement ID for the same actor
|
|
// within a single combat.
|
|
this._pinnedSeen = new Set();
|
|
// Bind so we can pass these to Hooks listeners.
|
|
this._onHudUpdate = this._onHudUpdate.bind(this);
|
|
this._onHudAchievement = this._onHudAchievement.bind(this);
|
|
this._onCombatStartHook = this._onCombatStartHook.bind(this);
|
|
this._onCombatEndHook = this._onCombatEndHook.bind(this);
|
|
// Register the listeners once. The HUD is a module-level
|
|
// singleton; main.js calls _registerHooks() after construction.
|
|
this._hooksRegistered = false;
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Foundry ApplicationV2 plumbing
|
|
* =========================================================== */
|
|
|
|
static DEFAULT_OPTIONS = {
|
|
id: "battle-focus-hud",
|
|
classes: ["battle-focus", "bf-app"],
|
|
tag: "div",
|
|
window: {
|
|
title: "Battle Focus",
|
|
frame: false, // no Foundry chrome — it's a HUD overlay
|
|
positioned: false, // we control position via CSS (top/bottom/left/right)
|
|
minimizable: false,
|
|
resizable: false,
|
|
},
|
|
position: {
|
|
width: 320,
|
|
height: "auto",
|
|
},
|
|
};
|
|
|
|
static PARTS = {
|
|
body: {
|
|
template: "modules/battle-focus/templates/hud.html",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Build the context object that the template renders against.
|
|
* Pure function over `this._state` + the current encounter.
|
|
*/
|
|
_prepareContext(_options) {
|
|
// Update the position from the current setting on every render.
|
|
this._state.position = getHudPosition();
|
|
// Update the time-since-start live so the timer ticks even if no
|
|
// event has fired in the last second.
|
|
if (this._combatStartedAt != null) {
|
|
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
|
|
}
|
|
// Update viewMode based on the current user.
|
|
this._state.viewMode = (() => {
|
|
try { return game?.user?.isGM ? "gm" : "player"; }
|
|
catch (_) { return "gm"; }
|
|
})();
|
|
return { ...this._state, timeSinceStart: formatDuration(this._state.timeSinceStart) };
|
|
}
|
|
|
|
/**
|
|
* Render the application. We override render() to enforce the
|
|
* throttle — call this from the event listeners and the throttle
|
|
* will coalesce.
|
|
*/
|
|
async render(force = false, options = {}) {
|
|
if (!force) {
|
|
const now = Date.now();
|
|
const elapsed = now - this._lastRenderedAt;
|
|
if (elapsed < RENDER_THROTTLE_MS) {
|
|
// Schedule a deferred render at the throttle boundary.
|
|
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}] HUD throttled render failed:`, e)
|
|
);
|
|
}, wait);
|
|
return;
|
|
}
|
|
}
|
|
this._lastRenderedAt = Date.now();
|
|
return super.render(force, options);
|
|
}
|
|
|
|
/**
|
|
* On render, wire up the close button. We don't need any other
|
|
* event handlers — the HUD is read-only.
|
|
*/
|
|
_onRender(context, options) {
|
|
// ApplicationV2 exposes `this.element` as a getter — assigning
|
|
// to it (e.g. `this.element = this.element`) throws. We just
|
|
// need the live element for our querySelector calls below; the
|
|
// getter handles that.
|
|
const root = this.element?.[0] ?? this.element;
|
|
if (!root) return;
|
|
const closeBtn = root.querySelector?.('[data-bf-action="close"]');
|
|
if (closeBtn && !closeBtn.dataset.bfWired) {
|
|
closeBtn.dataset.bfWired = "true";
|
|
closeBtn.addEventListener("click", (ev) => {
|
|
ev.preventDefault();
|
|
this.close();
|
|
});
|
|
}
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Public API — used by main.js and tests
|
|
* =========================================================== */
|
|
|
|
isOpen() {
|
|
try { return !!this.rendered; }
|
|
catch (_) { return false; }
|
|
}
|
|
|
|
open() {
|
|
// render(true) shows the window without toggling the throttle.
|
|
return this.render(true, { force: true });
|
|
}
|
|
|
|
/**
|
|
* Force-render bypassing the throttle. Used by tests and by the
|
|
* close path to make sure the final state is visible.
|
|
*/
|
|
forceRender() {
|
|
this._lastRenderedAt = 0;
|
|
return this.render(true, { force: true });
|
|
}
|
|
|
|
async close(options = {}) {
|
|
this._state.isActive = false;
|
|
this._combatStartedAt = null;
|
|
// Don't kill the singleton — main.js will call open() again on
|
|
// the next combat. We just hide.
|
|
return super.close(options);
|
|
}
|
|
|
|
/**
|
|
* Schedule a close after `delay` ms. If a new combat starts before
|
|
* the timer fires, call {@link cancelPendingClose} to abort. This
|
|
* avoids the race where back-to-back combat-end / combat-start
|
|
* sequences close the new HUD that just opened.
|
|
*/
|
|
scheduleClose(delay = 300) {
|
|
this.cancelPendingClose();
|
|
this._pendingCloseTimer = setTimeout(() => {
|
|
this._pendingCloseTimer = null;
|
|
this.close().catch((e) =>
|
|
console.warn(`[${MODULE_ID}] HUD close failed:`, e)
|
|
);
|
|
}, delay);
|
|
}
|
|
|
|
cancelPendingClose() {
|
|
if (this._pendingCloseTimer) {
|
|
clearTimeout(this._pendingCloseTimer);
|
|
this._pendingCloseTimer = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read-only snapshot of the current HUD state. Used by tests.
|
|
*/
|
|
getState() {
|
|
return { ...this._state };
|
|
}
|
|
|
|
/**
|
|
* Return a snapshot filtered for a given user. The default
|
|
* ({}) returns the current user's view. Tests can pass a fake
|
|
* user to assert the player view.
|
|
*/
|
|
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";
|
|
let combatants = [...this._state.combatants];
|
|
if (!isGM && playerCharId) {
|
|
combatants = combatants.filter(
|
|
(c) => !c.isPlayer || c.actorId === playerCharId
|
|
);
|
|
}
|
|
return {
|
|
...this._state,
|
|
viewMode,
|
|
combatants,
|
|
};
|
|
}
|
|
|
|
getDiceStreak() {
|
|
return this._state.diceStreak;
|
|
}
|
|
|
|
getPinnedAchievements() {
|
|
return [...this._pinnedQueue];
|
|
}
|
|
|
|
/** ===========================================================
|
|
* Hook listeners
|
|
* =========================================================== */
|
|
|
|
/**
|
|
* Register Foundry hook listeners. Called from main.js after the
|
|
* module is fully loaded. Idempotent.
|
|
*/
|
|
registerHooks() {
|
|
if (this._hooksRegistered) return;
|
|
this._hooksRegistered = true;
|
|
// The main event bus. main.js fires `battle-focus:hud-update`
|
|
// after every ingested event.
|
|
Hooks.on("battle-focus:hud-update", this._onHudUpdate);
|
|
Hooks.on("battle-focus:hud-achievement", this._onHudAchievement);
|
|
// Also listen to Foundry's combat lifecycle so the HUD knows
|
|
// when to open and close even if main.js misses a beat.
|
|
Hooks.on("combatStart", this._onCombatStartHook);
|
|
Hooks.on("combatEnd", this._onCombatEndHook);
|
|
}
|
|
|
|
/**
|
|
* Unregister hook listeners. Called from teardown if needed.
|
|
*/
|
|
unregisterHooks() {
|
|
if (!this._hooksRegistered) return;
|
|
this._hooksRegistered = false;
|
|
Hooks.off("battle-focus:hud-update", this._onHudUpdate);
|
|
Hooks.off("battle-focus:hud-achievement", this._onHudAchievement);
|
|
Hooks.off("combatStart", this._onCombatStartHook);
|
|
Hooks.off("combatEnd", this._onCombatEndHook);
|
|
}
|
|
|
|
/**
|
|
* combatStart: set the startedAt timestamp and mark active. The
|
|
* HUD itself is opened by main.js; this is a defensive fallback.
|
|
*/
|
|
_onCombatStartHook(combat) {
|
|
this._combatStartedAt = Date.now();
|
|
this._state.isActive = true;
|
|
// Reset the dice streak and pinned-achievements feed for the
|
|
// new combat.
|
|
this._state.diceStreak = 0;
|
|
this._state.lastDiceValue = null;
|
|
this._state.lastDiceAt = null;
|
|
this._pinnedQueue = [];
|
|
this._pinnedSeen = new Set();
|
|
}
|
|
|
|
/**
|
|
* combatEnd: clear isActive. Don't close here — main.js owns
|
|
* open/close. We just stop the timer.
|
|
*/
|
|
_onCombatEndHook(combat) {
|
|
this._state.isActive = false;
|
|
this._combatStartedAt = null;
|
|
}
|
|
|
|
/**
|
|
* The main event bus. main.js broadcasts the full updated state
|
|
* snapshot. We accept it as-is and re-render.
|
|
*
|
|
* Payload shape (set by main.js):
|
|
* {
|
|
* round, turn, currentTurn, combatants, event, ...
|
|
* }
|
|
*
|
|
* We also fold the dice-streak logic in here: a d20 attack-roll
|
|
* that's the same as the previous one (within the gap window)
|
|
* increments the streak; otherwise resets it.
|
|
*/
|
|
_onHudUpdate(payload) {
|
|
if (!payload) return;
|
|
// Update state from the payload.
|
|
if (typeof payload.round === "number") this._state.round = payload.round;
|
|
if (typeof payload.turn === "number") this._state.turn = payload.turn;
|
|
if (payload.currentTurn !== undefined) this._state.currentTurn = payload.currentTurn;
|
|
if (Array.isArray(payload.combatants)) this._state.combatants = payload.combatants;
|
|
// If the payload includes a startedAt, refresh our internal one.
|
|
if (typeof payload.startedAt === "number" && this._combatStartedAt == null) {
|
|
this._combatStartedAt = payload.startedAt;
|
|
}
|
|
// Update the timer live.
|
|
if (this._combatStartedAt != null) {
|
|
this._state.timeSinceStart = Date.now() - this._combatStartedAt;
|
|
}
|
|
|
|
// Dice streak logic. We look at the most recent attack-roll's d20.
|
|
if (payload.event?.kind === "attack-roll" || payload.lastAttackRoll) {
|
|
const ev = payload.lastAttackRoll ?? payload.event;
|
|
const d20 = extractD20FromEvent(ev);
|
|
if (d20 != null) {
|
|
this._updateDiceStreak(d20, ev?.ts ?? Date.now());
|
|
}
|
|
}
|
|
|
|
// Re-render (throttled).
|
|
this.render(false).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] HUD render failed:`, e)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Achievement broadcast. Adds to the pinned-achievements feed
|
|
* and re-renders. The throttle applies.
|
|
*/
|
|
_onHudAchievement(payload) {
|
|
if (!payload || !payload.id) return;
|
|
const dedupeKey = `${payload.actorKey ?? "?"}::${payload.id}`;
|
|
if (this._pinnedSeen.has(dedupeKey)) return;
|
|
this._pinnedSeen.add(dedupeKey);
|
|
const entry = {
|
|
id: payload.id,
|
|
name: payload.name ?? payload.id,
|
|
icon: payload.icon ?? "🏅",
|
|
description: payload.description ?? "",
|
|
awardedAt: payload.awardedAt ?? Date.now(),
|
|
actorKey: payload.actorKey ?? null,
|
|
};
|
|
this._pinnedQueue.push(entry);
|
|
// Cap the queue. Oldest entries fall off the bottom.
|
|
if (this._pinnedQueue.length > PINNED_MAX) {
|
|
this._pinnedQueue.splice(0, this._pinnedQueue.length - PINNED_MAX);
|
|
}
|
|
// Also keep the state copy in sync so getState() / getView() see it.
|
|
this._state.pinnedAchievements = [...this._pinnedQueue];
|
|
this.render(false).catch((e) =>
|
|
console.warn(`[${MODULE_ID}] HUD render (achievement) failed:`, e)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update the dice streak. Two consecutive matching d20s within
|
|
* the gap window increment; anything else resets to 1 (the new
|
|
* value) or 0 (if the new value is the same as old but stale).
|
|
*/
|
|
_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) {
|
|
// Consecutive matching roll — extend the streak.
|
|
nextStreak = (this._state.diceStreak ?? 0) + 1;
|
|
} else if (lastVal == null) {
|
|
// First roll of the combat — count it as a streak of 1.
|
|
nextStreak = 1;
|
|
} else {
|
|
// Non-matching d20 (or gap too long) after a prior roll —
|
|
// reset to 0; the new d20 hasn't matched anything yet, so a
|
|
// follow-up matching roll will set the streak to 1.
|
|
nextStreak = 0;
|
|
}
|
|
this._state.diceStreak = nextStreak;
|
|
this._state.lastDiceValue = d20;
|
|
this._state.lastDiceAt = ts;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract the d20 value from an attack-roll event. The dnd5e
|
|
* roll-attack event has the roll on event.rawRolls or in
|
|
* ev.rolls (an array of D20Rolls with terms[0].results[0].result).
|
|
* Falls back to ev.d20 for synthetic test events.
|
|
*/
|
|
function extractD20FromEvent(ev) {
|
|
if (!ev) return null;
|
|
if (typeof ev.d20 === "number") return ev.d20;
|
|
// Synthetic test events attach the d20 directly. Real Foundry
|
|
// events put the roll data in rolls (or rawRolls).
|
|
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;
|
|
}
|
|
|
|
// Module-level singleton. main.js imports `getHud()` and binds
|
|
// it to mod.api.hud.
|
|
let _singleton = null;
|
|
|
|
/**
|
|
* Get or create the module-level HUD singleton. The HUD is a
|
|
* passive observer; it doesn't own the event pipeline.
|
|
*/
|
|
export function getHud() {
|
|
if (!_singleton) {
|
|
_singleton = new BattleFocusHUD();
|
|
_singleton.registerHooks();
|
|
}
|
|
return _singleton;
|
|
}
|
|
|
|
/**
|
|
* Build a payload object for `battle-focus:hud-update` from the
|
|
* current encounter state. Used by main.js — extracted here so
|
|
* the HUD module owns the payload contract.
|
|
*/
|
|
export function buildHudUpdatePayload(encounter, event) {
|
|
if (!encounter) return null;
|
|
// Build per-PC combatant rows.
|
|
const combatants = [];
|
|
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;
|
|
// Aggregate the per-round stat block into totals for the HUD.
|
|
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;
|
|
}
|
|
}
|
|
combatants.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),
|
|
});
|
|
}
|
|
// Current turn: best-effort. The current combatant is the one
|
|
// whose turn it is on game.combat. We try to find their token
|
|
// document for the portrait.
|
|
let currentTurn = null;
|
|
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 ?? 0,
|
|
turn: encounter.currentTurn ?? 0,
|
|
currentTurn,
|
|
combatants,
|
|
startedAt: encounter.startedAt,
|
|
event: event ?? null,
|
|
};
|
|
}
|