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.
192 lines
6.3 KiB
JavaScript
192 lines
6.3 KiB
JavaScript
// combat-hud-hub — module entry point (v0.2.5).
|
|
//
|
|
// Generic combat HUD host. Consumer modules register sections via
|
|
// the public API; the hub aggregates built-in core sections +
|
|
// consumer-registered feeds.
|
|
//
|
|
// Soft dependencies (both optional): foundry-hooks-lib (envelope
|
|
// stream), battle-focus (encounter seam). Core sections register
|
|
// when battle-focus is present; consumer-registered sections work
|
|
// whether or not either is present.
|
|
|
|
import { CombatHudHubApp, getHud, registerCoreSections } from "./hud.js";
|
|
|
|
const MODULE_ID = "combat-hud-hub";
|
|
const MODULE_VERSION = "0.2.5";
|
|
|
|
const COMBAT_HOOK_NAMES = [
|
|
"combatStart",
|
|
"combatEnd",
|
|
"combatRound",
|
|
"combatTurn",
|
|
"createCombatant",
|
|
"deleteCombatant",
|
|
"dnd5e.rollAttackV2",
|
|
"dnd5e.rollDamageV2",
|
|
];
|
|
|
|
// ── Settings registration ─────────────────────────────────────────────
|
|
|
|
function registerSettings() {
|
|
if (typeof game === "undefined" || !game?.settings?.register) return;
|
|
game.settings.register(MODULE_ID, "hudPosition", {
|
|
name: "HUD Position",
|
|
hint: "Where the combat HUD sits on the canvas.",
|
|
scope: "user",
|
|
config: true,
|
|
type: String,
|
|
choices: { top: "Top", bottom: "Bottom", left: "Left", right: "Right" },
|
|
default: "top",
|
|
});
|
|
}
|
|
|
|
// ── Section registry ───────────────────────────────────────────────────
|
|
|
|
const _sections = new Map();
|
|
const _feedBySection = new Map();
|
|
|
|
function _syncHudMirror() {
|
|
const hud = hudInstance;
|
|
if (!hud?._syncSections) return;
|
|
hud._syncSections(
|
|
Array.from(_sections.values()),
|
|
Array.from(_feedBySection.entries())
|
|
);
|
|
if (hud.isOpen()) hud.forceRender();
|
|
}
|
|
|
|
const api = {
|
|
id: MODULE_ID,
|
|
version: MODULE_VERSION,
|
|
addSection(spec) {
|
|
if (!spec || typeof spec !== "object") throw new Error("addSection: spec required");
|
|
if (!spec.id) throw new Error("addSection: spec.id required");
|
|
if (typeof spec.render !== "function") throw new Error("addSection: spec.render must be a function");
|
|
const id = spec.id;
|
|
const wrappedRender = (ctx) => {
|
|
const feed = _feedBySection.get(id) ?? [];
|
|
return spec.render({ ...(ctx ?? {}), feed, section: _sections.get(id) });
|
|
};
|
|
_sections.set(id, { id, label: spec.label ?? id, render: wrappedRender });
|
|
if (!_feedBySection.has(id)) _feedBySection.set(id, []);
|
|
_syncHudMirror();
|
|
return id;
|
|
},
|
|
removeSection(id) {
|
|
_sections.delete(id);
|
|
_feedBySection.delete(id);
|
|
_syncHudMirror();
|
|
},
|
|
listSections() {
|
|
return Array.from(_sections.values()).map(s => ({
|
|
id: s.id, label: s.label, render: s.render,
|
|
}));
|
|
},
|
|
pushFeedEntry(sectionId, entry) {
|
|
if (!_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
|
|
_feedBySection.get(sectionId).push(entry);
|
|
_syncHudMirror();
|
|
},
|
|
getFeed(sectionId) {
|
|
return [...(_feedBySection.get(sectionId) ?? [])];
|
|
},
|
|
clearFeed(sectionId) {
|
|
if (_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
|
|
_syncHudMirror();
|
|
},
|
|
isReady() {
|
|
return _ready;
|
|
},
|
|
getHud() {
|
|
return hudInstance;
|
|
},
|
|
openHud() {
|
|
if (!hudInstance) return;
|
|
return hudInstance.open();
|
|
},
|
|
closeHud() {
|
|
if (!hudInstance) return;
|
|
return hudInstance.close();
|
|
},
|
|
};
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
|
|
let hudInstance = null;
|
|
|
|
let _ready = false;
|
|
Hooks.once("init", () => {
|
|
const mod = game.modules.get(MODULE_ID);
|
|
mod.api = api;
|
|
registerSettings();
|
|
// Construct the singleton HUD so the api is reachable even before ready.
|
|
hudInstance = getHud();
|
|
console.log(`[${MODULE_ID} v${MODULE_VERSION}] init`);
|
|
});
|
|
|
|
Hooks.once("ready", () => {
|
|
const hooksLibMod = game.modules.get("foundry-hooks-lib");
|
|
const battleFocusMod = game.modules.get("battle-focus");
|
|
const hooksLibActive = !!hooksLibMod?.active;
|
|
const battleFocusActive = !!battleFocusMod?.active;
|
|
const hooksLibApi = hooksLibActive ? hooksLibMod.api : null;
|
|
|
|
// Register core sections when battle-focus is available.
|
|
let coreRegistered = false;
|
|
if (battleFocusActive) {
|
|
registerCoreSections(api);
|
|
coreRegistered = true;
|
|
}
|
|
|
|
// Subscribe to envelope stream.
|
|
if (hooksLibApi?.subscribeMany) {
|
|
hudInstance.wireHooks(hooksLibApi);
|
|
} else {
|
|
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; the HUD will not receive live updates.`);
|
|
}
|
|
|
|
// ── Session-open: if battle-focus has an active encounter
|
|
// resumed from a prior session (or a combat tracker is sitting
|
|
// in "pending" / mid-combat), open the HUD so the user sees the
|
|
// same state they'd see if the combatStart envelope had just
|
|
// fired. Without this, refreshing the browser mid-combat leaves
|
|
// the HUD invisible until something else fires an envelope.
|
|
let sessionResumed = false;
|
|
if (battleFocusActive && battleFocusMod.api?.getActiveEncounter) {
|
|
const enc = battleFocusMod.api.getActiveEncounter();
|
|
// The encounter is "live" if it exists and hasn't ended.
|
|
if (enc && !enc.endedAt) {
|
|
// Populate the HUD's encounter + state from the live encounter
|
|
// so the first render has the right data.
|
|
hudInstance._encounter = enc;
|
|
hudInstance._state.isActive = true;
|
|
if (enc.startedAt && hudInstance._combatStartedAt == null) {
|
|
hudInstance._combatStartedAt = enc.startedAt;
|
|
}
|
|
// Open the HUD. Use .open() directly (not api.openHud which
|
|
// has the same effect but with one extra indirection).
|
|
hudInstance.open().catch((e) =>
|
|
console.warn(`[${MODULE_ID}] session-open auto-open failed:`, e)
|
|
);
|
|
sessionResumed = true;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`[${MODULE_ID} v${MODULE_VERSION}] ready ` +
|
|
`(core sections: ${coreRegistered}, hooks-lib: ${hooksLibActive}, ` +
|
|
`session-resumed: ${sessionResumed})`,
|
|
);
|
|
_ready = true;
|
|
});
|
|
|
|
Hooks.on("unregisterModule", (moduleId) => {
|
|
if (moduleId === MODULE_ID) {
|
|
try { hudInstance?.unwireHooks?.(); } catch (e) {
|
|
console.warn(`[${MODULE_ID}] unwireHooks failed:`, e);
|
|
}
|
|
console.log(`[${MODULE_ID}] unregisterModule: cleaned up`);
|
|
}
|
|
});
|
|
|
|
export { api }; |