@solidrt/core 0.0.60 → 0.0.61

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/input-map.ts CHANGED
@@ -37,23 +37,41 @@
37
37
  // list of names, which a preset's action object already is
38
38
  // (`input.disable(...Object.keys(gameActions))`): Unity's per-map enable
39
39
  // and Unreal's stacked contexts, on one map, with no extra concept.
40
+ //
41
+ // A settings screen has three things on the map: save()/load() carry the
42
+ // bindings as source ids (input-id.ts) to and from storage, rebind()
43
+ // listens for the next input on the devices given and binds it, and
44
+ // device() is the device that last moved anything bound, so button
45
+ // prompts follow the player from the keyboard to the pad (Unity's control
46
+ // schemes, without a second concept: a scheme is the bindings of one
47
+ // device, which bindings() lists by source.device).
40
48
 
41
49
  import { createEffect, createRoot, createSignal, untrack } from "@solidjs/signals"
42
50
  import type { KeyEvent } from "./types"
43
51
  import { combineRates } from "./input-axes"
44
52
  import type { Axes, AxesDecl, AxisValue, Vec2 } from "./input-axes"
53
+ import { checkSource } from "./input-processors"
54
+ import { resolveSource } from "./input-id"
55
+ import { keySpec } from "./input-keyboard"
45
56
 
46
57
  export type ActionKind = "button" | "axis" | "vec2"
47
58
  export type ActionValue<K extends ActionKind> = K extends "button" ? boolean : K extends "axis" ? number : Vec2
48
59
  export type ActionsDecl = Record<string, ActionKind>
49
60
 
50
- /** Where a delta source delivers: the gesture brackets and the deltas. */
61
+ /** Where a delta source delivers: the gesture brackets and the deltas.
62
+ * An end may carry the release velocity in the source's units per second
63
+ * (a drag's in element heights per second), the fling fact a consumer
64
+ * glides on; absent or zero when the finger rested. */
51
65
  export interface DeltaSink {
52
66
  begin(): void
53
67
  delta(value: number | Vec2, focal?: Vec2): void
54
- end(): void
68
+ end(velocity?: number | Vec2): void
55
69
  }
56
70
 
71
+ /** The devices a map knows by name: the id prefix of their sources, and
72
+ * what rebind() listens on and load() resolves through. */
73
+ export type DeviceName = "keyboard" | "gamepad" | "pointer"
74
+
57
75
  /**
58
76
  * One device control as an input source. `rate` reads its current value
59
77
  * (reactive); `deltas` subscribes a sink to the amounts a gesture
@@ -64,60 +82,54 @@ export interface InputSource<K extends ActionKind = ActionKind> {
64
82
  readonly kind: K
65
83
  /** For bindings listings and settings screens: "keyboard W/A/S/D". */
66
84
  readonly label: string
85
+ /** The serializable name (input-id.ts: "keyboard:key:Space",
86
+ * "invert(gamepad:leftStick)"): what save() stores and load() resolves.
87
+ * A custom source names itself under a prefix of its own. */
88
+ readonly id: string
89
+ /** The device the source reads, for InputMap.device(); a custom source
90
+ * may leave it out and never counts as the active device. */
91
+ readonly device?: DeviceName
67
92
  rate?: () => ActionValue<K>
68
93
  deltas?: (sink: DeltaSink) => () => void
69
94
  key?: (event: KeyEvent, down: boolean) => void
70
95
  blur?: () => void
71
96
  }
72
97
 
73
- export type Binding = { action: string; source: InputSource }
74
-
75
- /**
76
- * A source with its values negated - Unity's Invert processor. The one
77
- * convention split every engine has: a drag moves the CONTENT under the
78
- * finger, while keys and sticks move the CAMERA, so a preset binds the
79
- * arrows or a stick to `pan` or `rotate` through invert() and the axis
80
- * keeps one meaning.
81
- */
82
- export function invert<K extends "axis" | "vec2">(source: InputSource<K>): InputSource<K> {
83
- checkSource(source)
84
- if ((source.kind as ActionKind) === "button") throw new Error(`invert: "${source.label}" is a button`)
85
- let neg = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? -v : [-v[0], -v[1]])
86
- return {
87
- kind: source.kind,
88
- label: `${source.label} (inverted)`,
89
- rate: source.rate ? () => neg(source.rate!() as number | Vec2) as never : undefined,
90
- deltas: source.deltas
91
- ? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(neg(value), focal) })
92
- : undefined,
93
- key: source.key,
94
- blur: source.blur,
95
- }
98
+ /** What a map asks of a device: sources by spec (the id's device half),
99
+ * and, for rebind(), a listener for the next control of a kind the player
100
+ * actuates. The keyboard has no listener of its own: the map captures
101
+ * the next key through its handlers. */
102
+ export interface InputDevice {
103
+ readonly name: DeviceName
104
+ resolve(spec: string): InputSource
105
+ listen?(kind: ActionKind, found: (source: InputSource) => void): () => void
96
106
  }
