@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/Touch.ts CHANGED
@@ -28,8 +28,9 @@
28
28
  *
29
29
  * @example
30
30
  * ```ts
31
- * const touch = Touch.track(canvas, window, ({ width, height }) => ({
32
- * stick: { region: { left: 0, top: 0, width: width / 2, height }, radius: 60 },
31
+ * const touch = Touch.actions(canvas, window, ({ width, height }) => ({
32
+ * // `vector` names the action the stick drives, so it merges into `Controls`.
33
+ * stick: { region: { left: 0, top: 0, width: width / 2, height }, radius: 60, vector: "move" },
33
34
  * buttons: {
34
35
  * attack: { center: { x: width - 90, y: height - 90 }, radius: 55, aimRadius: 80 },
35
36
  * dash: { center: { x: width - 200, y: height - 60 }, radius: 36 }
@@ -37,14 +38,16 @@
37
38
  * }))
38
39
  *
39
40
  * // Once per update:
40
- * const { stick, buttons } = touch.snapshot()
41
- * move(stick.vector)
42
- * if (buttons.attack.released && !buttons.attack.cancelled) fire(buttons.attack.aim)
41
+ * const { vectors, actions } = touch.snapshot()
42
+ * move(vectors.move)
43
+ * if (actions.attack.released && !actions.attack.cancelled) fire(actions.attack.aim)
43
44
  * ```
44
45
  *
45
46
  * For tests and headless runs, `scripted(...)` plays a timeline of stick
46
47
  * vectors, presses, drags, and releases.
47
48
  */
49
+ import type * as System from "@typeonce/ritmo/System"
50
+ import { anyUsed } from "./internal/used.ts"
48
51
  import type { ActionState } from "./Keyboard.ts"
49
52
 
50
53
  /** A position in CSS pixels relative to the element's top-left corner. */
@@ -59,6 +62,7 @@ export interface Vector {
59
62
  readonly y: number
60
63
  }
61
64
 
