/** * A lightweight, framework-agnostic pub/sub event bus. * * Used project-wide to decouple: * - UI layer (floating controls) → logic layer (player controller) * - Logic layer (damage system) → UI layer (HUD / feedback) * - Any manager → Logger / BI埋点 * * Design notes: * - `emit` is synchronous to avoid a frame of latency for combat events. * - Subscribing the same callback twice is a no-op (idempotent) to avoid * double-fire bugs when views are rebuilt. * - `once` unsubscribes itself after the first invocation. * - Handler errors are caught and forwarded to a user-provided error hook * (defaulting to `console.error`) so that one bad listener cannot break * the rest of the fan-out. */ export type EventHandler = (payload: TPayload) => void; export type ErrorHook = (event: string, err: unknown) => void; interface HandlerRecord { fn: EventHandler; once: boolean; } export class EventBus { private readonly handlers = new Map(); private errorHook: ErrorHook = (event, err) => { // Fallback error hook; replaced via `setErrorHook` once Logger is // available. // eslint-disable-next-line no-console console.error(`[EventBus] handler for "${event}" threw:`, err); }; /** Override the default error hook (used by Logger integration). */ public setErrorHook(hook: ErrorHook): void { this.errorHook = hook; } /** * Subscribe a handler. Idempotent — the same `fn` cannot be registered * twice for the same event. */ public on(event: string, fn: EventHandler): void { this.register(event, fn as EventHandler, false); } /** Subscribe a handler that auto-unsubscribes after one invocation. */ public once(event: string, fn: EventHandler): void { this.register(event, fn as EventHandler, true); } /** * Unsubscribe. If `fn` is omitted, all handlers for `event` are cleared. */ public off(event: string, fn?: EventHandler): void { const list = this.handlers.get(event); if (!list) { return; } if (!fn) { this.handlers.delete(event); return; } const filtered = list.filter((r) => r.fn !== fn); if (filtered.length === 0) { this.handlers.delete(event); } else { this.handlers.set(event, filtered); } } /** Synchronously dispatch `payload` to every handler registered for `event`. */ public emit(event: string, payload?: T): void { const list = this.handlers.get(event); if (!list || list.length === 0) { return; } // Snapshot first: `once` handlers will mutate `list` via `off`. const snapshot = list.slice(); for (const record of snapshot) { try { record.fn(payload as unknown); } catch (err) { this.errorHook(event, err); } if (record.once) { this.off(event, record.fn); } } } /** Return the number of handlers registered for `event`. */ public listenerCount(event: string): number { return this.handlers.get(event)?.length ?? 0; } /** Remove every handler of every event (used in unit tests / scene unload). */ public clear(): void { this.handlers.clear(); } private register(event: string, fn: EventHandler, once: boolean): void { const list = this.handlers.get(event) ?? []; if (list.some((r) => r.fn === fn)) { return; } list.push({ fn, once }); this.handlers.set(event, list); } } /** Shared, process-wide event bus. Tests should create a fresh `new EventBus()`. */ export const globalEventBus = new EventBus();