Files
skills/docs/gaming/games/controller/src/events/events.md
2026-05-29 08:20:56 +01:00

3.1 KiB

events — InputEventEmitter

(no submodules)

Purpose

Typed event dispatcher used by BaseController to fan input, state and combo events out to listeners. Centralising the dispatcher in its own module keeps the lifecycle FSM in BaseController focused on normalisation logic, and lets downstream consumers (e.g. the merger in Chunk C) reuse the same disposable-listener contract without depending on the base class.

Features

  • Three typed channels: onInput(key, cb), onStateChange(cb), onCombo(cb). Every registration returns a Disposer that removes exactly the registered listener without affecting siblings.
  • Per-key bucket storage with Set semantics: O(1) add, O(1) remove, iteration in insertion order.
  • Last-value tracking — each emitInput passes the previous value to the listener so consumers can compute deltas without storing state.
  • Strict-mode opt-in: new InputEventEmitter({ strict: true }) throws ControllerError(DESTROYED_INSTANCE) when an on* method is called after destroy(). Default mode silently returns a no-op disposer.
  • Zod-validated options boundary — invalid construction throws ControllerError(INVALID_OPTIONS) with the Zod issue array in context.issues.
  • Idempotent destroy() — calling twice is safe.

Usage: Basic

import { InputEventEmitter } from "@suaveplan/controller";

const emitter = new InputEventEmitter();
const off = emitter.onInput("up", (input, value, prev) => {
    console.log(input, value, prev);
});
emitter.emitInput("up", 255);
off();
emitter.destroy();

Usage: Advanced

import { InputEventEmitter, ControllerError, CONTROLLER_CODES } from "@suaveplan/controller";

const emitter = new InputEventEmitter({ strict: true });
emitter.destroy();
try {
    emitter.onInput("a", () => undefined);
} catch (err) {
    if (err instanceof ControllerError && err.code === CONTROLLER_CODES.DESTROYED_INSTANCE) {
        console.warn("emitter no longer accepting listeners");
    }
}

The strict mode is what BaseController opts into to enforce its own destroyed-state guarantees; default mode preserves the upstream emitter shape used by older fan-out callers that expect post-destroy registrations to be silent.

API Reference

  • new InputEventEmitter({ strict? }: InputEventEmitterOptions)
  • onInput(input, cb): Disposer
  • onStateChange(cb): Disposer
  • onCombo(cb): Disposer
  • emitInput(input, value): void
  • emitStateChange(state, oldState?): void
  • emitCombo(combo): void
  • clear(): void
  • destroy(): void
  • destroyed: boolean (getter)

Implementation Notes

The per-key bucket is allocated lazily so an emitter with no listeners on a given key pays no map cost. Disposers capture the callback and input by closure rather than indexing into the listener registry, so multiple onInput("up", cb) calls with the same callback are coalesced by the Set (no double-fire), and disposing one call removes the entry cleanly. emitInput records the new value into lastInputValues even when there is no registered listener, so a late subscriber's first event sees the correct prevValue.