@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.
- package/CHANGELOG.md +106 -0
- package/README.md +6 -1
- package/dist/Controls.d.ts +496 -0
- package/dist/Controls.d.ts.map +1 -0
- package/dist/Controls.js +343 -0
- package/dist/Controls.js.map +1 -0
- package/dist/Gamepad.d.ts +376 -0
- package/dist/Gamepad.d.ts.map +1 -0
- package/dist/Gamepad.js +459 -0
- package/dist/Gamepad.js.map +1 -0
- package/dist/InputCapture.d.ts +46 -16
- package/dist/InputCapture.d.ts.map +1 -1
- package/dist/InputCapture.js +1 -1
- package/dist/InputCapture.js.map +1 -1
- package/dist/Keyboard.d.ts +177 -25
- package/dist/Keyboard.d.ts.map +1 -1
- package/dist/Keyboard.js +257 -91
- package/dist/Keyboard.js.map +1 -1
- package/dist/Pointer.d.ts +233 -64
- package/dist/Pointer.d.ts.map +1 -1
- package/dist/Pointer.js +205 -145
- package/dist/Pointer.js.map +1 -1
- package/dist/Touch.d.ts +75 -33
- package/dist/Touch.d.ts.map +1 -1
- package/dist/Touch.js +25 -30
- package/dist/Touch.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/buttons.d.ts +52 -0
- package/dist/internal/buttons.d.ts.map +1 -0
- package/dist/internal/buttons.js +211 -0
- package/dist/internal/buttons.js.map +1 -0
- package/dist/internal/introspect.d.ts +14 -0
- package/dist/internal/introspect.d.ts.map +1 -0
- package/dist/internal/introspect.js +19 -0
- package/dist/internal/introspect.js.map +1 -0
- package/dist/internal/state.d.ts +13 -0
- package/dist/internal/state.d.ts.map +1 -0
- package/dist/internal/state.js +2 -0
- package/dist/internal/state.js.map +1 -0
- package/dist/internal/used.d.ts +8 -0
- package/dist/internal/used.d.ts.map +1 -0
- package/dist/internal/used.js +14 -0
- package/dist/internal/used.js.map +1 -0
- package/dist/internal/vectors.d.ts +29 -0
- package/dist/internal/vectors.d.ts.map +1 -0
- package/dist/internal/vectors.js +22 -0
- package/dist/internal/vectors.js.map +1 -0
- package/package.json +4 -3
- package/src/Controls.ts +800 -0
- package/src/Gamepad.ts +752 -0
- package/src/InputCapture.ts +43 -24
- package/src/Keyboard.ts +387 -72
- package/src/Pointer.ts +392 -166
- package/src/Touch.ts +113 -61
- package/src/index.ts +10 -1
- package/src/internal/buttons.ts +222 -0
- package/src/internal/introspect.ts +23 -0
- package/src/internal/state.ts +12 -0
- package/src/internal/used.ts +14 -0
- 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:
|
|
3
|
-
* are ignored by default; on-screen touch controls are
|
|
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
|
-
*
|
|
6
|
-
* actions
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
* //
|
|
26
|
-
* const
|
|
27
|
-
*
|
|
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
|
-
*
|
|
51
|
+
* mouse.dispose()
|
|
32
52
|
* ```
|
|
33
53
|
*
|
|
34
|
-
* For tests, replays, and headless runs, `scripted(
|
|
35
|
-
* moves, presses, and releases, and `recording(...)`
|
|
36
|
-
* capture the timeline a session produced.
|
|
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 "./
|
|
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
|
-
|
|
94
|
+
const buttonSet: ReadonlySet<string> = new Set(buttonNames)
|
|
44
95
|
|
|
45
|
-
/**
|
|
46
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
middle: { held: false, pressed: false, released: false }
|
|
295
|
+
locked: false,
|
|
296
|
+
used: false
|
|
145
297
|
})
|
|
146
298
|
|
|
147
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
171
|
-
* releases held buttons when the
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 || !
|
|
204
|
-
|
|
205
|
-
if (
|
|
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))
|
|
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",
|
|
437
|
+
target.addEventListener("pointerenter", onMove)
|
|
241
438
|
target.addEventListener("pointerleave", onLeave)
|
|
242
439
|
target.addEventListener("pointercancel", onCancel)
|
|
243
440
|
target.addEventListener("contextmenu", onContextMenu)
|
|
244
|
-
|
|
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
|
|
249
|
-
|
|
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",
|
|
455
|
+
target.removeEventListener("pointerenter", onMove)
|
|
258
456
|
target.removeEventListener("pointerleave", onLeave)
|
|
259
457
|
target.removeEventListener("pointercancel", onCancel)
|
|
260
458
|
target.removeEventListener("contextmenu", onContextMenu)
|
|
261
|
-
|
|
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
|
|
268
|
-
* presses, then releases. `frame` is the
|
|
269
|
-
* the first `snapshot()` call. A button
|
|
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
|
|
276
|
-
readonly
|
|
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
|
-
|
|
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`,
|
|
295
|
-
* frame is applied. Buttons stay held from their press
|
|
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
|
|
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)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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`,
|
|
384
|
-
* optional finite `move`, and
|
|
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
|
|
395
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (typeof
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
parsed[field] = [...value]
|
|
422
632
|
}
|
|
423
|
-
|
|
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>
|