@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/Pointer.ts CHANGED
@@ -1,54 +1,122 @@
1
1
  /**
2
- * Mouse and pen input over one element: position and button edges. Touches
3
- * are ignored by default; on-screen touch controls are `Touch`.
2
+ * Mouse and pen input over one element: bound actions, raw buttons, and the
3
+ * position. Touches are ignored by default; on-screen touch controls are
4
+ * `Touch`.
4
5
  *
5
- * Games that aim with the mouse read one snapshot per update, like keyboard
6
- * actions. The module owns the browser plumbing: element-relative
7
- * coordinates, press/release edges that survive clicks shorter than one
8
- * frame, pointer capture so a release outside the element is still seen,
9
- * suppressing the context menu, clearing buttons on window blur, and listener
10
- * cleanup. It does not know about the ECS; `InputCapture.system(...)` copies
11
- * snapshots from a service into a resource, directly or through an adapter
12
- * that merges pointer and keyboard snapshots.
6
+ * `Pointer.actions(target, window, bindings)` binds game actions to
7
+ * buttons, like `Keyboard.actions` and `Gamepad.actions`, so the pointer
8
+ * merges into `Controls` (group it with the keyboard there, so the two count
9
+ * as one device for `lastUsed` and `available`). Games that aim with the
10
+ * mouse read `position`. The module owns the browser plumbing:
11
+ * element-relative coordinates, press/release edges that survive clicks
12
+ * shorter than one frame, pointer capture so a release outside the element
13
+ * is still seen, suppressing the context menu, clearing buttons on window
14
+ * blur, and listener cleanup. It does not know about the ECS;
15
+ * `InputCapture.system(...)` copies snapshots into a resource.
13
16
  *
14
17
  * Positions are in CSS pixels relative to the element's top-left corner.
15
18
  * Converting them into world coordinates (camera, zoom, projection) is game
16
- * code.
19
+ * code. `rebind(...)` swaps the buttons of the same actions, and
20
+ * `parseRebinding(...)` validates saved ones.
21
+ *
22
+ * The wheel reports both ways games use it: `wheel` is how far it scrolled
23
+ * (zoom), and the `wheelUp`/`wheelDown`/`wheelLeft`/`wheelRight` buttons
24
+ * pulse once per notch, so a stepping action (next weapon) binds them and
25
+ * merges with a gamepad bumper. For mouse look, pass `lock: document`: a
26
+ * click locks the pointer to the element, `movement` keeps reporting raw
27
+ * motion, and `locked` turns `false` when the player presses Escape.
17
28
  *
18
29
  * @module Pointer
19
30
  * @docGroup browser
20
31
  *
21
32
  * @example
22
33
  * ```ts
23
- * const pointer = Pointer.track(canvas, window)
34
+ * const bindings = { fire: ["primary"], aim: ["secondary"], nextWeapon: ["wheelDown"] } as const
35
+ * const mouse = Pointer.actions(canvas, window, bindings, { lock: document })
36
+ *
37
+ * // Once per update (through InputCapture in a game):
38
+ * const input = mouse.snapshot()
39
+ * aimAt(input.position)
40
+ * if (input.actions.fire.pressed) fire()
41
+ *
42
+ * // Zoom with the wheel, look with a locked pointer:
43
+ * zoomBy(input.wheel.y)
44
+ * if (input.locked) turn(input.movement.x)
24
45
  *
25
- * // Once per update:
26
- * const snapshot = pointer.snapshot()
27
- * aimAt(snapshot.position)
28
- * if (snapshot.primary.held) fire()
46
+ * // Settings screen: bind the button the player clicks.
47
+ * const clicked = Pointer.buttonNames.find((button) => input.buttons[button].pressed)
48
+ * if (clicked !== undefined) mouse.rebind({ fire: [clicked], aim: ["secondary"], nextWeapon: ["wheelDown"] })
29
49
  *
30
50
  * // On teardown:
31
- * pointer.dispose()
51
+ * mouse.dispose()
32
52
  * ```
33
53
  *
34
- * For tests, replays, and headless runs, `scripted(...)` plays a timeline of
35
- * moves, presses, and releases, and `recording(...)` wraps a live pointer to
36
- * capture the timeline a session produced. `parseTimeline(...)` validates a
37
- * timeline loaded from JSON.
54
+ * For tests, replays, and headless runs, `scripted(bindings, timeline)`
55
+ * plays a timeline of moves, presses, and releases, and `recording(...)`
56
+ * wraps a live pointer to capture the timeline a session produced.
57
+ * `parseTimeline(...)` validates a timeline loaded from JSON.
38
58
  */
59
+ import type * as System from "@typeonce/ritmo/System"
39
60
  import * as Result from "@typeonce/ritmo/Result"
40
61
 
41
62
  import type { ActionState } from "./Keyboard.ts"
63
+ import * as ButtonsInternal from "./internal/buttons.ts"
64
+ import { buttonTracker, idleActions, idleButtons } from "./internal/buttons.ts"
65
+ import { anyUsed } from "./internal/used.ts"
42
66
 
