@solidrt/core 0.0.59 → 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/AGENTS.md +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/audio.ts +4 -3
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +82 -15
- package/src/window.ts +16 -9
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
export
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
338
|
-
if (pressed === want &&
|
|
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
|
-
|
|
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:
|
|
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) {
|