97
107
 
98
- /** A source scaled by `factor` (rates and deltas alike) - Unity's Scale
99
- * processor: a slower stick, a finer wheel. */
100
- export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor: number): InputSource<K> {
101
- checkSource(source)
102
- if ((source.kind as ActionKind) === "button") throw new Error(`scale: "${source.label}" is a button`)
103
- if (!Number.isFinite(factor)) throw new Error(`scale: factor must be a finite number, got ${factor}`)
104
- let mul = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? v * factor : [v[0] * factor, v[1] * factor])
105
- return {
106
- kind: source.kind,
107
- label: `${source.label} (x${factor})`,
108
- rate: source.rate ? () => mul(source.rate!() as number | Vec2) as never : undefined,
109
- deltas: source.deltas
110
- ? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(mul(value), focal) })
111
- : undefined,
112
- key: source.key,
113
- blur: source.blur,
114
- }
115
- }
108
+ export type InputDeviceSet = Partial<Record<DeviceName, InputDevice>>
109
+
110
+ export type Binding = { action: string; source: InputSource }
116
111
 
117
112
  export interface GestureListener<K extends "axis" | "vec2" = "axis" | "vec2"> {
118
113
  begin?: () => void
119
114
  delta?: (value: AxisValue<K>, focal: Vec2 | undefined) => void
120
- end?: () => void
115
+ /** `velocity` is the release velocity in the action's units per second
116
+ * when the source measured one (a drag's lift), else undefined. */
117
+ end?: (velocity: AxisValue<K> | undefined) => void
118
+ }
119
+
120
+ /** The half of a keyboard composite a key rebinds: `neg`/`pos` of an
121
+ * axis, `up`/`down`/`left`/`right` of a vec2. */
122
+ export type RebindPart = "neg" | "pos" | "up" | "down" | "left" | "right"
123
+
124
+ export interface RebindOptions {
125
+ /** Cancels the listen; the promise rejects with the signal's reason. */
126
+ signal?: AbortSignal
127
+ /** Unbind the action's sources of the same device first (default true),
128
+ * so a row on a settings screen replaces rather than adds. */
129
+ replace?: boolean
130
+ /** On an axis or vec2 action, which part of the bound keyboard
131
+ * composite a key press replaces; without it keys are ignored there. */
132
+ part?: RebindPart
121
133
  }
122
134
 
123
135
  type AxisActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? never : N }[keyof A] & string