65
+ /** A rectangle in CSS pixels relative to the element's top-left corner. */
62
66
  export interface Rect {
63
67
  readonly left: number
64
68
  readonly top: number
@@ -66,15 +70,19 @@ export interface Rect {
66
70
  readonly height: number
67
71
  }
68
72
 
69
- export interface StickZone {
73
+ /** The floating stick of a {@link Layout}. */
74
+ export interface StickZone<V extends string = never> {
70
75
  /** Where a touch starts the stick. The stick is centered where the thumb lands. */
71
76
  readonly region: Rect
72
77
  /** Drag distance in CSS pixels for full tilt. */
73
78
  readonly radius: number
74
79
  /** Fraction of `radius` that reads as zero. Defaults to 0.15. */
75
80
  readonly deadZone?: number
81
+ /** The vector action the stick drives (`"move"`), reported in the snapshot's `vectors`. */
82
+ readonly vector?: V
76
83
  }
77
84
 
85
+ /** A round on-screen button of a {@link Layout}; it reports the action named like its key. */
78
86
  export interface ButtonZone {
79
87
  readonly center: Position
80
88
  /** Touch radius in CSS pixels. */
@@ -85,13 +93,18 @@ export interface ButtonZone {
85
93
  readonly aimDeadZone?: number
86
94
  }
87
95
 
96
+ /** The swipe area of a {@link Layout}. */
88
97
  export interface SwipeZone {
89
98
  /** Where a touch that is not on a button or the stick starts a swipe. */
90
99
  readonly region: Rect
91
100
  }
92
101
 
93
- export interface Layout<B extends string> {
94
- readonly stick: StickZone
102
+ /**
103
+ * The touch controls, returned by the layout function {@link actions} calls
104
+ * with the element's size. The button names are fixed by the first layout.
105
+ */
106
+ export interface Layout<B extends string, V extends string = never> {
107
+ readonly stick: StickZone<V>
95
108
  readonly buttons: { readonly [K in B]: ButtonZone }
96
109
  /** Optional: fingers here report their movement, for turning or looking around. */
97
110
  readonly swipe?: SwipeZone
@@ -103,6 +116,7 @@ export interface Size {
103
116
  readonly height: number
104
117
  }
105
118
 
119
+ /** The stick's state in a snapshot. */
106
120
  export interface Stick {
107
121
  /** Whether a thumb is on the stick. */
108
122
  readonly active: boolean
@@ -110,6 +124,7 @@ export interface Stick {
110
124
  readonly vector: Vector
111
125
  }
112
126
 
127
+ /** One button's state: its action's edges, plus the drag aim and whether the release was cancelled. */
113
128
  export interface ButtonState extends ActionState {
114
129
  /**
115
130
  * The drag from the button's center as a fraction of `aimRadius` (length at
@@ -121,6 +136,7 @@ export interface ButtonState extends ActionState {
121
136
  readonly cancelled: boolean
122
137
  }
123
138
 
139
+ /** The swipe area's state in a snapshot. */
124
140
  export interface Swipe {
125
141
  /** Whether a finger is on the swipe area. */
126
142
  readonly active: boolean
@@ -128,27 +144,57 @@ export interface Swipe {
128
144
  readonly delta: Vector
129
145
  }
130
146
 
131
- export interface Snapshot<B extends string> {
147
+ /**
148
+ * The controls' state. Each button is an action named like it, and the
149
+ * stick drives the vector action its layout names, so the snapshot has the
150
+ * same core as the other devices' (`actions`, `vectors`, and `used`) and
151
+ * `Controls` can merge it.
152
+ */
153
+ export interface Snapshot<B extends string, V extends string = never> {
132
154
  readonly stick: Stick
133
- readonly buttons: { readonly [K in B]: ButtonState }
155
+ readonly actions: { readonly [K in B]: ButtonState }
156
+ /** The vector action the stick drives, when the layout names one: the stick's vector. */
157
+ readonly vectors: { readonly [K in V]: Vector }
134
158
  readonly swipe: Swipe
135
- /** Whether any tracked touch has been seen, for switching controls on. */
136
- readonly touched: boolean
159
+ /**
160
+ * A button was pressed in this window or is held, or a finger is on the
161
+ * stick or the swipe area: the player is using the touch controls.
162
+ */
163
+ readonly used: boolean
137
164
  }
138
165
 
166
+ const snapshotOf = <B extends string, V extends string>(
167
+ vector: V | undefined,
168
+ stick: Stick,
169
+ actions: { readonly [K in B]: ButtonState },
170
+ swipe: Swipe
171
+ ): Snapshot<B, V> => ({
172
+ stick,
173
+ actions,
174
+ vectors: (vector === undefined ? {} : { [vector]: stick.vector }) as { readonly [K in V]: Vector },
175
+ swipe,
176
+ used: stick.active || swipe.active || anyUsed(actions)
177
+ })
178
+
139
179
  /** Live control positions for drawing, in CSS pixels. Reading it consumes nothing. */
140
- export interface View<B extends string> {
141
- readonly layout: Layout<B>
180
+ export interface View<B extends string, V extends string = never> {
181
+ readonly layout: Layout<B, V>
142
182
  /** Where the stick was centered and where the thumb is, while it is held. */
143
183
  readonly stick: { readonly origin: Position; readonly knob: Position } | null
144
184
  readonly buttons: { readonly [K in B]: { readonly held: boolean; readonly aim: Vector | null } }
145
185
  }
146
186
 
147
- export interface Touch<B extends string> {
187
+ /**
188
+ * Touch controls, built with {@link actions} (live) or {@link scripted}.
189
+ * Capture them with `InputCapture.system(...)`, or merge them with
190
+ * `Controls` (through `lacking(...)` for actions with no on-screen
191
+ * control). There is no `rebind`: the layout is the binding.
192
+ */
193
+ export interface Actions<B extends string, V extends string = never> extends System.Consumes<"snapshot"> {
148
194
  /** Returns the controls' state and starts a new press/release window. */
149
- snapshot(): Snapshot<B>
195
+ snapshot(): Snapshot<B, V>
150
196
  /** The live state for drawing. */
151
- view(): View<B>
197
+ view(): View<B, V>
152
198
  /** Removes the event listeners and restores the element's `touch-action`. */
153
199
  dispose(): void
154
200
  }
@@ -177,7 +223,8 @@ export interface BlurHost {
177
223
  removeEventListener(type: "blur", listener: () => void): void
178
224
  }
179
225
 
180
- export interface TrackOptions {
226
+ /** Options for {@link actions}. */
227
+ export interface ActionsOptions {
181
228
  /** `PointerEvent.pointerType`s that drive the controls. Defaults to `["touch"]`. */
182
229
  readonly pointerTypes?: ReadonlyArray<string>
183
230
  }
@@ -186,13 +233,25 @@ const zero: Vector = { x: 0, y: 0 }
186
233
 
187
234
  const released = (): ButtonState => ({ held: false, pressed: false, released: false, aim: null, cancelled: false })
188
235
 
189
- /** A snapshot with nothing held, for initializing an input resource before the first capture. */
190
- export const idle = <const B extends string>(buttons: ReadonlyArray<B>): Snapshot<B> => ({
191
- stick: { active: false, vector: zero },
192
- buttons: Object.fromEntries(buttons.map((button) => [button, released()])) as { readonly [K in B]: ButtonState },
193
- swipe: { active: false, delta: zero },
194
- touched: false
195
- })
236
+ /** The names a layout's controls report, for {@link idle} and {@link scripted}. */
237
+ export interface Names<B extends string, V extends string = never> {
238
+ readonly buttons: ReadonlyArray<B>
239
+ /** The vector action the stick drives, as the layout's `stick.vector`. */
240
+ readonly vector?: V
241
+ }
242
+
243
+ /**
244
+ * A snapshot with nothing held, for initializing an input resource before
245
+ * the first capture: pass the layout's button names and stick vector, as
246
+ * `Touch.idle({ buttons: ["attack", "dash"], vector: "move" })`.
247
+ */
248
+ export const idle = <const B extends string, const V extends string = never>(names: Names<B, V>): Snapshot<B, V> =>
249
+ snapshotOf<B, V>(
250
+ names.vector,
251
+ { active: false, vector: zero },
252
+ Object.fromEntries(names.buttons.map((button) => [button, released()])) as { readonly [K in B]: ButtonState },
253
+ { active: false, delta: zero }
254
+ )
196
255
 
197
256
  /** `offset / radius`, clamped to length 1, with the inner `deadZone` fraction mapped to zero. */
198
257
  const tilt = (offset: Position, radius: number, deadZone: number): Vector => {
@@ -261,14 +320,15 @@ const lift = (track: ButtonTrack, cancelled: boolean): void => {
261
320
  * Starts tracking touches over `target` with the controls `layout` returns
262
321
  * for the element's current size (it is read again on every new touch, so a
263
322
  * resize takes effect at the next touch). `blurHost` (usually `window`)
264
- * cancels held controls when the page loses focus.
323
+ * cancels held controls when the page loses focus. Use `Pointer.actions`
324
+ * for mouse and pen input.
265
325
  */
266
- export const track = <const B extends string>(
326
+ export const actions = <const B extends string, const V extends string = never>(
267
327
  target: TouchTarget,
268
328
  blurHost: BlurHost,
269
- layout: (size: Size) => Layout<B>,
270
- options: TrackOptions = {}
271
- ): Touch<B> => {
329
+ layout: (size: Size) => Layout<B, V>,
330
+ options: ActionsOptions = {}
331
+ ): Actions<B, V> => {
272
332
  const pointerTypes = options.pointerTypes ?? ["touch"]
273
333
  const sizeOf = (): Size => {
274
334
  const rect = target.getBoundingClientRect()
@@ -277,7 +337,6 @@ export const track = <const B extends string>(
277
337
  let current = layout(sizeOf())
278
338
  const names = Object.keys(current.buttons) as Array<B>
279
339
  const buttons = makeButtonTracks(names)
280
- let touched = false
281
340
  let stick: { readonly pointerId: number; readonly origin: Position; knob: Position } | undefined
282
341
  /** Which button each finger holds. */
283
342
  const fingers = new Map<number, B>()
@@ -298,7 +357,6 @@ export const track = <const B extends string>(
298
357
  // A pointer id that comes down again without an up in between (the up
299
358
  // was lost) releases what it held first, as a cancel.
300
359
  end(event, true)
301
- touched = true
302
360
  current = layout(sizeOf())
303
361
  const at = locate(event)
304
362
  for (const name of names) {
@@ -382,12 +440,12 @@ export const track = <const B extends string>(
382
440
 
383
441
  return {
384
442
  snapshot() {
385
- const snapshot: Snapshot<B> = {
386
- stick: stickState(),
387
- buttons: Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
388
- swipe: { active: swipe !== undefined, delta: swiped },
389
- touched
390
- }
443
+ const snapshot = snapshotOf<B, V>(
444
+ current.stick.vector,
445
+ stickState(),
446
+ Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
447
+ { active: swipe !== undefined, delta: swiped }
448
+ )
391
449
  for (const button of buttons.values()) startWindow(button)
392
450
  swiped = zero
393
451
  return snapshot
@@ -407,7 +465,7 @@ export const track = <const B extends string>(
407
465
  buttons: Object.fromEntries(names.map((name) => {
408
466
  const button = buttons.get(name)!
409
467
  return [name, { held: button.held, aim: button.held ? button.aim : null }]
410
- })) as View<B>["buttons"]
468
+ })) as View<B, V>["buttons"]
411
469
  }
412
470
  },
413
471
  dispose() {
@@ -445,17 +503,18 @@ export interface TimelineEntry<B extends string> {
445
503
  export type Timeline<B extends string> = ReadonlyArray<TimelineEntry<B>>
446
504
 
447
505
  /** Touch controls driven by a timeline instead of an element. */
448
- export interface Scripted<B extends string> extends Touch<B> {
506
+ export interface Scripted<B extends string, V extends string = never> extends Actions<B, V> {
449
507
  /** Number of snapshots taken so far, which is the next frame index. */
450
508
  readonly frames: () => number
451
509
  }
452
510
 
453
511
  /**
454
- * Plays `timeline` over controls named `buttons`: before the snapshot at index
455
- * `frame`, every entry for that frame is applied. Vectors longer than 1 are
456
- * clamped. Scripted controls report `touched` from the first entry on.
512
+ * Plays `timeline` over controls with `names` (the buttons, and the vector
513
+ * action the stick drives): before the snapshot at index `frame`, every
514
+ * entry for that frame is applied. Vectors longer than 1 are clamped.
457
515
  */
458
- export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, timeline: Timeline<NoInfer<B>>): Scripted<B> => {
516
+ export const scripted = <const B extends string, const V extends string = never>(names: Names<B, V>, timeline: Timeline<NoInfer<B>>): Scripted<B, V> => {
517
+ const buttons = names.buttons
459
518
  const byFrame = new Map<number, Array<TimelineEntry<B>>>()
460
519
  for (const entry of timeline) {
461
520
  const entries = byFrame.get(entry.frame)
@@ -468,10 +527,9 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
468
527
  }
469
528
  const tracks = makeButtonTracks(buttons)
470
529
  let stick: Vector | null = null
471
- let touched = false
472
530
  let frame = 0
473
- const layout: Layout<B> = {
474
- stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1 },
531
+ const layout: Layout<B, V> = {
532
+ stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1, ...(names.vector === undefined ? {} : { vector: names.vector }) },
475
533
  buttons: Object.fromEntries(buttons.map((button) => [button, { center: zero, radius: 0 }])) as Layout<B>["buttons"]
476
534
  }
477
535
  return {
@@ -479,7 +537,6 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
479
537
  for (const track of tracks.values()) startWindow(track)
480
538
  const swipe = { active: false, x: 0, y: 0 }
481
539
  for (const entry of byFrame.get(frame) ?? []) {
482
- touched = true
483
540
  if (entry.swipe !== undefined) {
484
541
  swipe.active = true
485
542
  swipe.x += entry.swipe.x
@@ -495,12 +552,12 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
495
552
  for (const name of entry.cancel ?? []) lift(tracks.get(name)!, true)
496
553
  }
497
554
  frame += 1
498
- return {
499
- stick: { active: stick !== null, vector: stick ?? zero },
500
- buttons: Object.fromEntries(buttons.map((name) => [name, buttonSnapshot(tracks.get(name)!)])) as { readonly [K in B]: ButtonState },
501
- swipe: { active: swipe.active, delta: { x: swipe.x, y: swipe.y } },
502
- touched
503
- }
555
+ return snapshotOf<B, V>(
556
+ names.vector,
557
+ { active: stick !== null, vector: stick ?? zero },
558
+ Object.fromEntries(buttons.map((name) => [name, buttonSnapshot(tracks.get(name)!)])) as { readonly [K in B]: ButtonState },
559
+ { active: swipe.active, delta: { x: swipe.x, y: swipe.y } }
560
+ )
504
561
  },
505
562
  view() {
506
563
  return {
package/src/index.ts CHANGED
@@ -1,9 +1,18 @@
1
- /** Browser host integrations kept outside the renderer-agnostic ECS core. */
1
+ /**
2
+ * Browser host integrations kept outside the renderer-agnostic ECS core:
3
+ * animation-frame timing (`BrowserTickSource`, `BrowserHostClock`), input
4
+ * devices bound to actions (`Keyboard`, `Gamepad`, `Pointer`, `Touch`),
5
+ * merging and local multiplayer (`Controls`), and the system that copies
6
+ * input into a resource (`InputCapture`).
7
+ */
2
8
  export * as BrowserHostClock from "./BrowserHostClock.ts"
3
9
  export * as BrowserTickSource from "./BrowserTickSource.ts"
10
+ export * as Controls from "./Controls.ts"
11
+ export * as Gamepad from "./Gamepad.ts"
4
12
  export * as InputCapture from "./InputCapture.ts"
5
13
  export * as Keyboard from "./Keyboard.ts"
6
14
  export * as Pointer from "./Pointer.ts"
7
15
  export * as Touch from "./Touch.ts"
8
16
 
17
+ /** The package's tag, `"browser"`. */
9
18
  export const packageTag = "browser" as const
@@ -0,0 +1,222 @@
1
+ import * as Result from "@typeonce/ritmo/Result"
2
+
3
+ import type { ActionState } from "../Keyboard.ts"
4
+ import { directionName, directions, isVectorBinding, zero } from "./vectors.ts"
5
+ import type { Direction, Vector } from "./vectors.ts"
6
+
7
+ const idleState: ActionState = { held: false, pressed: false, released: false }
8
+
9
+ /** Action name to the buttons that trigger it, for a device with named buttons. */
10
+ type ButtonBindings<Button extends string> = Readonly<Record<string, readonly [Button, ...Array<Button>]>>
11
+
12
+ /**
13
+ * The held buttons, the bound actions, and the edges since the previous
14
+ * take, shared by devices with named buttons (`Gamepad`, `Pointer`) and by
15
+ * their live and scripted forms, so all of them build snapshots the same way.
16
+ */
17
+ export const buttonTracker = <Button extends string, B extends ButtonBindings<Button>>(
18
+ buttonNames: ReadonlyArray<Button>,
19
+ initial: B
20
+ ) => {
21
+ // Fixed at creation: a rebind swaps buttons, never the actions.
22
+ const names = Object.keys(initial) as Array<keyof B & string>
23
+ let bindings: Readonly<Record<keyof B, ReadonlyArray<Button>>> = initial
24
+ const actionsByButton = new Map<Button, Array<keyof B & string>>()
25
+ const index = (): void => {
26
+ actionsByButton.clear()
27
+ for (const name of names) {
28
+ for (const button of bindings[name]) {
29
+ const bound = actionsByButton.get(button)
30
+ if (bound) bound.push(name)
31
+ else actionsByButton.set(button, [name])
32
+ }
33
+ }
34
+ }
35
+ index()
36
+ const held = new Set<Button>()
37
+ const pressed = new Set<Button>()
38
+ const released = new Set<Button>()
39
+ /**
40
+ * Each held button and the actions it holds: those bound to it when it
41
+ * went down that are still bound to it.
42
+ */
43
+ const holding = new Map<Button, Set<keyof B & string>>()
44
+ const pressedActions = new Set<keyof B>()
45
+ const releasedActions = new Set<keyof B>()
46
+ const isHeld = (name: keyof B & string): boolean => bindings[name].some((button) => holding.get(button)?.has(name) === true)
47
+ const up = (button: Button): void => {
48
+ if (!held.delete(button)) return
49
+ released.add(button)
50
+ for (const name of holding.get(button) ?? []) releasedActions.add(name)
51
+ holding.delete(button)
52
+ }
53
+ return {
54
+ held,
55
+ down(button: Button) {
56
+ if (held.has(button)) return
57
+ held.add(button)
58
+ pressed.add(button)
59
+ const bound = actionsByButton.get(button) ?? []
60
+ holding.set(button, new Set(bound))
61
+ for (const name of bound) pressedActions.add(name)
62
+ },
63
+ up,
64
+ upAll() {
65
+ for (const button of [...held]) up(button)
66
+ },
67
+ rebind(next: Readonly<Record<keyof B, ReadonlyArray<Button>>>) {
68
+ const before = names.filter(isHeld)
69
+ bindings = next
70
+ index()
71
+ // A held button keeps only the actions still bound to it; new ones wait for a fresh press.
72
+ for (const [button, actions] of holding) {
73
+ const bound = actionsByButton.get(button) ?? []
74
+ for (const name of actions) if (!bound.includes(name)) actions.delete(name)
75
+ }
76
+ for (const name of before) if (!isHeld(name)) releasedActions.add(name)
77
+ },
78
+ /** The buttons and actions since the previous take; starts a new press/release window. */
79
+ take(): { readonly actions: Record<keyof B, ActionState>; readonly buttons: Record<Button, ActionState> } {
80
+ const buttons = {} as Record<Button, ActionState>
81
+ for (const name of buttonNames) {
82
+ const isDown = held.has(name)
83
+ buttons[name] = isDown || pressed.has(name) || released.has(name)
84
+ ? { held: isDown, pressed: pressed.has(name), released: released.has(name) && !isDown }
85
+ : idleState
86
+ }
87
+ const actions = {} as Record<keyof B, ActionState>
88
+ for (const name of names) {
89
+ const isDown = isHeld(name)
90
+ actions[name] = { held: isDown, pressed: pressedActions.has(name), released: releasedActions.has(name) && !isDown }
91
+ }
92
+ pressed.clear()
93
+ released.clear()
94
+ pressedActions.clear()
95
+ releasedActions.clear()
96
+ return { actions, buttons }
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Every button idle, by name. */
102
+ export const idleButtons = <Button extends string>(buttonNames: ReadonlyArray<Button>): Record<Button, ActionState> => {
103
+ const buttons = {} as Record<Button, ActionState>
104
+ for (const name of buttonNames) buttons[name] = idleState
105
+ return buttons
106
+ }
107
+
108
+ /** Every button action of `bindings` idle (vector actions are in {@link idleVectors}). */
109
+ export const idleActions = <B extends object>(bindings: B): Record<keyof B, ActionState> => {
110
+ const actions = {} as Record<keyof B, ActionState>
111
+ for (const [name, entry] of Object.entries(bindings)) if (!isVectorBinding(entry)) actions[name as keyof B] = idleState
112
+ return actions
113
+ }
114
+
115
+ /** Every vector action of `bindings` at rest. */
116
+ export const idleVectors = (bindings: object): Record<string, Vector> => {
117
+ const vectors: Record<string, Vector> = {}
118
+ for (const [name, entry] of Object.entries(bindings)) if (isVectorBinding(entry)) vectors[name] = zero
119
+ return vectors
120
+ }
121
+
122
+ /** A vector binding's digital part: buttons for the four directions, or none. */
123
+ interface DigitalVector<Button extends string> {
124
+ readonly _tag: "Vector"
125
+ readonly up?: ReadonlyArray<Button>
126
+ readonly down?: ReadonlyArray<Button>
127
+ readonly left?: ReadonlyArray<Button>
128
+ readonly right?: ReadonlyArray<Button>
129
+ }
130
+
131
+ /**
132
+ * Each action's buttons for the button tracker, with the digital part of
133
+ * every vector action split into its four directions (`move.up`, ...).
134
+ */
135
+ export const flattenButtons = <Button extends string>(bindings: object): Record<string, readonly [Button, ...Array<Button>]> => {
136
+ const flat: Record<string, readonly [Button, ...Array<Button>]> = {}
137
+ for (const [name, entry] of Object.entries(bindings)) {
138
+ if (isVectorBinding(entry)) {
139
+ const vector = entry as DigitalVector<Button>
140
+ // All four, empty when unbound (a stick-only vector), so the tracker's
141
+ // names stay the same whichever way a rebind binds the vector.
142
+ for (const direction of directions) {
143
+ flat[directionName(name, direction)] = (vector[direction] ?? []) as unknown as readonly [Button, ...Array<Button>]
144
+ }
145
+ } else flat[name] = entry as readonly [Button, ...Array<Button>]
146
+ }
147
+ return flat
148
+ }
149
+
150
+ /** Splits taken flat actions into the button actions of `bindings`, and whether each vector direction is held. */
151
+ export const splitActions = (
152
+ bindings: object,
153
+ taken: Readonly<Record<string, ActionState>>
154
+ ): { readonly actions: Record<string, ActionState>; readonly held: (vector: string, direction: Direction) => boolean } => {
155
+ const actions: Record<string, ActionState> = {}
156
+ for (const [name, entry] of Object.entries(bindings)) if (!isVectorBinding(entry)) actions[name] = taken[name]!
157
+ return { actions, held: (vector, direction) => taken[directionName(vector, direction)]?.held === true }
158
+ }
159
+
160
+ /** Why loaded data is not a rebinding; the devices export it under their own name. */
161
+ export interface InvalidRebinding {
162
+ readonly _tag: "InvalidRebinding"
163
+ readonly path: string
164
+ readonly message: string
165
+ }
166
+
167
+ /**
168
+ * Validates `data` as new buttons for exactly the actions of `bindings`:
169
+ * an object with the same action names, each a non-empty array of button
170
+ * names.
171
+ */
172
+ export const parseButtonRebinding = <Button extends string>(
173
+ buttonNames: ReadonlyArray<Button>,
174
+ bindings: object,
175
+ data: unknown,
176
+ sticks: ReadonlyArray<string> = []
177
+ ): Result.Result<Readonly<Record<string, unknown>>, InvalidRebinding> => {
178
+ const invalid = (path: string, message: string) => Result.failure<InvalidRebinding>({ _tag: "InvalidRebinding", path, message })
179
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return invalid("", "expected an object of action names")
180
+ const fields = data as Readonly<Record<string, unknown>>
181
+ const names = Object.keys(bindings)
182
+ for (const name of Object.keys(fields)) {
183
+ if (!names.includes(name)) return invalid(name, `unknown action, expected one of ${names.join(", ")}`)
184
+ }
185
+ const known: ReadonlySet<string> = new Set(buttonNames)
186
+ const buttonsAt = (value: unknown, path: string): Result.Result<ReadonlyArray<Button>, InvalidRebinding> => {
187
+ if (!Array.isArray(value) || value.length === 0) return invalid(path, "expected a non-empty array of button names")
188
+ for (let offset = 0; offset < value.length; offset++) {
189
+ if (!known.has(value[offset])) return invalid(`${path}[${offset}]`, `expected one of ${buttonNames.join(", ")}`)
190
+ }
191
+ return Result.success([...value])
192
+ }
193
+ const rebinding: Record<string, unknown> = {}
194
+ for (const name of names) {
195
+ const value = fields[name]
196
+ if (isVectorBinding((bindings as Readonly<Record<string, unknown>>)[name])) {
197
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
198
+ return invalid(name, "expected { stick } and/or { up, down, left, right } buttons for a vector action")
199
+ }
200
+ const parts = value as Readonly<Record<string, unknown>>
201
+ const vector: Record<string, unknown> = { _tag: "Vector" }
202
+ if (parts["stick"] !== undefined) {
203
+ if (typeof parts["stick"] !== "string" || !sticks.includes(parts["stick"])) return invalid(`${name}.stick`, `expected one of ${sticks.join(", ")}`)
204
+ vector["stick"] = parts["stick"]
205
+ }
206
+ const given = directions.filter((direction) => parts[direction] !== undefined)
207
+ if (given.length > 0) {
208
+ for (const direction of directions) {
209
+ const buttons = buttonsAt(parts[direction], `${name}.${direction}`)
210
+ if (!buttons.ok) return buttons
211
+ vector[direction] = buttons.value
212
+ }
213
+ } else if (vector["stick"] === undefined) return invalid(name, "expected a stick or buttons for up, down, left, and right")
214
+ rebinding[name] = vector
215
+ continue
216
+ }
217
+ const buttons = buttonsAt(value, name)
218
+ if (!buttons.ok) return buttons
219
+ rebinding[name] = buttons.value
220
+ }
221
+ return Result.success(rebinding)
222
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The keys a keyboard device binds now, as they read in a lint (`" "`,
3
+ * `KeyW`): what a player pool compares to report two players sharing a key.
4
+ * Live and scripted keyboards implement it.
5
+ */
6
+ export const boundKeys = Symbol("ritmo-browser/boundKeys")
7
+
8
+ export interface ReportsBoundKeys {
9
+ readonly [boundKeys]?: () => ReadonlyArray<string>
10
+ }
11
+
12
+ /** Every source a device was handed to (a merge, a pool), to report a device used twice. */
13
+ const owners = new WeakMap<object, Array<string>>()
14
+
15
+ /** Records that `owner` takes the snapshots of `device`. */
16
+ export const claim = (device: object, owner: string): void => {
17
+ const list = owners.get(device)
18
+ if (list === undefined) owners.set(device, [owner])
19
+ else list.push(owner)
20
+ }
21
+
22
+ /** The sources that take the snapshots of `device`. */
23
+ export const ownersOf = (device: object): ReadonlyArray<string> => owners.get(device) ?? []
@@ -0,0 +1,14 @@
1
+ import type { ActionState } from "../Keyboard.ts"
2
+
3
+ /**
4
+ * Whether any action in a snapshot was pressed or is held: the `used` of a
5
+ * device's snapshot. Releases do not count, since a blur or a disconnect
6
+ * releases everything without the player touching the device.
7
+ */
8
+ export const anyUsed = (actions: Readonly<Record<string, ActionState>>): boolean => {
9
+ for (const name in actions) {
10
+ const state = actions[name]!
11
+ if (state.held || state.pressed) return true
12
+ }
13
+ return false
14
+ }
@@ -0,0 +1,36 @@
1
+ /** A direction and strength, with length at most 1. `y` grows down the screen. */
2
+ export interface Vector {
3
+ readonly x: number
4
+ readonly y: number
5
+ }
6
+
7
+ /** The four directions a digital vector binding names. */
8
+ export const directions = ["up", "down", "left", "right"] as const
9
+
10
+ /** One direction of a vector action; a keyboard timeline names it as `"move.right"`. */
11
+ export type Direction = (typeof directions)[number]
12
+
13
+ export const zero: Vector = { x: 0, y: 0 }
14
+
15
+ /** The internal action name of one direction of a vector, for timelines and the button trackers. */
16
+ export const directionName = (vector: string, direction: Direction): string => `${vector}.${direction}`
17
+
18
+ /**
19
+ * The vector four held directions give: opposite directions cancel and a
20
+ * diagonal has length 1, so it is not faster than a straight line.
21
+ */
22
+ export const digital = (up: boolean, down: boolean, left: boolean, right: boolean): Vector => {
23
+ const x = (right ? 1 : 0) - (left ? 1 : 0)
24
+ const y = (down ? 1 : 0) - (up ? 1 : 0)
25
+ if (x === 0 && y === 0) return zero
26
+ return x !== 0 && y !== 0 ? { x: x * Math.SQRT1_2, y: y * Math.SQRT1_2 } : { x, y }
27
+ }
28
+
29
+ const lengthSquared = (vector: Vector): number => vector.x * vector.x + vector.y * vector.y
30
+
31
+ /** The longer of two vectors, the first on a tie: how vectors from several sources combine. */
32
+ export const longer = (a: Vector, b: Vector): Vector => lengthSquared(b) > lengthSquared(a) ? b : a
33
+
34
+ /** Whether a binding entry is a vector binding (`Keyboard.vector`, `Gamepad.vector`). */
35
+ export const isVectorBinding = (entry: unknown): entry is { readonly _tag: "Vector" } =>
36
+ typeof entry === "object" && entry !== null && !Array.isArray(entry) && (entry as { readonly _tag?: unknown })._tag === "Vector"