116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
/**
|
|
* 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<TPayload = unknown> = (payload: TPayload) => void;
|
|
export type ErrorHook = (event: string, err: unknown) => void;
|
|
|
|
interface HandlerRecord {
|
|
fn: EventHandler<any>;
|
|
once: boolean;
|
|
}
|
|
|
|
export class EventBus {
|
|
private readonly handlers = new Map<string, HandlerRecord[]>();
|
|
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<T>(event: string, fn: EventHandler<T>): void {
|
|
this.register(event, fn as EventHandler<any>, false);
|
|
}
|
|
|
|
/** Subscribe a handler that auto-unsubscribes after one invocation. */
|
|
public once<T>(event: string, fn: EventHandler<T>): void {
|
|
this.register(event, fn as EventHandler<any>, true);
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe. If `fn` is omitted, all handlers for `event` are cleared.
|
|
*/
|
|
public off<T>(event: string, fn?: EventHandler<T>): 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<T>(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<any>, 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();
|