@typeonce/ritmo-browser 0.12.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/src/Touch.ts CHANGED
@@ -1,17 +1,32 @@
1
1
  /**
2
- * Touch controls over one element: a floating stick, buttons (some of which
3
- * aim by dragging: tap to use, drag to aim, release to fire), and an optional
4
- * swipe area that reports how far a finger moved, for turning or camera
5
- * control (Genshin Impact style: move on the left, swipe anywhere to look).
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 stick when it lands in
10
- * the stick's region (the stick then appears under the thumb), otherwise the
11
- * swipe area when it lands in its region. Fingers that land elsewhere are
12
- * ignored, and a control keeps its finger until it lifts.
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: the stick's
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
- * // `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" },
34
- * buttons: {
35
- * attack: { center: { x: width - 90, y: height - 90 }, radius: 55, aimRadius: 80 },
36
- * dash: { center: { x: width - 200, y: height - 60 }, radius: 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 (actions.attack.released && !actions.attack.cancelled) fire(actions.attack.aim)
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
- /** The floating stick of a {@link Layout}. */
69
- export interface StickZone<V extends string = never> {
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
- /** The vector action the stick drives (`"move"`), reported in the snapshot's `vectors`. */
77
- readonly vector?: V
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 the stick starts a swipe. */
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 first layout.
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
- readonly stick: StickZone<V>
103
- readonly buttons: { readonly [K in B]: ButtonZone }
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
- /** The stick's state in a snapshot. */
115
- export interface Stick {
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, 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.
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
- /** The vector action the stick drives, when the layout names one: the stick's vector. */
152
- readonly vectors: { readonly [K in V]: Vector }
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 the
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
- vector: V | undefined,
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
- 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
- })
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
- /** Where the stick was centered and where the thumb is, while it is held. */
178
- readonly stick: { readonly origin: Position; readonly knob: Position } | null
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 vector action the stick drives, as the layout's `stick.vector`. */
235
- readonly vector?: V
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 names and stick vector, as
241
- * `Touch.idle({ buttons: ["attack", "dash"], vector: "move" })`.
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 (it is read again on every new touch, so a
317
- * resize takes effect at the next touch). `blurHost` (usually `window`)
318
- * cancels held controls when the page loses focus. Use `Pointer.actions`
319
- * for mouse and pen input.
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
- let stick: { readonly pointerId: number; readonly origin: Position; knob: Position } | undefined
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
- current = layout(sizeOf())
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
- if (stick === undefined && inside(current.stick.region, at)) {
369
- stick = { pointerId: event.pointerId, origin: at, knob: at }
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
- if (stick !== undefined && stick.pointerId === event.pointerId) {
383
- stick.knob = at
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
- if (name !== undefined) buttons.get(name)!.aim = aimOf(current.buttons[name], at)
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
- if (stick !== undefined && stick.pointerId === event.pointerId) {
398
- stick = undefined
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
- if (!cancelled) button.aim = aimOf(current.buttons[name], locate(event))
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
- stick = undefined
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 = (): Stick => {
431
- if (stick === undefined) return { active: false, vector: zero }
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
- return { active: true, vector: tilt(offset, current.stick.radius, current.stick.deadZone ?? 0.15) }
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
- current.stick.vector,
440
- stickState(),
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
- stick: stick === undefined
453
- ? null
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 [name, { held: button.held, aim: button.held ? button.aim : null }]
463
- })) as View<B, V>["buttons"]
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 stick
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
- /** The stick's vector from this frame on; `null` lifts the thumb. */
486
- readonly stick?: Vector | null
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 vector
508
- * action the stick drives): before the snapshot at index `frame`, every
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>(names: Names<B, V>, timeline: Timeline<NoInfer<B>>): Scripted<B, V> => {
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 byFrame = new Map<number, Array<TimelineEntry<B>>>()
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
- let stick: Vector | null = null
624
+ /** Each held stick's vector. */
625
+ const sticks = new Map<V, Vector>()
525
626
  let frame = 0
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 }) },
528
- buttons: Object.fromEntries(buttons.map((button) => [button, { center: zero, radius: 0 }])) as Layout<B>["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
- if (entry.stick !== undefined) stick = entry.stick === null ? null : clamp(entry.stick)
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
- 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 },
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
- stick: null,
561
- buttons: Object.fromEntries(buttons.map((name) => {
667
+ sticks: byName(stickNames, () => null),
668
+ buttons: byName(buttons, (name) => {
562
669
  const track = tracks.get(name)!
563
- return [name, { held: track.held, aim: track.held ? track.aim : null }]
564
- })) as View<B>["buttons"]
670
+ return { held: track.held, aim: track.held ? track.aim : null }
671
+ })
565
672
  }
566
673
  },
567
674
  dispose() {},