@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.
- package/CHANGELOG.md +285 -0
- package/README.md +6 -3
- package/dist/Controls.d.ts +495 -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 +375 -0
- package/dist/Gamepad.d.ts.map +1 -0
- package/dist/Gamepad.js +452 -0
- package/dist/Gamepad.js.map +1 -0
- package/dist/InputCapture.d.ts +47 -14
- 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 +184 -21
- 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 +231 -62
- 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 +73 -27
- 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/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 +24 -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 +5 -3
- package/src/Controls.ts +799 -0
- package/src/Gamepad.ts +747 -0
- package/src/InputCapture.ts +44 -22
- package/src/Keyboard.ts +395 -68
- package/src/Pointer.ts +392 -164
- package/src/Touch.ts +111 -54
- package/src/index.ts +10 -1
- package/src/internal/buttons.ts +222 -0
- package/src/internal/introspect.ts +23 -0
- package/src/internal/used.ts +14 -0
- 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:
|
|
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
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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
161
|
+
readonly locked: boolean
|
|
74
162
|
/**
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
middle: { held: false, pressed: false, released: false }
|
|
297
|
+
locked: false,
|
|
298
|
+
used: false
|
|
145
299
|
})
|
|
146
300
|
|
|
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() })
|
|
301
|
+
const pixelsPer = [1, 16, 800]
|
|
154
302
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
171
|
-
* releases held buttons when the
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 || !
|
|
204
|
-
|
|
205
|
-
if (
|
|
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))
|
|
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",
|
|
439
|
+
target.addEventListener("pointerenter", onMove)
|
|
241
440
|
target.addEventListener("pointerleave", onLeave)
|
|
242
441
|
target.addEventListener("pointercancel", onCancel)
|
|
243
442
|
target.addEventListener("contextmenu", onContextMenu)
|
|
244
|
-
|
|
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
|
|
249
|
-
|
|
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",
|
|
457
|
+
target.removeEventListener("pointerenter", onMove)
|
|
258
458
|
target.removeEventListener("pointerleave", onLeave)
|
|
259
459
|
target.removeEventListener("pointercancel", onCancel)
|
|
260
460
|
target.removeEventListener("contextmenu", onContextMenu)
|
|
261
|
-
|
|
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
|
|
268
|
-
* presses, then releases. `frame` is the
|
|
269
|
-
* the first `snapshot()` call. A button
|
|
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
|
|
276
|
-
readonly
|
|
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
|
-
|
|
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`,
|
|
295
|
-
* frame is applied. Buttons stay held from their press
|
|
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
|
|
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)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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`,
|
|
384
|
-
* optional finite `move`, and
|
|
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
|
|
395
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (typeof
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
+
parsed[field] = [...value]
|
|
422
634
|
}
|
|
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
|
-
})
|
|
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>
|