43
67
  export type { ActionState }
44
68
 
45
- /** The buttons a snapshot reports, from `PointerEvent.button` 0, 2, and 1. */
46
- export type Button = "primary" | "secondary" | "middle"
69
+ /** The physical buttons, in `PointerEvent.button` order 0 to 4. */
70
+ const physicalButtons = ["primary", "middle", "secondary", "back", "forward"] as const
71
+
72
+ /** A button that goes down and up, unlike the wheel's pulses: what a timeline presses and releases. */
73
+ export type PhysicalButton = (typeof physicalButtons)[number]
74
+
75
+ const isPhysical = (name: unknown): name is PhysicalButton => (physicalButtons as ReadonlyArray<unknown>).includes(name)
76
+
77
+ /**
78
+ * The buttons a snapshot reports: the main (usually left) button, the wheel
79
+ * button, the secondary (usually right) button, the side buttons, then the
80
+ * wheel's four directions. A wheel direction is a pulse, pressed and
81
+ * released in the same window, once per {@link wheelStep} pixels scrolled,
82
+ * so it binds actions that step (next weapon) and merges with a gamepad
83
+ * bumper in `Controls`; read the snapshot's `wheel` for how far it scrolled
84
+ * (zoom).
85
+ */
86
+ export const buttonNames = [...physicalButtons, "wheelUp", "wheelDown", "wheelLeft", "wheelRight"] as const
87
+
88
+ /** One pointer button, see {@link buttonNames}. */
89
+ export type Button = (typeof buttonNames)[number]
90
+
91
+ /** Pixels scrolled per wheel pulse: one notch of a typical mouse wheel. */
92
+ export const wheelStep = 100
93
+
94
+ const buttonOf = (code: number): Button | undefined => physicalButtons[code]
95
+
96
+ const buttonSet: ReadonlySet<string> = new Set(buttonNames)
47
97
 
48
- const buttons: ReadonlyArray<Button> = ["primary", "secondary", "middle"]
98
+ /** Whether `name` is a pointer button, for validating stored bindings. */
99
+ export const isButton = (name: unknown): name is Button => typeof name === "string" && buttonSet.has(name)
49
100
 
50
- const buttonOf = (code: number): Button | undefined =>
51
- code === 0 ? "primary" : code === 2 ? "secondary" : code === 1 ? "middle" : undefined
101
+ /**
102
+ * Action name to the buttons that trigger it. Every action needs at least
103
+ * one button; `{}` tracks the position and raw buttons only.
104
+ */
105
+ export type Bindings = Readonly<Record<string, readonly [Button, ...Array<Button>]>>
106
+
107
+ /**
108
+ * New buttons for the same actions, for {@link Actions.rebind}. Load saved
109
+ * ones with {@link parseRebinding}.
110
+ */
111
+ export type Rebinding<B extends Bindings> = { readonly [Action in keyof B]: readonly [Button, ...Array<Button>] }
112
+
113
+ /**
114
+ * Rejects actions a rebinding names that the bindings do not have, so
115
+ * {@link Actions.rebind} takes exactly the actions it was made with.
116
+ */
117
+ export type OnlyActionsOf<R, B extends Bindings> = {
118
+ readonly [Name in Exclude<keyof R, keyof B>]: `${Name & string} is not an action of these bindings`
119
+ }
52
120
 
53
121
  /** A position in CSS pixels relative to the element's top-left corner. */
