@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/Keyboard.ts CHANGED
@@ -30,12 +30,22 @@
30
30
  *
31
31
  * // Once per update:
32
32
  * const snapshot = input.snapshot()
33
- * if (snapshot.jump.pressed) jump()
33
+ * if (snapshot.actions.jump.pressed) jump()
34
34
  *
35
35
  * // On teardown:
36
36
  * input.dispose()
37
37
  * ```
38
38
  *
39
+ * Movement is one vector action: `move: Keyboard.vector({ up, down, left,
40
+ * right })` reports `vectors.move` with length at most 1 (opposite keys
41
+ * cancel, diagonals are not faster). Timelines name it by direction,
42
+ * `"move.right"`.
43
+ *
44
+ * For a controls settings screen, `presses(...)` reports every key that
45
+ * goes down (bound or not) so the screen can wait for the key to bind,
46
+ * `rebind(...)` swaps the keys of the same actions, and `parseRebinding(...)`
47
+ * validates bindings saved as JSON.
48
+ *
39
49
  * For tests, replays, and headless runs, `scripted(...)` plays a timeline of
40
50
  * presses and releases instead of listening to a host, and `recording(...)`
41
51
  * wraps live actions to capture the timeline a session produced.
@@ -53,8 +63,17 @@
53
63
  * }
54
64
  * ```
55
65
  */
66
+ import type * as System from "@typeonce/ritmo/System"
56
67
  import * as Result from "@typeonce/ritmo/Result"
57
68
 
69
+ import { boundKeys } from "./internal/introspect.ts"
70
+ import type { ReportsBoundKeys } from "./internal/introspect.ts"
71
+ import { anyUsed } from "./internal/used.ts"
72
+ import { digital, directionName, directions, isVectorBinding, zero } from "./internal/vectors.ts"
73
+ import type { Direction, Vector } from "./internal/vectors.ts"
74
+
75
+ export type { Direction, Vector }
76
+
58
77
  /**
59
78
  * The subset of `KeyboardEvent` the module reads. `code` (the physical key)
60
79
  * is optional so simple test hosts can omit it; browsers always provide it.
@@ -97,11 +116,53 @@ export interface PhysicalKey {
97
116
  */
98
117
  export type KeyBinding = string | PhysicalKey
99
118
 
119
+ /** The keys of one action: at least one. */
120
+ export type Keys = readonly [KeyBinding, ...Array<KeyBinding>]
121
+
100
122
  /**
101
- * Action name to the keys that trigger it. Every action needs at least one
102
- * key.
123
+ * Keys for the four directions of a vector action (movement), built with
124
+ * {@link vector}.
103
125
  */
104
- export type Bindings = Readonly<Record<string, readonly [KeyBinding, ...Array<KeyBinding>]>>
126
+ export interface VectorBinding {
127
+ readonly _tag: "Vector"
128
+ readonly up: Keys
129
+ readonly down: Keys
130
+ readonly left: Keys
131
+ readonly right: Keys
132
+ }
133
+
134
+ /**
135
+ * Action name to the keys that trigger it (at least one), or to a
136
+ * {@link vector} binding for a direction such as movement.
137
+ */
138
+ export type Bindings = Readonly<Record<string, Keys | VectorBinding>>
139
+
140
+ /** The bound button actions: the names bound to keys. */
141
+ export type ButtonNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? never : K }[keyof B] & string
142
+
143
+ /** The bound vector actions: the names bound with {@link vector}. */
144
+ export type VectorNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? K : never }[keyof B] & string
145
+
146
+ /**
147
+ * Binds a vector action to keys for its four directions. The snapshot's
148
+ * `vectors` reports it with length at most 1: opposite keys cancel and a
149
+ * diagonal is not faster than a straight line.
150
+ *
151
+ * @example
152
+ * ```ts
153
+ * const bindings = {
154
+ * move: Keyboard.vector({ up: ["w", "ArrowUp"], down: ["s", "ArrowDown"], left: ["a", "ArrowLeft"], right: ["d", "ArrowRight"] }),
155
+ * jump: [" "]
156
+ * } as const
157
+ * ```
158
+ */
159
+ export const vector = (keys: { readonly up: Keys; readonly down: Keys; readonly left: Keys; readonly right: Keys }): VectorBinding => ({
160
+ _tag: "Vector",
161
+ up: keys.up,
162
+ down: keys.down,
163
+ left: keys.left,
164
+ right: keys.right
165
+ })
105
166
 
