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