Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
# `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()`.
|
||||
Reference in New Issue
Block a user