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

170 lines
6.3 KiB
Markdown

# `merger` — `ControllerMerger`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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.max` reduction across N children, walked in stable
insertion order over the canonical `CONTROLLER_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
`eventEmitter` so consumers only listen in one place.
- Duplicate-detection by both instance identity and `child.id`, raising
`ControllerError(CONTROLLER.DUPLICATE_CHILD)` early.
- `removeController()` validates membership and throws
`ControllerError(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
injected `Clock` so tests can use a `FakeClock` without globals.
## Usage: Basic
```ts
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
```ts
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 BaseController`
- `constructor(options?: ControllerMergerOptions)` — accepts every
`BaseControllerOptions` field plus `children?: readonly BaseController[]`
seeded eagerly via `addController()`.
- `addController(child: BaseController): void` — subscribes to
`onAnyInput` + `onCombo`, captures the current snapshot, marks the
merger dirty. Throws `CONTROLLER.DUPLICATE_CHILD` on identity or
`id` collision.
- `removeController(child: BaseController): void` — disposes
subscriptions, drops the snapshot, marks dirty. Throws
`CONTROLLER.UNKNOWN_CHILD` when 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 over `CONTROLLER_INPUT_KEYS` and routes diffs through
`updateInput` so threshold/deadzone gating + event emission stay
consistent.
- `destroy(): void` — disposes all subscriptions, destroys every
child, clears local state, then defers to `BaseController.destroy`.
- `interface ControllerMergerOptions extends BaseControllerOptions`
- `children?: 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()`.