@@ -145,13 +157,13 @@ export interface InputMap<A extends ActionsDecl> {
145
157
  /** Injection by name, for scripts, debug commands, peers: `set` holds
146
158
  * a rate until set again (a value source of the map's own); `press`/
147
159
  * `release` do the same for a button; `nudge`, `begin` and `end` feed
148
- * the delta channel. */
160
+ * the delta channel (`end` with the release velocity flings). */
149
161
  set<N extends keyof A & string>(action: N, value: ActionValue<A[N]>): void
150
162
  press(action: ButtonActions<A>): void
151
163
  release(action: ButtonActions<A>): void
152
164
  nudge<N extends AxisActions<A>>(action: N, delta: ActionValue<A[N]>, focal?: Vec2): void
153
165
  begin(action: AxisActions<A>): void
154
- end(action: AxisActions<A>): void
166
+ end<N extends AxisActions<A>>(action: N, velocity?: ActionValue<A[N]>): void
155
167
  /** Switch actions on and off by name - a context. A disabled action
156
168
  * reads neutral (false, 0, [0, 0]), drops its deltas and closes the
157
169
  * gesture it had open (its listeners see the end); its sources keep
@@ -162,6 +174,25 @@ export interface InputMap<A extends ActionsDecl> {
162
174
  disable(...actions: (keyof A & string)[]): void
163
175
  /** Reactive: whether the action is enabled. */
164
176
  enabled(action: keyof A & string): boolean
177
+ /** Reactive: the device that last moved anything bound (a key down, a
178
+ * stick leaving rest, a gesture delta), undefined until one has. The
179
+ * button-prompt switch: show the bindings of this device. */
180
+ device(): DeviceName | undefined
181
+ /** The bindings as source ids per action, in binding order - JSON for
182
+ * storage. Every action is present, an unbound one as []. */
183
+ save(): Record<keyof A & string, string[]>
184
+ /** Replace the bindings of each action in `saved` with the ids resolved
185
+ * through `devices` (actions absent from `saved` keep theirs). Throws
186
+ * on an unknown action, a device not given or an id a device rejects,
187
+ * before changing anything. */
188
+ load(saved: Partial<Record<keyof A & string, string[]>>, devices: InputDeviceSet): void
189
+ /**
190
+ * Listen for the next input on `devices` that fits the action and bind
191
+ * it: a key down (the keyboard, through this map's handlers), a pad
192
+ * button, trigger or stick, a pointer gesture's first movement.
193
+ * Resolves with the source bound. One rebind at a time per map.
194
+ */
195
+ rebind(action: keyof A & string, devices: InputDeviceSet, options?: RebindOptions): Promise<InputSource>
165
196
  /** Key events for the bound keyboard sources: spread on the window
166
197
  * (app-global) or on the leaf that should hold focus for them. */
167
198
  handlers: {
@@ -195,12 +226,16 @@ type ActionState = {
195
226
  gesture: Set<GestureListener>
196
227
  /** Live delta subscriptions per bound source. */
197
228
  live: Map<InputSource, () => void>
229
+ /** The device watchers per bound source (see watchDevice). */
230
+ watch: Map<InputSource, () => void>
198
231
  }
199
232
 
200
233
  let compatible = (source: ActionKind, action: ActionKind): boolean => source === action || (source === "button" && action === "axis")
201
234
 
202
235
  let neutral = (kind: ActionKind): boolean | number | Vec2 => (kind === "button" ? false : kind === "axis" ? 0 : [0, 0])
203
236
 
237
+ let isNeutral = (v: boolean | number | Vec2): boolean => (typeof v === "boolean" ? !v : typeof v === "number" ? v === 0 : v[0] === 0 && v[1] === 0)
238
+
204
239
  function checkValue(what: string, kind: ActionKind, v: unknown): void {
205
240
  if (kind === "button") {
206
241
  if (typeof v !== "boolean") throw new Error(`${what}: expected a boolean, got ${String(v)}`)
@@ -211,18 +246,16 @@ function checkValue(what: string, kind: ActionKind, v: unknown): void {
211
246
  }
212
247
  }
213
248
 
214
- function checkSource(source: unknown): InputSource {
215
- let s = source as InputSource
216
- // A pointer gesture is a callable source (`pointer.drag("Ctrl")` is its chord variant).
217
- if (!s || (typeof s !== "object" && typeof s !== "function") || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
218
- throw new Error(`createInputMap: not an input source: ${String(source)}`)
219
- }
220
- return s
221
- }
249
+ // The parts of a keyboard composite spec, by kind, in id order.
250
+ const AXIS_PARTS: RebindPart[] = ["neg", "pos"]
251
+ const VEC2_PARTS: RebindPart[] = ["up", "down", "left", "right"]
222
252
 
223
253
  export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
224
254
  if (!actions || typeof actions !== "object") throw new Error("createInputMap: expected an object of action kinds")
225
255
  let states = new Map<string, ActionState>()
256
+ // ownedWrite: the device changes inside the watchers' effects and from
257
+ // gesture deltas delivered by event handlers.
258
+ let [device, setDevice] = createSignal<DeviceName | undefined>(undefined, { ownedWrite: true })
226
259
  for (let [name, kind] of Object.entries(actions)) {
227
260
  if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`createInputMap: action "${name}" has kind "${String(kind)}", expected "button", "axis" or "vec2"`)
228
261
  // ownedWrite: bind() and set() run from component bodies.
@@ -247,6 +280,10 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
247
280
  if (s !== null) values.push(s as number | Vec2)
248
281
  for (let src of state.sources) {
249
282
  if (!src.rate) continue
283
+ // A pulse (a double tap, a long press: a button source with a
284
+ // delta channel) on an axis action drives it through its delta
285
+ // alone; its one-task press is not a rate to integrate.
286
+ if (src.kind === "button" && src.deltas) continue
250
287
  let v = src.rate()
251
288
  if (src.kind === "button") values.push(v ? 1 : 0)
252
289
  else values.push(v as number | Vec2)
@@ -255,6 +292,7 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
255
292
  },
256
293
  gesture: new Set(),
257
294
  live: new Map(),
295
+ watch: new Map(),
258
296
  }
259
297
  states.set(name, state)
260
298
  }
@@ -273,27 +311,45 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
273
311
  if (s.kind !== "button") throw new Error(`createInputMap: ${what} needs a button action, "${name}" is a ${s.kind}`)
274
312
  return s
275
313
  }
