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