6.3 KiB
merger — ControllerMerger
(no submodules)
Purpose
ControllerMerger fuses an ordered list of BaseController children
into a single virtual controller whose ControllerInput snapshot is the
per-field maximum of every attached child. It is the layer that lets a
game treat keyboard, gamepad, touch, and remote network controllers as
one unified action source without re-binding consumers when a player
adds or removes a device mid-session.
The merger extends BaseController so it transparently inherits the
typed InputEventEmitter, threshold/deadzone normalisation, OpenTelemetry
counters, and lifecycle guards. Children are observed through their
public onAnyInput and onCombo subscriptions only — the merger never
peeks at protected state, so any subclass of BaseController (current
or future) plugs in without an adapter shim.
Features
- Per-field
Math.maxreduction across N children, walked in stable insertion order over the canonicalCONTROLLER_INPUT_KEYS. - Subscription-based wiring — child state-change events are funnelled
into per-child snapshots, then merged on
update(). - Combo events from any child are re-emitted through the merger's own
eventEmitterso consumers only listen in one place. - Duplicate-detection by both instance identity and
child.id, raisingControllerError(CONTROLLER.DUPLICATE_CHILD)early. removeController()validates membership and throwsControllerError(CONTROLLER.UNKNOWN_CHILD)for stale references.- Lifecycle propagation —
destroy()disposes every subscription and destroys every child before clearing local state. - pollRate gating inherited from
BaseController, driven by the same injectedClockso tests can use aFakeClockwithout globals.
Usage: Basic
import {
BaseController,
ControllerMerger,
} from "@suaveplan/controller";
// Two concrete children produced by Chunk B's adapters.
declare const keyboard: BaseController;
declare const gamepad: BaseController;
const merger = new ControllerMerger({ children: [keyboard, gamepad] });
merger.onAnyInput((state) => {
// Single subscription regardless of how many devices are connected.
render(state);
});
// Drive every tick of the game loop.
function tick(): void {
merger.update();
requestAnimationFrame(tick);
}
Usage: Advanced
import {
BaseController,
CONTROLLER_CODES,
ControllerError,
ControllerMerger,
} from "@suaveplan/controller";
import type { Clock, IdGenerator } from "@suaveplan/types";
const fakeClock: Clock = (() => {
let t = 0;
return () => (t += 16);
})();
let n = 0;
const ids: IdGenerator = () => `m-${++n}`;
const merger = new ControllerMerger({
clock: fakeClock,
idGenerator: ids,
pollRate: 120,
strict: true,
});
declare const padA: BaseController;
declare const padB: BaseController;
merger.addController(padA);
try {
merger.addController(padA);
} catch (err) {
if (
err instanceof ControllerError &&
err.code === CONTROLLER_CODES.DUPLICATE_CHILD
) {
console.warn("already attached:", err.context);
}
}
merger.addController(padB);
merger.setThreshold("lx", { deadzone: 8, threshold: 32 });
merger.update();
merger.removeController(padA);
merger.destroy(); // destroys padB as well
API Reference
class ControllerMerger extends BaseControllerconstructor(options?: ControllerMergerOptions)— accepts everyBaseControllerOptionsfield pluschildren?: readonly BaseController[]seeded eagerly viaaddController().addController(child: BaseController): void— subscribes toonAnyInput+onCombo, captures the current snapshot, marks the merger dirty. ThrowsCONTROLLER.DUPLICATE_CHILDon identity oridcollision.removeController(child: BaseController): void— disposes subscriptions, drops the snapshot, marks dirty. ThrowsCONTROLLER.UNKNOWN_CHILDwhen the child is not attached.getChildren(): MergerChildren— read-only snapshot of children in insertion order.get childCount(): number— current attachment count.update(): void— polls every child, then runs the per-field max reduction overCONTROLLER_INPUT_KEYSand routes diffs throughupdateInputso threshold/deadzone gating + event emission stay consistent.destroy(): void— disposes all subscriptions, destroys every child, clears local state, then defers toBaseController.destroy.
interface ControllerMergerOptions extends BaseControllerOptionschildren?: readonly BaseController[]— initial seed.
type MergerChildren = readonly BaseController[]
Implementation Notes
The merger captures child snapshots eagerly on subscription so the
first update() after construction reflects whatever state the child
already has, even if no further input events fire between addController
and update. Snapshots are stored keyed by child.id rather than by
identity reference so the lookup remains O(1) and the duplicate guard
shares the same key space.
update() is overridden in full rather than using the base
updateState() template. The base update() short-circuits when
needsUpdate is false, which would skip child polling entirely — a
visible bug in a tick-driven game loop where children must always be
polled regardless of whether the merger itself observed prior changes.
The override polls children first, then applies the same pollRate gating
the base class would have applied before mutating local state.
Per-field reduction uses Math.max because every input value lives in
[0, 255] and the desired semantic is "any source wins" — a pressed
button on the keyboard plus a released button on the gamepad should
read as pressed, and a partially deflected stick on one pad should not
be cancelled by a centred stick on another. This matches the upstream
contract and avoids subtle precedence rules between devices.
destroy() propagates to children because the merger owns the wiring;
leaving children alive after the merger goes away leaves dangling
subscriptions on the children's emitters and is the failure mode
removed-without-destroy would otherwise create. Callers that need
independent child lifecycles call removeController() before
destroy().