314
+ let touched = (source: InputSource | undefined): void => {
315
+ if (source?.device && untrack(device) !== source.device) setDevice(source.device)
316
+ }
276
317
  // The delta channel fan-out for one action. Brackets are counted as
277
318
  // delivered, so a disable can close what is open and an end whose begin
278
- // was dropped (or never delivered) reaches no listener.
279
- let closeGesture = (s: ActionState): void => {
319
+ // was dropped (or never delivered) reaches no listener. A delta from a
320
+ // device source also names it the active device.
321
+ let closeGesture = (s: ActionState, velocity?: number | Vec2): void => {
280
322
  s.depth--
281
- s.gesture.forEach(g => g.end?.())
323
+ s.gesture.forEach(g => g.end?.(velocity as never))
282
324
  }
283
- let sink = (s: ActionState): DeltaSink => ({
325
+ let sink = (s: ActionState, source?: InputSource): DeltaSink => ({
284
326
  begin: () => {
285
327
  if (!s.enabled) return
286
328
  s.depth++
287
329
  s.gesture.forEach(g => g.begin?.())
288
330
  },
289
331
  delta: (value, focal) => {
332
+ touched(source)
290
333
  if (!s.enabled) return
291
334
  s.gesture.forEach(g => g.delta?.(value as never, focal))
292
335
  },
293
- end: () => {
294
- if (s.depth > 0) closeGesture(s)
336
+ end: velocity => {
337
+ if (s.depth > 0) closeGesture(s, velocity)
295
338
  },
296
339
  })
340
+ // A rate source leaving rest names its device the active one: one
341
+ // effect per binding, under a root disposed at unbind.
342
+ let watchDevice = (source: InputSource): (() => void) =>
343
+ createRoot(dispose => {
344
+ createEffect(
345
+ () => source.rate!(),
346
+ v => {
347
+ if (!isNeutral(v)) touched(source)
348
+ },
349
+ { defer: true },
350
+ )
351
+ return dispose
352
+ })
297
353
  let switchActions = (names: string[], on: boolean, what: string): void => {
298
354
  if (names.length === 0) throw new Error(`createInputMap: ${what}() needs at least one action`)
299
355
  for (let name of names) {
@@ -305,14 +361,19 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
305
361
  }
306
362
  }
307
363
 
308
- let bindOne = (name: string, source: InputSource): void => {
364
+ let checkBind = (name: string, source: InputSource): ActionState => {
309
365
  let s = state(name)
310
366
  checkSource(source)
311
367
  if (!compatible(source.kind, s.kind)) throw new Error(`createInputMap: cannot bind ${source.kind} source "${source.label}" to ${s.kind} action "${name}"`)
368
+ return s
369
+ }
370
+ let bindOne = (name: string, source: InputSource): void => {
371
+ let s = checkBind(name, source)
312
372
  if (s.sources.includes(source)) return
313
373
  s.sources.push(source)
314
374
  s.bump()
315
- if (source.deltas && s.kind !== "button") s.live.set(source, source.deltas(sink(s)))
375
+ if (source.deltas && s.kind !== "button") s.live.set(source, source.deltas(sink(s, source)))
376
+ if (source.rate && source.device) s.watch.set(source, watchDevice(source))
316
377
  }
317
378
  let unbindOne = (name: string, source: InputSource): void => {
318
379
  let s = state(name)
@@ -325,17 +386,29 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
325
386
  s.live.delete(source)
326
387
  stop()
327
388
  }
389
+ let unwatch = s.watch.get(source)
390
+ if (unwatch) {
391
+ s.watch.delete(source)
392
+ unwatch()
393
+ }
328
394
  }
329
395
  let edge = (name: string, want: boolean, callback: () => void): (() => void) => {
330
396
  let s = buttonState(name, want ? "onPress" : "onRelease")
331
397
  if (typeof callback !== "function") throw new Error(`createInputMap: ${want ? "onPress" : "onRelease"}("${name}") expects a function`)
398
+ // The edge is relative to the value at registration, kept here rather
399
+ // than read from the effect's previous value: a deferred effect created
400
+ // while a pending write sits on the action (a bind in the same tick)
401
+ // runs on that flush with no previous value, which would count a source
402
+ // already held at registration as a press.
403
+ let last = untrack(() => s.value() as boolean)
332
404
  return createRoot(dispose => {
333
405
  // The callback is a side effect that may read state of its own (a
334
406
  // focus, a pose): a snapshot, not a dependency, so untracked.
335
407
  createEffect(
336
408
  () => s.value() as boolean,
337
- (pressed, prev) => {
338
- if (pressed === want && prev !== want) untrack(callback)
409
+ pressed => {
410
+ if (pressed === want && last !== want) untrack(callback)
411
+ last = pressed
339
412
  },
340
413
  { defer: true },
341
414
  )
@@ -343,6 +416,10 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
343
416
  })
344
417
  }
345
418
 
419
+ // The pending rebind's key capture: the next key down that is not a
420
+ // bare modifier is its source, and never reaches the bound sources.
421
+ let capture: ((event: KeyEvent) => void) | null = null
422
+
346
423
  let map: InputMap<A> = {
347
424
  actions,
348
425
  bind(actionOrList: string | Binding[], ...sources: InputSource[]) {
@@ -404,8 +481,10 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
404
481
  begin(action) {
405
482
  sink(axisState(action, "begin")).begin()
406
483
  },
407
- end(action) {
408
- sink(axisState(action, "end")).end()
484
+ end(action, velocity) {
485
+ let s = axisState(action, "end")
486
+ if (velocity !== undefined) checkValue(`end("${action}") velocity`, s.kind, velocity)
487
+ sink(s).end(velocity as number | Vec2 | undefined)
409
488
  },
410
489
  enable: (...actions) => switchActions(actions, true, "enable"),
411
490
  disable: (...actions) => switchActions(actions, false, "disable"),
@@ -414,6 +493,122 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
414
493
  s.version()
415
494
  return s.enabled
416
495
  },
496
+ device,
497
+ save() {
498
+ let out = {} as Record<keyof A & string, string[]>
499
+ for (let [name, s] of states) out[name as keyof A & string] = s.sources.map(source => source.id)
500
+ return out
501
+ },
502
+ load(saved, devices) {
503
+ if (!saved || typeof saved !== "object") throw new Error("createInputMap: load() expects an object of id lists per action")
504
+ if (!devices || typeof devices !== "object") throw new Error("createInputMap: load() expects the devices to resolve through")
505
+ // Resolve everything first: a bad id leaves the map as it was.
506
+ let plan: [string, InputSource[]][] = []
507
+ for (let [name, ids] of Object.entries(saved)) {
508
+ if (!Array.isArray(ids)) throw new Error(`createInputMap: load() action "${name}": expected an array of ids`)
509
+ let sources = ids.map(id => resolveSource(id, devices))
510
+ for (let source of sources) checkBind(name, source)
511
+ plan.push([name, sources])
512
+ }
513
+ for (let [name, sources] of plan) {
514
+ for (let source of [...state(name).sources]) unbindOne(name, source)
515
+ for (let source of sources) bindOne(name, source)
516
+ }
517
+ },
518
+ rebind(action, devices, options = {}) {
519
+ let s = state(action)
520
+ if (!devices || typeof devices !== "object") throw new Error("createInputMap: rebind() expects the devices to listen on")
521
+ if (capture) throw new Error("createInputMap: a rebind is already pending on this map")
522
+ let { signal, replace = true, part } = options
523
+ let parts = s.kind === "axis" ? AXIS_PARTS : s.kind === "vec2" ? VEC2_PARTS : []
524
+ if (part !== undefined) {
525
+ if (s.kind === "button") throw new Error(`createInputMap: rebind("${action}") part: "${action}" is a button, a key rebinds it whole`)
526
+ if (!parts.includes(part)) throw new Error(`createInputMap: rebind("${action}") part "${part}": a ${s.kind} action has ${parts.join("/")}`)
527
+ }
528
+ if (!devices.keyboard && !devices.gamepad?.listen && !devices.pointer?.listen) throw new Error(`createInputMap: rebind("${action}") has no device to listen on`)
529
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new Error("rebind aborted"))
530
+ return new Promise<InputSource>((resolve, reject) => {
531
+ let stops: (() => void)[] = []
532
+ let settled = false
533
+ // The signal's onabort is a single handler property (the runtime's
534
+ // AbortSignal subset): chain onto whatever the app set and restore it.
535
+ let prevAbort = signal?.onabort ?? null
536
+ let cleanup = () => {
537
+ settled = true
538
+ capture = null
539
+ if (signal) signal.onabort = prevAbort
540
+ for (let stop of stops) stop()
541
+ }
542
+ let abort = () => {
543
+ if (settled) return
544
+ cleanup()
545
+ reject(signal!.reason ?? new Error("rebind aborted"))
546
+ }
547
+ // A found source arrives from inside a device's effect or an event
548
+ // handler: apply on the microtask, outside the notifier.
549
+ let found = (source: InputSource, replacing: boolean) => {
550
+ if (settled) return
551
+ settled = true
552
+ queueMicrotask(() => {
553
+ try {
554
+ cleanup()
555
+ if (replacing) for (let bound of [...s.sources]) if (bound.device === source.device) unbindOne(action, bound)
556
+ bindOne(action, source)
557
+ resolve(source)
558
+ } catch (err) {
559
+ reject(err)
560
+ }
561
+ })
562
+ }
563
+ // The keyboard: a key rebinds a button action whole; on an axis or
564
+ // vec2 action it replaces one part of the bound composite, whose
565
+ // other parts stay.
566
+ let keyboard = devices.keyboard
567
+ if (keyboard) {
568
+ capture = event => {
569
+ let spec = keySpec(event)
570
+ if (spec === null) return
571
+ if (s.kind === "button") {
572
+ found(keyboard.resolve(`key:${spec}`), replace)
573
+ return
574
+ }
575
+ if (part === undefined) return
576
+ let prefix = `${s.kind}:`
577
+ let composite = s.sources.find(b => b.device === "keyboard" && b.id.startsWith(`keyboard:${prefix}`))
578
+ if (!composite) {
579
+ settled = true
580
+ cleanup()
581
+ reject(new Error(`createInputMap: rebind("${action}") part "${part}": no keyboard ${s.kind} composite is bound to "${action}"`))
582
+ return
583
+ }
584
+ let specs = composite.id.slice(`keyboard:${prefix}`.length).split("/")
585
+ specs[parts.indexOf(part)] = spec
586
+ let next = keyboard.resolve(`${prefix}${specs.join("/")}`)
587
+ // Only that composite is replaced, whatever `replace` says: a
588
+ // second keyboard composite on the action (arrows next to
589
+ // WASD) is a binding of its own, and two copies would add up.
590
+ unbindOne(action, composite)
591
+ found(next, false)
592
+ }
593
+ }
594
+ for (let name of ["gamepad", "pointer"] as const) {
595
+ let d = devices[name]
596
+ if (d?.listen) {
597
+ stops.push(
598
+ d.listen(s.kind, source => {
599
+ if (compatible(source.kind, s.kind)) found(source, replace)
600
+ }),
601
+ )
602
+ }
603
+ }
604
+ if (signal) {
605
+ signal.onabort = event => {
606
+ prevAbort?.(event)
607
+ abort()
608
+ }
609
+ }
610
+ })
611
+ },
417
612
  handlers: {
418
613
  onKeyDown: event => forwardKey(event, true),
419
614
  onKeyUp: event => forwardKey(event, false),
@@ -443,7 +638,7 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
443
638
  map.onGesture(mapped as never, {
444
639
  begin: () => axes.begin(axis),
445
640
  delta: (value, focal) => axes.nudge(axis, value as never, focal),
446
- end: () => axes.end(axis),
641
+ end: velocity => axes.end(axis, velocity as never),
447
642
  }),
448
643
  )
449
644
  }
@@ -453,8 +648,13 @@ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
453
648
  },
454
649
  }
455
650
  // A key event reaches each bound keyboard source once, however many
456
- // actions it is bound to.
651
+ // actions it is bound to. While a rebind listens, a key down is
652
+ // captured instead: the player is naming a key, not playing.
457
653
  let forwardKey = (event: KeyEvent, down: boolean): void => {
654
+ if (capture && down && !event.repeat) {
655
+ capture(event)
656
+ return
657
+ }
458
658
  let seen = new Set<InputSource>()
459
659
  for (let s of states.values()) {
460
660
  for (let source of s.sources) {