@typeonce/ritmo-browser 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/README.md +6 -1
  3. package/dist/Controls.d.ts +496 -0
  4. package/dist/Controls.d.ts.map +1 -0
  5. package/dist/Controls.js +343 -0
  6. package/dist/Controls.js.map +1 -0
  7. package/dist/Gamepad.d.ts +376 -0
  8. package/dist/Gamepad.d.ts.map +1 -0
  9. package/dist/Gamepad.js +459 -0
  10. package/dist/Gamepad.js.map +1 -0
  11. package/dist/InputCapture.d.ts +46 -16
  12. package/dist/InputCapture.d.ts.map +1 -1
  13. package/dist/InputCapture.js +1 -1
  14. package/dist/InputCapture.js.map +1 -1
  15. package/dist/Keyboard.d.ts +177 -25
  16. package/dist/Keyboard.d.ts.map +1 -1
  17. package/dist/Keyboard.js +257 -91
  18. package/dist/Keyboard.js.map +1 -1
  19. package/dist/Pointer.d.ts +233 -64
  20. package/dist/Pointer.d.ts.map +1 -1
  21. package/dist/Pointer.js +205 -145
  22. package/dist/Pointer.js.map +1 -1
  23. package/dist/Touch.d.ts +75 -33
  24. package/dist/Touch.d.ts.map +1 -1
  25. package/dist/Touch.js +25 -30
  26. package/dist/Touch.js.map +1 -1
  27. package/dist/index.d.ts +10 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +10 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/buttons.d.ts +52 -0
  32. package/dist/internal/buttons.d.ts.map +1 -0
  33. package/dist/internal/buttons.js +211 -0
  34. package/dist/internal/buttons.js.map +1 -0
  35. package/dist/internal/introspect.d.ts +14 -0
  36. package/dist/internal/introspect.d.ts.map +1 -0
  37. package/dist/internal/introspect.js +19 -0
  38. package/dist/internal/introspect.js.map +1 -0
  39. package/dist/internal/state.d.ts +13 -0
  40. package/dist/internal/state.d.ts.map +1 -0
  41. package/dist/internal/state.js +2 -0
  42. package/dist/internal/state.js.map +1 -0
  43. package/dist/internal/used.d.ts +8 -0
  44. package/dist/internal/used.d.ts.map +1 -0
  45. package/dist/internal/used.js +14 -0
  46. package/dist/internal/used.js.map +1 -0
  47. package/dist/internal/vectors.d.ts +29 -0
  48. package/dist/internal/vectors.d.ts.map +1 -0
  49. package/dist/internal/vectors.js +22 -0
  50. package/dist/internal/vectors.js.map +1 -0
  51. package/package.json +4 -3
  52. package/src/Controls.ts +800 -0
  53. package/src/Gamepad.ts +752 -0
  54. package/src/InputCapture.ts +43 -24
  55. package/src/Keyboard.ts +387 -72
  56. package/src/Pointer.ts +392 -166
  57. package/src/Touch.ts +113 -61
  58. package/src/index.ts +10 -1
  59. package/src/internal/buttons.ts +222 -0
  60. package/src/internal/introspect.ts +23 -0
  61. package/src/internal/state.ts +12 -0
  62. package/src/internal/used.ts +14 -0
  63. package/src/internal/vectors.ts +41 -0
