Files
combat-hud-hub/scripts/main.js
Kaysser Taylor 9c4e4c86fe v0.1.0: scaffold combat-hud-hub with public API + soft-dep wiring
- 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.
2026-06-22 12:04:43 -04:00

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 };