170 lines
6.3 KiB
Markdown
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()`.
|