- Public API: addSection, removeSection, pushFeedEntry, listSections, getHud, openHud, closeHud. - Soft-dep on foundry-hooks-lib (envelope subscription) + battle-focus (encounter seam). - Built-in core sections (round, current turn, per-PC damage, dice streak) register only when both deps are present at ready. - Smoke tests: 22/22 in <1s covering sections A-G of tests/PLAN.md. - HUD instance is a stub in v0.1.0; ApplicationV2 mounting in v0.2.0.
192 lines
6.9 KiB
JavaScript
192 lines
6.9 KiB
JavaScript
// combat-hud-hub — module entry point (v0.1.0).
|
|
//
|
|
// Generic combat HUD host. Consumer modules (its-achievable, future
|
|
// ones) register sections via the public API; the hub aggregates
|
|
// built-in core sections + consumer-registered feeds.
|
|
//
|
|
// Soft dependencies:
|
|
// - foundry-hooks-lib: subscribed for combat lifecycle envelopes
|
|
// (combatStart, combatEnd, combatRound, combatTurn,
|
|
// createCombatant, deleteCombatant, dnd5e.rollAttackV2,
|
|
// dnd5e.rollDamageV2). If missing, core sections hide.
|
|
// - battle-focus: read the active encounter via the public seam
|
|
// (battle-focus.api.getActiveEncounter()). If missing, core
|
|
// sections hide.
|
|
// Consumer-registered sections work whether or not either is present.
|
|
|
|
const MODULE_ID = "combat-hud-hub";
|
|
const MODULE_VERSION = "0.1.0";
|
|
|
|
const COMBAT_HOOK_NAMES = [
|
|
"combatStart",
|
|
"combatEnd",
|
|
"combatRound",
|
|
"combatTurn",
|
|
"createCombatant",
|
|
"deleteCombatant",
|
|
"dnd5e.rollAttackV2",
|
|
"dnd5e.rollDamageV2",
|
|
];
|
|
|
|
// ── Section registry ───────────────────────────────────────────────────
|
|
// Sections are named slots other modules register. Each section owns a
|
|
// label, an optional list of feed entries, and a render(slot, ctx) fn
|
|
// that produces the section's inner HTML. The HUD orchestrates when
|
|
// render is called (throttled to 1Hz).
|
|
|
|
const _sections = new Map();
|
|
let _feedBySection = new Map(); // sectionId -> [entry, ...]
|
|
|
|
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;
|
|
_sections.set(id, { id, label: spec.label ?? id, render: spec.render });
|
|
if (!_feedBySection.has(id)) _feedBySection.set(id, []);
|
|
// Wrap the consumer's render so it receives a context that includes
|
|
// the section's feed entries. The consumer can destructure.
|
|
const entry = _sections.get(id);
|
|
const originalRender = entry.render;
|
|
entry.render = (ctx) => {
|
|
const feed = _feedBySection.get(id) ?? [];
|
|
return originalRender({ ...(ctx ?? {}), feed, section: entry });
|
|
};
|
|
return id;
|
|
},
|
|
removeSection(id) {
|
|
_sections.delete(id);
|
|
_feedBySection.delete(id);
|
|
},
|
|
listSections() {
|
|
return Array.from(_sections.values());
|
|
},
|
|
pushFeedEntry(sectionId, entry) {
|
|
if (!_feedBySection.has(sectionId)) _feedBySection.set(sectionId, []);
|
|
_feedBySection.get(sectionId).push(entry);
|
|
},
|
|
getHud() {
|
|
return hudInstance;
|
|
},
|
|
openHud() { hudInstance?.open?.(); },
|
|
closeHud() { hudInstance?.close?.(); },
|
|
};
|
|
|
|
// ── Built-in core sections ─────────────────────────────────────────────
|
|
// These only register when foundry-hooks-lib AND battle-focus are both
|
|
// present at ready. They expose round / current turn / per-PC damage /
|
|
// dice streak. Detail implementation comes in v0.2.0 — for v0.1.0 the
|
|
// hub ships the public seam and soft-dep wiring; core sections
|
|
// themselves land in the next slice.
|
|
|
|
function _maybeRegisterCoreSections() {
|
|
const hooksLibMod = game.modules.get("foundry-hooks-lib");
|
|
const battleFocusMod = game.modules.get("battle-focus");
|
|
const hooksLibActive = !!hooksLibMod?.active;
|
|
const battleFocusActive = !!battleFocusMod?.active;
|
|
if (!hooksLibActive || !battleFocusActive) return false;
|
|
|
|
// core-round: simple round counter
|
|
api.addSection({
|
|
id: "core-round",
|
|
label: "Round",
|
|
render: (_slot, ctx) => {
|
|
const r = ctx?.round ?? 0;
|
|
return `<div class="chh-round">Round ${r}</div>`;
|
|
},
|
|
});
|
|
|
|
// core-current-turn: turn number + actor name
|
|
api.addSection({
|
|
id: "core-current-turn",
|
|
label: "Turn",
|
|
render: (_slot, ctx) => {
|
|
const t = ctx?.turn ?? 0;
|
|
const name = ctx?.currentTurn?.name ?? "—";
|
|
return `<div class="chh-turn">Turn ${t}: ${name}</div>`;
|
|
},
|
|
});
|
|
|
|
// core-damage: per-PC dealt/taken
|
|
api.addSection({
|
|
id: "core-damage",
|
|
label: "Damage",
|
|
render: (_slot, ctx) => {
|
|
const rows = (ctx?.combatants ?? []).map(c =>
|
|
`<li>${c.name}: dealt ${c.dealt ?? 0} / taken ${c.taken ?? 0}</li>`
|
|
).join("");
|
|
return `<ul class="chh-damage">${rows}</ul>`;
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
// ── HUD instance placeholder ───────────────────────────────────────────
|
|
// v0.1.0 ships a minimal stub so the seam works. Real ApplicationV2
|
|
// wiring (open, render, throttle) lands in v0.2.0 alongside the
|
|
// ported hud.js.
|
|
|
|
let hudInstance = null;
|
|
function _ensureHud() {
|
|
if (hudInstance) return hudInstance;
|
|
hudInstance = {
|
|
_lastRenderAt: 0,
|
|
_state: undefined,
|
|
_renderCtx: undefined,
|
|
forceRender(ctx) {
|
|
this._lastRenderAt = Date.now();
|
|
// Walk every registered section and invoke its render fn. The
|
|
// wrapper installed by addSection passes `{ feed, section, ...ctx }`.
|
|
const html = [];
|
|
for (const entry of _sections.values()) {
|
|
try {
|
|
html.push(`<section data-section="${entry.id}">${entry.render(this._renderCtx)}</section>`);
|
|
} catch (e) {
|
|
console.warn(`[${MODULE_ID}] section ${entry.id} render threw:`, e);
|
|
}
|
|
}
|
|
this._state = html.join("");
|
|
return this._state;
|
|
},
|
|
open() {},
|
|
close() {},
|
|
setRenderCtx(ctx) { this._renderCtx = ctx; },
|
|
};
|
|
return hudInstance;
|
|
}
|
|
|
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
|
|
Hooks.once("init", () => {
|
|
const mod = game.modules.get(MODULE_ID);
|
|
mod.api = api;
|
|
console.log(`[${MODULE_ID} v${MODULE_VERSION}] init`);
|
|
});
|
|
|
|
Hooks.once("ready", () => {
|
|
// Ensure the HUD stub exists so getHud/openHud/closeHud are reachable.
|
|
_ensureHud();
|
|
|
|
// Register core sections if both soft-deps are present.
|
|
const coreRegistered = _maybeRegisterCoreSections();
|
|
|
|
// Subscribe to foundry-hooks-lib if active.
|
|
const hooksLibMod = game.modules.get("foundry-hooks-lib");
|
|
const hooksLibApi = hooksLibMod?.active ? hooksLibMod.api : null;
|
|
if (hooksLibApi?.subscribeMany) {
|
|
hooksLibApi.subscribeMany(COMBAT_HOOK_NAMES, () => {
|
|
// v0.1.0: just record that we received an envelope. v0.2.0 will
|
|
// translate envelopes into a render context + call forceRender.
|
|
hudInstance._lastReceivedAt = Date.now();
|
|
});
|
|
console.log(`[${MODULE_ID} v${MODULE_VERSION}] ready (subscribed to foundry-hooks-lib v${hooksLibApi.version ?? "?"}, core sections: ${coreRegistered})`);
|
|
} else {
|
|
console.warn(`[${MODULE_ID}] foundry-hooks-lib not installed or not active; combat envelopes will not be received`);
|
|
}
|
|
});
|
|
|
|
export { api }; |