54
122
  export interface Position {
@@ -56,24 +124,66 @@ export interface Position {
56
124
  readonly y: number
57
125
  }
58
126
 
59
- export interface Snapshot {
127
+ /** A change in CSS pixels since the previous snapshot. */
128
+ export interface Delta {
129
+ readonly x: number
130
+ readonly y: number
131
+ }
132
+
133
+ /**
134
+ * The pointer's state, with the same core as the other devices' snapshots
135
+ * (`actions`, `vectors`, and `used`) so `Controls` can merge it.
136
+ */
137
+ export interface Snapshot<B extends Bindings> {
138
+ /** Every bound action's state. */
139
+ readonly actions: { readonly [Action in keyof B]: ActionState }
140
+ /** None: a pointer binds no vector actions (read `position` and `movement`); there for the snapshot core every device shares. */
141
+ readonly vectors: { readonly [Action in never]: Delta }
142
+ /** Every button's state by name, whatever the bindings (capture the button to bind from here). */
143
+ readonly buttons: { readonly [Name in Button]: ActionState }
60
144
  /** The last position the pointer was seen at, or where it was last left. */
61
145
  readonly position: Position
146
+ /**
147
+ * How far the pointer moved since the previous snapshot, from the raw
148
+ * motion (`movementX`/`movementY`), which keeps coming while the pointer is
149
+ * locked and the position stands still: read it for mouse look.
150
+ */
151
+ readonly movement: Delta
152
+ /** How far the wheel scrolled since the previous snapshot, in CSS pixels (positive `y` scrolls down). */
153
+ readonly wheel: Delta
62
154
  /** Whether the pointer is currently over the element (or captured by it). */
63
155
  readonly over: boolean
64
- readonly primary: ActionState
65
- readonly secondary: ActionState
66
- readonly middle: ActionState
67
- }
68
-
69
- export interface Pointer {
70
156
  /**
71
- * Returns the pointer state and starts a new press/release window.
157
+ * Whether the pointer is locked to the element (with the `lock` option).
158
+ * The player leaves the lock with Escape; show "click to resume" while it
159
+ * is `false`.
72
160
  */
73
- snapshot(): Snapshot
161
+ readonly locked: boolean
74
162
  /**
75
- * Removes the event listeners.
163
+ * Some button was pressed in this window or is held, or the pointer moved
164
+ * or scrolled over the element: the player is using the pointer.
76
165
  */
166
+ readonly used: boolean
167
+ }
168
+
169
+ /**
170
+ * A pointer bound to actions, built with {@link actions} (live),
171
+ * {@link scripted}, or {@link recording}. Capture it with
172
+ * `InputCapture.system(...)`, or group it with the keyboard in `Controls`.
173
+ */
174
+ export interface Actions<B extends Bindings> extends System.Consumes<"snapshot"> {
175
+ /** Returns the pointer state and starts a new press/release window. */
176
+ snapshot(): Snapshot<B>
177
+ /**
178
+ * Binds the same actions to new buttons, from the next snapshot, for a
179
+ * settings screen where players remap controls (capture the button to bind
180
+ * from the snapshot's `buttons`). An action held only through buttons it
181
+ * lost reports `released`; a button held across the rebind holds a newly
182
+ * bound action only once it is pressed again. It takes exactly the actions
183
+ * the pointer was made with.
184
+ */
185
+ rebind<const R extends Rebinding<B>>(bindings: R & OnlyActionsOf<R, B>): void
186
+ /** Removes the event listeners. */
77
187
  dispose(): void
78
188
  }
79
189
 
@@ -83,6 +193,9 @@ export interface Pointer {
83
193
  export interface PointerEventLike {
84
194
  readonly clientX: number
85
195
  readonly clientY: number
196
+ /** Raw motion since the previous event; events without it count as 0. */
197
+ readonly movementX?: number
198
+ readonly movementY?: number
86
199
  readonly button: number
87
200
  readonly pointerId: number
88
201
  /** `"mouse"`, `"pen"`, or `"touch"`; events without it count as mouse. */
@@ -104,9 +217,32 @@ export interface PointerTarget {
104
217
  listener: (event: PointerEventLike) => void
105
218
  ): void
106
219
  removeEventListener(type: "contextmenu", listener: (event: { preventDefault(): void }) => void): void
220
+ addEventListener(type: "wheel", listener: (event: WheelEventLike) => void, options: { readonly passive: false }): void
221
+ removeEventListener(type: "wheel", listener: (event: WheelEventLike) => void): void
107
222
  getBoundingClientRect(): { readonly left: number; readonly top: number }
108
223
  setPointerCapture?(pointerId: number): void
109
224
  releasePointerCapture?(pointerId: number): void
225
+ /** Called on a click with the `lock` option while the pointer is not locked. */
226
+ requestPointerLock?(): unknown
227
+ }
228
+
229
+ /** The subset of `WheelEvent` the module reads. */
230
+ export interface WheelEventLike {
231
+ readonly deltaX: number
232
+ readonly deltaY: number
233
+ /** 0 for pixels, 1 for lines (16 px each), 2 for pages (800 px each). */
234
+ readonly deltaMode: number
235
+ preventDefault(): void
236
+ }
237
+
238
+ /**
239
+ * Where the pointer lock is observed, usually `document`, for the `lock`
240
+ * option.
241
+ */
242
+ export interface LockHost {
243
+ readonly pointerLockElement: unknown
244
+ addEventListener(type: "pointerlockchange", listener: () => void): void
245
+ removeEventListener(type: "pointerlockchange", listener: () => void): void
110
246
  }
111
247
 
112
248
  /**
@@ -118,7 +254,8 @@ export interface BlurHost {
118
254
  removeEventListener(type: "blur", listener: () => void): void
119
255
  }
120
256
 
121
- export interface TrackOptions {
257
+ /** Options for {@link actions}. */
258
+ export interface ActionsOptions {
122
259
  /**
123
260
  * Suppress the context menu over the element, so the secondary button is
124
261
  * usable. Defaults to `true`.
@@ -130,57 +267,116 @@ export interface TrackOptions {
130
267
  * also a click.
131
268
  */
132
269
  readonly pointerTypes?: ReadonlyArray<string>
270
+ /**
271
+ * Keep the page from scrolling while the wheel turns over the element.
272
+ * Defaults to `true`.
273
+ */
274
+ readonly preventScroll?: boolean
275
+ /**
276
+ * Lock the pointer to the element on a click, for mouse look: pass where
277
+ * the lock is observed (usually `document`). Browsers grant a lock only
278
+ * inside a click, so the module requests it in its own listener; the
279
+ * click that locks is not reported as a press. Read `locked` and
280
+ * `movement`.
281
+ */
282
+ readonly lock?: LockHost
133
283
  }
134
284
 
135
285
  /**
136
- * A snapshot with no buttons down, for initializing an input resource before
137
- * the first capture.
286
+ * A snapshot with no buttons down and no use yet, for initializing an input
287
+ * resource before the first capture.
138
288
  */
139
- export const idle = (position: Position = { x: 0, y: 0 }): Snapshot => ({
289
+ export const idle = <const B extends Bindings>(bindings: B, position: Position = { x: 0, y: 0 }): Snapshot<B> => ({
290
+ actions: idleActions(bindings),
291
+ vectors: {},
292
+ buttons: idleButtons(buttonNames),
140
293
  position: { x: position.x, y: position.y },
294
+ movement: { x: 0, y: 0 },
295
+ wheel: { x: 0, y: 0 },
141
296
  over: false,
142
- primary: { held: false, pressed: false, released: false },
143
- secondary: { held: false, pressed: false, released: false },
144
- middle: { held: false, pressed: false, released: false }
297
+ locked: false,
298
+ used: false
145
299
  })
146
300
 
147
- interface ButtonEdges {
148
- readonly held: Set<Button>
149
- readonly pressed: Set<Button>
150
- readonly released: Set<Button>
151
- }
152
-
153
- const makeEdges = (): ButtonEdges => ({ held: new Set(), pressed: new Set(), released: new Set() })
301
+ const pixelsPer = [1, 16, 800]
154
302
 
155
- const snapshotOf = (position: Position, over: boolean, edges: ButtonEdges): Snapshot => {
156
- const state = (button: Button): ActionState => {
157
- const held = edges.held.has(button)
158
- return { held, pressed: edges.pressed.has(button), released: edges.released.has(button) && !held }
303
+ /**
304
+ * The button tracker with the position, motion, and wheel, shared by live
305
+ * and scripted pointers.
306
+ */
307
+ const tracker = <B extends Bindings>(initial: B, start: Position) => {
308
+ const buttons = buttonTracker(buttonNames, initial)
309
+ let position = start
310
+ let moved = false
311
+ let movement = { x: 0, y: 0 }
312
+ let wheel = { x: 0, y: 0 }
313
+ // Scrolled toward the next pulse, per axis; reset when the direction turns.
314
+ const pending = { x: 0, y: 0 }
315
+ const pulse = (button: Button): void => {
316
+ buttons.down(button)
317
+ buttons.up(button)
318
+ }
319
+ const step = (axis: "x" | "y", delta: number, negative: Button, positive: Button): void => {
320
+ if (delta === 0) return
321
+ if (Math.sign(pending[axis]) !== Math.sign(delta)) pending[axis] = 0
322
+ pending[axis] += delta
323
+ while (Math.abs(pending[axis]) >= wheelStep) {
324
+ pulse(pending[axis] < 0 ? negative : positive)
325
+ pending[axis] -= Math.sign(pending[axis]) * wheelStep
326
+ }
159
327
  }
160
328
  return {
161
- position: { x: position.x, y: position.y },
162
- over,
163
- primary: state("primary"),
164
- secondary: state("secondary"),
165
- middle: state("middle")
329
+ buttons,
330
+ move(next: Position) {
331
+ if (next.x === position.x && next.y === position.y) return
332
+ position = next
333
+ moved = true
334
+ },
335
+ motion(delta: Delta) {
336
+ movement = { x: movement.x + delta.x, y: movement.y + delta.y }
337
+ },
338
+ scroll(delta: Delta) {
339
+ wheel = { x: wheel.x + delta.x, y: wheel.y + delta.y }
340
+ step("y", delta.y, "wheelUp", "wheelDown")
341
+ step("x", delta.x, "wheelLeft", "wheelRight")
342
+ },
343
+ snapshot(over: boolean, locked: boolean): Snapshot<B> {
344
+ const taken = buttons.take()
345
+ const used = moved || movement.x !== 0 || movement.y !== 0 || wheel.x !== 0 || wheel.y !== 0 || anyUsed(taken.buttons)
346
+ const snapshot = { ...taken, vectors: {}, position: { x: position.x, y: position.y }, movement, wheel, over, locked, used }
347
+ moved = false
348
+ movement = { x: 0, y: 0 }
349
+ wheel = { x: 0, y: 0 }
350
+ return snapshot
351
+ }
166
352
  }
167
353
  }
168
354
 
169
355
  /**
170
- * Starts tracking the pointer over `target`. `blurHost` (usually `window`)
171
- * releases held buttons when the page loses focus.
356
+ * Starts tracking the pointer over `target` with `bindings`, until
357
+ * `dispose()`. `blurHost` (usually `window`) releases held buttons when the
358
+ * page loses focus; a cancelled pointer and leaving the pointer lock release
359
+ * them too. Use `Touch.actions` for on-screen touch controls.
172
360
  */
173
- export const track = (target: PointerTarget, blurHost: BlurHost, options: TrackOptions = {}): Pointer => {
361
+ export const actions = <const B extends Bindings>(
362
+ target: PointerTarget,
363
+ blurHost: BlurHost,
364
+ bindings: B,
365
+ options: ActionsOptions = {}
366
+ ): Actions<B> => {
174
367
  const preventContextMenu = options.preventContextMenu ?? true
368
+ const preventScroll = options.preventScroll ?? true
369
+ const lock = options.lock
175
370
  const pointerTypes = options.pointerTypes ?? ["mouse", "pen"]
176
371
  const tracked = (event: PointerEventLike): boolean => pointerTypes.includes(event.pointerType ?? "mouse")
177
- const edges = makeEdges()
178
- let position: Position = { x: 0, y: 0 }
372
+ const state = tracker(bindings, { x: 0, y: 0 })
179
373
  let over = false
374
+ const isLocked = (): boolean => lock !== undefined && lock.pointerLockElement === target
180
375
 
181
376
  const locate = (event: PointerEventLike): void => {
182
377
  const rect = target.getBoundingClientRect()
183
- position = { x: event.clientX - rect.left, y: event.clientY - rect.top }
378
+ state.move({ x: event.clientX - rect.left, y: event.clientY - rect.top })
379
+ state.motion({ x: event.movementX ?? 0, y: event.movementY ?? 0 })
184
380
  }
185
381
 
186
382
  const onDown = (event: PointerEventLike): void => {
@@ -190,19 +386,22 @@ export const track = (target: PointerTarget, blurHost: BlurHost, options: TrackO
190
386
  const button = buttonOf(event.button)
191
387
  if (button === undefined) return
192
388
  event.preventDefault()
389
+ if (lock !== undefined && !isLocked()) {
390
+ // The click that locks is not a press: "click to resume" does not also fire.
391
+ target.requestPointerLock?.()
392
+ return
393
+ }
193
394
  target.setPointerCapture?.(event.pointerId)
194
- if (edges.held.has(button)) return
195
- edges.held.add(button)
196
- edges.pressed.add(button)
395
+ state.buttons.down(button)
197
396
  }
198
397
 
199
398
  const onUp = (event: PointerEventLike): void => {
200
399
  if (!tracked(event)) return
201
400
  locate(event)
202
401
  const button = buttonOf(event.button)
203
- if (button === undefined || !edges.held.delete(button)) return
204
- edges.released.add(button)
205
- if (edges.held.size === 0) target.releasePointerCapture?.(event.pointerId)
402
+ if (button === undefined || !state.buttons.held.has(button)) return
403
+ state.buttons.up(button)
404
+ if (state.buttons.held.size === 0) target.releasePointerCapture?.(event.pointerId)
206
405
  }
207
406
 
208
407
  const onMove = (event: PointerEventLike): void => {
@@ -211,127 +410,132 @@ export const track = (target: PointerTarget, blurHost: BlurHost, options: TrackO
211
410
  over = true
212
411
  }
213
412
 
214
- const onEnter = (event: PointerEventLike): void => {
215
- if (!tracked(event)) return
216
- locate(event)
217
- over = true
218
- }
219
-
220
413
  const onLeave = (event: PointerEventLike): void => {
221
414
  if (tracked(event)) over = false
222
415
  }
223
416
 
224
- const releaseAll = (): void => {
225
- for (const button of edges.held) edges.released.add(button)
226
- edges.held.clear()
227
- }
228
-
229
417
  const onCancel = (event: PointerEventLike): void => {
230
- if (tracked(event)) releaseAll()
418
+ if (tracked(event)) state.buttons.upAll()
231
419
  }
232
420
 
233
421
  const onContextMenu = (event: { preventDefault(): void }): void => {
234
422
  if (preventContextMenu) event.preventDefault()
235
423
  }
236
424
 
425
+ const onWheel = (event: WheelEventLike): void => {
426
+ if (preventScroll) event.preventDefault()
427
+ const scale = pixelsPer[event.deltaMode] ?? 1
428
+ state.scroll({ x: event.deltaX * scale, y: event.deltaY * scale })
429
+ }
430
+
431
+ // Leaving the lock (Escape) releases what was held, like a blur.
432
+ const onLockChange = (): void => {
433
+ if (!isLocked()) state.buttons.upAll()
434
+ }
435
+
237
436
  target.addEventListener("pointerdown", onDown)
238
437
  target.addEventListener("pointerup", onUp)
239
438
  target.addEventListener("pointermove", onMove)
240
- target.addEventListener("pointerenter", onEnter)
439
+ target.addEventListener("pointerenter", onMove)
241
440
  target.addEventListener("pointerleave", onLeave)
242
441
  target.addEventListener("pointercancel", onCancel)
243
442
  target.addEventListener("contextmenu", onContextMenu)
244
- blurHost.addEventListener("blur", releaseAll)
443
+ target.addEventListener("wheel", onWheel, { passive: false })
444
+ blurHost.addEventListener("blur", state.buttons.upAll)
445
+ lock?.addEventListener("pointerlockchange", onLockChange)
245
446
 
246
447
  return {
247
- snapshot() {
248
- const snapshot = snapshotOf(position, over, edges)
249
- edges.pressed.clear()
250
- edges.released.clear()
251
- return snapshot
448
+ snapshot: () => {
449
+ const locked = isLocked()
450
+ return state.snapshot(over || locked, locked)
252
451
  },
452
+ rebind: state.buttons.rebind,
253
453
  dispose() {
254
454
  target.removeEventListener("pointerdown", onDown)
255
455
  target.removeEventListener("pointerup", onUp)
256
456
  target.removeEventListener("pointermove", onMove)
257
- target.removeEventListener("pointerenter", onEnter)
457
+ target.removeEventListener("pointerenter", onMove)
258
458
  target.removeEventListener("pointerleave", onLeave)
259
459
  target.removeEventListener("pointercancel", onCancel)
260
460
  target.removeEventListener("contextmenu", onContextMenu)
261
- blurHost.removeEventListener("blur", releaseAll)
461
+ target.removeEventListener("wheel", onWheel)
462
+ blurHost.removeEventListener("blur", state.buttons.upAll)
463
+ lock?.removeEventListener("pointerlockchange", onLockChange)
262
464
  }
263
465
  }
264
466
  }
265
467
 
266
468
  /**
267
- * Pointer changes applied just before one snapshot: the move first, then
268
- * presses, then releases. `frame` is the snapshot's index, counting from 0 at
269
- * the first `snapshot()` call. A button both pressed and released in one frame
270
- * is a click shorter than a frame.
469
+ * Pointer changes applied just before one snapshot: the move, the raw
470
+ * motion, and the wheel first, then presses, then releases. `frame` is the
471
+ * snapshot's index, counting from 0 at the first `snapshot()` call. A button
472
+ * both pressed and released in one frame is a click shorter than a frame.
473
+ * Wheel pulses come from `wheel`, not from `press`.
271
474
  */
272
475
  export interface TimelineEntry {
273
476
  readonly frame: number
274
477
  readonly move?: Position
275
- readonly press?: ReadonlyArray<Button>
276
- readonly release?: ReadonlyArray<Button>
478
+ readonly movement?: Delta
479
+ readonly wheel?: Delta
480
+ /** Locks or unlocks the pointer from this frame on; unlocking releases what was held. */
481
+ readonly locked?: boolean
482
+ readonly press?: ReadonlyArray<PhysicalButton>
483
+ readonly release?: ReadonlyArray<PhysicalButton>
277
484
  }
278
485
 
279
486
  /**
280
487
  * Pointer changes by snapshot index. Plain data, so it round-trips through
281
- * JSON; load it back with `parseTimeline`.
488
+ * JSON; load it back with `parseTimeline`. It names buttons, not actions, so
489
+ * a replay follows the bindings as live play does.
282
490
  */
283
491
  export type Timeline = ReadonlyArray<TimelineEntry>
284
492
 
285
- /**
286
- * A pointer driven by a timeline instead of an element.
287
- */
288
- export interface Scripted extends Pointer {
493
+ /** A pointer driven by a timeline instead of an element. */
494
+ export interface Scripted<B extends Bindings> extends Actions<B> {
289
495
  /** Number of snapshots taken so far, which is the next frame index. */
290
496
  readonly frames: () => number
291
497
  }
292
498
 
293
499
  /**
294
- * Plays `timeline`: before the snapshot at index `frame`, every entry for that
295
- * frame is applied. Buttons stay held from their press until their release.
296
- * The scripted pointer is always over the element.
500
+ * Plays `timeline` with `bindings`: before the snapshot at index `frame`,
501
+ * every entry for that frame is applied. Buttons stay held from their press
502
+ * until their release. The scripted pointer is always over the element.
297
503
  */
298
- export const scripted = (timeline: Timeline, start: Position = { x: 0, y: 0 }): Scripted => {
504
+ export const scripted = <const B extends Bindings>(bindings: B, timeline: Timeline, start: Position = { x: 0, y: 0 }): Scripted<B> => {
299
505
  const byFrame = new Map<number, Array<TimelineEntry>>()
300
506
  for (const entry of timeline) {
301
507
  const entries = byFrame.get(entry.frame)
302
508
  if (entries) entries.push(entry)
303
509
  else byFrame.set(entry.frame, [entry])
304
510
  }
305
- const edges = makeEdges()
306
- let position = start
511
+ const state = tracker(bindings, start)
307
512
  let frame = 0
513
+ let locked = false
308
514
  return {
309
515
  snapshot() {
310
- edges.pressed.clear()
311
- edges.released.clear()
312
516
  for (const entry of byFrame.get(frame) ?? []) {
313
- if (entry.move !== undefined) position = entry.move
314
- for (const button of entry.press ?? []) {
315
- edges.held.add(button)
316
- edges.pressed.add(button)
317
- }
318
- for (const button of entry.release ?? []) {
319
- if (edges.held.delete(button)) edges.released.add(button)
517
+ if (entry.move !== undefined) state.move(entry.move)
518
+ if (entry.movement !== undefined) state.motion(entry.movement)
519
+ if (entry.wheel !== undefined) state.scroll(entry.wheel)
520
+ if (entry.locked !== undefined) {
521
+ if (locked && !entry.locked) state.buttons.upAll()
522
+ locked = entry.locked
320
523
  }
524
+ for (const button of entry.press ?? []) state.buttons.down(button)
525
+ for (const button of entry.release ?? []) state.buttons.up(button)
321
526
  }
322
527
  frame += 1
323
- return snapshotOf(position, true, edges)
528
+ return state.snapshot(true, locked)
324
529
  },
530
+ rebind: state.buttons.rebind,
325
531
  dispose() {},
326
532
  frames: () => frame
327
533
  }
328
534
  }
329
535
 
330
- /**
331
- * A live pointer that also records the timeline it produces.
332
- */
333
- export interface Recording extends Pointer {
334
- /** The recorded timeline; `scripted(timeline)` replays the same positions and buttons. */
536
+ /** A live pointer that also records the timeline it produces. */
537
+ export interface Recording<B extends Bindings> extends Actions<B> {
538
+ /** The recorded timeline; `scripted(bindings, timeline)` replays the same positions and buttons. */
335
539
  readonly timeline: () => Timeline
336
540
  }
337
541
 
@@ -339,21 +543,29 @@ export interface Recording extends Pointer {
339
543
  * Wraps `pointer` and records, for every snapshot, the position when it moved
340
544
  * and the buttons pressed and released since the previous one.
341
545
  */
342
- export const recording = (pointer: Pointer): Recording => {
546
+ export const recording = <B extends Bindings>(pointer: Actions<B>): Recording<B> => {
343
547
  const entries: Array<TimelineEntry> = []
344
548
  let frame = 0
345
549
  let last: Position | undefined
550
+ let locked = false
551
+ const nonZero = (delta: Delta): boolean => delta.x !== 0 || delta.y !== 0
346
552
  return {
347
553
  snapshot() {
348
554
  const snapshot = pointer.snapshot()
349
555
  const moved = last === undefined || last.x !== snapshot.position.x || last.y !== snapshot.position.y
350
556
  last = snapshot.position
351
- const press = buttons.filter((button) => snapshot[button].pressed)
352
- const release = buttons.filter((button) => snapshot[button].released)
353
- if (moved || press.length > 0 || release.length > 0) {
557
+ // Wheel pulses replay from the recorded wheel.
558
+ const press = physicalButtons.filter((button) => snapshot.buttons[button].pressed)
559
+ const release = physicalButtons.filter((button) => snapshot.buttons[button].released)
560
+ const lockChanged = snapshot.locked !== locked
561
+ locked = snapshot.locked
562
+ if (moved || lockChanged || nonZero(snapshot.movement) || nonZero(snapshot.wheel) || press.length > 0 || release.length > 0) {
354
563
  entries.push({
355
564
  frame,
356
565
  ...(moved ? { move: snapshot.position } : {}),
566
+ ...(nonZero(snapshot.movement) ? { movement: snapshot.movement } : {}),
567
+ ...(nonZero(snapshot.wheel) ? { wheel: snapshot.wheel } : {}),
568
+ ...(lockChanged ? { locked: snapshot.locked } : {}),
357
569
  ...(press.length > 0 ? { press } : {}),
358
570
  ...(release.length > 0 ? { release } : {})
359
571
  })
@@ -361,9 +573,8 @@ export const recording = (pointer: Pointer): Recording => {
361
573
  frame += 1
362
574
  return snapshot
363
575
  },
364
- dispose() {
365
- pointer.dispose()
366
- },
576
+ rebind: (bindings) => pointer.rebind(bindings),
577
+ dispose: () => pointer.dispose(),
367
578
  timeline: () => [...entries]
368
579
  }
369
580
  }
@@ -380,8 +591,9 @@ export interface InvalidTimeline {
380
591
 
381
592
  /**
382
593
  * Validates untrusted data, such as a parsed JSON file, as a pointer
383
- * timeline: an array of entries with a non-negative integer `frame`, an
384
- * optional finite `move`, and optional `press`/`release` arrays of buttons.
594
+ * timeline: an array of entries with a non-negative integer `frame`,
595
+ * optional finite `move`, `movement`, and `wheel`, an optional boolean
596
+ * `locked`, and optional `press`/`release` arrays of physical buttons.
385
597
  */
386
598
  export const parseTimeline = (data: unknown): Result.Result<Timeline, InvalidTimeline> => {
387
599
  const invalid = (path: string, message: string) => Result.failure<InvalidTimeline>({ _tag: "InvalidTimeline", path, message })
@@ -391,45 +603,61 @@ export const parseTimeline = (data: unknown): Result.Result<Timeline, InvalidTim
391
603
  const entry: unknown = data[index]
392
604
  const path = `[${index}]`
393
605
  if (typeof entry !== "object" || entry === null) return invalid(path, "expected an object")
394
- const { frame, move, press, release } = entry as {
395
- readonly frame?: unknown
396
- readonly move?: unknown
397
- readonly press?: unknown
398
- readonly release?: unknown
399
- }
606
+ const fields = entry as Readonly<Record<string, unknown>>
607
+ const frame = fields["frame"]
400
608
  if (typeof frame !== "number" || !Number.isInteger(frame) || frame < 0) {
401
609
  return invalid(`${path}.frame`, "expected a non-negative integer")
402
610
  }
403
- let position: Position | undefined
404
- if (move !== undefined) {
405
- if (typeof move !== "object" || move === null) return invalid(`${path}.move`, "expected an object with x and y")
406
- const { x, y } = move as { readonly x?: unknown; readonly y?: unknown }
407
- if (typeof x !== "number" || !Number.isFinite(x)) return invalid(`${path}.move.x`, "expected a finite number")
408
- if (typeof y !== "number" || !Number.isFinite(y)) return invalid(`${path}.move.y`, "expected a finite number")
409
- position = { x, y }
611
+ const parsed: Record<string, unknown> = { frame }
612
+ for (const field of ["move", "movement", "wheel"] as const) {
613
+ const value = fields[field]
614
+ if (value === undefined) continue
615
+ if (typeof value !== "object" || value === null) return invalid(`${path}.${field}`, "expected an object with x and y")
616
+ const { x, y } = value as { readonly x?: unknown; readonly y?: unknown }
617
+ if (typeof x !== "number" || !Number.isFinite(x)) return invalid(`${path}.${field}.x`, "expected a finite number")
618
+ if (typeof y !== "number" || !Number.isFinite(y)) return invalid(`${path}.${field}.y`, "expected a finite number")
619
+ parsed[field] = { x, y }
410
620
  }
411
- const buttonsAt = (value: unknown, field: string): Result.Result<Array<Button> | undefined, InvalidTimeline> => {
412
- if (value === undefined) return Result.success(undefined)
621
+ const locked = fields["locked"]
622
+ if (locked !== undefined) {
623
+ if (typeof locked !== "boolean") return invalid(`${path}.locked`, "expected a boolean")
624
+ parsed["locked"] = locked
625
+ }
626
+ for (const field of ["press", "release"] as const) {
627
+ const value = fields[field]
628
+ if (value === undefined) continue
413
629
  if (!Array.isArray(value)) return invalid(`${path}.${field}`, "expected an array of buttons")
414
- const names: Array<Button> = []
415
630
  for (let offset = 0; offset < value.length; offset++) {
416
- const name: unknown = value[offset]
417
- const button = buttons.find((candidate) => candidate === name)
418
- if (button === undefined) return invalid(`${path}.${field}[${offset}]`, `expected one of ${buttons.join(", ")}`)
419
- names.push(button)
631
+ if (!isPhysical(value[offset])) return invalid(`${path}.${field}[${offset}]`, `expected one of ${physicalButtons.join(", ")}`)
420
632
  }
421
- return Result.success(names)
633
+ parsed[field] = [...value]
422
634
  }
423
- const pressed = buttonsAt(press, "press")
424
- if (!pressed.ok) return pressed
425
- const released = buttonsAt(release, "release")
426
- if (!released.ok) return released
427
- entries.push({
428
- frame,
429
- ...(position === undefined ? {} : { move: position }),
430
- ...(pressed.value === undefined ? {} : { press: pressed.value }),
431
- ...(released.value === undefined ? {} : { release: released.value })
432
- })
635
+ entries.push(parsed as unknown as TimelineEntry)
433
636
  }
434
637
  return Result.success(entries)
435
638
  }
639
+
640
+ /**
641
+ * Why loaded data is not a rebinding for the bindings. `path` points at the
642
+ * offending value, for example `fire[1]`.
643
+ */
644
+ export type InvalidRebinding = ButtonsInternal.InvalidRebinding
645
+
646
+ /**
647
+ * Validates untrusted data, such as saved settings parsed from JSON, as new
648
+ * buttons for `bindings`: an object with exactly the same action names, each
649
+ * mapped to a non-empty array of button names. Pass the result to
650
+ * {@link Actions.rebind}; on failure, keep the defaults.
651
+ *
652
+ * @example
653
+ * ```ts
654
+ * const saved = Pointer.parseRebinding(bindings, JSON.parse(stored))
655
+ * if (saved.ok) mouse.rebind(saved.value)
656
+ * ```
657
+ */
658
+ export const parseRebinding = <const B extends Bindings>(
659
+ bindings: B,
660
+ data: unknown
661
+ ): Result.Result<Rebinding<B>, InvalidRebinding> =>
662
+ // The checked names and buttons are exactly `Rebinding<B>`.
663
+ ButtonsInternal.parseButtonRebinding(buttonNames, bindings, data) as Result.Result<Rebinding<B>, InvalidRebinding>