package/src/Gamepad.ts ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Gamepad input as caller-named actions, plus the sticks and triggers.
3
+ *
4
+ * Games bind actions to buttons once and read one snapshot per update, as
5
+ * with `Keyboard`. The module owns the plumbing every game rewrites: polling
6
+ * (the Gamepad API has no button events), press/release edges, a radial dead
7
+ * zone on the sticks, following the pad that was used last when several are
8
+ * connected, and releasing everything held when a pad disconnects. It does
9
+ * not know about the ECS; `InputCapture.system(...)` copies snapshots from a
10
+ * service into a resource.
11
+ *
12
+ * Buttons are named by their place in the browser's standard mapping
13
+ * (`"south"` is A on Xbox, Cross on PlayStation, B on Nintendo), so bindings
14
+ * stay the same on every pad. A pad whose `mapping` is not `"standard"` is
15
+ * read by the same indices, which may not match its labels: let players
16
+ * remap (`rebind`). The snapshot also reports every `button` by name,
17
+ * whatever the bindings, for fixed menu navigation and for a settings screen
18
+ * that waits for the button to bind; `parseRebinding(...)` validates
19
+ * bindings saved as JSON.
20
+ *
21
+ * The Gamepad API has no button events, so the pads are read on every
22
+ * animation frame and on every `snapshot()`; a tap that starts and ends
23
+ * between two updates still counts. Browsers expose a pad only after
24
+ * a button is pressed on it, and only in a secure context (HTTPS or
25
+ * localhost); elsewhere, or when a permission policy blocks the API, the
26
+ * live actions read no pad and report `connected: false`.
27
+ *
28
+ * `actions(...)` is one player on whichever pad is used. For local
29
+ * multiplayer, `pads(host, bindings, max)` reads every pad on its own for
30
+ * `Controls.players(...).pool(...)` (`scriptedPads(...)` in tests).
31
+ *
32
+ * @module Gamepad
33
+ * @docGroup browser
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * const pad = Gamepad.actions(window, {
38
+ * move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }),
39
+ * attack: ["west"],
40
+ * pause: ["start"]
41
+ * })
42
+ *
43
+ * // Once per update:
44
+ * const snapshot = pad.snapshot()
45
+ * move(snapshot.vectors.move) // the stick's tilt, or the d-pad's direction
46
+ * if (snapshot.actions.attack.pressed) attack()
47
+ *
48
+ * // In a settings screen, rebinding keeps the action names:
49
+ * pad.rebind({ move: Gamepad.vector({ stick: "right" }), attack: ["north"], pause: ["start"] })
50
+ * ```
51
+ *
52
+ * For tests, replays, and headless runs, `scripted(...)` plays a timeline of
53
+ * button presses, releases, and stick moves instead of polling a host, and
54
+ * `recording(...)` wraps live actions to capture the timeline a session
55
+ * produced. `parseTimeline(...)` validates a timeline loaded from JSON.
56
+ */
57
+ import type * as System from "@typeonce/ritmo/System"
58
+ import * as Result from "@typeonce/ritmo/Result"
59
+
60
+ import type * as BrowserTickSource from "./BrowserTickSource.ts"
61
+ import type { ActionState } from "./internal/state.ts"
62
+ import type { Vector } from "./internal/vectors.ts"
63
+ import * as ButtonsInternal from "./internal/buttons.ts"
64
+ import { buttonTracker, flattenButtons, idleActions, idleButtons, idleVectors, splitActions } from "./internal/buttons.ts"
65
+ import { digital, isVectorBinding, longer } from "./internal/vectors.ts"
66
+ import { anyUsed } from "./internal/used.ts"
67
+
68
+ /**
69
+ * The buttons of the browser's standard mapping, in index order: face
70
+ * buttons by compass position, bumpers and triggers, the two small center
71
+ * buttons, the stick clicks, the d-pad, and the home button.
72
+ */
73
+ export const buttonNames = [
74
+ "south",
75
+ "east",
76
+ "west",
77
+ "north",
78
+ "leftBumper",
79
+ "rightBumper",
80
+ "leftTrigger",
81
+ "rightTrigger",
82
+ "select",
83
+ "start",
84
+ "leftStick",
85
+ "rightStick",
86
+ "dpadUp",
87
+ "dpadDown",
88
+ "dpadLeft",
89
+ "dpadRight",
90
+ "home"
91
+ ] as const
92
+
93
+ /** One button of the standard mapping, see {@link buttonNames}. */
94
+ export type Button = (typeof buttonNames)[number]
95
+
96
+ /** The buttons of one action: at least one. */
97
+ export type Buttons = readonly [Button, ...Array<Button>]
98
+
99
+ /** The two sticks a vector action can read. */
100
+ export type StickName = "left" | "right"
101
+
102
+ /**
103
+ * A vector action (movement) on the pad, built with {@link vector}: a
104
+ * stick, buttons for the four directions (the d-pad), or both.
105
+ */
106
+ export interface VectorBinding {
107
+ readonly _tag: "Vector"
108
+ readonly stick?: StickName
109
+ readonly up?: Buttons
110
+ readonly down?: Buttons
111
+ readonly left?: Buttons
112
+ readonly right?: Buttons
113
+ }
114
+
115
+ /**
116
+ * Action name to the buttons that trigger it (at least one), or to a
117
+ * {@link vector} binding for a direction such as movement.
118
+ */
119
+ export type Bindings = Readonly<Record<string, Buttons | VectorBinding>>
120
+
121
+ /** The bound button actions: the names bound to buttons. */
122
+ export type ButtonNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? never : K }[keyof B] & string
123
+
124
+ /** The bound vector actions: the names bound with {@link vector}. */
125
+ export type VectorNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? K : never }[keyof B] & string
126
+
127
+ /**
128
+ * Binds a vector action to a stick, to buttons for its four directions, or
129
+ * to both. The snapshot's `vectors` reports the longer of the two: the
130
+ * stick keeps its tilt (after the dead zone), and the buttons give length 1
131
+ * (a diagonal is not faster).
132
+ *
133
+ * @example
134
+ * ```ts
135
+ * const bindings = {
136
+ * move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }),
137
+ * jump: ["south"]
138
+ * } as const
139
+ * ```
140
+ */
141
+ export const vector = (
142
+ binding:
143
+ | { readonly stick: StickName }
144
+ | { readonly stick?: StickName; readonly up: Buttons; readonly down: Buttons; readonly left: Buttons; readonly right: Buttons }
145
+ ): VectorBinding => ({ _tag: "Vector", ...binding })
146
+
147
+ /**
148
+ * New buttons for the same actions, for {@link Actions.rebind}: buttons for
149
+ * a button action, a {@link vector} for a vector action. Load saved ones
150
+ * with {@link parseRebinding}.
151
+ */
152
+ export type Rebinding<B extends Bindings> = { readonly [Action in keyof B]: B[Action] extends VectorBinding ? VectorBinding : Buttons }
153
+
154
+ /** One button's reading, the subset of the browser's `GamepadButton` the module reads. */
155
+ export interface ButtonReading {
156
+ readonly pressed: boolean
157
+ readonly value: number
158
+ }
159
+
160
+ /** One pad's reading, the subset of the browser's `Gamepad` the module reads. */
161
+ export interface PadReading {
162
+ readonly id: string
163
+ readonly index: number
164
+ readonly connected: boolean
165
+ readonly mapping: string
166
+ readonly buttons: ReadonlyArray<ButtonReading>
167
+ readonly axes: ReadonlyArray<number>
168
+ }
169
+
170
+ /** Where pads are read from, the subset of the browser's `navigator` the module reads. */
171
+ export interface GamepadNavigator {
172
+ getGamepads(): ReadonlyArray<PadReading | null>
173
+ }
174
+
175
+ /**
176
+ * What live actions need from the window: the pads, and animation frames to
177
+ * read them on, since the Gamepad API has no button events.
178
+ */
179
+ export interface GamepadHost extends BrowserTickSource.Host {
180
+ readonly navigator: GamepadNavigator
181
+ }
182
+
183
+ /**
184
+ * The followed pad's state, with the same core as the other devices'
185
+ * snapshots (`actions`, `vectors`, and `used`) so `Controls` can merge it.
186
+ * With no pad everything is idle and `connected` is `false`.
187
+ */
188
+ export interface Snapshot<B extends Bindings> {
189
+ /** Every bound button action's state. */
190
+ readonly actions: { readonly [Action in ButtonNames<B>]: ActionState }
191
+ /** Each vector action's direction, length at most 1, `y` growing down. */
192
+ readonly vectors: { readonly [Action in VectorNames<B>]: Vector }
193
+ /** Every button's state by name, whatever the bindings. */
194
+ readonly buttons: { readonly [Name in Button]: ActionState }
195
+ /** The left stick's tilt, whatever the bindings. */
196
+ readonly left: Vector
197
+ /** The right stick's tilt, whatever the bindings. */
198
+ readonly right: Vector
199
+ /** How far the left trigger is pulled, 0 to 1. */
200
+ readonly leftTrigger: number
201
+ /** How far the right trigger is pulled, 0 to 1. */
202
+ readonly rightTrigger: number
203
+ /**
204
+ * Some button was pressed in this window or is held, or a stick is past
205
+ * the dead zone: the player is using the pad.
206
+ */
207
+ readonly used: boolean
208
+ /** Whether a pad is being followed. */
209
+ readonly connected: boolean
210
+ /** The followed pad's `id` (it names the maker, for button labels), or `null`. */
211
+ readonly id: string | null
212
+ }
213
+
214
+ /**
215
+ * Rejects actions a rebinding names that the bindings do not have, so
216
+ * {@link Actions.rebind} takes exactly the actions it was made with.
217
+ */
218
+ export type OnlyActionsOf<R, B extends Bindings> = {
219
+ readonly [Name in Exclude<keyof R, keyof B>]: `${Name & string} is not an action of these bindings`
220
+ }
221
+
222
+ /**
223
+ * A pad bound to actions, built with {@link actions} (live), {@link scripted},
224
+ * or {@link recording}. Capture it with `InputCapture.system(...)`, or merge
225
+ * it with `Controls`.
226
+ */
227
+ export interface Actions<B extends Bindings> extends System.Consumes<"snapshot"> {
228
+ /** Returns the input state and starts a new press/release window. */
229
+ snapshot(): Snapshot<B>
230
+ /**
231
+ * Binds the same actions to new buttons, from the next snapshot, for a
232
+ * settings screen where players remap controls (capture the button to bind
233
+ * from the snapshot's `buttons`). An action held only through buttons it
234
+ * lost reports `released`. A button held across the rebind holds a newly
235
+ * bound action only once it is pressed again, so an action never turns
236
+ * held without a press. It takes exactly the actions the source was made
237
+ * with.
238
+ */
239
+ rebind<const R extends Rebinding<B>>(bindings: R & OnlyActionsOf<R, B>): void
240
+ /** Stops reading pads and cancels the animation frame. */
241
+ dispose(): void
242
+ }
243
+
244
+ /** Options for {@link actions} and {@link pads}. */
245
+ export interface ActionsOptions {
246
+ /** Fraction of a stick's tilt that reads as zero. Defaults to 0.15. */
247
+ readonly deadZone?: number
248
+ /** How far a trigger is pulled before it counts as held. Defaults to 0.5. */
249
+ readonly triggerThreshold?: number
250
+ }
251
+
252
+ const zero: Vector = { x: 0, y: 0 }
253
+
254
+
255
+ /**
256
+ * A snapshot with nothing held and no pad, for initializing an input
257
+ * resource before the first capture.
258
+ */
259
+ export const idle = <const B extends Bindings>(bindings: B): Snapshot<B> => {
260
+ return { actions: idleActions(bindings), vectors: idleVectors(bindings) as Snapshot<B>["vectors"], buttons: idleButtons(buttonNames), left: zero, right: zero, leftTrigger: 0, rightTrigger: 0, used: false, connected: false, id: null }
261
+ }
262
+
263
+ const sameVector = (a: Vector, b: Vector): boolean => a.x === b.x && a.y === b.y
264
+
265
+ /** Removes the dead zone and rescales, so the stick goes from zero to full tilt smoothly. */
266
+ const stick = (x: number, y: number, deadZone: number): Vector => {
267
+ const length = Math.sqrt(x * x + y * y)
268
+ if (length <= deadZone) return zero
269
+ const scaled = Math.min(1, (length - deadZone) / (1 - deadZone))
270
+ return { x: (x / length) * scaled, y: (y / length) * scaled }
271
+ }
272
+
273
+ /** The button tracker with the pad's analog state, shared by live and scripted actions. */
274
+ const tracker = <B extends Bindings>(initial: B) => {
275
+ // Vector actions are tracked as their four directions, next to the button actions.
276
+ let bindings: Bindings = initial
277
+ const buttons = buttonTracker(buttonNames, flattenButtons<Button>(initial))
278
+ return {
279
+ held: buttons.held,
280
+ down: buttons.down,
281
+ up: buttons.up,
282
+ upAll: buttons.upAll,
283
+ rebind(next: Rebinding<B>) {
284
+ bindings = next
285
+ buttons.rebind(flattenButtons<Button>(next))
286
+ },
287
+ snapshot(analog: Omit<Snapshot<B>, "actions" | "vectors" | "buttons" | "used">): Snapshot<B> {
288
+ const taken = buttons.take()
289
+ // The actions are fixed at creation; a rebind only changes how each is bound.
290
+ const { actions, held } = splitActions(initial, taken.actions)
291
+ const vectors: Record<string, Vector> = {}
292
+ for (const [name, entry] of Object.entries(initial)) {
293
+ if (!isVectorBinding(entry)) continue
294
+ const binding = bindings[name] as VectorBinding
295
+ const pad = digital(held(name, "up"), held(name, "down"), held(name, "left"), held(name, "right"))
296
+ vectors[name] = binding.stick === undefined ? pad : longer(analog[binding.stick], pad)
297
+ }
298
+ const used = anyUsed(taken.buttons) || !sameVector(analog.left, zero) || !sameVector(analog.right, zero)
299
+ return { actions, vectors, buttons: taken.buttons, ...analog, used } as Snapshot<B>
300
+ }
301
+ }
302
+ }
303
+
304
+ /** Whether a pad is being touched: a button down or a stick past the dead zone. */
305
+ const inUse = (pad: PadReading, deadZone: number, triggerThreshold: number): boolean =>
306
+ pad.buttons.some((button, index) => isDown(button, index, triggerThreshold)) ||
307
+ // Only the sticks' axes: some pads report their triggers as extra axes resting at -1.
308
+ pad.axes.slice(0, 4).some((axis) => Math.abs(axis) > deadZone)
309
+
310
+ const triggerIndices = new Set([6, 7])
311
+
312
+ const isDown = (button: ButtonReading, index: number, triggerThreshold: number): boolean =>
313
+ triggerIndices.has(index) ? button.value >= triggerThreshold : button.pressed
314
+
315
+ /** Reads the pads, treating a host that throws (a blocked permission policy) as having none. */
316
+ const readPads = (host: GamepadHost): ReadonlyArray<PadReading | null> => {
317
+ try {
318
+ return host.navigator.getGamepads()
319
+ } catch {
320
+ return []
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Starts reading pads from `host`, usually `window`, on every animation
326
+ * frame until `dispose()`. With several pads connected it follows the one
327
+ * used last; switching pads, or the followed pad disconnecting, releases
328
+ * everything it held. Use {@link pads} to give each pad its own player.
329
+ */
330
+ export const actions = <const B extends Bindings>(host: GamepadHost, bindings: B, options: ActionsOptions = {}): Actions<B> => {
331
+ const deadZone = options.deadZone ?? 0.15
332
+ const triggerThreshold = options.triggerThreshold ?? 0.5
333
+ const state = tracker(bindings)
334
+ let followed: number | null = null
335
+ let disposed = false
336
+ let reading: PadReading | null = null
337
+ let frame = 0
338
+
339
+ const poll = (): void => {
340
+ if (disposed) return
341
+ const pads = readPads(host).filter((pad): pad is PadReading => pad !== null && pad.connected)
342
+ const current = pads.find((pad) => pad.index === followed)
343
+ // Keep the followed pad while it is in use; otherwise switch to another one being used, or keep the first there is.
344
+ const used = current !== undefined && inUse(current, deadZone, triggerThreshold)
345
+ ? current
346
+ : pads.find((pad) => pad.index !== followed && inUse(pad, deadZone, triggerThreshold))
347
+ const next = used ?? current ?? pads[0]
348
+ if (next?.index !== followed) state.upAll()
349
+ followed = next?.index ?? null
350
+ reading = next ?? null
351
+ if (next === undefined) return
352
+ readButtons(state, next, triggerThreshold)
353
+ }
354
+
355
+ const onFrame = (): void => {
356
+ if (disposed) return
357
+ poll()
358
+ frame = host.requestAnimationFrame(onFrame)
359
+ }
360
+ frame = host.requestAnimationFrame(onFrame)
361
+
362
+ return {
363
+ snapshot() {
364
+ poll()
365
+ const pad = reading
366
+ if (pad === null) return state.snapshot({ ...resting, connected: false, id: null })
367
+ return state.snapshot({ ...analogOf(pad, deadZone), connected: true, id: pad.id })
368
+ },
369
+ rebind: state.rebind,
370
+ dispose() {
371
+ if (disposed) return
372
+ disposed = true
373
+ host.cancelAnimationFrame(frame)
374
+ reading = null
375
+ state.upAll()
376
+ }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Every pad connected to the browser, each one on its own: what
382
+ * `Controls.players(...).pool(...)` hands to the player who joins with it.
383
+ * Use {@link actions} for one player, which follows whichever pad is used.
384
+ */
385
+ export interface Pads<B extends Bindings> extends System.Consumes<"snapshot"> {
386
+ readonly _tag: "Pads"
387
+ /** The most pads read at once: their places in the list run from 0 to `max - 1`. */
388
+ readonly max: number
389
+ /**
390
+ * One snapshot per pad seen so far, in the order they appeared, and
391
+ * starts a new press/release window on each. An unplugged pad stays in
392
+ * the list with `connected: false` and its last `id`, so a player keeps
393
+ * it until it comes back.
394
+ */
395
+ snapshot(): ReadonlyArray<Snapshot<B>>
396
+ /** Binds the same actions to new buttons on every pad, as {@link Actions.rebind}. */
397
+ rebind<const R extends Rebinding<B>>(bindings: R & OnlyActionsOf<R, B>): void
398
+ /** Stops reading pads and cancels the animation frame. */
399
+ dispose(): void
400
+ }
401
+
402
+ /** Presses and releases `state`'s buttons as `pad` reads now. */
403
+ const readButtons = (state: ReturnType<typeof tracker>, pad: PadReading, triggerThreshold: number): void => {
404
+ for (let index = 0; index < buttonNames.length; index++) {
405
+ const button = pad.buttons[index]
406
+ const name = buttonNames[index]!
407
+ if (button !== undefined && isDown(button, index, triggerThreshold)) state.down(name)
408
+ else state.up(name)
409
+ }
410
+ }
411
+
412
+ /** A pad's sticks (dead zone removed) and triggers. */
413
+ const analogOf = (pad: PadReading, deadZone: number) => {
414
+ const axis = (index: number) => pad.axes[index] ?? 0
415
+ const trigger = (index: number) => Math.min(1, Math.max(0, pad.buttons[index]?.value ?? 0))
416
+ return {
417
+ left: stick(axis(0), axis(1), deadZone),
418
+ right: stick(axis(2), axis(3), deadZone),
419
+ leftTrigger: trigger(6),
420
+ rightTrigger: trigger(7)
421
+ }
422
+ }
423
+
424
+ const resting = { left: zero, right: zero, leftTrigger: 0, rightTrigger: 0 }
425
+
426
+ /** Rejects a pad count that is not a whole number literal of 1 or more. */
427
+ type PadCount<N extends number> = number extends N
428
+ ? "max must be a number literal, like 4: the most pads read at once"
429
+ : `${N}` extends `-${string}` | `${string}.${string}` | "0"
430
+ ? "max must be a whole number of pads, 1 or more"
431
+ : N
432
+
433
+ /**
434
+ * Starts reading every pad on `host`, usually `window`, up to `max` of
435
+ * them, each with its own presses and sticks; pads beyond `max` are
436
+ * ignored. A pad keeps its place in the list while unplugged; one that comes
437
+ * back at the same browser index continues it, and a different pad at that
438
+ * index takes the place over with nothing held. `max` is a whole number
439
+ * literal, 1 or more: the most players the game seats on pads.
440
+ */
441
+ export const pads = <const B extends Bindings, const N extends number>(host: GamepadHost, bindings: B, max: PadCount<N>, options: ActionsOptions = {}): Pads<B> => {
442
+ const limit = max as number
443
+ const deadZone = options.deadZone ?? 0.15
444
+ const triggerThreshold = options.triggerThreshold ?? 0.5
445
+ const seen: Array<{ readonly index: number; readonly state: ReturnType<typeof tracker<B>>; id: string | null; reading: PadReading | null }> = []
446
+ let disposed = false
447
+ let frame = 0
448
+
449
+ const poll = (): void => {
450
+ if (disposed) return
451
+ const connected = readPads(host).filter((pad): pad is PadReading => pad !== null && pad.connected)
452
+ for (const pad of connected) {
453
+ if (seen.length < limit && !seen.some((entry) => entry.index === pad.index)) {
454
+ seen.push({ index: pad.index, state: tracker(bindings), id: pad.id, reading: null })
455
+ }
456
+ }
457
+ for (const entry of seen) {
458
+ const pad = connected.find((reading) => reading.index === entry.index)
459
+ // Unplugged, or another pad took the index: everything it held is released.
460
+ if (pad === undefined || pad.id !== entry.id) {
461
+ if (entry.reading !== null) entry.state.upAll()
462
+ entry.reading = null
463
+ if (pad === undefined) continue
464
+ entry.id = pad.id
465
+ }
466
+ entry.reading = pad
467
+ readButtons(entry.state, pad, triggerThreshold)
468
+ }
469
+ }
470
+
471
+ const onFrame = (): void => {
472
+ if (disposed) return
473
+ poll()
474
+ frame = host.requestAnimationFrame(onFrame)
475
+ }
476
+ frame = host.requestAnimationFrame(onFrame)
477
+
478
+ return {
479
+ _tag: "Pads",
480
+ max: limit,
481
+ snapshot() {
482
+ poll()
483
+ return seen.map((entry) => entry.state.snapshot(entry.reading === null
484
+ ? { ...resting, connected: false, id: entry.id }
485
+ : { ...analogOf(entry.reading, deadZone), connected: true, id: entry.id }))
486
+ },
487
+ rebind(next) {
488
+ for (const entry of seen) entry.state.rebind(next as Rebinding<B>)
489
+ // Pads that appear later start with the new bindings too.
490
+ bindings = next as unknown as B
491
+ },
492
+ dispose() {
493
+ if (disposed) return
494
+ disposed = true
495
+ host.cancelAnimationFrame(frame)
496
+ for (const entry of seen) entry.state.upAll()
497
+ }
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Pads driven by timelines instead of a host, one timeline per pad (as
503
+ * {@link scripted}), each with the id `scripted <n>` (from `scripted 1`)
504
+ * and `max` the number of timelines: for tests of games where every player
505
+ * has a pad.
506
+ *
507
+ * Unlike {@link pads}, every timeline is in the list from the first
508
+ * snapshot, with its id even while unplugged. Each pad connects at its
509
+ * timeline's first entry, so a pad plugged in later is a timeline that
510
+ * starts later, and an entry with `connected: false` unplugs it.
511
+ */
512
+ export const scriptedPads = <const B extends Bindings>(bindings: B, timelines: ReadonlyArray<Timeline>): Pads<B> => {
513
+ const each = timelines.map((timeline) => scripted(bindings, timeline))
514
+ return {
515
+ _tag: "Pads",
516
+ max: each.length,
517
+ snapshot: () => each.map((pad, index) => ({ ...pad.snapshot(), id: `scripted ${index + 1}` })),
518
+ rebind(next) {
519
+ for (const pad of each) pad.rebind(next as never)
520
+ },
521
+ dispose() {}
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Input changes applied just before one snapshot: the buttons, then the
527
+ * sticks and triggers, which keep their value until set again, then
528
+ * `connected`. `frame` is the snapshot's index, counting from 0 at the first
529
+ * `snapshot()` call. A button in both `press` and `release` changed twice
530
+ * within the frame: a tap shorter than a frame when it was up, a release and
531
+ * a new press when it was held.
532
+ */
533
+ export interface TimelineEntry {
534
+ readonly frame: number
535
+ /**
536
+ * Connects or disconnects the pad from this frame on. The first entry
537
+ * connects it unless it says otherwise. A disconnect releases every held
538
+ * button and zeroes the sticks and triggers, as with a live pad.
539
+ */
540
+ readonly connected?: boolean
541
+ readonly press?: ReadonlyArray<Button>
542
+ readonly release?: ReadonlyArray<Button>
543
+ readonly left?: Vector
544
+ readonly right?: Vector
545
+ readonly leftTrigger?: number
546
+ readonly rightTrigger?: number
547
+ }
548
+
549
+ /**
550
+ * Input changes by snapshot index. Plain data, so it round-trips through
551
+ * JSON; load it back with `parseTimeline`. It names buttons, not actions, so
552
+ * a replay follows the bindings as live play does.
553
+ */
554
+ export type Timeline = ReadonlyArray<TimelineEntry>
555
+
556
+ /** Actions driven by a timeline instead of a host. */
557
+ export interface Scripted<B extends Bindings> extends Actions<B> {
558
+ /** Number of snapshots taken so far, which is the next frame index. */
559
+ readonly frames: () => number
560
+ }
561
+
562
+ /**
563
+ * Plays `timeline`: before the snapshot at index `frame`, every entry for
564
+ * that frame is applied. A pad counts as connected (with the id
565
+ * `"scripted"`) from the first entry on, until an entry says
566
+ * `connected: false`; `scripted(bindings, [])` is a pad that never connects.
567
+ * Buttons stay held from their press until their release; pressing a held
568
+ * button is not a new press.
569
+ */
570
+ export const scripted = <const B extends Bindings>(bindings: B, timeline: Timeline): Scripted<B> => {
571
+ const state = tracker(bindings)
572
+ const byFrame = new Map<number, Array<TimelineEntry>>()
573
+ for (const entry of timeline) {
574
+ const entries = byFrame.get(entry.frame)
575
+ if (entries) entries.push(entry)
576
+ else byFrame.set(entry.frame, [entry])
577
+ }
578
+ const first = timeline.reduce((earliest, entry) => Math.min(earliest, entry.frame), Number.POSITIVE_INFINITY)
579
+ const resting = { left: zero, right: zero, leftTrigger: 0, rightTrigger: 0 }
580
+ let analog = resting
581
+ let connected = false
582
+ let frame = 0
583
+ return {
584
+ snapshot() {
585
+ for (const entry of byFrame.get(frame) ?? []) {
586
+ const releases = new Set(entry.release ?? [])
587
+ for (const button of entry.press ?? []) {
588
+ if (state.held.has(button) && releases.has(button)) {
589
+ // Released and pressed again within the frame.
590
+ releases.delete(button)
591
+ state.up(button)
592
+ }
593
+ state.down(button)
594
+ }
595
+ for (const button of releases) state.up(button)
596
+ analog = {
597
+ left: entry.left ?? analog.left,
598
+ right: entry.right ?? analog.right,
599
+ leftTrigger: entry.leftTrigger ?? analog.leftTrigger,
600
+ rightTrigger: entry.rightTrigger ?? analog.rightTrigger
601
+ }
602
+ connected = entry.connected ?? (frame === first ? true : connected)
603
+ if (!connected) {
604
+ state.upAll()
605
+ analog = resting
606
+ }
607
+ }
608
+ frame += 1
609
+ return state.snapshot({ ...analog, connected, id: connected ? "scripted" : null })
610
+ },
611
+ rebind: state.rebind,
612
+ dispose() {},
613
+ frames: () => frame
614
+ }
615
+ }
616
+
617
+ /** Live actions that also record the timeline they produce. */
618
+ export interface Recording<B extends Bindings> extends Actions<B> {
619
+ /** The recorded timeline; `scripted(bindings, timeline)` replays it exactly. */
620
+ readonly timeline: () => Timeline
621
+ }
622
+
623
+ /**
624
+ * Wraps `actions` and records, for every snapshot, the buttons pressed and
625
+ * released since the previous one and the sticks and triggers that moved.
626
+ */
627
+ export const recording = <B extends Bindings>(actions: Actions<B>): Recording<B> => {
628
+ const entries: Array<TimelineEntry> = []
629
+ let previous: Omit<Snapshot<B>, "actions" | "vectors" | "id" | "used"> = { buttons: idleButtons(buttonNames), left: zero, right: zero, leftTrigger: 0, rightTrigger: 0, connected: false }
630
+ let frame = 0
631
+ return {
632
+ snapshot() {
633
+ const snapshot = actions.snapshot()
634
+ const press = buttonNames.filter((name) => snapshot.buttons[name].pressed)
635
+ // A press of a button held at the previous snapshot means it was released in between.
636
+ const release = buttonNames.filter((name) => snapshot.buttons[name].released || (snapshot.buttons[name].pressed && previous.buttons[name].held))
637
+ const entry: Record<string, unknown> = {
638
+ ...(press.length > 0 ? { press } : {}),
639
+ ...(release.length > 0 ? { release } : {}),
640
+ ...(sameVector(snapshot.left, previous.left) ? {} : { left: snapshot.left }),
641
+ ...(sameVector(snapshot.right, previous.right) ? {} : { right: snapshot.right }),
642
+ ...(snapshot.leftTrigger === previous.leftTrigger ? {} : { leftTrigger: snapshot.leftTrigger }),
643
+ ...(snapshot.rightTrigger === previous.rightTrigger ? {} : { rightTrigger: snapshot.rightTrigger }),
644
+ // The first entry connects the pad by itself; later changes are written out.
645
+ ...(snapshot.connected === previous.connected || entries.length === 0 ? {} : { connected: snapshot.connected })
646
+ }
647
+ // The first entry also marks when a pad appeared, so a replay connects on the same frame.
648
+ if (Object.keys(entry).length > 0 || (entries.length === 0 && snapshot.connected)) entries.push({ frame, ...entry })
649
+ previous = snapshot
650
+ frame += 1
651
+ return snapshot
652
+ },
653
+ rebind: (bindings) => actions.rebind(bindings),
654
+ dispose: () => actions.dispose(),
655
+ timeline: () => [...entries]
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Why loaded data is not a timeline. `path` points at the offending value,
661
+ * for example `[3].press[0]`.
662
+ */
663
+ export interface InvalidTimeline {
664
+ readonly _tag: "InvalidTimeline"
665
+ readonly path: string
666
+ readonly message: string
667
+ }
668
+
669
+ const buttonSet: ReadonlySet<string> = new Set(buttonNames)
670
+
671
+ /** Whether `name` is a button of the standard mapping, for validating stored bindings. */
672
+ export const isButton = (name: unknown): name is Button => typeof name === "string" && buttonSet.has(name)
673
+
674
+ /**
675
+ * Validates untrusted data, such as a parsed JSON file, as a timeline: an
676
+ * array of entries with a non-negative integer `frame`, optional
677
+ * `press`/`release` arrays of button names, optional `left`/`right` vectors
678
+ * of length at most 1, optional triggers from 0 to 1, and an optional
679
+ * boolean `connected`.
680
+ */
681
+ export const parseTimeline = (data: unknown): Result.Result<Timeline, InvalidTimeline> => {
682
+ const invalid = (path: string, message: string) => Result.failure<InvalidTimeline>({ _tag: "InvalidTimeline", path, message })
683
+ if (!Array.isArray(data)) return invalid("", "expected an array of entries")
684
+ const entries: Array<TimelineEntry> = []
685
+ for (let index = 0; index < data.length; index++) {
686
+ const entry: unknown = data[index]
687
+ const path = `[${index}]`
688
+ if (typeof entry !== "object" || entry === null) return invalid(path, "expected an object")
689
+ const fields = entry as Readonly<Record<string, unknown>>
690
+ const frame = fields["frame"]
691
+ if (typeof frame !== "number" || !Number.isInteger(frame) || frame < 0) return invalid(`${path}.frame`, "expected a non-negative integer")
692
+ const parsed: Record<string, unknown> = { frame }
693
+ for (const field of ["press", "release"] as const) {
694
+ const value = fields[field]
695
+ if (value === undefined) continue
696
+ if (!Array.isArray(value)) return invalid(`${path}.${field}`, "expected an array of button names")
697
+ for (let offset = 0; offset < value.length; offset++) {
698
+ if (!isButton(value[offset])) return invalid(`${path}.${field}[${offset}]`, `expected one of ${buttonNames.join(", ")}`)
699
+ }
700
+ parsed[field] = [...value]
701
+ }
702
+ for (const field of ["left", "right"] as const) {
703
+ const value = fields[field]
704
+ if (value === undefined) continue
705
+ const { x, y } = (typeof value === "object" && value !== null ? value : {}) as { readonly x?: unknown; readonly y?: unknown }
706
+ if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y) || x * x + y * y > 1 + 1e-9) {
707
+ return invalid(`${path}.${field}`, "expected { x, y } of length at most 1")
708
+ }
709
+ parsed[field] = { x, y }
710
+ }
711
+ for (const field of ["leftTrigger", "rightTrigger"] as const) {
712
+ const value = fields[field]
713
+ if (value === undefined) continue
714
+ if (typeof value !== "number" || !(value >= 0 && value <= 1)) return invalid(`${path}.${field}`, "expected a number from 0 to 1")
715
+ parsed[field] = value
716
+ }
717
+ const connected = fields["connected"]
718
+ if (connected !== undefined) {
719
+ if (typeof connected !== "boolean") return invalid(`${path}.connected`, "expected a boolean")
720
+ parsed["connected"] = connected
721
+ }
722
+ entries.push(parsed as unknown as TimelineEntry)
723
+ }
724
+ return Result.success(entries)
725
+ }
726
+
727
+ /**
728
+ * Why loaded data is not a rebinding for the bindings. `path` points at the
729
+ * offending value, for example `attack[1]`.
730
+ */
731
+ export type InvalidRebinding = ButtonsInternal.InvalidRebinding
732
+
733
+ /**
734
+ * Validates untrusted data, such as saved settings parsed from JSON, as new
735
+ * buttons for `bindings`: an object with exactly the same action names, each
736
+ * mapped to a non-empty array of button names (for a vector action, an
737
+ * object with an optional `stick` and buttons for all four directions or
738
+ * none, as `Gamepad.vector` saves). Pass the result to
739
+ * {@link Actions.rebind}; on failure, keep the defaults.
740
+ *
741
+ * @example
742
+ * ```ts
743
+ * const saved = Gamepad.parseRebinding(bindings, JSON.parse(stored))
744
+ * if (saved.ok) pad.rebind(saved.value)
745
+ * ```
746
+ */
747
+ export const parseRebinding = <const B extends Bindings>(
748
+ bindings: B,
749
+ data: unknown
750
+ ): Result.Result<Rebinding<B>, InvalidRebinding> =>
751
+ // The checked names and buttons are exactly `Rebinding<B>`.
752
+ ButtonsInternal.parseButtonRebinding(buttonNames, bindings, data, ["left", "right"]) as Result.Result<Rebinding<B>, InvalidRebinding>