@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/Touch.ts
CHANGED
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* @example
|
|
30
30
|
* ```ts
|
|
31
|
-
* const touch = Touch.
|
|
32
|
-
*
|
|
31
|
+
* const touch = Touch.actions(canvas, window, ({ width, height }) => ({
|
|
32
|
+
* // `vector` names the action the stick drives, so it merges into `Controls`.
|
|
33
|
+
* stick: { region: { left: 0, top: 0, width: width / 2, height }, radius: 60, vector: "move" },
|
|
33
34
|
* buttons: {
|
|
34
35
|
* attack: { center: { x: width - 90, y: height - 90 }, radius: 55, aimRadius: 80 },
|
|
35
36
|
* dash: { center: { x: width - 200, y: height - 60 }, radius: 36 }
|
|
@@ -37,15 +38,18 @@
|
|
|
37
38
|
* }))
|
|
38
39
|
*
|
|
39
40
|
* // Once per update:
|
|
40
|
-
* const {
|
|
41
|
-
* move(
|
|
42
|
-
* if (
|
|
41
|
+
* const { vectors, actions } = touch.snapshot()
|
|
42
|
+
* move(vectors.move)
|
|
43
|
+
* if (actions.attack.released && !actions.attack.cancelled) fire(actions.attack.aim)
|
|
43
44
|
* ```
|
|
44
45
|
*
|
|
45
46
|
* For tests and headless runs, `scripted(...)` plays a timeline of stick
|
|
46
47
|
* vectors, presses, drags, and releases.
|
|
47
48
|
*/
|
|
48
|
-
import type
|
|
49
|
+
import type * as System from "@typeonce/ritmo/System"
|
|
50
|
+
import { anyUsed } from "./internal/used.ts"
|
|
51
|
+
import type { ActionState } from "./internal/state.ts"
|
|
52
|
+
import type { Vector } from "./internal/vectors.ts"
|
|
49
53
|
|
|
50
54
|
/** A position in CSS pixels relative to the element's top-left corner. */
|
|
51
55
|
export interface Position {
|
|
@@ -53,12 +57,7 @@ export interface Position {
|
|
|
53
57
|
readonly y: number
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
/** A
|
|
57
|
-
export interface Vector {
|
|
58
|
-
readonly x: number
|
|
59
|
-
readonly y: number
|
|
60
|
-
}
|
|
61
|
-
|
|
60
|
+
/** A rectangle in CSS pixels relative to the element's top-left corner. */
|
|
62
61
|
export interface Rect {
|
|
63
62
|
readonly left: number
|
|
64
63
|
readonly top: number
|
|
@@ -66,15 +65,19 @@ export interface Rect {
|
|
|
66
65
|
readonly height: number
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
/** The floating stick of a {@link Layout}. */
|
|
69
|
+
export interface StickZone<V extends string = never> {
|
|
70
70
|
/** Where a touch starts the stick. The stick is centered where the thumb lands. */
|
|
71
71
|
readonly region: Rect
|
|
72
72
|
/** Drag distance in CSS pixels for full tilt. */
|
|
73
73
|
readonly radius: number
|
|
74
74
|
/** Fraction of `radius` that reads as zero. Defaults to 0.15. */
|
|
75
75
|
readonly deadZone?: number
|
|
76
|
+
/** The vector action the stick drives (`"move"`), reported in the snapshot's `vectors`. */
|
|
77
|
+
readonly vector?: V
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/** A round on-screen button of a {@link Layout}; it reports the action named like its key. */
|
|
78
81
|
export interface ButtonZone {
|
|
79
82
|
readonly center: Position
|
|
80
83
|
/** Touch radius in CSS pixels. */
|
|
@@ -85,13 +88,18 @@ export interface ButtonZone {
|
|
|
85
88
|
readonly aimDeadZone?: number
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
/** The swipe area of a {@link Layout}. */
|
|
88
92
|
export interface SwipeZone {
|
|
89
93
|
/** Where a touch that is not on a button or the stick starts a swipe. */
|
|
90
94
|
readonly region: Rect
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The touch controls, returned by the layout function {@link actions} calls
|
|
99
|
+
* with the element's size. The button names are fixed by the first layout.
|
|
100
|
+
*/
|
|
101
|
+
export interface Layout<B extends string, V extends string = never> {
|
|
102
|
+
readonly stick: StickZone<V>
|
|
95
103
|
readonly buttons: { readonly [K in B]: ButtonZone }
|
|
96
104
|
/** Optional: fingers here report their movement, for turning or looking around. */
|
|
97
105
|
readonly swipe?: SwipeZone
|
|
@@ -103,6 +111,7 @@ export interface Size {
|
|
|
103
111
|
readonly height: number
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
/** The stick's state in a snapshot. */
|
|
106
115
|
export interface Stick {
|
|
107
116
|
/** Whether a thumb is on the stick. */
|
|
108
117
|
readonly active: boolean
|
|
@@ -110,6 +119,7 @@ export interface Stick {
|
|
|
110
119
|
readonly vector: Vector
|
|
111
120
|
}
|
|
112
121
|
|
|
122
|
+
/** One button's state: its action's edges, plus the drag aim and whether the release was cancelled. */
|
|
113
123
|
export interface ButtonState extends ActionState {
|
|
114
124
|
/**
|
|
115
125
|
* The drag from the button's center as a fraction of `aimRadius` (length at
|
|
@@ -121,6 +131,7 @@ export interface ButtonState extends ActionState {
|
|
|
121
131
|
readonly cancelled: boolean
|
|
122
132
|
}
|
|
123
133
|
|
|
134
|
+
/** The swipe area's state in a snapshot. */
|
|
124
135
|
export interface Swipe {
|
|
125
136
|
/** Whether a finger is on the swipe area. */
|
|
126
137
|
readonly active: boolean
|
|
@@ -128,27 +139,57 @@ export interface Swipe {
|
|
|
128
139
|
readonly delta: Vector
|
|
129
140
|
}
|
|
130
141
|
|
|
131
|
-
|
|
142
|
+
/**
|
|
143
|
+
* The controls' state. Each button is an action named like it, and the
|
|
144
|
+
* stick drives the vector action its layout names, so the snapshot has the
|
|
145
|
+
* same core as the other devices' (`actions`, `vectors`, and `used`) and
|
|
146
|
+
* `Controls` can merge it.
|
|
147
|
+
*/
|
|
148
|
+
export interface Snapshot<B extends string, V extends string = never> {
|
|
132
149
|
readonly stick: Stick
|
|
133
|
-
readonly
|
|
150
|
+
readonly actions: { readonly [K in B]: ButtonState }
|
|
151
|
+
/** The vector action the stick drives, when the layout names one: the stick's vector. */
|
|
152
|
+
readonly vectors: { readonly [K in V]: Vector }
|
|
134
153
|
readonly swipe: Swipe
|
|
135
|
-
/**
|
|
136
|
-
|
|
154
|
+
/**
|
|
155
|
+
* A button was pressed in this window or is held, or a finger is on the
|
|
156
|
+
* stick or the swipe area: the player is using the touch controls.
|
|
157
|
+
*/
|
|
158
|
+
readonly used: boolean
|
|
137
159
|
}
|
|
138
160
|
|
|
161
|
+
const snapshotOf = <B extends string, V extends string>(
|
|
162
|
+
vector: V | undefined,
|
|
163
|
+
stick: Stick,
|
|
164
|
+
actions: { readonly [K in B]: ButtonState },
|
|
165
|
+
swipe: Swipe
|
|
166
|
+
): Snapshot<B, V> => ({
|
|
167
|
+
stick,
|
|
168
|
+
actions,
|
|
169
|
+
vectors: (vector === undefined ? {} : { [vector]: stick.vector }) as { readonly [K in V]: Vector },
|
|
170
|
+
swipe,
|
|
171
|
+
used: stick.active || swipe.active || anyUsed(actions)
|
|
172
|
+
})
|
|
173
|
+
|
|
139
174
|
/** Live control positions for drawing, in CSS pixels. Reading it consumes nothing. */
|
|
140
|
-
export interface View<B extends string> {
|
|
141
|
-
readonly layout: Layout<B>
|
|
175
|
+
export interface View<B extends string, V extends string = never> {
|
|
176
|
+
readonly layout: Layout<B, V>
|
|
142
177
|
/** Where the stick was centered and where the thumb is, while it is held. */
|
|
143
178
|
readonly stick: { readonly origin: Position; readonly knob: Position } | null
|
|
144
179
|
readonly buttons: { readonly [K in B]: { readonly held: boolean; readonly aim: Vector | null } }
|
|
145
180
|
}
|
|
146
181
|
|
|
147
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Touch controls, built with {@link actions} (live) or {@link scripted}.
|
|
184
|
+
* Capture them with `InputCapture.system(...)`, or merge them with
|
|
185
|
+
* `Controls` (through `lacking(...)` for actions with no on-screen
|
|
186
|
+
* control). There is no `rebind`: the layout is the binding.
|
|
187
|
+
*/
|
|
188
|
+
export interface Actions<B extends string, V extends string = never> extends System.Consumes<"snapshot"> {
|
|
148
189
|
/** Returns the controls' state and starts a new press/release window. */
|
|
149
|
-
snapshot(): Snapshot<B>
|
|
190
|
+
snapshot(): Snapshot<B, V>
|
|
150
191
|
/** The live state for drawing. */
|
|
151
|
-
view(): View<B>
|
|
192
|
+
view(): View<B, V>
|
|
152
193
|
/** Removes the event listeners and restores the element's `touch-action`. */
|
|
153
194
|
dispose(): void
|
|
154
195
|
}
|
|
@@ -177,7 +218,8 @@ export interface BlurHost {
|
|
|
177
218
|
removeEventListener(type: "blur", listener: () => void): void
|
|
178
219
|
}
|
|
179
220
|
|
|
180
|
-
|
|
221
|
+
/** Options for {@link actions}. */
|
|
222
|
+
export interface ActionsOptions {
|
|
181
223
|
/** `PointerEvent.pointerType`s that drive the controls. Defaults to `["touch"]`. */
|
|
182
224
|
readonly pointerTypes?: ReadonlyArray<string>
|
|
183
225
|
}
|
|
@@ -186,13 +228,25 @@ const zero: Vector = { x: 0, y: 0 }
|
|
|
186
228
|
|
|
187
229
|
const released = (): ButtonState => ({ held: false, pressed: false, released: false, aim: null, cancelled: false })
|
|
188
230
|
|
|
189
|
-
/**
|
|
190
|
-
export
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
231
|
+
/** The names a layout's controls report, for {@link idle} and {@link scripted}. */
|
|
232
|
+
export interface Names<B extends string, V extends string = never> {
|
|
233
|
+
readonly buttons: ReadonlyArray<B>
|
|
234
|
+
/** The vector action the stick drives, as the layout's `stick.vector`. */
|
|
235
|
+
readonly vector?: V
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A snapshot with nothing held, for initializing an input resource before
|
|
240
|
+
* the first capture: pass the layout's button names and stick vector, as
|
|
241
|
+
* `Touch.idle({ buttons: ["attack", "dash"], vector: "move" })`.
|
|
242
|
+
*/
|
|
243
|
+
export const idle = <const B extends string, const V extends string = never>(names: Names<B, V>): Snapshot<B, V> =>
|
|
244
|
+
snapshotOf<B, V>(
|
|
245
|
+
names.vector,
|
|
246
|
+
{ active: false, vector: zero },
|
|
247
|
+
Object.fromEntries(names.buttons.map((button) => [button, released()])) as { readonly [K in B]: ButtonState },
|
|
248
|
+
{ active: false, delta: zero }
|
|
249
|
+
)
|
|
196
250
|
|
|
197
251
|
/** `offset / radius`, clamped to length 1, with the inner `deadZone` fraction mapped to zero. */
|
|
198
252
|
const tilt = (offset: Position, radius: number, deadZone: number): Vector => {
|
|
@@ -261,14 +315,15 @@ const lift = (track: ButtonTrack, cancelled: boolean): void => {
|
|
|
261
315
|
* Starts tracking touches over `target` with the controls `layout` returns
|
|
262
316
|
* for the element's current size (it is read again on every new touch, so a
|
|
263
317
|
* resize takes effect at the next touch). `blurHost` (usually `window`)
|
|
264
|
-
* cancels held controls when the page loses focus.
|
|
318
|
+
* cancels held controls when the page loses focus. Use `Pointer.actions`
|
|
319
|
+
* for mouse and pen input.
|
|
265
320
|
*/
|
|
266
|
-
export const
|
|
321
|
+
export const actions = <const B extends string, const V extends string = never>(
|
|
267
322
|
target: TouchTarget,
|
|
268
323
|
blurHost: BlurHost,
|
|
269
|
-
layout: (size: Size) => Layout<B>,
|
|
270
|
-
options:
|
|
271
|
-
):
|
|
324
|
+
layout: (size: Size) => Layout<B, V>,
|
|
325
|
+
options: ActionsOptions = {}
|
|
326
|
+
): Actions<B, V> => {
|
|
272
327
|
const pointerTypes = options.pointerTypes ?? ["touch"]
|
|
273
328
|
const sizeOf = (): Size => {
|
|
274
329
|
const rect = target.getBoundingClientRect()
|
|
@@ -277,7 +332,6 @@ export const track = <const B extends string>(
|
|
|
277
332
|
let current = layout(sizeOf())
|
|
278
333
|
const names = Object.keys(current.buttons) as Array<B>
|
|
279
334
|
const buttons = makeButtonTracks(names)
|
|
280
|
-
let touched = false
|
|
281
335
|
let stick: { readonly pointerId: number; readonly origin: Position; knob: Position } | undefined
|
|
282
336
|
/** Which button each finger holds. */
|
|
283
337
|
const fingers = new Map<number, B>()
|
|
@@ -298,7 +352,6 @@ export const track = <const B extends string>(
|
|
|
298
352
|
// A pointer id that comes down again without an up in between (the up
|
|
299
353
|
// was lost) releases what it held first, as a cancel.
|
|
300
354
|
end(event, true)
|
|
301
|
-
touched = true
|
|
302
355
|
current = layout(sizeOf())
|
|
303
356
|
const at = locate(event)
|
|
304
357
|
for (const name of names) {
|
|
@@ -382,12 +435,12 @@ export const track = <const B extends string>(
|
|
|
382
435
|
|
|
383
436
|
return {
|
|
384
437
|
snapshot() {
|
|
385
|
-
const snapshot
|
|
386
|
-
stick
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
438
|
+
const snapshot = snapshotOf<B, V>(
|
|
439
|
+
current.stick.vector,
|
|
440
|
+
stickState(),
|
|
441
|
+
Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
|
|
442
|
+
{ active: swipe !== undefined, delta: swiped }
|
|
443
|
+
)
|
|
391
444
|
for (const button of buttons.values()) startWindow(button)
|
|
392
445
|
swiped = zero
|
|
393
446
|
return snapshot
|
|
@@ -407,7 +460,7 @@ export const track = <const B extends string>(
|
|
|
407
460
|
buttons: Object.fromEntries(names.map((name) => {
|
|
408
461
|
const button = buttons.get(name)!
|
|
409
462
|
return [name, { held: button.held, aim: button.held ? button.aim : null }]
|
|
410
|
-
})) as View<B>["buttons"]
|
|
463
|
+
})) as View<B, V>["buttons"]
|
|
411
464
|
}
|
|
412
465
|
},
|
|
413
466
|
dispose() {
|
|
@@ -445,17 +498,18 @@ export interface TimelineEntry<B extends string> {
|
|
|
445
498
|
export type Timeline<B extends string> = ReadonlyArray<TimelineEntry<B>>
|
|
446
499
|
|
|
447
500
|
/** Touch controls driven by a timeline instead of an element. */
|
|
448
|
-
export interface Scripted<B extends string> extends
|
|
501
|
+
export interface Scripted<B extends string, V extends string = never> extends Actions<B, V> {
|
|
449
502
|
/** Number of snapshots taken so far, which is the next frame index. */
|
|
450
503
|
readonly frames: () => number
|
|
451
504
|
}
|
|
452
505
|
|
|
453
506
|
/**
|
|
454
|
-
* Plays `timeline` over controls
|
|
455
|
-
*
|
|
456
|
-
*
|
|
507
|
+
* Plays `timeline` over controls with `names` (the buttons, and the vector
|
|
508
|
+
* action the stick drives): before the snapshot at index `frame`, every
|
|
509
|
+
* entry for that frame is applied. Vectors longer than 1 are clamped.
|
|
457
510
|
*/
|
|
458
|
-
export const scripted = <const B extends string>(
|
|
511
|
+
export const scripted = <const B extends string, const V extends string = never>(names: Names<B, V>, timeline: Timeline<NoInfer<B>>): Scripted<B, V> => {
|
|
512
|
+
const buttons = names.buttons
|
|
459
513
|
const byFrame = new Map<number, Array<TimelineEntry<B>>>()
|
|
460
514
|
for (const entry of timeline) {
|
|
461
515
|
const entries = byFrame.get(entry.frame)
|
|
@@ -468,10 +522,9 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
468
522
|
}
|
|
469
523
|
const tracks = makeButtonTracks(buttons)
|
|
470
524
|
let stick: Vector | null = null
|
|
471
|
-
let touched = false
|
|
472
525
|
let frame = 0
|
|
473
|
-
const layout: Layout<B> = {
|
|
474
|
-
stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1 },
|
|
526
|
+
const layout: Layout<B, V> = {
|
|
527
|
+
stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1, ...(names.vector === undefined ? {} : { vector: names.vector }) },
|
|
475
528
|
buttons: Object.fromEntries(buttons.map((button) => [button, { center: zero, radius: 0 }])) as Layout<B>["buttons"]
|
|
476
529
|
}
|
|
477
530
|
return {
|
|
@@ -479,7 +532,6 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
479
532
|
for (const track of tracks.values()) startWindow(track)
|
|
480
533
|
const swipe = { active: false, x: 0, y: 0 }
|
|
481
534
|
for (const entry of byFrame.get(frame) ?? []) {
|
|
482
|
-
touched = true
|
|
483
535
|
if (entry.swipe !== undefined) {
|
|
484
536
|
swipe.active = true
|
|
485
537
|
swipe.x += entry.swipe.x
|
|
@@ -495,12 +547,12 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
495
547
|
for (const name of entry.cancel ?? []) lift(tracks.get(name)!, true)
|
|
496
548
|
}
|
|
497
549
|
frame += 1
|
|
498
|
-
return
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
550
|
+
return snapshotOf<B, V>(
|
|
551
|
+
names.vector,
|
|
552
|
+
{ active: stick !== null, vector: stick ?? zero },
|
|
553
|
+
Object.fromEntries(buttons.map((name) => [name, buttonSnapshot(tracks.get(name)!)])) as { readonly [K in B]: ButtonState },
|
|
554
|
+
{ active: swipe.active, delta: { x: swipe.x, y: swipe.y } }
|
|
555
|
+
)
|
|
504
556
|
},
|
|
505
557
|
view() {
|
|
506
558
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Browser host integrations kept outside the renderer-agnostic ECS core:
|
|
3
|
+
* animation-frame timing (`BrowserTickSource`, `BrowserHostClock`), input
|
|
4
|
+
* devices bound to actions (`Keyboard`, `Gamepad`, `Pointer`, `Touch`),
|
|
5
|
+
* merging and local multiplayer (`Controls`), and the system that copies
|
|
6
|
+
* input into a resource (`InputCapture`).
|
|
7
|
+
*/
|
|
2
8
|
export * as BrowserHostClock from "./BrowserHostClock.ts"
|
|
3
9
|
export * as BrowserTickSource from "./BrowserTickSource.ts"
|
|
10
|
+
export * as Controls from "./Controls.ts"
|
|
11
|
+
export * as Gamepad from "./Gamepad.ts"
|
|
4
12
|
export * as InputCapture from "./InputCapture.ts"
|
|
5
13
|
export * as Keyboard from "./Keyboard.ts"
|
|
6
14
|
export * as Pointer from "./Pointer.ts"
|
|
7
15
|
export * as Touch from "./Touch.ts"
|
|
8
16
|
|
|
17
|
+
/** The package's tag, `"browser"`. */
|
|
9
18
|
export const packageTag = "browser" as const
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import * as Result from "@typeonce/ritmo/Result"
|
|
2
|
+
|
|
3
|
+
import type { ActionState } from "./state.ts"
|
|
4
|
+
import { directionName, directions, isVectorBinding, zero } from "./vectors.ts"
|
|
5
|
+
import type { Direction, Vector } from "./vectors.ts"
|
|
6
|
+
|
|
7
|
+
const idleState: ActionState = { held: false, pressed: false, released: false }
|
|
8
|
+
|
|
9
|
+
/** Action name to the buttons that trigger it, for a device with named buttons. */
|
|
10
|
+
type ButtonBindings<Button extends string> = Readonly<Record<string, readonly [Button, ...Array<Button>]>>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The held buttons, the bound actions, and the edges since the previous
|
|
14
|
+
* take, shared by devices with named buttons (`Gamepad`, `Pointer`) and by
|
|
15
|
+
* their live and scripted forms, so all of them build snapshots the same way.
|
|
16
|
+
*/
|
|
17
|
+
export const buttonTracker = <Button extends string, B extends ButtonBindings<Button>>(
|
|
18
|
+
buttonNames: ReadonlyArray<Button>,
|
|
19
|
+
initial: B
|
|
20
|
+
) => {
|
|
21
|
+
// Fixed at creation: a rebind swaps buttons, never the actions.
|
|
22
|
+
const names = Object.keys(initial) as Array<keyof B & string>
|
|
23
|
+
let bindings: Readonly<Record<keyof B, ReadonlyArray<Button>>> = initial
|
|
24
|
+
const actionsByButton = new Map<Button, Array<keyof B & string>>()
|
|
25
|
+
const index = (): void => {
|
|
26
|
+
actionsByButton.clear()
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
for (const button of bindings[name]) {
|
|
29
|
+
const bound = actionsByButton.get(button)
|
|
30
|
+
if (bound) bound.push(name)
|
|
31
|
+
else actionsByButton.set(button, [name])
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
index()
|
|
36
|
+
const held = new Set<Button>()
|
|
37
|
+
const pressed = new Set<Button>()
|
|
38
|
+
const released = new Set<Button>()
|
|
39
|
+
/**
|
|
40
|
+
* Each held button and the actions it holds: those bound to it when it
|
|
41
|
+
* went down that are still bound to it.
|
|
42
|
+
*/
|
|
43
|
+
const holding = new Map<Button, Set<keyof B & string>>()
|
|
44
|
+
const pressedActions = new Set<keyof B>()
|
|
45
|
+
const releasedActions = new Set<keyof B>()
|
|
46
|
+
const isHeld = (name: keyof B & string): boolean => bindings[name].some((button) => holding.get(button)?.has(name) === true)
|
|
47
|
+
const up = (button: Button): void => {
|
|
48
|
+
if (!held.delete(button)) return
|
|
49
|
+
released.add(button)
|
|
50
|
+
for (const name of holding.get(button) ?? []) releasedActions.add(name)
|
|
51
|
+
holding.delete(button)
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
held,
|
|
55
|
+
down(button: Button) {
|
|
56
|
+
if (held.has(button)) return
|
|
57
|
+
held.add(button)
|
|
58
|
+
pressed.add(button)
|
|
59
|
+
const bound = actionsByButton.get(button) ?? []
|
|
60
|
+
holding.set(button, new Set(bound))
|
|
61
|
+
for (const name of bound) pressedActions.add(name)
|
|
62
|
+
},
|
|
63
|
+
up,
|
|
64
|
+
upAll() {
|
|
65
|
+
for (const button of [...held]) up(button)
|
|
66
|
+
},
|
|
67
|
+
rebind(next: Readonly<Record<keyof B, ReadonlyArray<Button>>>) {
|
|
68
|
+
const before = names.filter(isHeld)
|
|
69
|
+
bindings = next
|
|
70
|
+
index()
|
|
71
|
+
// A held button keeps only the actions still bound to it; new ones wait for a fresh press.
|
|
72
|
+
for (const [button, actions] of holding) {
|
|
73
|
+
const bound = actionsByButton.get(button) ?? []
|
|
74
|
+
for (const name of actions) if (!bound.includes(name)) actions.delete(name)
|
|
75
|
+
}
|
|
76
|
+
for (const name of before) if (!isHeld(name)) releasedActions.add(name)
|
|
77
|
+
},
|
|
78
|
+
/** The buttons and actions since the previous take; starts a new press/release window. */
|
|
79
|
+
take(): { readonly actions: Record<keyof B, ActionState>; readonly buttons: Record<Button, ActionState> } {
|
|
80
|
+
const buttons = {} as Record<Button, ActionState>
|
|
81
|
+
for (const name of buttonNames) {
|
|
82
|
+
const isDown = held.has(name)
|
|
83
|
+
buttons[name] = isDown || pressed.has(name) || released.has(name)
|
|
84
|
+
? { held: isDown, pressed: pressed.has(name), released: released.has(name) && !isDown }
|
|
85
|
+
: idleState
|
|
86
|
+
}
|
|
87
|
+
const actions = {} as Record<keyof B, ActionState>
|
|
88
|
+
for (const name of names) {
|
|
89
|
+
const isDown = isHeld(name)
|
|
90
|
+
actions[name] = { held: isDown, pressed: pressedActions.has(name), released: releasedActions.has(name) && !isDown }
|
|
91
|
+
}
|
|
92
|
+
pressed.clear()
|
|
93
|
+
released.clear()
|
|
94
|
+
pressedActions.clear()
|
|
95
|
+
releasedActions.clear()
|
|
96
|
+
return { actions, buttons }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every button idle, by name. */
|
|
102
|
+
export const idleButtons = <Button extends string>(buttonNames: ReadonlyArray<Button>): Record<Button, ActionState> => {
|
|
103
|
+
const buttons = {} as Record<Button, ActionState>
|
|
104
|
+
for (const name of buttonNames) buttons[name] = idleState
|
|
105
|
+
return buttons
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Every button action of `bindings` idle (vector actions are in {@link idleVectors}). */
|
|
109
|
+
export const idleActions = <B extends object>(bindings: B): Record<keyof B, ActionState> => {
|
|
110
|
+
const actions = {} as Record<keyof B, ActionState>
|
|
111
|
+
for (const [name, entry] of Object.entries(bindings)) if (!isVectorBinding(entry)) actions[name as keyof B] = idleState
|
|
112
|
+
return actions
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every vector action of `bindings` at rest. */
|
|
116
|
+
export const idleVectors = (bindings: object): Record<string, Vector> => {
|
|
117
|
+
const vectors: Record<string, Vector> = {}
|
|
118
|
+
for (const [name, entry] of Object.entries(bindings)) if (isVectorBinding(entry)) vectors[name] = zero
|
|
119
|
+
return vectors
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A vector binding's digital part: buttons for the four directions, or none. */
|
|
123
|
+
interface DigitalVector<Button extends string> {
|
|
124
|
+
readonly _tag: "Vector"
|
|
125
|
+
readonly up?: ReadonlyArray<Button>
|
|
126
|
+
readonly down?: ReadonlyArray<Button>
|
|
127
|
+
readonly left?: ReadonlyArray<Button>
|
|
128
|
+
readonly right?: ReadonlyArray<Button>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Each action's buttons for the button tracker, with the digital part of
|
|
133
|
+
* every vector action split into its four directions (`move.up`, ...).
|
|
134
|
+
*/
|
|
135
|
+
export const flattenButtons = <Button extends string>(bindings: object): Record<string, readonly [Button, ...Array<Button>]> => {
|
|
136
|
+
const flat: Record<string, readonly [Button, ...Array<Button>]> = {}
|
|
137
|
+
for (const [name, entry] of Object.entries(bindings)) {
|
|
138
|
+
if (isVectorBinding(entry)) {
|
|
139
|
+
const vector = entry as DigitalVector<Button>
|
|
140
|
+
// All four, empty when unbound (a stick-only vector), so the tracker's
|
|
141
|
+
// names stay the same whichever way a rebind binds the vector.
|
|
142
|
+
for (const direction of directions) {
|
|
143
|
+
flat[directionName(name, direction)] = (vector[direction] ?? []) as unknown as readonly [Button, ...Array<Button>]
|
|
144
|
+
}
|
|
145
|
+
} else flat[name] = entry as readonly [Button, ...Array<Button>]
|
|
146
|
+
}
|
|
147
|
+
return flat
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Splits taken flat actions into the button actions of `bindings`, and whether each vector direction is held. */
|
|
151
|
+
export const splitActions = (
|
|
152
|
+
bindings: object,
|
|
153
|
+
taken: Readonly<Record<string, ActionState>>
|
|
154
|
+
): { readonly actions: Record<string, ActionState>; readonly held: (vector: string, direction: Direction) => boolean } => {
|
|
155
|
+
const actions: Record<string, ActionState> = {}
|
|
156
|
+
for (const [name, entry] of Object.entries(bindings)) if (!isVectorBinding(entry)) actions[name] = taken[name]!
|
|
157
|
+
return { actions, held: (vector, direction) => taken[directionName(vector, direction)]?.held === true }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Why loaded data is not a rebinding; the devices export it under their own name. */
|
|
161
|
+
export interface InvalidRebinding {
|
|
162
|
+
readonly _tag: "InvalidRebinding"
|
|
163
|
+
readonly path: string
|
|
164
|
+
readonly message: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Validates `data` as new buttons for exactly the actions of `bindings`:
|
|
169
|
+
* an object with the same action names, each a non-empty array of button
|
|
170
|
+
* names.
|
|
171
|
+
*/
|
|
172
|
+
export const parseButtonRebinding = <Button extends string>(
|
|
173
|
+
buttonNames: ReadonlyArray<Button>,
|
|
174
|
+
bindings: object,
|
|
175
|
+
data: unknown,
|
|
176
|
+
sticks: ReadonlyArray<string> = []
|
|
177
|
+
): Result.Result<Readonly<Record<string, unknown>>, InvalidRebinding> => {
|
|
178
|
+
const invalid = (path: string, message: string) => Result.failure<InvalidRebinding>({ _tag: "InvalidRebinding", path, message })
|
|
179
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return invalid("", "expected an object of action names")
|
|
180
|
+
const fields = data as Readonly<Record<string, unknown>>
|
|
181
|
+
const names = Object.keys(bindings)
|
|
182
|
+
for (const name of Object.keys(fields)) {
|
|
183
|
+
if (!names.includes(name)) return invalid(name, `unknown action, expected one of ${names.join(", ")}`)
|
|
184
|
+
}
|
|
185
|
+
const known: ReadonlySet<string> = new Set(buttonNames)
|
|
186
|
+
const buttonsAt = (value: unknown, path: string): Result.Result<ReadonlyArray<Button>, InvalidRebinding> => {
|
|
187
|
+
if (!Array.isArray(value) || value.length === 0) return invalid(path, "expected a non-empty array of button names")
|
|
188
|
+
for (let offset = 0; offset < value.length; offset++) {
|
|
189
|
+
if (!known.has(value[offset])) return invalid(`${path}[${offset}]`, `expected one of ${buttonNames.join(", ")}`)
|
|
190
|
+
}
|
|
191
|
+
return Result.success([...value])
|
|
192
|
+
}
|
|
193
|
+
const rebinding: Record<string, unknown> = {}
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
const value = fields[name]
|
|
196
|
+
if (isVectorBinding((bindings as Readonly<Record<string, unknown>>)[name])) {
|
|
197
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
198
|
+
return invalid(name, "expected { stick } and/or { up, down, left, right } buttons for a vector action")
|
|
199
|
+
}
|
|
200
|
+
const parts = value as Readonly<Record<string, unknown>>
|
|
201
|
+
const vector: Record<string, unknown> = { _tag: "Vector" }
|
|
202
|
+
if (parts["stick"] !== undefined) {
|
|
203
|
+
if (typeof parts["stick"] !== "string" || !sticks.includes(parts["stick"])) return invalid(`${name}.stick`, `expected one of ${sticks.join(", ")}`)
|
|
204
|
+
vector["stick"] = parts["stick"]
|
|
205
|
+
}
|
|
206
|
+
const given = directions.filter((direction) => parts[direction] !== undefined)
|
|
207
|
+
if (given.length > 0) {
|
|
208
|
+
for (const direction of directions) {
|
|
209
|
+
const buttons = buttonsAt(parts[direction], `${name}.${direction}`)
|
|
210
|
+
if (!buttons.ok) return buttons
|
|
211
|
+
vector[direction] = buttons.value
|
|
212
|
+
}
|
|
213
|
+
} else if (vector["stick"] === undefined) return invalid(name, "expected a stick or buttons for up, down, left, and right")
|
|
214
|
+
rebinding[name] = vector
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
const buttons = buttonsAt(value, name)
|
|
218
|
+
if (!buttons.ok) return buttons
|
|
219
|
+
rebinding[name] = buttons.value
|
|
220
|
+
}
|
|
221
|
+
return Result.success(rebinding)
|
|
222
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keys a keyboard device binds now, as they read in a lint (`" "`,
|
|
3
|
+
* `KeyW`): what a player pool compares to report two players sharing a key.
|
|
4
|
+
* Live and scripted keyboards implement it.
|
|
5
|
+
*/
|
|
6
|
+
export const boundKeys = Symbol("ritmo-browser/boundKeys")
|
|
7
|
+
|
|
8
|
+
export interface ReportsBoundKeys {
|
|
9
|
+
readonly [boundKeys]?: () => ReadonlyArray<string>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Every source a device was handed to (a merge, a pool), to report a device used twice. */
|
|
13
|
+
const owners = new WeakMap<object, Array<string>>()
|
|
14
|
+
|
|
15
|
+
/** Records that `owner` takes the snapshots of `device`. */
|
|
16
|
+
export const claim = (device: object, owner: string): void => {
|
|
17
|
+
const list = owners.get(device)
|
|
18
|
+
if (list === undefined) owners.set(device, [owner])
|
|
19
|
+
else list.push(owner)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The sources that take the snapshots of `device`. */
|
|
23
|
+
export const ownersOf = (device: object): ReadonlyArray<string> => owners.get(device) ?? []
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One button action's state in a snapshot, the same on every device
|
|
3
|
+
* (`Keyboard`, `Gamepad`, `Pointer`, `Touch`) and in `Controls`.
|
|
4
|
+
*/
|
|
5
|
+
export interface ActionState {
|
|
6
|
+
/** At least one bound key or button is down now. */
|
|
7
|
+
readonly held: boolean
|
|
8
|
+
/** A bound key or button went down since the previous snapshot, even if already released. */
|
|
9
|
+
readonly pressed: boolean
|
|
10
|
+
/** A bound key or button went up since the previous snapshot and none is held now. */
|
|
11
|
+
readonly released: boolean
|
|
12
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ActionState } from "./state.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether any action in a snapshot was pressed or is held: the `used` of a
|
|
5
|
+
* device's snapshot. Releases do not count, since a blur or a disconnect
|
|
6
|
+
* releases everything without the player touching the device.
|
|
7
|
+
*/
|
|
8
|
+
export const anyUsed = (actions: Readonly<Record<string, ActionState>>): boolean => {
|
|
9
|
+
for (const name in actions) {
|
|
10
|
+
const state = actions[name]!
|
|
11
|
+
if (state.held || state.pressed) return true
|
|
12
|
+
}
|
|
13
|
+
return false
|
|
14
|
+
}
|