@solidrt/core 0.0.56 → 0.0.58

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.
@@ -0,0 +1,468 @@
1
+ // The input map: named, typed actions between devices and consumers -
2
+ // Godot's InputMap and Unity's action maps, in reactive form. An app
3
+ // declares its actions once (`look: "vec2"`, `jump: "button"`), binds
4
+ // device sources to them (a stick, a key composite, a pointer drag), and
5
+ // consumers read the action, never the device. Anything can then drive a
6
+ // consumer: a second pad on a second map for split screen, a debug
7
+ // command or a network peer through the by-name injection, a replay on
8
+ // the action stream, a settings screen over bindings().
9
+ //
10
+ // Each axis or vec2 action carries two channels, which is the part Godot
11
+ // (no mouse motion in actions) and Unity (mouse delta and stick share one
12
+ // value, consumers scale by dt themselves) each got half of:
13
+ // - the RATE: the sum of every bound source's current value, clamped (an
14
+ // axis to -1..1, a vec2 to unit length), reactive, sampled per frame by
15
+ // the consumer and integrated over dt at its own speed;
16
+ // - the DELTAS: immediate, device-free amounts a gesture produced (a drag
17
+ // in view heights, a wheel notch in octaves), delivered as they happen
18
+ // with begin/end brackets around a finger's gesture.
19
+ // Values use the screen convention: x right, y down.
20
+ //
21
+ // A button action reads pressed while any bound button source is, and
22
+ // may also bind to an axis action (1 while pressed). Kinds are checked at
23
+ // bind: a vec2 source on an axis action throws.
24
+ //
25
+ // Reactivity: rates are memos over reactive sources, so a consumer's
26
+ // active() gate wakes on a stick or a key. Create the map in an owned
27
+ // scope (a component body): onPress/onRelease run effects under it.
28
+ //
29
+ // Consumers with the Axes contract (createAxes; the camera controls) are
30
+ // connected by name with drive(): each axis takes the action of the same
31
+ // name, or the name given, for both channels.
32
+ //
33
+ // Contexts (a menu open, a cutscene) switch actions off and on by name:
34
+ // enable/disable. A disabled action reads neutral, drops its deltas and
35
+ // closes the gesture it had open, while its sources keep their state, so
36
+ // a key still held when the action comes back reads at once. A set is a
37
+ // list of names, which a preset's action object already is
38
+ // (`input.disable(...Object.keys(gameActions))`): Unity's per-map enable
39
+ // and Unreal's stacked contexts, on one map, with no extra concept.
40
+
41
+ import { createEffect, createRoot, createSignal, untrack } from "@solidjs/signals"
42
+ import type { KeyEvent } from "./types"
43
+ import { combineRates } from "./input-axes"
44
+ import type { Axes, AxesDecl, AxisValue, Vec2 } from "./input-axes"
45
+
46
+ export type ActionKind = "button" | "axis" | "vec2"
47
+ export type ActionValue<K extends ActionKind> = K extends "button" ? boolean : K extends "axis" ? number : Vec2
48
+ export type ActionsDecl = Record<string, ActionKind>
49
+
50
+ /** Where a delta source delivers: the gesture brackets and the deltas. */
51
+ export interface DeltaSink {
52
+ begin(): void
53
+ delta(value: number | Vec2, focal?: Vec2): void
54
+ end(): void
55
+ }
56
+
57
+ /**
58
+ * One device control as an input source. `rate` reads its current value
59
+ * (reactive); `deltas` subscribes a sink to the amounts a gesture
60
+ * produces; `key`/`blur` receive the key events a map forwards
61
+ * (InputMap.handlers). A source may carry any subset.
62
+ */
63
+ export interface InputSource<K extends ActionKind = ActionKind> {
64
+ readonly kind: K
65
+ /** For bindings listings and settings screens: "keyboard W/A/S/D". */
66
+ readonly label: string
67
+ rate?: () => ActionValue<K>
68
+ deltas?: (sink: DeltaSink) => () => void
69
+ key?: (event: KeyEvent, down: boolean) => void
70
+ blur?: () => void
71
+ }
72
+
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
+ }
96
+ }
97
+
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
+ }
116
+
117
+ export interface GestureListener<K extends "axis" | "vec2" = "axis" | "vec2"> {
118
+ begin?: () => void
119
+ delta?: (value: AxisValue<K>, focal: Vec2 | undefined) => void
120
+ end?: () => void
121
+ }
122
+
123
+ type AxisActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? never : N }[keyof A] & string
124
+ type ButtonActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? N : never }[keyof A] & string
125
+
126
+ export interface InputMap<A extends ActionsDecl> {
127
+ readonly actions: A
128
+ /** Bind sources to an action (kinds checked), or a list of bindings (a
129
+ * preset's return). Returns the remover for what this call bound. */
130
+ bind<N extends keyof A & string>(action: N, ...sources: InputSource[]): () => void
131
+ bind(bindings: Binding[]): () => void
132
+ unbind(action: keyof A & string, source: InputSource): void
133
+ /** Current bindings, of one action or all, in binding order. */
134
+ bindings(action?: keyof A & string): Binding[]
135
+ /** The action's rate now (reactive): pressed for a button, -1..1 for an
136
+ * axis, a unit-clamped [x, y] for a vec2. */
137
+ value<N extends keyof A & string>(action: N): ActionValue<A[N]>
138
+ /** Reactive: whether a button action is pressed. */
139
+ pressed(action: ButtonActions<A>): boolean
140
+ /** Edge callbacks on a button action; return the unsubscribe. */
141
+ onPress(action: ButtonActions<A>, callback: () => void): () => void
142
+ onRelease(action: ButtonActions<A>, callback: () => void): () => void
143
+ /** Subscribe to an axis or vec2 action's delta channel. */
144
+ onGesture<N extends AxisActions<A>>(action: N, listener: GestureListener<A[N] extends "axis" ? "axis" : "vec2">): () => void
145
+ /** Injection by name, for scripts, debug commands, peers: `set` holds
146
+ * a rate until set again (a value source of the map's own); `press`/
147
+ * `release` do the same for a button; `nudge`, `begin` and `end` feed
148
+ * the delta channel. */
149
+ set<N extends keyof A & string>(action: N, value: ActionValue<A[N]>): void
150
+ press(action: ButtonActions<A>): void
151
+ release(action: ButtonActions<A>): void
152
+ nudge<N extends AxisActions<A>>(action: N, delta: ActionValue<A[N]>, focal?: Vec2): void
153
+ begin(action: AxisActions<A>): void
154
+ end(action: AxisActions<A>): void
155
+ /** Switch actions on and off by name - a context. A disabled action
156
+ * reads neutral (false, 0, [0, 0]), drops its deltas and closes the
157
+ * gesture it had open (its listeners see the end); its sources keep
158
+ * their state, so a key still held when the action comes back reads at
159
+ * once. Edge callbacks see the switch as a release or a press. All
160
+ * actions start enabled. */
161
+ enable(...actions: (keyof A & string)[]): void
162
+ disable(...actions: (keyof A & string)[]): void
163
+ /** Reactive: whether the action is enabled. */
164
+ enabled(action: keyof A & string): boolean
165
+ /** Key events for the bound keyboard sources: spread on the window
166
+ * (app-global) or on the leaf that should hold focus for them. */
167
+ handlers: {
168
+ onKeyDown(event: KeyEvent): void
169
+ onKeyUp(event: KeyEvent): void
170
+ onBlur(): void
171
+ }
172
+ /**
173
+ * Drive a control's axes (createAxes) from this map: each axis reads
174
+ * the action of the same name, or the one `names` gives it (null skips
175
+ * an axis). Both channels: rates as a source, deltas and brackets as
176
+ * nudges. Returns the disconnect.
177
+ */
178
+ drive<X extends AxesDecl>(axes: Axes<X>, names?: Partial<Record<keyof X & string, keyof A & string | null>>): () => void
179
+ }
180
+
181
+ // Per action: the bound sources and the script value are plain state (a
182
+ // write and a read in the same synchronous block must agree, which a
183
+ // signal's deferred write does not give); `version` notifies the reactive
184
+ // readers of a change.
185
+ type ActionState = {
186
+ kind: ActionKind
187
+ sources: InputSource[]
188
+ script: boolean | number | Vec2 | null
189
+ enabled: boolean
190
+ /** Gesture brackets delivered to the listeners and not yet closed. */
191
+ depth: number
192
+ version: () => number
193
+ bump: () => void
194
+ value: () => boolean | number | Vec2
195
+ gesture: Set<GestureListener>
196
+ /** Live delta subscriptions per bound source. */
197
+ live: Map<InputSource, () => void>
198
+ }
199
+
200
+ let compatible = (source: ActionKind, action: ActionKind): boolean => source === action || (source === "button" && action === "axis")
201
+
202
+ let neutral = (kind: ActionKind): boolean | number | Vec2 => (kind === "button" ? false : kind === "axis" ? 0 : [0, 0])
203
+
204
+ function checkValue(what: string, kind: ActionKind, v: unknown): void {
205
+ if (kind === "button") {
206
+ if (typeof v !== "boolean") throw new Error(`${what}: expected a boolean, got ${String(v)}`)
207
+ } else if (kind === "axis") {
208
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what}: expected a finite number, got ${String(v)}`)
209
+ } else if (!Array.isArray(v) || v.length !== 2 || !Number.isFinite(v[0]) || !Number.isFinite(v[1])) {
210
+ throw new Error(`${what}: expected [x, y] finite numbers, got ${JSON.stringify(v)}`)
211
+ }
212
+ }
213
+
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
+ }
222
+
223
+ export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
224
+ if (!actions || typeof actions !== "object") throw new Error("createInputMap: expected an object of action kinds")
225
+ let states = new Map<string, ActionState>()
226
+ for (let [name, kind] of Object.entries(actions)) {
227
+ if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`createInputMap: action "${name}" has kind "${String(kind)}", expected "button", "axis" or "vec2"`)
228
+ // ownedWrite: bind() and set() run from component bodies.
229
+ let [version, setVersion] = createSignal(0, { ownedWrite: true })
230
+ let state: ActionState = {
231
+ kind,
232
+ sources: [],
233
+ script: null,
234
+ enabled: true,
235
+ depth: 0,
236
+ version,
237
+ bump: () => setVersion(untrack(version) + 1),
238
+ value: (): boolean | number | Vec2 => {
239
+ version()
240
+ if (!state.enabled) return neutral(kind)
241
+ let s = state.script
242
+ if (kind === "button") {
243
+ if (s === true) return true
244
+ return state.sources.some(src => src.rate?.() === true)
245
+ }
246
+ let values: (number | Vec2)[] = []
247
+ if (s !== null) values.push(s as number | Vec2)
248
+ for (let src of state.sources) {
249
+ if (!src.rate) continue
250
+ let v = src.rate()
251
+ if (src.kind === "button") values.push(v ? 1 : 0)
252
+ else values.push(v as number | Vec2)
253
+ }
254
+ return combineRates(kind, values)
255
+ },
256
+ gesture: new Set(),
257
+ live: new Map(),
258
+ }
259
+ states.set(name, state)
260
+ }
261
+ let state = (name: string): ActionState => {
262
+ let s = states.get(name)
263
+ if (!s) throw new Error(`createInputMap: no action "${name}" (declared: ${[...states.keys()].join(", ")})`)
264
+ return s
265
+ }
266
+ let axisState = (name: string, what: string): ActionState => {
267
+ let s = state(name)
268
+ if (s.kind === "button") throw new Error(`createInputMap: ${what} needs an axis or vec2 action, "${name}" is a button`)
269
+ return s
270
+ }
271
+ let buttonState = (name: string, what: string): ActionState => {
272
+ let s = state(name)
273
+ if (s.kind !== "button") throw new Error(`createInputMap: ${what} needs a button action, "${name}" is a ${s.kind}`)
274
+ return s
275
+ }
276
+ // The delta channel fan-out for one action. Brackets are counted as
277
+ // 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 => {
280
+ s.depth--
281
+ s.gesture.forEach(g => g.end?.())
282
+ }
283
+ let sink = (s: ActionState): DeltaSink => ({
284
+ begin: () => {
285
+ if (!s.enabled) return
286
+ s.depth++
287
+ s.gesture.forEach(g => g.begin?.())
288
+ },
289
+ delta: (value, focal) => {
290
+ if (!s.enabled) return
291
+ s.gesture.forEach(g => g.delta?.(value as never, focal))
292
+ },
293
+ end: () => {
294
+ if (s.depth > 0) closeGesture(s)
295
+ },
296
+ })
297
+ let switchActions = (names: string[], on: boolean, what: string): void => {
298
+ if (names.length === 0) throw new Error(`createInputMap: ${what}() needs at least one action`)
299
+ for (let name of names) {
300
+ let s = state(name)
301
+ if (s.enabled === on) continue
302
+ s.enabled = on
303
+ while (!on && s.depth > 0) closeGesture(s)
304
+ s.bump()
305
+ }
306
+ }
307
+
308
+ let bindOne = (name: string, source: InputSource): void => {
309
+ let s = state(name)
310
+ checkSource(source)
311
+ if (!compatible(source.kind, s.kind)) throw new Error(`createInputMap: cannot bind ${source.kind} source "${source.label}" to ${s.kind} action "${name}"`)
312
+ if (s.sources.includes(source)) return
313
+ s.sources.push(source)
314
+ s.bump()
315
+ if (source.deltas && s.kind !== "button") s.live.set(source, source.deltas(sink(s)))
316
+ }
317
+ let unbindOne = (name: string, source: InputSource): void => {
318
+ let s = state(name)
319
+ let i = s.sources.indexOf(source)
320
+ if (i < 0) return
321
+ s.sources.splice(i, 1)
322
+ s.bump()
323
+ let stop = s.live.get(source)
324
+ if (stop) {
325
+ s.live.delete(source)
326
+ stop()
327
+ }
328
+ }
329
+ let edge = (name: string, want: boolean, callback: () => void): (() => void) => {
330
+ let s = buttonState(name, want ? "onPress" : "onRelease")
331
+ if (typeof callback !== "function") throw new Error(`createInputMap: ${want ? "onPress" : "onRelease"}("${name}") expects a function`)
332
+ return createRoot(dispose => {
333
+ // The callback is a side effect that may read state of its own (a
334
+ // focus, a pose): a snapshot, not a dependency, so untracked.
335
+ createEffect(
336
+ () => s.value() as boolean,
337
+ (pressed, prev) => {
338
+ if (pressed === want && prev !== want) untrack(callback)
339
+ },
340
+ { defer: true },
341
+ )
342
+ return dispose
343
+ })
344
+ }
345
+
346
+ let map: InputMap<A> = {
347
+ actions,
348
+ bind(actionOrList: string | Binding[], ...sources: InputSource[]) {
349
+ let list: Binding[] = Array.isArray(actionOrList) ? actionOrList : sources.map(source => ({ action: actionOrList, source }))
350
+ if (list.length === 0) throw new Error("createInputMap: bind() needs at least one source")
351
+ for (let b of list) bindOne(b.action, b.source)
352
+ return () => {
353
+ for (let b of list) unbindOne(b.action, b.source)
354
+ }
355
+ },
356
+ unbind: unbindOne,
357
+ bindings(action) {
358
+ let out: Binding[] = []
359
+ for (let [name, s] of states) {
360
+ if (action !== undefined && name !== action) continue
361
+ for (let source of s.sources) out.push({ action: name, source })
362
+ }
363
+ if (action !== undefined) state(action)
364
+ return out
365
+ },
366
+ value(action) {
367
+ return state(action).value() as never
368
+ },
369
+ pressed(action) {
370
+ return buttonState(action, "pressed").value() as boolean
371
+ },
372
+ onPress: (action, callback) => edge(action, true, callback),
373
+ onRelease: (action, callback) => edge(action, false, callback),
374
+ onGesture(action, listener) {
375
+ let s = axisState(action, "onGesture")
376
+ if (!listener || typeof listener !== "object") throw new Error(`createInputMap: onGesture("${action}") expects a listener object`)
377
+ s.gesture.add(listener as GestureListener)
378
+ return () => {
379
+ s.gesture.delete(listener as GestureListener)
380
+ }
381
+ },
382
+ set(action, value) {
383
+ let s = state(action)
384
+ checkValue(`set("${action}")`, s.kind, value)
385
+ s.script = value
386
+ s.bump()
387
+ },
388
+ press(action) {
389
+ let s = buttonState(action, "press")
390
+ s.script = true
391
+ s.bump()
392
+ },
393
+ release(action) {
394
+ let s = buttonState(action, "release")
395
+ s.script = false
396
+ s.bump()
397
+ },
398
+ nudge(action, delta, focal) {
399
+ let s = axisState(action, "nudge")
400
+ checkValue(`nudge("${action}")`, s.kind, delta)
401
+ if (focal !== undefined) checkValue(`nudge("${action}") focal`, "vec2", focal)
402
+ sink(s).delta(delta as number | Vec2, focal)
403
+ },
404
+ begin(action) {
405
+ sink(axisState(action, "begin")).begin()
406
+ },
407
+ end(action) {
408
+ sink(axisState(action, "end")).end()
409
+ },
410
+ enable: (...actions) => switchActions(actions, true, "enable"),
411
+ disable: (...actions) => switchActions(actions, false, "disable"),
412
+ enabled(action) {
413
+ let s = state(action)
414
+ s.version()
415
+ return s.enabled
416
+ },
417
+ handlers: {
418
+ onKeyDown: event => forwardKey(event, true),
419
+ onKeyUp: event => forwardKey(event, false),
420
+ onBlur: () => {
421
+ let seen = new Set<InputSource>()
422
+ for (let s of states.values()) {
423
+ for (let source of s.sources) {
424
+ if (seen.has(source)) continue
425
+ seen.add(source)
426
+ source.blur?.()
427
+ }
428
+ }
429
+ },
430
+ },
431
+ drive(axes, names) {
432
+ let stops: (() => void)[] = []
433
+ for (let [axis, kind] of Object.entries(axes.kinds)) {
434
+ let mapped = names && axis in names ? names[axis] : axis
435
+ if (mapped === null) continue
436
+ if (mapped === undefined || !states.has(mapped)) {
437
+ throw new Error(`createInputMap: drive() has no action for axis "${axis}" (declared: ${[...states.keys()].join(", ")}); declare it, map it with names, or skip it with null`)
438
+ }
439
+ let s = state(mapped)
440
+ if (s.kind !== kind) throw new Error(`createInputMap: drive() maps ${kind} axis "${axis}" to ${s.kind} action "${mapped}"`)
441
+ stops.push(axes.add(axis, () => s.value() as never))
442
+ stops.push(
443
+ map.onGesture(mapped as never, {
444
+ begin: () => axes.begin(axis),
445
+ delta: (value, focal) => axes.nudge(axis, value as never, focal),
446
+ end: () => axes.end(axis),
447
+ }),
448
+ )
449
+ }
450
+ return () => {
451
+ for (let stop of stops) stop()
452
+ }
453
+ },
454
+ }
455
+ // A key event reaches each bound keyboard source once, however many
456
+ // actions it is bound to.
457
+ let forwardKey = (event: KeyEvent, down: boolean): void => {
458
+ let seen = new Set<InputSource>()
459
+ for (let s of states.values()) {
460
+ for (let source of s.sources) {
461
+ if (seen.has(source) || !source.key) continue
462
+ seen.add(source)
463
+ source.key(event, down)
464
+ }
465
+ }
466
+ }
467
+ return map
468
+ }