@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/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,15 +38,18 @@
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
  */
48
- import type { ActionState } from "./Keyboard.ts"
49
+ import type * as System from "@typeonce/ritmo/System"
50
+ import { anyUsed } from "./internal/used.ts"
51
+ import type { ActionState } from "./internal/state.ts"
52
+ import type { Vector } from "./internal/vectors.ts"
49
53
 
50
54
  /** A position in CSS pixels relative to the element's top-left corner. */
51
55
  export interface Position {
@@ -53,12 +57,7 @@ export interface Position {
53
57
  readonly y: number
54
58
  }
55
59
 
56
- /** A direction and strength, with length at most 1. `y` grows down the screen. */
57
- export interface Vector {
58
- readonly x: number
59
- readonly y: number
60
- }
61
-
60
+ /** A rectangle in CSS pixels relative to the element's top-left corner. */
62
61
  export interface Rect {
63
62
  readonly left: number
64
63
  readonly top: number
@@ -66,15 +65,19 @@ export interface Rect {
66
65
  readonly height: number
67
66
  }
68
67
 
69
- export interface StickZone {
68
+ /** The floating stick of a {@link Layout}. */
69
+ export interface StickZone<V extends string = never> {
70
70
  /** Where a touch starts the stick. The stick is centered where the thumb lands. */
71
71
  readonly region: Rect
72
72
  /** Drag distance in CSS pixels for full tilt. */
73
73
  readonly radius: number
74
74
  /** Fraction of `radius` that reads as zero. Defaults to 0.15. */
75
75
  readonly deadZone?: number
76
+ /** The vector action the stick drives (`"move"`), reported in the snapshot's `vectors`. */
77
+ readonly vector?: V
76
78
  }
77
79
 
80
+ /** A round on-screen button of a {@link Layout}; it reports the action named like its key. */
78
81
  export interface ButtonZone {
79
82
  readonly center: Position
80
83
  /** Touch radius in CSS pixels. */
@@ -85,13 +88,18 @@ export interface ButtonZone {
85
88
  readonly aimDeadZone?: number
86
89
  }
87
90
 
91
+ /** The swipe area of a {@link Layout}. */
88
92
  export interface SwipeZone {
89
93
  /** Where a touch that is not on a button or the stick starts a swipe. */
90
94
  readonly region: Rect
91
95
  }
92
96
 
93
- export interface Layout<B extends string> {
94
- readonly stick: StickZone
97
+ /**
98
+ * The touch controls, returned by the layout function {@link actions} calls
99
+ * with the element's size. The button names are fixed by the first layout.
100
+ */
101
+ export interface Layout<B extends string, V extends string = never> {
102
+ readonly stick: StickZone<V>
95
103
  readonly buttons: { readonly [K in B]: ButtonZone }
96
104
  /** Optional: fingers here report their movement, for turning or looking around. */
97
105
  readonly swipe?: SwipeZone
@@ -103,6 +111,7 @@ export interface Size {
103
111
  readonly height: number
104
112
  }
105
113
 
114
+ /** The stick's state in a snapshot. */
106
115
  export interface Stick {
107
116
  /** Whether a thumb is on the stick. */
108
117
  readonly active: boolean
@@ -110,6 +119,7 @@ export interface Stick {
110
119
  readonly vector: Vector
111
120
  }
112
121
 
122
+ /** One button's state: its action's edges, plus the drag aim and whether the release was cancelled. */
113
123
  export interface ButtonState extends ActionState {
114
124
  /**
115
125
  * The drag from the button's center as a fraction of `aimRadius` (length at
@@ -121,6 +131,7 @@ export interface ButtonState extends ActionState {
121
131
  readonly cancelled: boolean
122
132
  }
123
133
 
134
+ /** The swipe area's state in a snapshot. */
124
135
  export interface Swipe {
125
136
  /** Whether a finger is on the swipe area. */
126
137
  readonly active: boolean
@@ -128,27 +139,57 @@ export interface Swipe {
128
139
  readonly delta: Vector
129
140
  }
130
141
 
131
- export interface Snapshot<B extends string> {
142
+ /**
143
+ * The controls' state. Each button is an action named like it, and the
144
+ * stick drives the vector action its layout names, so the snapshot has the
145
+ * same core as the other devices' (`actions`, `vectors`, and `used`) and
146
+ * `Controls` can merge it.
147
+ */
148
+ export interface Snapshot<B extends string, V extends string = never> {
132
149
  readonly stick: Stick
133
- readonly buttons: { readonly [K in B]: ButtonState }
150
+ readonly actions: { readonly [K in B]: ButtonState }
151
+ /** The vector action the stick drives, when the layout names one: the stick's vector. */
152
+ readonly vectors: { readonly [K in V]: Vector }
134
153
  readonly swipe: Swipe
135
- /** Whether any tracked touch has been seen, for switching controls on. */
136
- readonly touched: boolean
154
+ /**
155
+ * A button was pressed in this window or is held, or a finger is on the
156
+ * stick or the swipe area: the player is using the touch controls.
157
+ */
158
+ readonly used: boolean
137
159
  }
138
160
 
161
+ const snapshotOf = <B extends string, V extends string>(
162
+ vector: V | undefined,
163
+ stick: Stick,
164
+ actions: { readonly [K in B]: ButtonState },
165
+ swipe: Swipe
166
+ ): Snapshot<B, V> => ({
167
+ stick,
168
+ actions,
169
+ vectors: (vector === undefined ? {} : { [vector]: stick.vector }) as { readonly [K in V]: Vector },
170
+ swipe,
171
+ used: stick.active || swipe.active || anyUsed(actions)
172
+ })
173
+
139
174
  /** Live control positions for drawing, in CSS pixels. Reading it consumes nothing. */
140
- export interface View<B extends string> {
141
- readonly layout: Layout<B>
175
+ export interface View<B extends string, V extends string = never> {
176
+ readonly layout: Layout<B, V>
142
177
  /** Where the stick was centered and where the thumb is, while it is held. */
143
178
  readonly stick: { readonly origin: Position; readonly knob: Position } | null
144
179
  readonly buttons: { readonly [K in B]: { readonly held: boolean; readonly aim: Vector | null } }
145
180
  }
146
181
 
147
- export interface Touch<B extends string> {
182
+ /**
183
+ * Touch controls, built with {@link actions} (live) or {@link scripted}.
184
+ * Capture them with `InputCapture.system(...)`, or merge them with
185
+ * `Controls` (through `lacking(...)` for actions with no on-screen
186
+ * control). There is no `rebind`: the layout is the binding.
187
+ */
188
+ export interface Actions<B extends string, V extends string = never> extends System.Consumes<"snapshot"> {
148
189
  /** Returns the controls' state and starts a new press/release window. */
149
- snapshot(): Snapshot<B>
190
+ snapshot(): Snapshot<B, V>
150
191
  /** The live state for drawing. */
151
- view(): View<B>
192
+ view(): View<B, V>
152
193
  /** Removes the event listeners and restores the element's `touch-action`. */
153
194
  dispose(): void
154
195
  }
@@ -177,7 +218,8 @@ export interface BlurHost {
177
218
  removeEventListener(type: "blur", listener: () => void): void
178
219
  }
179
220
 
180
- export interface TrackOptions {
221
+ /** Options for {@link actions}. */
222
+ export interface ActionsOptions {
181
223
  /** `PointerEvent.pointerType`s that drive the controls. Defaults to `["touch"]`. */
182
224
  readonly pointerTypes?: ReadonlyArray<string>
183
225
  }
@@ -186,13 +228,25 @@ const zero: Vector = { x: 0, y: 0 }
186
228
 
187
229
  const released = (): ButtonState => ({ held: false, pressed: false, released: false, aim: null, cancelled: false })
188
230
 
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
- })
231
+ /** The names a layout's controls report, for {@link idle} and {@link scripted}. */
232
+ export interface Names<B extends string, V extends string = never> {
233
+ readonly buttons: ReadonlyArray<B>
234
+ /** The vector action the stick drives, as the layout's `stick.vector`. */
235
+ readonly vector?: V
236
+ }
237
+
238
+ /**
239
+ * A snapshot with nothing held, for initializing an input resource before
240
+ * the first capture: pass the layout's button names and stick vector, as
241
+ * `Touch.idle({ buttons: ["attack", "dash"], vector: "move" })`.
242
+ */
243
+ export const idle = <const B extends string, const V extends string = never>(names: Names<B, V>): Snapshot<B, V> =>
244
+ snapshotOf<B, V>(
245
+ names.vector,
246
+ { active: false, vector: zero },
247
+ Object.fromEntries(names.buttons.map((button) => [button, released()])) as { readonly [K in B]: ButtonState },
248
+ { active: false, delta: zero }
249
+ )
196
250
 
197
251
  /** `offset / radius`, clamped to length 1, with the inner `deadZone` fraction mapped to zero. */
198
252
  const tilt = (offset: Position, radius: number, deadZone: number): Vector => {
@@ -261,14 +315,15 @@ const lift = (track: ButtonTrack, cancelled: boolean): void => {
261
315
  * Starts tracking touches over `target` with the controls `layout` returns
262
316
  * for the element's current size (it is read again on every new touch, so a
263
317
  * resize takes effect at the next touch). `blurHost` (usually `window`)
264
- * cancels held controls when the page loses focus.
318
+ * cancels held controls when the page loses focus. Use `Pointer.actions`
319
+ * for mouse and pen input.
265
320
  */
266
- export const track = <const B extends string>(
321
+ export const actions = <const B extends string, const V extends string = never>(
267
322
  target: TouchTarget,
268
323
  blurHost: BlurHost,
269
- layout: (size: Size) => Layout<B>,
270
- options: TrackOptions = {}
271
- ): Touch<B> => {
324
+ layout: (size: Size) => Layout<B, V>,
325
+ options: ActionsOptions = {}
326
+ ): Actions<B, V> => {
272
327
  const pointerTypes = options.pointerTypes ?? ["touch"]
273
328
  const sizeOf = (): Size => {
274
329
  const rect = target.getBoundingClientRect()
@@ -277,7 +332,6 @@ export const track = <const B extends string>(
277
332
  let current = layout(sizeOf())
278
333
  const names = Object.keys(current.buttons) as Array<B>
279
334
  const buttons = makeButtonTracks(names)
280
- let touched = false
281
335
  let stick: { readonly pointerId: number; readonly origin: Position; knob: Position } | undefined
282
336
  /** Which button each finger holds. */
283
337
  const fingers = new Map<number, B>()
@@ -298,7 +352,6 @@ export const track = <const B extends string>(
298
352
  // A pointer id that comes down again without an up in between (the up
299
353
  // was lost) releases what it held first, as a cancel.
300
354
  end(event, true)
301
- touched = true
302
355
  current = layout(sizeOf())
303
356
  const at = locate(event)
304
357
  for (const name of names) {
@@ -382,12 +435,12 @@ export const track = <const B extends string>(
382
435
 
383
436
  return {
384
437
  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
- }
438
+ const snapshot = snapshotOf<B, V>(
439
+ current.stick.vector,
440
+ stickState(),
441
+ Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
442
+ { active: swipe !== undefined, delta: swiped }
443
+ )
391
444
  for (const button of buttons.values()) startWindow(button)
392
445
  swiped = zero
393
446
  return snapshot
@@ -407,7 +460,7 @@ export const track = <const B extends string>(
407
460
  buttons: Object.fromEntries(names.map((name) => {
408
461
  const button = buttons.get(name)!
409
462
  return [name, { held: button.held, aim: button.held ? button.aim : null }]
410
- })) as View<B>["buttons"]
463
+ })) as View<B, V>["buttons"]
411
464
  }
412
465
  },
413
466
  dispose() {
@@ -445,17 +498,18 @@ export interface TimelineEntry<B extends string> {
445
498
  export type Timeline<B extends string> = ReadonlyArray<TimelineEntry<B>>
446
499
 
447
500
  /** Touch controls driven by a timeline instead of an element. */
448
- export interface Scripted<B extends string> extends Touch<B> {
501
+ export interface Scripted<B extends string, V extends string = never> extends Actions<B, V> {
449
502
  /** Number of snapshots taken so far, which is the next frame index. */
450
503
  readonly frames: () => number
451
504
  }
452
505
 
453
506
  /**
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.
507
+ * Plays `timeline` over controls with `names` (the buttons, and the vector
508
+ * action the stick drives): before the snapshot at index `frame`, every
509
+ * entry for that frame is applied. Vectors longer than 1 are clamped.
457
510
  */
458
- export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, timeline: Timeline<NoInfer<B>>): Scripted<B> => {
511
+ export const scripted = <const B extends string, const V extends string = never>(names: Names<B, V>, timeline: Timeline<NoInfer<B>>): Scripted<B, V> => {
512
+ const buttons = names.buttons
459
513
  const byFrame = new Map<number, Array<TimelineEntry<B>>>()
460
514
  for (const entry of timeline) {
461
515
  const entries = byFrame.get(entry.frame)
@@ -468,10 +522,9 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
468
522
  }
469
523
  const tracks = makeButtonTracks(buttons)
470
524
  let stick: Vector | null = null
471
- let touched = false
472
525
  let frame = 0
473
- const layout: Layout<B> = {
474
- stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1 },
526
+ const layout: Layout<B, V> = {
527
+ stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1, ...(names.vector === undefined ? {} : { vector: names.vector }) },
475
528
  buttons: Object.fromEntries(buttons.map((button) => [button, { center: zero, radius: 0 }])) as Layout<B>["buttons"]
476
529
  }
477
530
  return {
@@ -479,7 +532,6 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
479
532
  for (const track of tracks.values()) startWindow(track)
480
533
  const swipe = { active: false, x: 0, y: 0 }
481
534
  for (const entry of byFrame.get(frame) ?? []) {
482
- touched = true
483
535
  if (entry.swipe !== undefined) {
484
536
  swipe.active = true
485
537
  swipe.x += entry.swipe.x
@@ -495,12 +547,12 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
495
547
  for (const name of entry.cancel ?? []) lift(tracks.get(name)!, true)
496
548
  }
497
549
  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
- }
550
+ return snapshotOf<B, V>(
551
+ names.vector,
552
+ { active: stick !== null, vector: stick ?? zero },
553
+ Object.fromEntries(buttons.map((name) => [name, buttonSnapshot(tracks.get(name)!)])) as { readonly [K in B]: ButtonState },
554
+ { active: swipe.active, delta: { x: swipe.x, y: swipe.y } }
555
+ )
504
556
  },
505
557
  view() {
506
558
  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 "./state.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,12 @@
1
+ /**
2
+ * One button action's state in a snapshot, the same on every device
3
+ * (`Keyboard`, `Gamepad`, `Pointer`, `Touch`) and in `Controls`.
4
+ */
5
+ export interface ActionState {
6
+ /** At least one bound key or button is down now. */
7
+ readonly held: boolean
8
+ /** A bound key or button went down since the previous snapshot, even if already released. */
9
+ readonly pressed: boolean
10
+ /** A bound key or button went up since the previous snapshot and none is held now. */
11
+ readonly released: boolean
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { ActionState } from "./state.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
+ }