106
167
  /**
107
168
  * A binding to a physical key by its `KeyboardEvent.code`.
@@ -117,32 +178,71 @@ export type Bindings = Readonly<Record<string, readonly [KeyBinding, ...Array<Ke
117
178
  export const code = <const Code extends string>(code: Code): PhysicalKey & { readonly code: Code } => ({ code })
118
179
 
119
180
  /**
120
- * One action's state in a snapshot.
181
+ * One action's state in a snapshot, the same on every device (`Gamepad`,
182
+ * `Pointer`, and `Touch` buttons too).
121
183
  */
122
184
  export interface ActionState {
123
- /** At least one bound key is down now. */
185
+ /** At least one bound key or button is down now. */
124
186
  readonly held: boolean
125
- /** A bound key went down since the previous snapshot, even if already released. */
187
+ /** A bound key or button went down since the previous snapshot, even if already released. */
126
188
  readonly pressed: boolean
127
- /** A bound key went up since the previous snapshot and none is held now. */
189
+ /** A bound key or button went up since the previous snapshot and none is held now. */
128
190
  readonly released: boolean
129
191
  }
130
192
 
131
- export type Snapshot<B extends Bindings> = {
132
- readonly [Action in keyof B]: ActionState
193
+ /**
194
+ * Every action's state, with the same core as the other devices' snapshots
195
+ * (`actions`, `vectors`, and `used`) so `Controls` can merge them.
196
+ */
197
+ export interface Snapshot<B extends Bindings> {
198
+ readonly actions: { readonly [Action in ButtonNames<B>]: ActionState }
199
+ /** Each vector action's direction, length at most 1, `y` growing down. */
200
+ readonly vectors: { readonly [Action in VectorNames<B>]: Vector }
201
+ /** Some key bound to an action was pressed in this window or is held: the player is using the keyboard. */
202
+ readonly used: boolean
203
+ }
204
+
205
+ /**
206
+ * New keys for the same actions, for {@link Actions.rebind}: keys for a
207
+ * button action, a {@link vector} for a vector action. Load saved ones with
208
+ * {@link parseRebinding}.
209
+ */
210
+ export type Rebinding<B extends Bindings> = { readonly [Action in keyof B]: B[Action] extends VectorBinding ? VectorBinding : Keys }
211
+
212
+ /**
213
+ * Rejects actions a rebinding names that the bindings do not have, so
214
+ * {@link Actions.rebind} takes exactly the actions it was made with.
215
+ */
216
+ export type OnlyActionsOf<R, B extends Bindings> = {
217
+ readonly [Name in Exclude<keyof R, keyof B>]: `${Name & string} is not an action of these bindings`
133
218
  }
134
219
 
135
- export interface Actions<B extends Bindings> {
220
+ /**
221
+ * A keyboard bound to actions, built with {@link actions} (live),
222
+ * {@link scripted}, or {@link recording}. Capture it with
223
+ * `InputCapture.system(...)`, or merge it with `Controls`.
224
+ */
225
+ export interface Actions<B extends Bindings> extends System.Consumes<"snapshot"> {
136
226
  /**
137
227
  * Returns every action's state and starts a new press/release window.
138
228
  */
139
229
  snapshot(): Snapshot<B>
230
+ /**
231
+ * Binds the same actions to new keys, from the next snapshot, for a
232
+ * settings screen where players remap controls (capture the key to bind
233
+ * with {@link presses}). An action held only through keys it lost reports
234
+ * `released`. A key held across the rebind holds a newly bound action only
235
+ * once it is pressed again, so an action never turns held without a
236
+ * press. It takes exactly the actions the source was made with.
237
+ */
238
+ rebind<const R extends Rebinding<B>>(bindings: R & OnlyActionsOf<R, B>): void
140
239
  /**
141
240
  * Removes the event listeners.
142
241
  */
143
242
  dispose(): void
144
243
  }
145
244
 
245
+ /** Options for {@link actions}. */
146
246
  export interface ActionsOptions {
147
247
  /**
148
248
  * Call `preventDefault()` for bound keys, so arrows and space do not scroll
@@ -156,11 +256,42 @@ export interface ActionsOptions {
156
256
  * before the first capture.
157
257
  */
158
258
  export const idle = <const B extends Bindings>(bindings: B): Snapshot<B> => {
159
- const snapshot = {} as Record<keyof B, ActionState>
160
- for (const name of Object.keys(bindings) as Array<keyof B>) {
161
- snapshot[name] = { held: false, pressed: false, released: false }
259
+ const idleState: ActionState = { held: false, pressed: false, released: false }
260
+ return assemble(bindings, () => idleState, false) as Snapshot<B>
261
+ }
262
+
263
+ /** Each action's keys, with every vector action split into its four directions (`move.up`, ...). */
264
+ const flatten = (bindings: Bindings): Readonly<Record<string, Keys>> => {
265
+ const flat: Record<string, Keys> = {}
266
+ for (const [name, entry] of Object.entries(bindings)) {
267
+ if (isVectorBinding(entry)) {
268
+ for (const direction of directions) flat[directionName(name, direction)] = (entry as VectorBinding)[direction]
269
+ } else flat[name] = entry as Keys
270
+ }
271
+ return flat
272
+ }
273
+
274
+ /** The snapshot for `bindings` from the state of each flat action (button actions and vector directions). */
275
+ const assemble = (bindings: Bindings, state: (name: string) => ActionState, used: boolean): Snapshot<Bindings> => {
276
+ const actions: Record<string, ActionState> = {}
277
+ const vectors: Record<string, Vector> = {}
278
+ for (const [name, entry] of Object.entries(bindings)) {
279
+ if (isVectorBinding(entry)) {
280
+ const held = (direction: Direction): boolean => state(directionName(name, direction)).held
281
+ vectors[name] = digital(held("up"), held("down"), held("left"), held("right"))
282
+ } else actions[name] = state(name)
162
283
  }
163
- return snapshot
284
+ return { actions, vectors, used } as Snapshot<Bindings>
285
+ }
286
+
287
+ /**
288
+ * The flat action states behind the last snapshot of a live or scripted
289
+ * keyboard, so `recording` sees each direction of a vector action.
290
+ */
291
+ const flatStates = Symbol("Keyboard/flatStates")
292
+
293
+ interface WithFlatStates {
294
+ readonly [flatStates]?: () => Readonly<Record<string, ActionState>>
164
295
  }
165
296
 
166
297
  const normalize = (key: string): string => key.length === 1 ? key.toLowerCase() : key
@@ -170,35 +301,57 @@ const keyToken = (key: string): string => `key:${normalize(key)}`
170
301
  const codeToken = (code: string): string => `code:${code}`
171
302
  const bindingToken = (binding: KeyBinding): string => typeof binding === "string" ? keyToken(binding) : codeToken(binding.code)
172
303
 
304
+ /** How a binding reads in a lint: the character quoted, a physical key by its code. */
305
+ const bindingLabel = (binding: KeyBinding): string => typeof binding === "string" ? JSON.stringify(normalize(binding)) : binding.code
306
+
307
+ const labelsOf = (flat: Readonly<Record<string, Keys>>): ReadonlyArray<string> =>
308
+ [...new Set(Object.values(flat).flatMap((keys) => keys.map(bindingLabel)))]
309
+
173
310
  /**
174
- * Starts tracking bound keys on `host`.
311
+ * Starts tracking bound keys on `host`, usually `window`, until `dispose()`.
312
+ * When the window loses focus every held action is released. Each call is
313
+ * its own device: build one per source (one `Controls` merge, one player
314
+ * of a pool). Use {@link scripted} for tests and headless runs, and
315
+ * {@link presses} to capture an unbound key on a settings screen.
175
316
  */
176
317
  export const actions = <const B extends Bindings>(
177
318
  host: KeyboardHost,
178
- bindings: B,
319
+ initial: B,
179
320
  options: ActionsOptions = {}
180
321
  ): Actions<B> => {
181
322
  const preventDefault = options.preventDefault ?? true
182
- const names = Object.keys(bindings) as Array<keyof B & string>
183
- const actionsByToken = new Map<string, Array<keyof B & string>>()
184
- for (const name of names) {
185
- for (const binding of bindings[name]!) {
186
- const token = bindingToken(binding)
187
- const bound = actionsByToken.get(token)
188
- if (bound) bound.push(name)
189
- else actionsByToken.set(token, [name])
323
+ // Fixed at creation: a rebind swaps keys, never the actions. Vector
324
+ // actions are tracked as their four directions.
325
+ const names = Object.keys(flatten(initial))
326
+ let bindings = flatten(initial)
327
+ const actionsByToken = new Map<string, Array<string>>()
328
+ const index = (): void => {
329
+ actionsByToken.clear()
330
+ for (const name of names) {
331
+ for (const binding of bindings[name]!) {
332
+ const token = bindingToken(binding)
333
+ const bound = actionsByToken.get(token)
334
+ if (bound) bound.push(name)
335
+ else actionsByToken.set(token, [name])
336
+ }
190
337
  }
191
338
  }
339
+ index()
192
340
 
193
- const heldTokens = new Set<string>()
341
+ /**
342
+ * Each held token and the actions it holds: those bound to it when it went
343
+ * down that are still bound to it. A token stays here while held even if a
344
+ * rebind unbound it, so its release is still seen.
345
+ */
346
+ const heldTokens = new Map<string, Set<string>>()
194
347
  /**
195
348
  * The character token each physical key produced when it went down, so its
196
349
  * release matches even if a modifier changed the character meanwhile
197
350
  * (W pressed, Option pressed, W released as "∑").
198
351
  */
199
352
  const keyTokenByCode = new Map<string, string>()
200
- const pressed = new Set<keyof B>()
201
- const released = new Set<keyof B>()
353
+ const pressed = new Set<string>()
354
+ const released = new Set<string>()
202
355
 
203
356
  const onKeyDown = (event: KeyEvent): void => {
204
357
  const tokens = [keyToken(event.key)]
@@ -212,7 +365,7 @@ export const actions = <const B extends Bindings>(
212
365
  if (!bound) continue
213
366
  handled = true
214
367
  if (event.repeat || heldTokens.has(token)) continue
215
- heldTokens.add(token)
368
+ heldTokens.set(token, new Set(bound))
216
369
  for (const name of bound) pressed.add(name)
217
370
  }
218
371
  if (handled && preventDefault) event.preventDefault()
@@ -227,15 +380,16 @@ export const actions = <const B extends Bindings>(
227
380
  tokens.push(codeToken(event.code))
228
381
  }
229
382
  for (const token of tokens) {
230
- const bound = actionsByToken.get(token)
231
- if (!bound || !heldTokens.delete(token)) continue
232
- for (const name of bound) released.add(name)
383
+ const holding = heldTokens.get(token)
384
+ if (!holding) continue
385
+ heldTokens.delete(token)
386
+ for (const name of holding) released.add(name)
233
387
  }
234
388
  }
235
389
 
236
390
  const onBlur = (): void => {
237
- for (const token of heldTokens) {
238
- for (const name of actionsByToken.get(token)!) released.add(name)
391
+ for (const holding of heldTokens.values()) {
392
+ for (const name of holding) released.add(name)
239
393
  }
240
394
  heldTokens.clear()
241
395
  keyTokenByCode.clear()
@@ -245,19 +399,21 @@ export const actions = <const B extends Bindings>(
245
399
  host.addEventListener("keyup", onKeyUp)
246
400
  host.addEventListener("blur", onBlur)
247
401
 
248
- const isHeld = (name: keyof B & string): boolean => {
402
+ const isHeld = (name: string): boolean => {
249
403
  for (const binding of bindings[name]!) {
250
- if (heldTokens.has(bindingToken(binding))) return true
404
+ if (heldTokens.get(bindingToken(binding))?.has(name)) return true
251
405
  }
252
406
  return false
253
407
  }
254
408
 
255
- return {
409
+ let last: Readonly<Record<string, ActionState>> = {}
410
+ const live: Actions<B> & WithFlatStates & ReportsBoundKeys = {
411
+ [boundKeys]: () => labelsOf(bindings),
256
412
  snapshot() {
257
- const snapshot = {} as Record<keyof B, ActionState>
413
+ const states: Record<string, ActionState> = {}
258
414
  for (const name of names) {
259
415
  const held = isHeld(name)
260
- snapshot[name] = {
416
+ states[name] = {
261
417
  held,
262
418
  pressed: pressed.has(name),
263
419
  released: released.has(name) && !held
@@ -265,7 +421,20 @@ export const actions = <const B extends Bindings>(
265
421
  }
266
422
  pressed.clear()
267
423
  released.clear()
268
- return snapshot
424
+ last = states
425
+ return assemble(initial, (name) => states[name]!, anyUsed(states)) as Snapshot<B>
426
+ },
427
+ [flatStates]: () => last,
428
+ rebind(next) {
429
+ const before = names.filter(isHeld)
430
+ bindings = flatten(next as Bindings)
431
+ index()
432
+ // A held key keeps only the actions still bound to it; new ones wait for a fresh press.
433
+ for (const [token, holding] of heldTokens) {
434
+ const bound = actionsByToken.get(token) ?? []
435
+ for (const name of holding) if (!bound.includes(name)) holding.delete(name)
436
+ }
437
+ for (const name of before) if (!isHeld(name)) released.add(name)
269
438
  },
270
439
  dispose() {
271
440
  host.removeEventListener("keydown", onKeyDown)
@@ -273,20 +442,79 @@ export const actions = <const B extends Bindings>(
273
442
  host.removeEventListener("blur", onBlur)
274
443
  }
275
444
  }
445
+ return live
446
+ }
447
+
448
+ /** One key that went down, in the two forms a {@link KeyBinding} can name it. */
449
+ export interface KeyPress {
450
+ /** The character typed (`KeyboardEvent.key`), for a character binding. */
451
+ readonly key: string
452
+ /** The physical key (`KeyboardEvent.code`), for `Keyboard.code(...)`; `null` when the host gave none. */
453
+ readonly code: string | null
454
+ }
455
+
456
+ /** Every key press on a host, bound or not; see {@link presses}. */
457
+ export interface Presses extends System.Consumes<"snapshot"> {
458
+ /** The keys that went down since the previous snapshot, in order, without auto-repeats. */
459
+ snapshot(): ReadonlyArray<KeyPress>
460
+ /** Removes the event listener. */
461
+ dispose(): void
276
462
  }
277
463
 
278
464
  /**
279
- * Input changes applied just before one snapshot: presses first, then
280
- * releases. `frame` is the snapshot's index, counting from 0 at the first
281
- * `snapshot()` call. An action both pressed and released in one frame is a
282
- * tap shorter than a frame.
465
+ * Starts collecting every key press on `host`, whatever the bindings, for a
466
+ * settings screen that waits for the key to bind. Use {@link actions} for
467
+ * gameplay. It never calls `preventDefault()`.
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * const presses = Keyboard.presses(window)
472
+ *
473
+ * // While the settings screen waits for the new jump key:
474
+ * const press = presses.snapshot()[0]
475
+ * if (press !== undefined) {
476
+ * input.rebind({ ...current, jump: [press.code === null ? press.key : Keyboard.code(press.code)] })
477
+ * }
478
+ * ```
479
+ */
480
+ export const presses = (host: KeyboardHost): Presses => {
481
+ let pending: Array<KeyPress> = []
482
+ const onKeyDown = (event: KeyEvent): void => {
483
+ if (event.repeat) return
484
+ pending.push({ key: event.key, code: event.code ?? null })
485
+ }
486
+ host.addEventListener("keydown", onKeyDown)
487
+ return {
488
+ snapshot() {
489
+ const snapshot = pending
490
+ pending = []
491
+ return snapshot
492
+ },
493
+ dispose() {
494
+ host.removeEventListener("keydown", onKeyDown)
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Input changes applied just before one snapshot. `frame` is the snapshot's
501
+ * index, counting from 0 at the first `snapshot()` call. Entries name
502
+ * actions, and a vector action by direction (`"move.right"`). An action in both
503
+ * `press` and `release` changed twice within the frame: a tap shorter than a
504
+ * frame when it was up, a release and a new press when it was held.
283
505
  */
284
506
  export interface TimelineEntry<B extends Bindings> {
285
507
  readonly frame: number
286
- readonly press?: ReadonlyArray<keyof B & string>
287
- readonly release?: ReadonlyArray<keyof B & string>
508
+ readonly press?: ReadonlyArray<TimelineName<B>>
509
+ readonly release?: ReadonlyArray<TimelineName<B>>
288
510
  }
289
511
 
512
+ /**
513
+ * What a timeline presses and releases: a button action, or one direction
514
+ * of a vector action (`"move.right"`).
515
+ */
516
+ export type TimelineName<B extends Bindings> = ButtonNames<B> | `${VectorNames<B>}.${Direction}`
517
+
290
518
  /**
291
519
  * Input changes by snapshot index. Plain data, so it round-trips through
292
520
  * JSON; load it back with `parseTimeline`.
@@ -294,7 +522,8 @@ export interface TimelineEntry<B extends Bindings> {
294
522
  export type Timeline<B extends Bindings> = ReadonlyArray<TimelineEntry<B>>
295
523
 
296
524
  /**
297
- * Actions driven by a timeline instead of a host.
525
+ * Actions driven by a timeline instead of a host. The timeline names
526
+ * actions, not keys, so `rebind` changes nothing.
298
527
  */
299
528
  export interface Scripted<B extends Bindings> extends Actions<B> {
300
529
  /** Number of snapshots taken so far, which is the next frame index. */
@@ -307,41 +536,56 @@ export interface Scripted<B extends Bindings> extends Actions<B> {
307
536
  * release, like keys; pressing an action already held is not a new press.
308
537
  */
309
538
  export const scripted = <const B extends Bindings>(bindings: B, timeline: Timeline<B>): Scripted<B> => {
310
- const names = Object.keys(bindings) as Array<keyof B & string>
539
+ const names = Object.keys(flatten(bindings))
311
540
  const byFrame = new Map<number, Array<TimelineEntry<B>>>()
312
541
  for (const entry of timeline) {
313
542
  const entries = byFrame.get(entry.frame)
314
543
  if (entries) entries.push(entry)
315
544
  else byFrame.set(entry.frame, [entry])
316
545
  }
317
- const held = new Set<keyof B>()
546
+ const held = new Set<string>()
318
547
  let frame = 0
319
- return {
548
+ let last: Readonly<Record<string, ActionState>> = {}
549
+ const played: Scripted<B> & WithFlatStates & ReportsBoundKeys = {
550
+ [boundKeys]: () => labelsOf(flatten(bindings)),
320
551
  snapshot() {
321
- const pressed = new Set<keyof B>()
322
- const released = new Set<keyof B>()
552
+ const pressed = new Set<string>()
553
+ const released = new Set<string>()
323
554
  for (const entry of byFrame.get(frame) ?? []) {
555
+ const releases = new Set(entry.release ?? [])
324
556
  for (const name of entry.press ?? []) {
557
+ if (held.has(name) && releases.has(name)) {
558
+ // Released and pressed again within the frame.
559
+ releases.delete(name)
560
+ released.add(name)
561
+ pressed.add(name)
562
+ continue
563
+ }
325
564
  // A press of a held action is not a new press, as with a live
326
565
  // keyboard's auto-repeat.
327
566
  if (!held.has(name)) pressed.add(name)
328
567
  held.add(name)
329
568
  }
330
- for (const name of entry.release ?? []) {
569
+ for (const name of releases) {
331
570
  if (held.delete(name)) released.add(name)
332
571
  }
333
572
  }
334
573
  frame += 1
335
- const snapshot = {} as Record<keyof B, ActionState>
574
+ const states: Record<string, ActionState> = {}
336
575
  for (const name of names) {
337
576
  const isHeld = held.has(name)
338
- snapshot[name] = { held: isHeld, pressed: pressed.has(name), released: released.has(name) && !isHeld }
577
+ states[name] = { held: isHeld, pressed: pressed.has(name), released: released.has(name) && !isHeld }
339
578
  }
340
- return snapshot
579
+ last = states
580
+ return assemble(bindings, (name) => states[name]!, anyUsed(states)) as Snapshot<B>
341
581
  },
582
+ [flatStates]: () => last,
583
+ // A timeline names actions, not keys, so new keys change nothing.
584
+ rebind() {},
342
585
  dispose() {},
343
586
  frames: () => frame
344
587
  }
588
+ return played
345
589
  }
346
590
 
347
591
  /**
@@ -358,17 +602,25 @@ export interface Recording<B extends Bindings> extends Actions<B> {
358
602
  */
359
603
  export const recording = <B extends Bindings>(actions: Actions<B>): Recording<B> => {
360
604
  const entries: Array<TimelineEntry<B>> = []
605
+ let wasHeld = new Set<TimelineName<B>>()
361
606
  let frame = 0
362
- return {
607
+ const recorded: Recording<B> & WithFlatStates & ReportsBoundKeys = {
608
+ [boundKeys]: () => (actions as ReportsBoundKeys)[boundKeys]?.() ?? [],
363
609
  snapshot() {
364
610
  const snapshot = actions.snapshot()
365
- const press: Array<keyof B & string> = []
366
- const release: Array<keyof B & string> = []
367
- for (const name of Object.keys(snapshot) as Array<keyof B & string>) {
368
- const state = snapshot[name]!
611
+ // Each direction of a vector action, from the keyboard this wraps.
612
+ const states = (actions as WithFlatStates)[flatStates]?.() ?? (snapshot.actions as Readonly<Record<string, ActionState>>)
613
+ const press: Array<TimelineName<B>> = []
614
+ const release: Array<TimelineName<B>> = []
615
+ const held = new Set<TimelineName<B>>()
616
+ for (const name of Object.keys(states) as Array<TimelineName<B>>) {
617
+ const state = states[name]!
618
+ if (state.held) held.add(name)
369
619
  if (state.pressed) press.push(name)
370
- if (state.released) release.push(name)
620
+ // A press of an action held at the previous snapshot means it was released in between.
621
+ if (state.released || (state.pressed && wasHeld.has(name))) release.push(name)
371
622
  }
623
+ wasHeld = held
372
624
  if (press.length > 0 || release.length > 0) {
373
625
  entries.push({
374
626
  frame,
@@ -379,11 +631,16 @@ export const recording = <B extends Bindings>(actions: Actions<B>): Recording<B>
379
631
  frame += 1
380
632
  return snapshot
381
633
  },
634
+ [flatStates]: () => (actions as WithFlatStates)[flatStates]?.() ?? {},
635
+ rebind(bindings) {
636
+ actions.rebind(bindings)
637
+ },
382
638
  dispose() {
383
639
  actions.dispose()
384
640
  },
385
641
  timeline: () => [...entries]
386
642
  }
643
+ return recorded
387
644
  }
388
645
 
389
646
  /**
@@ -399,7 +656,8 @@ export interface InvalidTimeline {
399
656
  /**
400
657
  * Validates untrusted data, such as a parsed JSON file, as a timeline for
401
658
  * `bindings`: an array of entries with a non-negative integer `frame` and
402
- * optional `press`/`release` arrays of bound action names.
659
+ * optional `press`/`release` arrays of bound action names (a vector action
660
+ * by direction, `"move.right"`).
403
661
  */
404
662
  export const parseTimeline = <const B extends Bindings>(
405
663
  bindings: B,
@@ -407,6 +665,7 @@ export const parseTimeline = <const B extends Bindings>(
407
665
  ): Result.Result<Timeline<B>, InvalidTimeline> => {
408
666
  const invalid = (path: string, message: string) => Result.failure<InvalidTimeline>({ _tag: "InvalidTimeline", path, message })
409
667
  if (!Array.isArray(data)) return invalid("", "expected an array of entries")
668
+ const flat = flatten(bindings)
410
669
  const entries: Array<TimelineEntry<B>> = []
411
670
  for (let index = 0; index < data.length; index++) {
412
671
  const entry: unknown = data[index]
@@ -416,16 +675,16 @@ export const parseTimeline = <const B extends Bindings>(
416
675
  if (typeof frame !== "number" || !Number.isInteger(frame) || frame < 0) {
417
676
  return invalid(`${path}.frame`, "expected a non-negative integer")
418
677
  }
419
- const actionsAt = (value: unknown, field: string): Result.Result<Array<keyof B & string> | undefined, InvalidTimeline> => {
678
+ const actionsAt = (value: unknown, field: string): Result.Result<Array<TimelineName<B>> | undefined, InvalidTimeline> => {
420
679
  if (value === undefined) return Result.success(undefined)
421
680
  if (!Array.isArray(value)) return invalid(`${path}.${field}`, "expected an array of action names")
422
- const names: Array<keyof B & string> = []
681
+ const names: Array<TimelineName<B>> = []
423
682
  for (let offset = 0; offset < value.length; offset++) {
424
683
  const name: unknown = value[offset]
425
- if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(bindings, name)) {
426
- return invalid(`${path}.${field}[${offset}]`, `expected one of ${Object.keys(bindings).join(", ")}`)
684
+ if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(flat, name)) {
685
+ return invalid(`${path}.${field}[${offset}]`, `expected one of ${Object.keys(flat).join(", ")}`)
427
686
  }
428
- names.push(name)
687
+ names.push(name as TimelineName<B>)
429
688
  }
430
689
  return Result.success(names)
431
690
  }
@@ -441,3 +700,71 @@ export const parseTimeline = <const B extends Bindings>(
441
700
  }
442
701
  return Result.success(entries)
443
702
  }
703
+
704
+ /**
705
+ * Why loaded data is not a rebinding for the bindings. `path` points at the
706
+ * offending value, for example `jump[1]`.
707
+ */
708
+ export interface InvalidRebinding {
709
+ readonly _tag: "InvalidRebinding"
710
+ readonly path: string
711
+ readonly message: string
712
+ }
713
+
714
+ /**
715
+ * Validates untrusted data, such as saved settings parsed from JSON, as new
716
+ * keys for `bindings`: an object with exactly the same action names, each
717
+ * mapped to a non-empty array of characters or `{ code }` physical keys
718
+ * (for a vector action, an object with such keys for `up`, `down`, `left`,
719
+ * and `right`, as `Keyboard.vector` saves).
720
+ * Pass the result to {@link Actions.rebind}; on failure, keep the defaults.
721
+ *
722
+ * @example
723
+ * ```ts
724
+ * const saved = Keyboard.parseRebinding(bindings, JSON.parse(stored))
725
+ * if (saved.ok) input.rebind(saved.value)
726
+ * ```
727
+ */
728
+ export const parseRebinding = <const B extends Bindings>(
729
+ bindings: B,
730
+ data: unknown
731
+ ): Result.Result<Rebinding<B>, InvalidRebinding> => {
732
+ const invalid = (path: string, message: string) => Result.failure<InvalidRebinding>({ _tag: "InvalidRebinding", path, message })
733
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return invalid("", "expected an object of action names")
734
+ const fields = data as Readonly<Record<string, unknown>>
735
+ const names = Object.keys(bindings)
736
+ for (const name of Object.keys(fields)) {
737
+ if (!names.includes(name)) return invalid(name, `unknown action, expected one of ${names.join(", ")}`)
738
+ }
739
+ const keysAt = (value: unknown, path: string): Result.Result<Keys, InvalidRebinding> => {
740
+ if (!Array.isArray(value) || value.length === 0) return invalid(path, "expected a non-empty array of keys")
741
+ const keys: Array<KeyBinding> = []
742
+ for (let offset = 0; offset < value.length; offset++) {
743
+ const key: unknown = value[offset]
744
+ if (typeof key === "string" && key.length > 0) keys.push(key)
745
+ else if (typeof key === "object" && key !== null && typeof (key as { readonly code?: unknown }).code === "string" && (key as PhysicalKey).code.length > 0) {
746
+ keys.push({ code: (key as PhysicalKey).code })
747
+ } else return invalid(`${path}[${offset}]`, "expected a key character or { code }")
748
+ }
749
+ return Result.success(keys as unknown as Keys)
750
+ }
751
+ const rebinding: Record<string, Keys | VectorBinding> = {}
752
+ for (const name of names) {
753
+ const value = fields[name]
754
+ if (isVectorBinding(bindings[name])) {
755
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid(name, "expected { up, down, left, right } keys for a vector action")
756
+ const parts: Partial<Record<Direction, Keys>> = {}
757
+ for (const direction of directions) {
758
+ const keys = keysAt((value as Readonly<Record<string, unknown>>)[direction], `${name}.${direction}`)
759
+ if (!keys.ok) return keys
760
+ parts[direction] = keys.value
761
+ }
762
+ rebinding[name] = vector(parts as Record<Direction, Keys>)
763
+ continue
764
+ }
765
+ const keys = keysAt(value, name)
766
+ if (!keys.ok) return keys
767
+ rebinding[name] = keys.value
768
+ }
769
+ return Result.success(rebinding as unknown as Rebinding<B>)
770
+ }