Files
KateLegend2_proj/assets/scripts/common/EventBus.ts
T
2026-05-06 08:17:32 +08:00

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();