@typeonce/ritmo-browser 0.13.0 → 0.14.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 +108 -0
- package/README.md +1 -1
- package/dist/Controls.d.ts.map +1 -1
- package/dist/Controls.js +7 -3
- package/dist/Controls.js.map +1 -1
- package/dist/Keyboard.d.ts +23 -3
- package/dist/Keyboard.d.ts.map +1 -1
- package/dist/Keyboard.js +20 -3
- package/dist/Keyboard.js.map +1 -1
- package/dist/Touch.d.ts +108 -58
- package/dist/Touch.d.ts.map +1 -1
- package/dist/Touch.js +116 -56
- package/dist/Touch.js.map +1 -1
- package/package.json +2 -2
- package/src/Controls.ts +7 -2
- package/src/Keyboard.ts +38 -4
- package/src/Touch.ts +220 -113
package/src/Touch.ts
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Touch controls over one element:
|
|
3
|
-
* aim by dragging: tap to use, drag to aim, release to fire), and an
|
|
4
|
-
* swipe area that reports how far a finger moved, for turning or
|
|
5
|
-
* control (Genshin Impact style: move on the left, swipe anywhere to
|
|
2
|
+
* Touch controls over one element: named floating sticks, buttons (some of
|
|
3
|
+
* which aim by dragging: tap to use, drag to aim, release to fire), and an
|
|
4
|
+
* optional swipe area that reports how far a finger moved, for turning or
|
|
5
|
+
* camera control (Genshin Impact style: move on the left, swipe anywhere to
|
|
6
|
+
* look).
|
|
7
|
+
*
|
|
8
|
+
* A stick's key is the vector it drives and a button's key is the action it
|
|
9
|
+
* reports, so a twin-stick shooter declares `move` and `aim` sticks and
|
|
10
|
+
* reads `vectors.move` and `vectors.aim`. Use a stick for a direction the
|
|
11
|
+
* player holds and changes continuously (walking, aiming a stream of shots);
|
|
12
|
+
* use a button with `aimRadius` for a direction picked once per use (aim a
|
|
13
|
+
* throw, release to fire), which reports the aim on the release.
|
|
6
14
|
*
|
|
7
15
|
* The layout is a function of the element's size, so controls stay anchored
|
|
8
16
|
* to corners on any screen. Each finger belongs to the first control it lands
|
|
9
|
-
* on: a button when it lands inside one, otherwise the
|
|
10
|
-
* the
|
|
11
|
-
* swipe area when it lands in its region.
|
|
12
|
-
* ignored, and a control keeps its finger
|
|
17
|
+
* on: a button when it lands inside one, otherwise the first free stick (in
|
|
18
|
+
* the layout's key order) whose region contains it (the stick then appears
|
|
19
|
+
* under the thumb), otherwise the swipe area when it lands in its region.
|
|
20
|
+
* Fingers that land elsewhere are ignored, and a control keeps its finger
|
|
21
|
+
* until it lifts, so two thumbs drive two sticks at once.
|
|
22
|
+
*
|
|
23
|
+
* A control the game hides (a stick for walking while the player drives) is
|
|
24
|
+
* `null` in the layout. The layout is read again on every snapshot and every
|
|
25
|
+
* new touch, so a control turning `null` under a held finger releases at
|
|
26
|
+
* once, as a cancel, and that finger does nothing until it lifts: a thumb
|
|
27
|
+
* resting on the walking stick does not keep walking a car.
|
|
13
28
|
*
|
|
14
|
-
* Games read one snapshot per update, like keyboard actions:
|
|
29
|
+
* Games read one snapshot per update, like keyboard actions: each stick's
|
|
15
30
|
* vector, and per button `held`/`pressed`/`released` edges that survive taps
|
|
16
31
|
* shorter than a frame, plus the drag `aim`, which is still reported on the
|
|
17
32
|
* release. A touch the browser cancels (a system gesture, the window losing
|
|
@@ -29,18 +44,19 @@
|
|
|
29
44
|
* @example
|
|
30
45
|
* ```ts
|
|
31
46
|
* const touch = Touch.actions(canvas, window, ({ width, height }) => ({
|
|
32
|
-
* //
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* }
|
|
47
|
+
* // Each stick drives the vector named like its key, so it merges into `Controls`.
|
|
48
|
+
* sticks: {
|
|
49
|
+
* move: { region: { left: 0, top: 0, width: width / 2, height }, radius: 56 },
|
|
50
|
+
* aim: { region: { left: width / 2, top: 0, width: width / 2, height }, radius: 56 }
|
|
51
|
+
* },
|
|
52
|
+
* buttons: { dash: { center: { x: width - 84, y: height - 150 }, radius: 36 } }
|
|
38
53
|
* }))
|
|
39
54
|
*
|
|
40
55
|
* // Once per update:
|
|
41
56
|
* const { vectors, actions } = touch.snapshot()
|
|
42
57
|
* move(vectors.move)
|
|
43
|
-
* if (
|
|
58
|
+
* if (vectors.aim.active) shoot(vectors.aim)
|
|
59
|
+
* if (actions.dash.pressed) dash()
|
|
44
60
|
* ```
|
|
45
61
|
*
|
|
46
62
|
* For tests and headless runs, `scripted(...)` plays a timeline of stick
|
|
@@ -65,18 +81,28 @@ export interface Rect {
|
|
|
65
81
|
readonly height: number
|
|
66
82
|
}
|
|
67
83
|
|
|
68
|
-
/**
|
|
69
|
-
export interface StickZone
|
|
84
|
+
/** One floating stick of a {@link Layout}; it drives the vector named like its key in `sticks`. */
|
|
85
|
+
export interface StickZone {
|
|
70
86
|
/** Where a touch starts the stick. The stick is centered where the thumb lands. */
|
|
71
87
|
readonly region: Rect
|
|
72
88
|
/** Drag distance in CSS pixels for full tilt. */
|
|
73
89
|
readonly radius: number
|
|
74
90
|
/** Fraction of `radius` that reads as zero. Defaults to 0.15. */
|
|
75
91
|
readonly deadZone?: number
|
|
76
|
-
/**
|
|
77
|
-
readonly vector?:
|
|
92
|
+
/** Not a setting: a stick drives the vector named by its key in `Layout.sticks`. */
|
|
93
|
+
readonly vector?: "a stick drives the vector named by its key in sticks: remove vector"
|
|
78
94
|
}
|
|
79
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The zone of the stick named `K` in a {@link Layout}: a {@link StickZone}
|
|
98
|
+
* (`null` while the stick is hidden), or an error naming the cause when a
|
|
99
|
+
* button has the same name (a name is either a vector a stick drives or an
|
|
100
|
+
* action a button reports).
|
|
101
|
+
*/
|
|
102
|
+
export type StickNamed<K extends string, B extends string> = K extends B
|
|
103
|
+
? `${K} names both a stick and a button: give them different names`
|
|
104
|
+
: StickZone | null
|
|
105
|
+
|
|
80
106
|
/** A round on-screen button of a {@link Layout}; it reports the action named like its key. */
|
|
81
107
|
export interface ButtonZone {
|
|
82
108
|
readonly center: Position
|
|
@@ -90,17 +116,29 @@ export interface ButtonZone {
|
|
|
90
116
|
|
|
91
117
|
/** The swipe area of a {@link Layout}. */
|
|
92
118
|
export interface SwipeZone {
|
|
93
|
-
/** Where a touch that is not on a button or
|
|
119
|
+
/** Where a touch that is not on a button or a stick starts a swipe. */
|
|
94
120
|
readonly region: Rect
|
|
95
121
|
}
|
|
96
122
|
|
|
97
123
|
/**
|
|
98
124
|
* The touch controls, returned by the layout function {@link actions} calls
|
|
99
|
-
* with the element's size. The button names are fixed by the
|
|
125
|
+
* with the element's size. The stick and button names are fixed by the
|
|
126
|
+
* first layout; a control the game hides keeps its name with a `null` zone.
|
|
100
127
|
*/
|
|
101
128
|
export interface Layout<B extends string, V extends string = never> {
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The floating sticks, each keyed by the vector it drives (`move`, `aim`);
|
|
131
|
+
* `{}` for buttons only. A finger that is not on a button goes to the
|
|
132
|
+
* first stick, in key order, that is free and whose region contains it,
|
|
133
|
+
* so where regions overlap the earlier key wins. `null` hides a stick:
|
|
134
|
+
* it takes no finger and reports a resting vector.
|
|
135
|
+
*/
|
|
136
|
+
readonly sticks: { readonly [K in V]: StickNamed<K, B> }
|
|
137
|
+
/**
|
|
138
|
+
* The buttons, each keyed by the action it reports. `null` hides a
|
|
139
|
+
* button: it takes no finger and reports nothing held.
|
|
140
|
+
*/
|
|
141
|
+
readonly buttons: { readonly [K in B]: ButtonZone | null }
|
|
104
142
|
/** Optional: fingers here report their movement, for turning or looking around. */
|
|
105
143
|
readonly swipe?: SwipeZone
|
|
106
144
|
}
|
|
@@ -111,12 +149,14 @@ export interface Size {
|
|
|
111
149
|
readonly height: number
|
|
112
150
|
}
|
|
113
151
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
152
|
+
/**
|
|
153
|
+
* One stick's state: its vector (tilt with the dead zone removed, zero at
|
|
154
|
+
* rest, length 1 at full tilt), plus whether a thumb is on it. It is a
|
|
155
|
+
* `Controls.Vector`, so `Controls` merges it like any vector.
|
|
156
|
+
*/
|
|
157
|
+
export interface StickState extends Vector {
|
|
116
158
|
/** Whether a thumb is on the stick. */
|
|
117
159
|
readonly active: boolean
|
|
118
|
-
/** Tilt with the dead zone removed: zero at rest, length 1 at full tilt. */
|
|
119
|
-
readonly vector: Vector
|
|
120
160
|
}
|
|
121
161
|
|
|
122
162
|
/** One button's state: its action's edges, plus the drag aim and whether the release was cancelled. */
|
|
@@ -140,42 +180,44 @@ export interface Swipe {
|
|
|
140
180
|
}
|
|
141
181
|
|
|
142
182
|
/**
|
|
143
|
-
* The controls' state. Each button is an action named like it
|
|
144
|
-
* stick
|
|
145
|
-
*
|
|
146
|
-
*
|
|
183
|
+
* The controls' state. Each button is an action named like it and each
|
|
184
|
+
* stick a vector named like it, so the snapshot has the same core as the
|
|
185
|
+
* other devices' (`actions`, `vectors`, and `used`) and `Controls` can
|
|
186
|
+
* merge it.
|
|
147
187
|
*/
|
|
148
188
|
export interface Snapshot<B extends string, V extends string = never> {
|
|
149
|
-
readonly stick: Stick
|
|
150
189
|
readonly actions: { readonly [K in B]: ButtonState }
|
|
151
|
-
/**
|
|
152
|
-
readonly vectors: { readonly [K in V]:
|
|
190
|
+
/** Each stick's vector, and whether a thumb is on it. */
|
|
191
|
+
readonly vectors: { readonly [K in V]: StickState }
|
|
153
192
|
readonly swipe: Swipe
|
|
154
193
|
/**
|
|
155
|
-
* A button was pressed in this window or is held, or a finger is on
|
|
194
|
+
* A button was pressed in this window or is held, or a finger is on a
|
|
156
195
|
* stick or the swipe area: the player is using the touch controls.
|
|
157
196
|
*/
|
|
158
197
|
readonly used: boolean
|
|
159
198
|
}
|
|
160
199
|
|
|
161
200
|
const snapshotOf = <B extends string, V extends string>(
|
|
162
|
-
|
|
163
|
-
stick: Stick,
|
|
201
|
+
vectors: { readonly [K in V]: StickState },
|
|
164
202
|
actions: { readonly [K in B]: ButtonState },
|
|
165
203
|
swipe: Swipe
|
|
166
|
-
): Snapshot<B, V> =>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
204
|
+
): Snapshot<B, V> => {
|
|
205
|
+
let held = false
|
|
206
|
+
for (const name in vectors) held ||= vectors[name].active
|
|
207
|
+
return { actions, vectors, swipe, used: held || swipe.active || anyUsed(actions) }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Where a held stick was centered and where its knob is drawn (on the rim at most). */
|
|
211
|
+
export interface StickView {
|
|
212
|
+
readonly origin: Position
|
|
213
|
+
readonly knob: Position
|
|
214
|
+
}
|
|
173
215
|
|
|
174
216
|
/** Live control positions for drawing, in CSS pixels. Reading it consumes nothing. */
|
|
175
217
|
export interface View<B extends string, V extends string = never> {
|
|
176
218
|
readonly layout: Layout<B, V>
|
|
177
|
-
/**
|
|
178
|
-
readonly
|
|
219
|
+
/** Each stick while a thumb holds it, `null` otherwise. */
|
|
220
|
+
readonly sticks: { readonly [K in V]: StickView | null }
|
|
179
221
|
readonly buttons: { readonly [K in B]: { readonly held: boolean; readonly aim: Vector | null } }
|
|
180
222
|
}
|
|
181
223
|
|
|
@@ -226,27 +268,33 @@ export interface ActionsOptions {
|
|
|
226
268
|
|
|
227
269
|
const zero: Vector = { x: 0, y: 0 }
|
|
228
270
|
|
|
271
|
+
const resting: StickState = { x: 0, y: 0, active: false }
|
|
272
|
+
|
|
229
273
|
const released = (): ButtonState => ({ held: false, pressed: false, released: false, aim: null, cancelled: false })
|
|
230
274
|
|
|
231
275
|
/** The names a layout's controls report, for {@link idle} and {@link scripted}. */
|
|
232
276
|
export interface Names<B extends string, V extends string = never> {
|
|
277
|
+
/** The layout's button keys: the actions they report. */
|
|
233
278
|
readonly buttons: ReadonlyArray<B>
|
|
234
|
-
/** The
|
|
235
|
-
readonly
|
|
279
|
+
/** The layout's stick keys: the vectors they drive (`[]` for buttons only). */
|
|
280
|
+
readonly sticks: ReadonlyArray<V>
|
|
236
281
|
}
|
|
237
282
|
|
|
283
|
+
/** Rejects a name given both as a stick and as a button. */
|
|
284
|
+
export type DistinctNames<B extends string, V extends string> = [B & V] extends [never]
|
|
285
|
+
? unknown
|
|
286
|
+
: `${B & V} names both a stick and a button: give them different names`
|
|
287
|
+
|
|
288
|
+
const byName = <K extends string, T>(names: ReadonlyArray<K>, value: (name: K) => T): { readonly [P in K]: T } =>
|
|
289
|
+
Object.fromEntries(names.map((name) => [name, value(name)])) as { readonly [P in K]: T }
|
|
290
|
+
|
|
238
291
|
/**
|
|
239
292
|
* A snapshot with nothing held, for initializing an input resource before
|
|
240
|
-
* the first capture: pass the layout's button
|
|
241
|
-
* `Touch.idle({ buttons: ["
|
|
293
|
+
* the first capture: pass the layout's button and stick names, as
|
|
294
|
+
* `Touch.idle({ buttons: ["dash"], sticks: ["move", "aim"] })`.
|
|
242
295
|
*/
|
|
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
|
-
)
|
|
296
|
+
export const idle = <const B extends string, const V extends string = never>(names: Names<B, V> & DistinctNames<B, V>): Snapshot<B, V> =>
|
|
297
|
+
snapshotOf<B, V>(byName(names.sticks, () => resting), byName(names.buttons, released), { active: false, delta: zero })
|
|
250
298
|
|
|
251
299
|
/** `offset / radius`, clamped to length 1, with the inner `deadZone` fraction mapped to zero. */
|
|
252
300
|
const tilt = (offset: Position, radius: number, deadZone: number): Vector => {
|
|
@@ -311,12 +359,22 @@ const lift = (track: ButtonTrack, cancelled: boolean): void => {
|
|
|
311
359
|
track.cancelled = cancelled
|
|
312
360
|
}
|
|
313
361
|
|
|
362
|
+
/** A held stick between snapshots: its finger, where it was centered, and the thumb. */
|
|
363
|
+
interface StickTrack {
|
|
364
|
+
readonly pointerId: number
|
|
365
|
+
readonly origin: Position
|
|
366
|
+
knob: Position
|
|
367
|
+
}
|
|
368
|
+
|
|
314
369
|
/**
|
|
315
370
|
* Starts tracking touches over `target` with the controls `layout` returns
|
|
316
|
-
* for the element's current size
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
371
|
+
* for the element's current size. The layout is read again on every
|
|
372
|
+
* snapshot and every new touch, so a resize or a control the game hides
|
|
373
|
+
* (`null`) takes effect at once; keep it cheap, since it runs every update.
|
|
374
|
+
* A held control that turns `null` releases with `cancelled: true`, and its
|
|
375
|
+
* finger does nothing until it lifts. `blurHost` (usually `window`) cancels
|
|
376
|
+
* held controls when the page loses focus. Use `Pointer.actions` for mouse
|
|
377
|
+
* and pen input.
|
|
320
378
|
*/
|
|
321
379
|
export const actions = <const B extends string, const V extends string = never>(
|
|
322
380
|
target: TouchTarget,
|
|
@@ -330,9 +388,13 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
330
388
|
return { width: rect.width, height: rect.height }
|
|
331
389
|
}
|
|
332
390
|
let current = layout(sizeOf())
|
|
391
|
+
/** The stick zones of the current layout; names that clash with a button do not compile. */
|
|
392
|
+
const zoneOf = (name: V): StickZone | null => current.sticks[name] as StickZone | null
|
|
333
393
|
const names = Object.keys(current.buttons) as Array<B>
|
|
394
|
+
const stickNames = Object.keys(current.sticks) as Array<V>
|
|
334
395
|
const buttons = makeButtonTracks(names)
|
|
335
|
-
|
|
396
|
+
/** The finger and positions of each held stick. */
|
|
397
|
+
const sticks = new Map<V, StickTrack>()
|
|
336
398
|
/** Which button each finger holds. */
|
|
337
399
|
const fingers = new Map<number, B>()
|
|
338
400
|
let swipe: { readonly pointerId: number; last: Position } | undefined
|
|
@@ -346,17 +408,39 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
346
408
|
|
|
347
409
|
const tracked = (event: TouchEventLike): boolean => pointerTypes.includes(event.pointerType)
|
|
348
410
|
|
|
411
|
+
const stickOf = (pointerId: number): V | undefined => {
|
|
412
|
+
for (const [name, stick] of sticks) if (stick.pointerId === pointerId) return name
|
|
413
|
+
return undefined
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reads the layout again and lets go of the controls it hides: a held
|
|
418
|
+
* button releases as a cancel, and its finger, like a hidden stick's or
|
|
419
|
+
* swipe's, is forgotten, so it does nothing until it lifts.
|
|
420
|
+
*/
|
|
421
|
+
const relayout = (): void => {
|
|
422
|
+
current = layout(sizeOf())
|
|
423
|
+
for (const [pointerId, name] of fingers) {
|
|
424
|
+
if (current.buttons[name] !== null) continue
|
|
425
|
+
fingers.delete(pointerId)
|
|
426
|
+
lift(buttons.get(name)!, true)
|
|
427
|
+
}
|
|
428
|
+
for (const name of sticks.keys()) if (zoneOf(name) === null) sticks.delete(name)
|
|
429
|
+
if (current.swipe === undefined) swipe = undefined
|
|
430
|
+
}
|
|
431
|
+
|
|
349
432
|
const onDown = (event: TouchEventLike): void => {
|
|
350
433
|
if (!tracked(event)) return
|
|
351
434
|
event.preventDefault()
|
|
352
435
|
// A pointer id that comes down again without an up in between (the up
|
|
353
436
|
// was lost) releases what it held first, as a cancel.
|
|
354
437
|
end(event, true)
|
|
355
|
-
|
|
438
|
+
relayout()
|
|
356
439
|
const at = locate(event)
|
|
357
440
|
for (const name of names) {
|
|
358
441
|
const zone = current.buttons[name]
|
|
359
442
|
const button = buttons.get(name)!
|
|
443
|
+
if (zone === null) continue
|
|
360
444
|
const dx = at.x - zone.center.x
|
|
361
445
|
const dy = at.y - zone.center.y
|
|
362
446
|
if (button.held || dx * dx + dy * dy > zone.radius * zone.radius) continue
|
|
@@ -365,8 +449,10 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
365
449
|
target.setPointerCapture?.(event.pointerId)
|
|
366
450
|
return
|
|
367
451
|
}
|
|
368
|
-
|
|
369
|
-
|
|
452
|
+
for (const name of stickNames) {
|
|
453
|
+
const zone = zoneOf(name)
|
|
454
|
+
if (zone === null || sticks.has(name) || !inside(zone.region, at)) continue
|
|
455
|
+
sticks.set(name, { pointerId: event.pointerId, origin: at, knob: at })
|
|
370
456
|
target.setPointerCapture?.(event.pointerId)
|
|
371
457
|
return
|
|
372
458
|
}
|
|
@@ -379,8 +465,9 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
379
465
|
const onMove = (event: TouchEventLike): void => {
|
|
380
466
|
if (!tracked(event)) return
|
|
381
467
|
const at = locate(event)
|
|
382
|
-
|
|
383
|
-
|
|
468
|
+
const stick = stickOf(event.pointerId)
|
|
469
|
+
if (stick !== undefined) {
|
|
470
|
+
sticks.get(stick)!.knob = at
|
|
384
471
|
return
|
|
385
472
|
}
|
|
386
473
|
if (swipe !== undefined && swipe.pointerId === event.pointerId) {
|
|
@@ -389,13 +476,15 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
389
476
|
return
|
|
390
477
|
}
|
|
391
478
|
const name = fingers.get(event.pointerId)
|
|
392
|
-
|
|
479
|
+
const zone = name === undefined ? null : current.buttons[name]
|
|
480
|
+
if (name !== undefined && zone !== null) buttons.get(name)!.aim = aimOf(zone, at)
|
|
393
481
|
}
|
|
394
482
|
|
|
395
483
|
const end = (event: TouchEventLike, cancelled: boolean): void => {
|
|
396
484
|
if (!tracked(event)) return
|
|
397
|
-
|
|
398
|
-
|
|
485
|
+
const stick = stickOf(event.pointerId)
|
|
486
|
+
if (stick !== undefined) {
|
|
487
|
+
sticks.delete(stick)
|
|
399
488
|
return
|
|
400
489
|
}
|
|
401
490
|
if (swipe !== undefined && swipe.pointerId === event.pointerId) {
|
|
@@ -406,14 +495,15 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
406
495
|
if (name === undefined) return
|
|
407
496
|
fingers.delete(event.pointerId)
|
|
408
497
|
const button = buttons.get(name)!
|
|
409
|
-
|
|
498
|
+
const zone = current.buttons[name]
|
|
499
|
+
if (!cancelled && zone !== null) button.aim = aimOf(zone, locate(event))
|
|
410
500
|
lift(button, cancelled)
|
|
411
501
|
}
|
|
412
502
|
|
|
413
503
|
const onUp = (event: TouchEventLike): void => end(event, false)
|
|
414
504
|
const onCancel = (event: TouchEventLike): void => end(event, true)
|
|
415
505
|
const onBlur = (): void => {
|
|
416
|
-
|
|
506
|
+
sticks.clear()
|
|
417
507
|
swipe = undefined
|
|
418
508
|
fingers.clear()
|
|
419
509
|
for (const button of buttons.values()) lift(button, true)
|
|
@@ -427,18 +517,32 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
427
517
|
target.addEventListener("pointercancel", onCancel)
|
|
428
518
|
blurHost.addEventListener("blur", onBlur)
|
|
429
519
|
|
|
430
|
-
const stickState = ():
|
|
431
|
-
|
|
520
|
+
const stickState = (name: V): StickState => {
|
|
521
|
+
const stick = sticks.get(name)
|
|
522
|
+
const zone = zoneOf(name)
|
|
523
|
+
if (stick === undefined || zone === null) return resting
|
|
524
|
+
const offset = { x: stick.knob.x - stick.origin.x, y: stick.knob.y - stick.origin.y }
|
|
525
|
+
const vector = tilt(offset, zone.radius, zone.deadZone ?? 0.15)
|
|
526
|
+
return { x: vector.x, y: vector.y, active: true }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const stickView = (name: V): StickView | null => {
|
|
530
|
+
const stick = sticks.get(name)
|
|
531
|
+
const zone = zoneOf(name)
|
|
532
|
+
if (stick === undefined || zone === null) return null
|
|
533
|
+
const radius = zone.radius
|
|
432
534
|
const offset = { x: stick.knob.x - stick.origin.x, y: stick.knob.y - stick.origin.y }
|
|
433
|
-
|
|
535
|
+
const length = Math.sqrt(offset.x * offset.x + offset.y * offset.y)
|
|
536
|
+
const scale = length > radius ? radius / length : 1
|
|
537
|
+
return { origin: stick.origin, knob: { x: stick.origin.x + offset.x * scale, y: stick.origin.y + offset.y * scale } }
|
|
434
538
|
}
|
|
435
539
|
|
|
436
540
|
return {
|
|
437
541
|
snapshot() {
|
|
542
|
+
relayout()
|
|
438
543
|
const snapshot = snapshotOf<B, V>(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
Object.fromEntries(names.map((name) => [name, buttonSnapshot(buttons.get(name)!)])) as { readonly [K in B]: ButtonState },
|
|
544
|
+
byName(stickNames, stickState),
|
|
545
|
+
byName(names, (name) => buttonSnapshot(buttons.get(name)!)),
|
|
442
546
|
{ active: swipe !== undefined, delta: swiped }
|
|
443
547
|
)
|
|
444
548
|
for (const button of buttons.values()) startWindow(button)
|
|
@@ -446,21 +550,13 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
446
550
|
return snapshot
|
|
447
551
|
},
|
|
448
552
|
view() {
|
|
449
|
-
const radius = current.stick.radius
|
|
450
553
|
return {
|
|
451
554
|
layout: current,
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
: (() => {
|
|
455
|
-
const offset = { x: stick.knob.x - stick.origin.x, y: stick.knob.y - stick.origin.y }
|
|
456
|
-
const length = Math.sqrt(offset.x * offset.x + offset.y * offset.y)
|
|
457
|
-
const scale = length > radius ? radius / length : 1
|
|
458
|
-
return { origin: stick.origin, knob: { x: stick.origin.x + offset.x * scale, y: stick.origin.y + offset.y * scale } }
|
|
459
|
-
})(),
|
|
460
|
-
buttons: Object.fromEntries(names.map((name) => {
|
|
555
|
+
sticks: byName(stickNames, stickView),
|
|
556
|
+
buttons: byName(names, (name) => {
|
|
461
557
|
const button = buttons.get(name)!
|
|
462
|
-
return
|
|
463
|
-
})
|
|
558
|
+
return { held: button.held, aim: button.held ? button.aim : null }
|
|
559
|
+
})
|
|
464
560
|
}
|
|
465
561
|
},
|
|
466
562
|
dispose() {
|
|
@@ -475,15 +571,15 @@ export const actions = <const B extends string, const V extends string = never>(
|
|
|
475
571
|
}
|
|
476
572
|
|
|
477
573
|
/**
|
|
478
|
-
* Control changes applied just before one snapshot, in this order: the
|
|
574
|
+
* Control changes applied just before one snapshot, in this order: the sticks
|
|
479
575
|
* and swipe, presses, aims, releases. `frame` is the snapshot's index, counting from 0 at
|
|
480
576
|
* the first `snapshot()` call. A button pressed and released in one frame is a
|
|
481
577
|
* tap shorter than a frame.
|
|
482
578
|
*/
|
|
483
|
-
export interface TimelineEntry<B extends string> {
|
|
579
|
+
export interface TimelineEntry<B extends string, V extends string = never> {
|
|
484
580
|
readonly frame: number
|
|
485
|
-
/**
|
|
486
|
-
readonly
|
|
581
|
+
/** Stick vectors from this frame on, by stick name; `null` lifts the thumb. */
|
|
582
|
+
readonly sticks?: { readonly [K in V]?: Vector | null }
|
|
487
583
|
/** A swipe of this many CSS pixels reported by this frame's snapshot only. */
|
|
488
584
|
readonly swipe?: Vector
|
|
489
585
|
readonly press?: ReadonlyArray<B>
|
|
@@ -495,7 +591,7 @@ export interface TimelineEntry<B extends string> {
|
|
|
495
591
|
}
|
|
496
592
|
|
|
497
593
|
/** Control changes by snapshot index. Plain data, so it round-trips through JSON. */
|
|
498
|
-
export type Timeline<B extends string> = ReadonlyArray<TimelineEntry<B>>
|
|
594
|
+
export type Timeline<B extends string, V extends string = never> = ReadonlyArray<TimelineEntry<B, V>>
|
|
499
595
|
|
|
500
596
|
/** Touch controls driven by a timeline instead of an element. */
|
|
501
597
|
export interface Scripted<B extends string, V extends string = never> extends Actions<B, V> {
|
|
@@ -504,13 +600,17 @@ export interface Scripted<B extends string, V extends string = never> extends Ac
|
|
|
504
600
|
}
|
|
505
601
|
|
|
506
602
|
/**
|
|
507
|
-
* Plays `timeline` over controls with `names` (the buttons, and the
|
|
508
|
-
*
|
|
603
|
+
* Plays `timeline` over controls with `names` (the buttons, and the sticks
|
|
604
|
+
* by the vectors they drive): before the snapshot at index `frame`, every
|
|
509
605
|
* entry for that frame is applied. Vectors longer than 1 are clamped.
|
|
510
606
|
*/
|
|
511
|
-
export const scripted = <const B extends string, const V extends string = never>(
|
|
607
|
+
export const scripted = <const B extends string, const V extends string = never>(
|
|
608
|
+
names: Names<B, V> & DistinctNames<B, V>,
|
|
609
|
+
timeline: Timeline<NoInfer<B>, NoInfer<V>>
|
|
610
|
+
): Scripted<B, V> => {
|
|
512
611
|
const buttons = names.buttons
|
|
513
|
-
const
|
|
612
|
+
const stickNames = names.sticks
|
|
613
|
+
const byFrame = new Map<number, Array<TimelineEntry<B, V>>>()
|
|
514
614
|
for (const entry of timeline) {
|
|
515
615
|
const entries = byFrame.get(entry.frame)
|
|
516
616
|
if (entries) entries.push(entry)
|
|
@@ -521,12 +621,13 @@ export const scripted = <const B extends string, const V extends string = never>
|
|
|
521
621
|
return length <= 1 ? vector : { x: vector.x / length, y: vector.y / length }
|
|
522
622
|
}
|
|
523
623
|
const tracks = makeButtonTracks(buttons)
|
|
524
|
-
|
|
624
|
+
/** Each held stick's vector. */
|
|
625
|
+
const sticks = new Map<V, Vector>()
|
|
525
626
|
let frame = 0
|
|
526
|
-
const layout
|
|
527
|
-
|
|
528
|
-
buttons:
|
|
529
|
-
}
|
|
627
|
+
const layout = {
|
|
628
|
+
sticks: byName(stickNames, () => ({ region: { left: 0, top: 0, width: 0, height: 0 }, radius: 1 })),
|
|
629
|
+
buttons: byName(buttons, () => ({ center: zero, radius: 0 }))
|
|
630
|
+
} as Layout<B, V>
|
|
530
631
|
return {
|
|
531
632
|
snapshot() {
|
|
532
633
|
for (const track of tracks.values()) startWindow(track)
|
|
@@ -537,7 +638,11 @@ export const scripted = <const B extends string, const V extends string = never>
|
|
|
537
638
|
swipe.x += entry.swipe.x
|
|
538
639
|
swipe.y += entry.swipe.y
|
|
539
640
|
}
|
|
540
|
-
|
|
641
|
+
for (const [name, vector] of Object.entries(entry.sticks ?? {}) as Array<[V, Vector | null | undefined]>) {
|
|
642
|
+
if (vector === undefined || !stickNames.includes(name)) continue
|
|
643
|
+
if (vector === null) sticks.delete(name)
|
|
644
|
+
else sticks.set(name, clamp(vector))
|
|
645
|
+
}
|
|
541
646
|
for (const name of entry.press ?? []) press(tracks.get(name)!, null)
|
|
542
647
|
for (const [name, aim] of Object.entries(entry.aim ?? {}) as Array<[B, Vector | null | undefined]>) {
|
|
543
648
|
const track = tracks.get(name)
|
|
@@ -548,20 +653,22 @@ export const scripted = <const B extends string, const V extends string = never>
|
|
|
548
653
|
}
|
|
549
654
|
frame += 1
|
|
550
655
|
return snapshotOf<B, V>(
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
656
|
+
byName(stickNames, (name) => {
|
|
657
|
+
const vector = sticks.get(name)
|
|
658
|
+
return vector === undefined ? resting : { x: vector.x, y: vector.y, active: true }
|
|
659
|
+
}),
|
|
660
|
+
byName(buttons, (name) => buttonSnapshot(tracks.get(name)!)),
|
|
554
661
|
{ active: swipe.active, delta: { x: swipe.x, y: swipe.y } }
|
|
555
662
|
)
|
|
556
663
|
},
|
|
557
664
|
view() {
|
|
558
665
|
return {
|
|
559
666
|
layout,
|
|
560
|
-
|
|
561
|
-
buttons:
|
|
667
|
+
sticks: byName(stickNames, () => null),
|
|
668
|
+
buttons: byName(buttons, (name) => {
|
|
562
669
|
const track = tracks.get(name)!
|
|
563
|
-
return
|
|
564
|
-
})
|
|
670
|
+
return { held: track.held, aim: track.held ? track.aim : null }
|
|
671
|
+
})
|
|
565
672
|
}
|
|
566
673
|
},
|
|
567
674
|
dispose() {},
|