@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/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,14 +38,16 @@
|
|
|
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
|
*/
|
|
49
|
+
import type * as System from "@typeonce/ritmo/System"
|
|
50
|
+
import { anyUsed } from "./internal/used.ts"
|
|
48
51
|
import type { ActionState } from "./Keyboard.ts"
|
|
49
52
|
|
|
50
53
|
/** A position in CSS pixels relative to the element's top-left corner. */
|
|
@@ -59,6 +62,7 @@ export interface Vector {
|
|
|
59
62
|
readonly y: number
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/** A rectangle in CSS pixels relative to the element's top-left corner. */
|
|
62
66
|
export interface Rect {
|
|
63
67
|
readonly left: number
|
|
64
68
|
readonly top: number
|
|
@@ -66,15 +70,19 @@ export interface Rect {
|
|
|
66
70
|
readonly height: number
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
|
|
73
|
+
/** The floating stick of a {@link Layout}. */
|
|
74
|
+
export interface StickZone<V extends string = never> {
|
|
70
75
|
/** Where a touch starts the stick. The stick is centered where the thumb lands. */
|
|
71
76
|
readonly region: Rect
|
|
72
77
|
/** Drag distance in CSS pixels for full tilt. */
|
|
73
78
|
readonly radius: number
|
|
74
79
|
/** Fraction of `radius` that reads as zero. Defaults to 0.15. */
|
|
75
80
|
readonly deadZone?: number
|
|
81
|
+
/** The vector action the stick drives (`"move"`), reported in the snapshot's `vectors`. */
|
|
82
|
+
readonly vector?: V
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
/** A round on-screen button of a {@link Layout}; it reports the action named like its key. */
|
|
78
86
|
export interface ButtonZone {
|
|
79
87
|
readonly center: Position
|
|
80
88
|
/** Touch radius in CSS pixels. */
|
|
@@ -85,13 +93,18 @@ export interface ButtonZone {
|
|
|
85
93
|
readonly aimDeadZone?: number
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/** The swipe area of a {@link Layout}. */
|
|
88
97
|
export interface SwipeZone {
|
|
89
98
|
/** Where a touch that is not on a button or the stick starts a swipe. */
|
|
90
99
|
readonly region: Rect
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The touch controls, returned by the layout function {@link actions} calls
|
|
104
|
+
* with the element's size. The button names are fixed by the first layout.
|
|
105
|
+
*/
|
|
106
|
+
export interface Layout<B extends string, V extends string = never> {
|
|
107
|
+
readonly stick: StickZone<V>
|
|
95
108
|
readonly buttons: { readonly [K in B]: ButtonZone }
|
|
96
109
|
/** Optional: fingers here report their movement, for turning or looking around. */
|
|
97
110
|
readonly swipe?: SwipeZone
|
|
@@ -103,6 +116,7 @@ export interface Size {
|
|
|
103
116
|
readonly height: number
|
|
104
117
|
}
|
|
105
118
|
|
|
119
|
+
/** The stick's state in a snapshot. */
|
|
106
120
|
export interface Stick {
|
|
107
121
|
/** Whether a thumb is on the stick. */
|
|
108
122
|
readonly active: boolean
|
|
@@ -110,6 +124,7 @@ export interface Stick {
|
|
|
110
124
|
readonly vector: Vector
|
|
111
125
|
}
|
|
112
126
|
|
|
127
|
+
/** One button's state: its action's edges, plus the drag aim and whether the release was cancelled. */
|
|
113
128
|
export interface ButtonState extends ActionState {
|
|
114
129
|
/**
|
|
115
130
|
* The drag from the button's center as a fraction of `aimRadius` (length at
|
|
@@ -121,6 +136,7 @@ export interface ButtonState extends ActionState {
|
|
|
121
136
|
readonly cancelled: boolean
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
/** The swipe area's state in a snapshot. */
|
|
124
140
|
export interface Swipe {
|
|
125
141
|
/** Whether a finger is on the swipe area. */
|
|
126
142
|
readonly active: boolean
|
|
@@ -128,27 +144,57 @@ export interface Swipe {
|
|
|
128
144
|
readonly delta: Vector
|
|
129
145
|
}
|
|
130
146
|
|
|
131
|
-
|
|
147
|
+
/**
|
|
148
|
+
* The controls' state. Each button is an action named like it, and the
|
|
149
|
+
* stick drives the vector action its layout names, so the snapshot has the
|
|
150
|
+
* same core as the other devices' (`actions`, `vectors`, and `used`) and
|
|
151
|
+
* `Controls` can merge it.
|
|
152
|
+
*/
|
|
153
|
+
export interface Snapshot<B extends string, V extends string = never> {
|
|
132
154
|
readonly stick: Stick
|
|
133
|
-
readonly
|
|
155
|
+
readonly actions: { readonly [K in B]: ButtonState }
|
|
156
|
+
/** The vector action the stick drives, when the layout names one: the stick's vector. */
|
|
157
|
+
readonly vectors: { readonly [K in V]: Vector }
|
|
134
158
|
readonly swipe: Swipe
|
|
135
|
-
/**
|
|
136
|
-
|
|
159
|
+
/**
|
|
160
|
+
* A button was pressed in this window or is held, or a finger is on the
|
|
161
|
+
* stick or the swipe area: the player is using the touch controls.
|
|
162
|
+
*/
|
|
163
|
+
readonly used: boolean
|
|
137
164
|
}
|
|
138
165
|
|
|
166
|
+
const snapshotOf = <B extends string, V extends string>(
|
|
167
|
+
vector: V | undefined,
|
|
168
|
+
stick: Stick,
|
|
169
|
+
actions: { readonly [K in B]: ButtonState },
|
|
170
|
+
swipe: Swipe
|
|
171
|
+
): Snapshot<B, V> => ({
|
|
172
|
+
stick,
|
|
173
|
+
actions,
|
|
174
|
+
vectors: (vector === undefined ? {} : { [vector]: stick.vector }) as { readonly [K in V]: Vector },
|
|
175
|
+
swipe,
|
|
176
|
+
used: stick.active || swipe.active || anyUsed(actions)
|
|
177
|
+
})
|
|
178
|
+
|
|
139
179
|
/** Live control positions for drawing, in CSS pixels. Reading it consumes nothing. */
|
|
140
|
-
export interface View<B extends string> {
|
|
141
|
-
readonly layout: Layout<B>
|
|
180
|
+
export interface View<B extends string, V extends string = never> {
|
|
181
|
+
readonly layout: Layout<B, V>
|
|
142
182
|
/** Where the stick was centered and where the thumb is, while it is held. */
|
|
143
183
|
readonly stick: { readonly origin: Position; readonly knob: Position } | null
|
|
144
184
|
readonly buttons: { readonly [K in B]: { readonly held: boolean; readonly aim: Vector | null } }
|
|
145
185
|
}
|
|
146
186
|
|
|
147
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Touch controls, built with {@link actions} (live) or {@link scripted}.
|
|
189
|
+
* Capture them with `InputCapture.system(...)`, or merge them with
|
|
190
|
+
* `Controls` (through `lacking(...)` for actions with no on-screen
|
|
191
|
+
* control). There is no `rebind`: the layout is the binding.
|
|
192
|
+
*/
|
|
193
|
+
export interface Actions<B extends string, V extends string = never> extends System.Consumes<"snapshot"> {
|
|
148
194
|
/** Returns the controls' state and starts a new press/release window. */
|
|
149
|
-
snapshot(): Snapshot<B>
|
|
195
|
+
snapshot(): Snapshot<B, V>
|
|
150
196
|
/** The live state for drawing. */
|
|
151
|
-
view(): View<B>
|
|
197
|
+
view(): View<B, V>
|
|
152
198
|
/** Removes the event listeners and restores the element's `touch-action`. */
|
|
153
199
|
dispose(): void
|
|
154
200
|
}
|
|
@@ -177,7 +223,8 @@ export interface BlurHost {
|
|
|
177
223
|
removeEventListener(type: "blur", listener: () => void): void
|
|
178
224
|
}
|
|
179
225
|
|
|
180
|
-
|
|
226
|
+
/** Options for {@link actions}. */
|
|
227
|
+
export interface ActionsOptions {
|
|
181
228
|
/** `PointerEvent.pointerType`s that drive the controls. Defaults to `["touch"]`. */
|
|
182
229
|
readonly pointerTypes?: ReadonlyArray<string>
|
|
183
230
|
}
|
|
@@ -186,13 +233,25 @@ const zero: Vector = { x: 0, y: 0 }
|
|
|
186
233
|
|
|
187
234
|
const released = (): ButtonState => ({ held: false, pressed: false, released: false, aim: null, cancelled: false })
|
|
188
235
|
|
|
189
|
-
/**
|
|
190
|
-
export
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
236
|
+
/** The names a layout's controls report, for {@link idle} and {@link scripted}. */
|
|
237
|
+
export interface Names<B extends string, V extends string = never> {
|
|
238
|
+
readonly buttons: ReadonlyArray<B>
|
|
239
|
+
/** The vector action the stick drives, as the layout's `stick.vector`. */
|
|
240
|
+
readonly vector?: V
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A snapshot with nothing held, for initializing an input resource before
|
|
245
|
+
* the first capture: pass the layout's button names and stick vector, as
|
|
246
|
+
* `Touch.idle({ buttons: ["attack", "dash"], vector: "move" })`.
|
|
247
|
+
*/
|
|
248
|
+
export const idle = <const B extends string, const V extends string = never>(names: Names<B, V>): Snapshot<B, V> =>
|
|
249
|
+
snapshotOf<B, V>(
|
|
250
|
+
names.vector,
|
|
251
|
+
{ active: false, vector: zero },
|
|
252
|
+
Object.fromEntries(names.buttons.map((button) => [button, released()])) as { readonly [K in B]: ButtonState },
|
|
253
|
+
{ active: false, delta: zero }
|
|
254
|
+
)
|
|
196
255
|
|
|
197
256
|
/** `offset / radius`, clamped to length 1, with the inner `deadZone` fraction mapped to zero. */
|
|
198
257
|
const tilt = (offset: Position, radius: number, deadZone: number): Vector => {
|
|
@@ -261,14 +320,15 @@ const lift = (track: ButtonTrack, cancelled: boolean): void => {
|
|
|
261
320
|
* Starts tracking touches over `target` with the controls `layout` returns
|
|
262
321
|
* for the element's current size (it is read again on every new touch, so a
|
|
263
322
|
* resize takes effect at the next touch). `blurHost` (usually `window`)
|
|
264
|
-
* cancels held controls when the page loses focus.
|
|
323
|
+
* cancels held controls when the page loses focus. Use `Pointer.actions`
|
|
324
|
+
* for mouse and pen input.
|
|
265
325
|
*/
|
|
266
|
-
export const
|
|
326
|
+
export const actions = <const B extends string, const V extends string = never>(
|
|
267
327
|
target: TouchTarget,
|
|
268
328
|
blurHost: BlurHost,
|
|
269
|
-
layout: (size: Size) => Layout<B>,
|
|
270
|
-
options:
|
|
271
|
-
):
|
|
329
|
+
layout: (size: Size) => Layout<B, V>,
|
|
330
|
+
options: ActionsOptions = {}
|
|
331
|
+
): Actions<B, V> => {
|
|
272
332
|
const pointerTypes = options.pointerTypes ?? ["touch"]
|
|
273
333
|
const sizeOf = (): Size => {
|
|
274
334
|
const rect = target.getBoundingClientRect()
|
|
@@ -277,7 +337,6 @@ export const track = <const B extends string>(
|
|
|
277
337
|
let current = layout(sizeOf())
|
|
278
338
|
const names = Object.keys(current.buttons) as Array<B>
|
|
279
339
|
const buttons = makeButtonTracks(names)
|
|
280
|
-
let touched = false
|
|
281
340
|
let stick: { readonly pointerId: number; readonly origin: Position; knob: Position } | undefined
|
|
282
341
|
/** Which button each finger holds. */
|
|
283
342
|
const fingers = new Map<number, B>()
|
|
@@ -298,7 +357,6 @@ export const track = <const B extends string>(
|
|
|
298
357
|
// A pointer id that comes down again without an up in between (the up
|
|
299
358
|
// was lost) releases what it held first, as a cancel.
|
|
300
359
|
end(event, true)
|
|
301
|
-
touched = true
|
|
302
360
|
current = layout(sizeOf())
|
|
303
361
|
const at = locate(event)
|
|
304
362
|
for (const name of names) {
|
|
@@ -382,12 +440,12 @@ export const track = <const B extends string>(
|
|
|
382
440
|
|
|
383
441
|
return {
|
|
384
442
|
snapshot() {
|
|
385
|
-
const snapshot
|
|
386
|
-
stick
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
443
|
+
const snapshot = snapshotOf<B, V>(
|
|
444
|
+
current.stick.vector,
|
|
445
|
+
stickState(),
|
|
446
|
+
Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
|
|
447
|
+
{ active: swipe !== undefined, delta: swiped }
|
|
448
|
+
)
|
|
391
449
|
for (const button of buttons.values()) startWindow(button)
|
|
392
450
|
swiped = zero
|
|
393
451
|
return snapshot
|
|
@@ -407,7 +465,7 @@ export const track = <const B extends string>(
|
|
|
407
465
|
buttons: Object.fromEntries(names.map((name) => {
|
|
408
466
|
const button = buttons.get(name)!
|
|
409
467
|
return [name, { held: button.held, aim: button.held ? button.aim : null }]
|
|
410
|
-
})) as View<B>["buttons"]
|
|
468
|
+
})) as View<B, V>["buttons"]
|
|
411
469
|
}
|
|
412
470
|
},
|
|
413
471
|
dispose() {
|
|
@@ -445,17 +503,18 @@ export interface TimelineEntry<B extends string> {
|
|
|
445
503
|
export type Timeline<B extends string> = ReadonlyArray<TimelineEntry<B>>
|
|
446
504
|
|
|
447
505
|
/** Touch controls driven by a timeline instead of an element. */
|
|
448
|
-
export interface Scripted<B extends string> extends
|
|
506
|
+
export interface Scripted<B extends string, V extends string = never> extends Actions<B, V> {
|
|
449
507
|
/** Number of snapshots taken so far, which is the next frame index. */
|
|
450
508
|
readonly frames: () => number
|
|
451
509
|
}
|
|
452
510
|
|
|
453
511
|
/**
|
|
454
|
-
* Plays `timeline` over controls
|
|
455
|
-
*
|
|
456
|
-
*
|
|
512
|
+
* Plays `timeline` over controls with `names` (the buttons, and the vector
|
|
513
|
+
* action the stick drives): before the snapshot at index `frame`, every
|
|
514
|
+
* entry for that frame is applied. Vectors longer than 1 are clamped.
|
|
457
515
|
*/
|
|
458
|
-
export const scripted = <const B extends string>(
|
|
516
|
+
export const scripted = <const B extends string, const V extends string = never>(names: Names<B, V>, timeline: Timeline<NoInfer<B>>): Scripted<B, V> => {
|
|
517
|
+
const buttons = names.buttons
|
|
459
518
|
const byFrame = new Map<number, Array<TimelineEntry<B>>>()
|
|
460
519
|
for (const entry of timeline) {
|
|
461
520
|
const entries = byFrame.get(entry.frame)
|
|
@@ -468,10 +527,9 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
468
527
|
}
|
|
469
528
|
const tracks = makeButtonTracks(buttons)
|
|
470
529
|
let stick: Vector | null = null
|
|
471
|
-
let touched = false
|
|
472
530
|
let frame = 0
|
|
473
|
-
const layout: Layout<B> = {
|
|
474
|
-
stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1 },
|
|
531
|
+
const layout: Layout<B, V> = {
|
|
532
|
+
stick: { region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1, ...(names.vector === undefined ? {} : { vector: names.vector }) },
|
|
475
533
|
buttons: Object.fromEntries(buttons.map((button) => [button, { center: zero, radius: 0 }])) as Layout<B>["buttons"]
|
|
476
534
|
}
|
|
477
535
|
return {
|
|
@@ -479,7 +537,6 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
479
537
|
for (const track of tracks.values()) startWindow(track)
|
|
480
538
|
const swipe = { active: false, x: 0, y: 0 }
|
|
481
539
|
for (const entry of byFrame.get(frame) ?? []) {
|
|
482
|
-
touched = true
|
|
483
540
|
if (entry.swipe !== undefined) {
|
|
484
541
|
swipe.active = true
|
|
485
542
|
swipe.x += entry.swipe.x
|
|
@@ -495,12 +552,12 @@ export const scripted = <const B extends string>(buttons: ReadonlyArray<B>, time
|
|
|
495
552
|
for (const name of entry.cancel ?? []) lift(tracks.get(name)!, true)
|
|
496
553
|
}
|
|
497
554
|
frame += 1
|
|
498
|
-
return
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
555
|
+
return snapshotOf<B, V>(
|
|
556
|
+
names.vector,
|
|
557
|
+
{ active: stick !== null, vector: stick ?? zero },
|
|
558
|
+
Object.fromEntries(buttons.map((name) => [name, buttonSnapshot(tracks.get(name)!)])) as { readonly [K in B]: ButtonState },
|
|
559
|
+
{ active: swipe.active, delta: { x: swipe.x, y: swipe.y } }
|
|
560
|
+
)
|
|
504
561
|
},
|
|
505
562
|
view() {
|
|
506
563
|
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 "../Keyboard.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,14 @@
|
|
|
1
|
+
import type { ActionState } from "../Keyboard.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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A direction and strength, with length at most 1. `y` grows down the screen. */
|
|
2
|
+
export interface Vector {
|
|
3
|
+
readonly x: number
|
|
4
|
+
readonly y: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** The four directions a digital vector binding names. */
|
|
8
|
+
export const directions = ["up", "down", "left", "right"] as const
|
|
9
|
+
|
|
10
|
+
/** One direction of a vector action; a keyboard timeline names it as `"move.right"`. */
|
|
11
|
+
export type Direction = (typeof directions)[number]
|
|
12
|
+
|
|
13
|
+
export const zero: Vector = { x: 0, y: 0 }
|
|
14
|
+
|
|
15
|
+
/** The internal action name of one direction of a vector, for timelines and the button trackers. */
|
|
16
|
+
export const directionName = (vector: string, direction: Direction): string => `${vector}.${direction}`
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The vector four held directions give: opposite directions cancel and a
|
|
20
|
+
* diagonal has length 1, so it is not faster than a straight line.
|
|
21
|
+
*/
|
|
22
|
+
export const digital = (up: boolean, down: boolean, left: boolean, right: boolean): Vector => {
|
|
23
|
+
const x = (right ? 1 : 0) - (left ? 1 : 0)
|
|
24
|
+
const y = (down ? 1 : 0) - (up ? 1 : 0)
|
|
25
|
+
if (x === 0 && y === 0) return zero
|
|
26
|
+
return x !== 0 && y !== 0 ? { x: x * Math.SQRT1_2, y: y * Math.SQRT1_2 } : { x, y }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lengthSquared = (vector: Vector): number => vector.x * vector.x + vector.y * vector.y
|
|
30
|
+
|
|
31
|
+
/** The longer of two vectors, the first on a tie: how vectors from several sources combine. */
|
|
32
|
+
export const longer = (a: Vector, b: Vector): Vector => lengthSquared(b) > lengthSquared(a) ? b : a
|
|
33
|
+
|
|
34
|
+
/** Whether a binding entry is a vector binding (`Keyboard.vector`, `Gamepad.vector`). */
|
|
35
|
+
export const isVectorBinding = (entry: unknown): entry is { readonly _tag: "Vector" } =>
|
|
36
|
+
typeof entry === "object" && entry !== null && !Array.isArray(entry) && (entry as { readonly _tag?: unknown })._tag === "Vector"
|