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