@solidrt/core 0.0.56 → 0.0.57

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 CHANGED
@@ -132,6 +132,14 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
132
132
  window (message, stack, a Reset button that retries the failed
133
133
  computations) and logs `Uncaught error`. `<Errored>` gives a subtree its
134
134
  own in-place fallback.
135
+ An `onFrame` callback is its own boundary, event-listener style: a throw
136
+ is caught and logged (`Error in onFrame callback`, repeating every
137
+ frame), the subscription stays, and THE REST OF THAT CALLBACK IS
138
+ ABANDONED - what it wrote before the throw lands, what came after never
139
+ does, frame after frame while the app keeps presenting. A partial frame
140
+ is therefore a live possibility: every sprite drawn after the throwing
141
+ line simply stops appearing, which reads on screen as a logic bug, not a
142
+ crash. Check the logs before reasoning about a rendering symptom.
135
143
 
136
144
  - Two kinds of element:
137
145
  - Containers - `<window>`, `<view>`. Do layout + transform + pointer events.
@@ -312,6 +320,87 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
312
320
  rather than track pointers ad hoc, or they will double-handle against
313
321
  scrollers and pressables.
314
322
 
323
+ - The input map (input-map.ts) is how anything drives a control:
324
+ ARCHITECTURE.md's rule is that a control (a camera, a character
325
+ controller) consumes a device-free abstraction and never handles events
326
+ or reads a device itself, and the map is the named, typed layer between
327
+ devices and consumers - Godot's InputMap and Unity's action maps in
328
+ reactive form. `createInputMap({ look: "vec2", move: "vec2", zoom:
329
+ "axis", jump: "button" })` declares the app's actions once; devices bind
330
+ to them by name (`input.bind("look", pointer.drag, gamepad(0).rightStick)`,
331
+ `input.bind("move", keyboard.wasd, gamepad(0).leftStick)`); consumers
332
+ read the action, never the device. Every axis/vec2 action carries TWO
333
+ channels: the RATE (the sum of every bound source's current value,
334
+ clamped - an axis to -1..1, a vec2 to unit length - reactive, sampled per
335
+ frame by the consumer and integrated over dt at its own speed) and the
336
+ DELTAS (immediate amounts a gesture produced, in device-free units: a
337
+ drag in element heights, a wheel notch or a pinch in octaves, a twist in
338
+ turns, with begin/end brackets around a finger's gesture and the focal
339
+ point as a fraction of the element). Values use the screen convention
340
+ throughout: x right, y down, a stick pushed up reads y = -1, and a
341
+ consumer that means "forward" by a vec2 reads -y. A button source may
342
+ bind to an axis action (1 while pressed); a vec2 source on an axis
343
+ action throws.
344
+ Devices are the nouns: `keyboard.key("Space")`, `keyboard.axis("KeyQ",
345
+ "KeyE")`, `keyboard.vec2({ up, down, left, right })`, `keyboard.wasd`,
346
+ `keyboard.arrows`, a spec with modifiers ahead of the key
347
+ ("Shift+Tab", "Ctrl+KeyS": the most specific spec in a source wins a
348
+ down, an up releases on the bare key) (key events reach them through
349
+ `input.handlers`, spread on `<window>` for app-global keys or on the
350
+ leaf that should hold focus for them - keys bubble to the window, so a
351
+ TextInput's consumed keys never arrive); `gamepad(slot)`, `gamepad()`
352
+ for every pad, or `gamepad.next()` for the next unclaimed pad to press
353
+ any button (split screen seats players in pick-up order; its `slot`
354
+ resolves reactively and frees when the creating scope is disposed),
355
+ with `leftStick`, `rightStick`, `dpad`, `triggers` (right minus left),
356
+ `shoulders`, `axis(name)`, `button(name)`, reactive over gamepads() so a
357
+ stick wakes a control's frame loop; `createPointerFeed()` for the one
358
+ device that is not global - a pointer event belongs to the element
359
+ under it, so the feed is per element: spread `feed.handlers` on the
360
+ leaf (`<Scene pointer>`, `<View3d pointer>`, `<SpriteLayer pointer>` and
361
+ `<View2d pointer>` take a feed and spread it on their built-in leaf) and
362
+ bind its gestures `drag` (one pointer), `pan` (two), `pinch`, `twist`,
363
+ `wheel` and `mouseDelta` (raw motion while pointerLocked()). One
364
+ merged recognizer serves them, so they arbitrate in the arena as one. A
365
+ detached d-* leaf has no layout box: give the feed `{ layout }` or it
366
+ throws at the first press. `invert(source)` and `scale(source, k)` are
367
+ the two processors: keys and sticks move the CAMERA where a drag moves
368
+ the content, so a preset binds arrows or a stick to `pan`/`rotate`
369
+ through invert() and the action keeps one meaning.
370
+ Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
371
+ "axis" }, { onNudge, onBegin, onEnd })`, what every camera control
372
+ exposes as `.axes`) are connected by name with `input.drive(control.axes,
373
+ names?)`: each axis takes the action of the same name, or the one
374
+ `names` gives it (null skips one), both channels; the camera components
375
+ take the map as their `input` prop and do exactly that. Injection by
376
+ name is the wire for scripts, debug commands, peers and replays:
377
+ `input.set("move", [0, -1])` holds a rate, `press`/`release` a button,
378
+ `nudge`/`begin`/`end` feed the delta channel. `bindings()` lists what is
379
+ bound (source labels are display strings), `unbind` removes one, and a
380
+ preset (`orbitBindings`, `firstPersonBindings` in @solidrt/3d,
381
+ `camera2dBindings` in @solidrt/2d) is a plain bindings list the app
382
+ applies and edits - nothing binds unless the app says so (the one
383
+ built-in default is the UI set inside components' createFocusNav, the
384
+ platform's own layer). The map is an instance: two pads on two views is
385
+ two maps. Contexts are a switch per action: `input.disable("move",
386
+ "jump")` / `enable(...)` (a set is a list of names, so
387
+ `disable(...Object.keys(gameActions))` when a menu opens); a disabled
388
+ action reads neutral, drops its deltas and closes the gesture it had
389
+ open, while its sources keep their state so a key still held when it
390
+ comes back reads at once; `enabled(name)` is reactive. Create maps and
391
+ feeds in an owned scope (a component body): onPress/onRelease run
392
+ effects under it and the feed's recognizer registers its cleanup there. The vocabulary the
393
+ controls share, one kind and unit per word: `pan` vec2 (view heights),
394
+ `zoom` axis (octaves, positive in), `rotate` vec2 (orbit turns), `roll`
395
+ axis (turns about the view axis), `look` vec2 (turns), `move` vec2
396
+ ([right, forward], forward = -y), `rise` axis (up). The runtime-free
397
+ half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
398
+ importable as `@solidrt/core/input` for headless checks;
399
+ checks/input-map-check.ts runs it on the bare flux binary, and
400
+ checks/input-gamepad-check.ts drives the gamepad device and the join
401
+ order over a signal of pad snapshots (input-gamepad-device.ts is
402
+ runtime-free; input-gamepad.ts hands it gamepads()).
403
+
315
404
  - Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
316
405
  is as you expect, but `createEffect` takes the 2.0 two-function shape: a
317
406
  TRACKED compute that reads signals and returns a value, then an UNTRACKED
@@ -347,7 +436,11 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
347
436
  TypeScript with no @solidrt imports, with the JSX reading it and the
348
437
  per-frame hook doing one state step plus a handful of property writes.
349
438
  That shape runs under bun with no window (tests, audits, replaying a whole
350
- session in seconds) and it is what keeps per-frame work cheap.
439
+ session in seconds) and it is what keeps per-frame work cheap. For a game
440
+ it is the tuning loop itself: minutes of play run headless in a fraction
441
+ of a second, so a difficulty curve or a spawn table is tuned against
442
+ scripted policies in seconds instead of by playing, and the design bugs
443
+ that only show at scale surface there first.
351
444
  - Animation is target-shaped first: declare `transition` on the element and
352
445
  write targets, and the runtime animates natively with no per-frame JS.
353
446
  Enter and exit are per-property entries on that same spec: `from` is the
@@ -359,7 +452,11 @@ latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
359
452
  descendants declare `from`/`exit`. An enter plays once per mount; to
360
453
  replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
361
454
  then bump `epoch`). See examples/stagger.tsx.
362
- Reach for per-frame work only for genuinely procedural motion:
455
+ Per-frame work is for genuinely procedural motion, and a game or a
456
+ simulation is that in full: its natural shape is ONE `onFrame` doing one
457
+ sim step and N property (or sprite) writes, which is not a smell but the
458
+ budget the runtime is built for - agents/performance.md rule 4 has the
459
+ rules, @solidrt/2d's notes the measured write costs at scale.
363
460
  `onFrame((tick, frame, rate) => {})` is the native hook (runtime-paced,
364
461
  returns a cleanup, auto-cleaned inside an owned scope; from an effect's
365
462
  apply, return it so the loop runs only while the condition holds); `rate` is the
@@ -25,6 +25,9 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
25
25
  ## Pointer input
26
26
  - `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
27
27
 
28
+ ## Gamepads
29
+ - `gamepad.tsx` - the pad test bed: every connected pad's raw `gamepads()` snapshot (slots, buttons, axes) and two players seated by `gamepad.next()` - press any button on a pad to join player 1, another pad joins player 2 - each an input map (`move` on the left stick and dpad, `zoom` on the triggers, `jump` on south) printing its values live and walking a marker with `move`. Nothing moves until a pad does.
30
+
28
31
  ## Performance
29
32
  - `snapshot-texture.tsx` - `snapshotTexture(ref)`: a `repaintBoundary="snapshot"` view's rasterized pixels as a live texture id; a shader texture samples the panel and a sibling `<texture>` shows the warped copy. The boundary re-rasterizes only when its content changes.
30
33
  - `repaint-boundary.tsx` - `repaintBoundary` on a `<view>` to keep static content from rebuilding while a neighbor animates: `{true}` retains the recorded draw list, `"snapshot"` also retains the rasterized pixels as a GPU texture (for raster-expensive, screen-aligned, static subtrees). `"snapshot-no-aa"` rasterizes without anti-aliasing: cheaper, fine for text and axis-aligned rects, hard-edged on vector content.
@@ -0,0 +1,94 @@
1
+ // Gamepad test bed: every connected pad's raw snapshot (gamepads()) at the
2
+ // top, and two players seated by gamepad.next() below - press any button
3
+ // on a pad and it joins player 1, another pad joins player 2. Each player
4
+ // is an input map (`move` vec2 on the left stick and the dpad, `zoom` axis
5
+ // on the triggers, `jump` button on south) whose values print live, plus
6
+ // a marker that walks with `move`, so what a pad does is on screen.
7
+ // Nothing moves until a pad does.
8
+ import { createInputMap, createSignal, gamepad, gamepads, onFrame, render, untrack, windowSize, For } from "@solidrt/core"
9
+ import type { Vec2 } from "@solidrt/core"
10
+
11
+ const PLAYERS = 2
12
+ // Marker travel at full stick deflection, pixels per second.
13
+ const SPEED = 300
14
+ const MARKER = 28
15
+ const COLORS = ["#ffcc4d", "#66e6ff"]
16
+
17
+ let fmt = (v: number) => (v < 0 ? "" : " ") + v.toFixed(2)
18
+ let clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
19
+
20
+ function App() {
21
+ let players = Array.from({ length: PLAYERS }, (_, i) => {
22
+ let pad = gamepad.next()
23
+ let input = createInputMap({ move: "vec2", zoom: "axis", jump: "button" })
24
+ input.bind("move", pad.leftStick, pad.dpad)
25
+ input.bind("zoom", pad.triggers)
26
+ input.bind("jump", pad.button("south"))
27
+ // The marker: plain numbers stepped per frame, a signal to redraw.
28
+ let x = 80 + i * 120
29
+ let y = 260
30
+ let [pos, setPos] = createSignal<Vec2>([x, y])
31
+ let [jumps, setJumps] = createSignal(0)
32
+ input.onPress("jump", () => setJumps(n => n + 1))
33
+ let step = (dt: number) => {
34
+ let [mx, my] = untrack(() => input.value("move"))
35
+ if (mx === 0 && my === 0) return
36
+ let size = untrack(windowSize)
37
+ x = clamp(x + mx * SPEED * dt, 0, Math.max(0, size.width - MARKER))
38
+ y = clamp(y + my * SPEED * dt, 0, Math.max(0, size.height - MARKER))
39
+ setPos([x, y])
40
+ }
41
+ return { pad, input, pos, jumps, step }
42
+ })
43
+ let last: number | null = null
44
+ onFrame(tick => {
45
+ let dt = last === null ? 0 : (tick - last) / 1000
46
+ last = tick
47
+ for (let p of players) p.step(dt)
48
+ })
49
+ let padLine = (slot: number, pad: { name: string; buttons: string[]; axes: Record<string, number> } | null) => {
50
+ if (!pad) return `slot ${slot}: empty`
51
+ let a = pad.axes
52
+ return `slot ${slot}: ${pad.name} - buttons [${pad.buttons.join(" ")}] left${fmt(a.leftX ?? 0)},${fmt(a.leftY ?? 0)} right${fmt(a.rightX ?? 0)},${fmt(a.rightY ?? 0)} triggers${fmt(a.leftTrigger ?? 0)},${fmt(a.rightTrigger ?? 0)}`
53
+ }
54
+ return (
55
+ <window>
56
+ <d-rect color="#181820" />
57
+ <view flexDirection="column" gap={10} padding={24}>
58
+ <text color="#eef4ff" fontSize={22} fontWeight={700}>
59
+ Gamepads
60
+ </text>
61
+ <text color="#a9bcd6" fontSize={14}>
62
+ press any button on a pad to join as player 1, another pad for player 2 - left stick or dpad walks the marker, triggers read zoom, south counts jumps
63
+ </text>
64
+ <view flexDirection="column" gap={2}>
65
+ <text color="#a9bcd6" fontSize={14}>
66
+ connected: {String(gamepads().filter(p => p !== null).length)}
67
+ </text>
68
+ <For each={gamepads()} keyed={false}>
69
+ {(pad, i) => (
70
+ <text color="#eef4ff" fontSize={14}>
71
+ {padLine(i, pad())}
72
+ </text>
73
+ )}
74
+ </For>
75
+ </view>
76
+ <For each={players}>
77
+ {(p, i) => (
78
+ <view flexDirection="column" gap={2}>
79
+ <text color={COLORS[i()]} fontSize={16} fontWeight={700}>
80
+ player {String(i() + 1)}: {p.pad.slot === undefined ? "press any button on a pad to join" : `pad ${p.pad.slot}`}
81
+ </text>
82
+ <text color="#eef4ff" fontSize={14}>
83
+ move{fmt(p.input.value("move")[0])},{fmt(p.input.value("move")[1])} zoom{fmt(p.input.value("zoom"))} jump {p.input.pressed("jump") ? "down" : "up"} (x{String(p.jumps())})
84
+ </text>
85
+ </view>
86
+ )}
87
+ </For>
88
+ </view>
89
+ <For each={players}>{(p, i) => <d-rect x={p.pos()[0]} y={p.pos()[1]} w={MARKER} h={MARKER} radius={6} color={COLORS[i()]} />}</For>
90
+ </window>
91
+ )
92
+ }
93
+
94
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.56",
3
+ "version": "0.0.57",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -13,6 +13,7 @@
13
13
  "./data": "./src/data.ts",
14
14
  "./gpu": "./src/gpu.ts",
15
15
  "./image": "./src/image.ts",
16
+ "./input": "./src/input.ts",
16
17
  "./audio": "./src/audio.ts",
17
18
  "./microphone": "./src/microphone.ts",
18
19
  "./speech-recognition": "./src/speech-recognition.ts",
@@ -30,7 +31,7 @@
30
31
  "AGENTS.md"
31
32
  ],
32
33
  "devDependencies": {
33
- "@solidrt/flux-types": "0.0.56"
34
+ "@solidrt/flux-types": "0.0.57"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "@solidjs/signals": "2.0.0-rc.6",
package/src/index.ts CHANGED
@@ -28,6 +28,16 @@ export { createPan } from "./pan"
28
28
  export type { PanAxis, PanOptions } from "./pan"
29
29
  export { createTransform } from "./transform"
30
30
  export type { TransformDelta, TransformOptions } from "./transform"
31
+ export { createInputMap, invert, scale } from "./input-map"
32
+ export type { ActionKind, ActionValue, ActionsDecl, Binding, DeltaSink, GestureListener, InputMap, InputSource } from "./input-map"
33
+ export { createAxes } from "./input-axes"
34
+ export type { Axes, AxesDecl, AxesHooks, AxisKind, AxisValue, Vec2 } from "./input-axes"
35
+ export { keyboard } from "./input-keyboard"
36
+ export type { KeyboardDevice, KeyboardVec2Keys } from "./input-keyboard"
37
+ export { gamepad } from "./input-gamepad"
38
+ export type { GamepadDevice } from "./input-gamepad"
39
+ export { createPointerFeed } from "./input-pointer"
40
+ export type { PointerFeed, PointerFeedOptions } from "./input-pointer"
31
41
  export type {
32
42
  Element,
33
43
  LayoutProps,
@@ -91,6 +101,8 @@ export {
91
101
  createRoot,
92
102
  createStore,
93
103
  reconcile,
104
+ merge,
105
+ omit,
94
106
  mapArray,
95
107
  repeat,
96
108
  untrack,
@@ -0,0 +1,180 @@
1
+ // The control side of the input abstraction (ARCHITECTURE.md: controls
2
+ // through an abstraction, never direct event handling). A control - a
3
+ // camera controller, a character controller - declares the axes it
4
+ // consumes and receives input only through them, in device-free units:
5
+ //
6
+ // - rates: -1..1 values sampled at update time (a held key, a deflected
7
+ // stick, a value an app set), which the control integrates over dt at
8
+ // its own speeds. Sources are added, never set: several may feed one
9
+ // axis (keys and a stick), and the control sums and clamps them, so
10
+ // removing one never has to know what the others hold.
11
+ // - nudges: immediate deltas (a drag, a wheel notch, a pinch) in the
12
+ // vocabulary's units - view heights of travel, octaves of zoom, turns -
13
+ // applied at once, with the focal point (a fraction of the element)
14
+ // when the gesture has one. begin/end bracket a gesture, so a control
15
+ // can tell a finger's tracking (apply exactly) from an impulse (a wheel
16
+ // notch, unbracketed: ease it), stop a glide when a finger lands, and
17
+ // fling when it lifts.
18
+ //
19
+ // The values use the screen convention throughout: x right, y down, the
20
+ // way pointer deltas and the web Gamepad API report (a stick pushed up
21
+ // reads y = -1). A control that means "forward" by a vec2 reads -y.
22
+ //
23
+ // `active()` is the frame-loop gate: reactive, true while any rate source
24
+ // reads non-zero, so a control's loop runs only while something drives it.
25
+ // A rate source must therefore read reactive state (a signal, gamepads());
26
+ // a source over plain variables never wakes the loop - the caller runs
27
+ // update(dt) itself then.
28
+ //
29
+ // An input map drives an Axes object by action name (InputMap.drive); an
30
+ // app can also add its own sources and nudges directly.
31
+
32
+ import { createMemo, createSignal, untrack } from "@solidjs/signals"
33
+
34
+ export type AxisKind = "axis" | "vec2"
35
+ export type Vec2 = [number, number]
36
+ export type AxisValue<K extends AxisKind> = K extends "axis" ? number : Vec2
37
+
38
+ export type AxesDecl = Record<string, AxisKind>
39
+
40
+ export interface AxesHooks<A extends AxesDecl> {
41
+ /** An immediate delta on `name`: view heights for a vec2, the axis's
42
+ * unit (octaves, turns) for an axis; `focal` in 0..1 of the element
43
+ * when the gesture has one (a pinch, a wheel). */
44
+ onNudge?: <N extends keyof A & string>(name: N, delta: AxisValue<A[N]>, focal: Vec2 | undefined) => void
45
+ /** A gesture on `name` started (a finger landed) / ended (lifted). */
46
+ onBegin?: (name: keyof A & string) => void
47
+ onEnd?: (name: keyof A & string) => void
48
+ }
49
+
50
+ export interface Axes<A extends AxesDecl> {
51
+ /** The declared axes, name to kind - what a map's drive() reads. */
52
+ readonly kinds: A
53
+ /** Add a rate source (reactive read, -1..1 per component); returns the
54
+ * remover. Several sources sum, then clamp (an axis to -1..1, a vec2 to
55
+ * unit length). */
56
+ add<N extends keyof A & string>(name: N, rate: () => AxisValue<A[N]>): () => void
57
+ /** The combined rate of `name` now (reactive read). */
58
+ rate<N extends keyof A & string>(name: N): AxisValue<A[N]>
59
+ /** Reactive: whether any rate reads non-zero - the frame-loop gate. */
60
+ active(): boolean
61
+ /** Apply an immediate delta (see AxesHooks.onNudge). */
62
+ nudge<N extends keyof A & string>(name: N, delta: AxisValue<A[N]>, focal?: Vec2): void
63
+ /** Bracket a gesture on `name`; nested begins count. */
64
+ begin(name: keyof A & string): void
65
+ end(name: keyof A & string): void
66
+ /** Whether a gesture on `name` is open (between begin and end). */
67
+ inGesture(name: keyof A & string): boolean
68
+ }
69
+
70
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
71
+
72
+ /** Sum rate values of one kind and clamp: an axis to -1..1, a vec2 to unit length. */
73
+ export function combineRates(kind: AxisKind, values: (number | Vec2)[]): number | Vec2 {
74
+ if (kind === "axis") {
75
+ let sum = 0
76
+ for (let v of values) sum += v as number
77
+ return clampNum(sum, -1, 1)
78
+ }
79
+ let x = 0
80
+ let y = 0
81
+ for (let v of values) {
82
+ x += (v as Vec2)[0]
83
+ y += (v as Vec2)[1]
84
+ }
85
+ let len = Math.hypot(x, y)
86
+ return len > 1 ? [x / len, y / len] : [x, y]
87
+ }
88
+
89
+ function checkValue(what: string, kind: AxisKind, v: unknown): void {
90
+ if (kind === "axis") {
91
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what}: expected a finite number, got ${String(v)}`)
92
+ return
93
+ }
94
+ if (!Array.isArray(v) || v.length !== 2 || !Number.isFinite(v[0]) || !Number.isFinite(v[1])) {
95
+ throw new Error(`${what}: expected [x, y] finite numbers, got ${JSON.stringify(v)}`)
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Declare a control's axes: `createAxes({ rotate: "vec2", zoom: "axis" },
101
+ * { onNudge, onBegin, onEnd })`. The hooks are how nudges reach the
102
+ * control's pose; rates are read back with `rate()` from its update(dt).
103
+ */
104
+ export function createAxes<A extends AxesDecl>(kinds: A, hooks: AxesHooks<A> = {}): Axes<A> {
105
+ for (let [name, kind] of Object.entries(kinds)) {
106
+ if (kind !== "axis" && kind !== "vec2") throw new Error(`createAxes: axis "${name}" has kind "${String(kind)}", expected "axis" or "vec2"`)
107
+ }
108
+ let check = (name: string): AxisKind => {
109
+ let kind = kinds[name]
110
+ if (!kind) throw new Error(`createAxes: no axis "${name}" (declared: ${Object.keys(kinds).join(", ")})`)
111
+ return kind
112
+ }
113
+ // Sources per axis: a plain list is the truth (a write and a read in
114
+ // the same synchronous block must agree, which a signal's deferred
115
+ // write does not give), and a version signal notifies the reactive
116
+ // readers - active() and whoever tracks rate() - of a change.
117
+ let sources = new Map<string, (() => number | Vec2)[]>()
118
+ // ownedWrite: add() runs from component bodies (a map's drive()).
119
+ let [version, setVersion] = createSignal(0, { ownedWrite: true })
120
+ let bump = () => setVersion(untrack(version) + 1)
121
+ for (let name of Object.keys(kinds)) sources.set(name, [])
122
+ let rate = (name: string): number | Vec2 => {
123
+ version()
124
+ let values: (number | Vec2)[] = []
125
+ for (let read of sources.get(name)!) values.push(read())
126
+ return combineRates(kinds[name]!, values)
127
+ }
128
+ let active = createMemo(() => {
129
+ for (let [name, kind] of Object.entries(kinds)) {
130
+ let v = rate(name)
131
+ if (kind === "axis" ? v !== 0 : (v as Vec2)[0] !== 0 || (v as Vec2)[1] !== 0) return true
132
+ }
133
+ return false
134
+ })
135
+ let open = new Map<string, number>()
136
+ return {
137
+ kinds,
138
+ add(name, read) {
139
+ check(name)
140
+ if (typeof read !== "function") throw new Error(`createAxes: add("${name}") expects a function`)
141
+ let list = sources.get(name)!
142
+ list.push(read)
143
+ bump()
144
+ return () => {
145
+ let i = list.indexOf(read)
146
+ if (i < 0) return
147
+ list.splice(i, 1)
148
+ bump()
149
+ }
150
+ },
151
+ rate(name) {
152
+ check(name)
153
+ return rate(name) as never
154
+ },
155
+ active,
156
+ nudge(name, delta, focal) {
157
+ let kind = check(name)
158
+ checkValue(`nudge("${name}")`, kind, delta)
159
+ if (focal !== undefined) checkValue(`nudge("${name}") focal`, "vec2", focal)
160
+ hooks.onNudge?.(name, delta, focal)
161
+ },
162
+ begin(name) {
163
+ check(name)
164
+ let depth = open.get(name) ?? 0
165
+ open.set(name, depth + 1)
166
+ if (depth === 0) hooks.onBegin?.(name)
167
+ },
168
+ end(name) {
169
+ check(name)
170
+ let depth = open.get(name) ?? 0
171
+ if (depth === 0) return
172
+ open.set(name, depth - 1)
173
+ if (depth === 1) hooks.onEnd?.(name)
174
+ },
175
+ inGesture(name) {
176
+ check(name)
177
+ return (open.get(name) ?? 0) > 0
178
+ },
179
+ }
180
+ }
@@ -0,0 +1,161 @@
1
+ // A gamepad as input sources, over any accessor of pad snapshots: sticks
2
+ // and the dpad as vec2, triggers and shoulders as axes, any button as a
3
+ // button, any raw axis by name. Runtime-free (the accessor is injected,
4
+ // the public `gamepad` in input-gamepad.ts hands in core's gamepads()),
5
+ // so the join logic below runs headless in the checks.
6
+ //
7
+ // Every read is reactive when the accessor is (gamepads() is a signal),
8
+ // so a stick moved while a control's frame loop is off wakes it. Sticks
9
+ // pass through a radial dead zone; the screen convention holds (a stick
10
+ // pushed up reads y = -1, as the web Gamepad API reports it).
11
+ //
12
+ // Joining: `createGamepadJoin` makes a device with no slot until a pad no
13
+ // other joining device holds presses any button, then keeps that slot -
14
+ // Unity's join manager, for a couch game that seats players in the order
15
+ // they pick up a pad. A claim lasts until the owning scope is disposed
16
+ // (an unmounted pane frees its pad); with no owner, for the session.
17
+
18
+ import { createEffect, createRoot, createSignal, getOwner, onCleanup } from "@solidjs/signals"
19
+ import type { GamepadState } from "./gamepad"
20
+ import type { InputSource } from "./input-map"
21
+ import type { Vec2 } from "./input-axes"
22
+
23
+ // Stick deflection below which a resting stick reads as zero (radial:
24
+ // the whole vector, so a stick nudged diagonally also rests).
25
+ const STICK_DEADZONE = 0.15
26
+
27
+ export type PadsAccessor = () => (GamepadState | null)[]
28
+
29
+ export interface GamepadDevice {
30
+ /** The slot this device reads: a number for one pad, undefined for every
31
+ * connected pad (`gamepad()`) or for a joining device no pad has claimed
32
+ * yet (reactive: it resolves when one joins). */
33
+ readonly slot: number | undefined
34
+ leftStick: InputSource<"vec2">
35
+ rightStick: InputSource<"vec2">
36
+ /** The dpad as a digital vec2 (each component -1, 0 or 1). */
37
+ dpad: InputSource<"vec2">
38
+ /** Right trigger minus left trigger, -1..1. */
39
+ triggers: InputSource<"axis">
40
+ /** Right shoulder minus left shoulder, -1, 0 or 1. */
41
+ shoulders: InputSource<"axis">
42
+ /** A raw axis by SDL name ("leftX", "rightY", "leftTrigger", ...). */
43
+ axis(name: string): InputSource<"axis">
44
+ /** A button by SDL positional name ("south", "start", "leftShoulder", ...). */
45
+ button(name: string): InputSource<"button">
46
+ }
47
+
48
+ let deadzone = (x: number, y: number): Vec2 => (Math.hypot(x, y) < STICK_DEADZONE ? [0, 0] : [x, y])
49
+
50
+ /**
51
+ * The sources over `pads()` (the pads this device reads now, reactive),
52
+ * with `slot()` as the device's slot and `who` as the label prefix.
53
+ */
54
+ export function createGamepadDevice(pads: () => GamepadState[], slot: () => number | undefined, who: string): GamepadDevice {
55
+ let sumAxis = (read: (pad: GamepadState) => number) => (): number => {
56
+ let sum = 0
57
+ for (let pad of pads()) sum += read(pad)
58
+ return sum
59
+ }
60
+ let sumVec2 = (read: (pad: GamepadState) => Vec2) => (): Vec2 => {
61
+ let x = 0
62
+ let y = 0
63
+ for (let pad of pads()) {
64
+ let v = read(pad)
65
+ x += v[0]
66
+ y += v[1]
67
+ }
68
+ return [x, y]
69
+ }
70
+ let anyButton = (name: string) => (): boolean => pads().some(pad => pad.buttons.includes(name))
71
+ let pressed = (pad: GamepadState, name: string) => (pad.buttons.includes(name) ? 1 : 0)
72
+ let stick = (side: "left" | "right"): InputSource<"vec2"> => ({
73
+ kind: "vec2",
74
+ label: `${who} ${side} stick`,
75
+ rate: sumVec2(pad => deadzone(pad.axes[`${side}X`] ?? 0, pad.axes[`${side}Y`] ?? 0)),
76
+ })
77
+ return {
78
+ get slot() {
79
+ return slot()
80
+ },
81
+ leftStick: stick("left"),
82
+ rightStick: stick("right"),
83
+ dpad: {
84
+ kind: "vec2",
85
+ label: `${who} dpad`,
86
+ rate: sumVec2(pad => [pressed(pad, "dpadRight") - pressed(pad, "dpadLeft"), pressed(pad, "dpadDown") - pressed(pad, "dpadUp")]),
87
+ },
88
+ triggers: {
89
+ kind: "axis",
90
+ label: `${who} triggers`,
91
+ rate: sumAxis(pad => (pad.axes.rightTrigger ?? 0) - (pad.axes.leftTrigger ?? 0)),
92
+ },
93
+ shoulders: {
94
+ kind: "axis",
95
+ label: `${who} shoulders`,
96
+ rate: sumAxis(pad => pressed(pad, "rightShoulder") - pressed(pad, "leftShoulder")),
97
+ },
98
+ axis(name) {
99
+ if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.axis: expected an axis name, got ${String(name)}`)
100
+ return { kind: "axis", label: `${who} ${name}`, rate: sumAxis(pad => pad.axes[name] ?? 0) }
101
+ },
102
+ button(name) {
103
+ if (typeof name !== "string" || name.length === 0) throw new Error(`gamepad.button: expected a button name, got ${String(name)}`)
104
+ return { kind: "button", label: `${who} ${name}`, rate: anyButton(name) }
105
+ },
106
+ }
107
+ }
108
+
109
+ /** One slot, or every connected pad when `slot` is undefined. */
110
+ export function createGamepadSlot(read: PadsAccessor, slot: number | undefined): GamepadDevice {
111
+ if (slot !== undefined && !(Number.isInteger(slot) && slot >= 0)) throw new Error(`gamepad: slot must be a non-negative integer, got ${String(slot)}`)
112
+ let pads = (): GamepadState[] => {
113
+ let all = read()
114
+ if (slot === undefined) return all.filter((p): p is GamepadState => p !== null)
115
+ let pad = all[slot]
116
+ return pad ? [pad] : []
117
+ }
118
+ return createGamepadDevice(pads, () => slot, slot === undefined ? "gamepad" : `gamepad ${slot}`)
119
+ }
120
+
121
+ // Slots held by joining devices, shared so a pad joins once.
122
+ let claimed = new Set<number>()
123
+
124
+ /** A device that claims the next unclaimed pad to press any button. */
125
+ export function createGamepadJoin(read: PadsAccessor): GamepadDevice {
126
+ // ownedWrite: the claim lands inside an effect, the release in a cleanup.
127
+ let [slot, setSlot] = createSignal<number | undefined>(undefined, { ownedWrite: true })
128
+ let mine: number | undefined
129
+ // A root of its own: the watch outlives the render scope that created
130
+ // the device only when there is none to tie it to.
131
+ let dispose = createRoot(dispose => {
132
+ createEffect(
133
+ () => read(),
134
+ pads => {
135
+ if (mine !== undefined) return
136
+ for (let i = 0; i < pads.length; i++) {
137
+ let pad = pads[i]
138
+ if (!pad || claimed.has(i) || pad.buttons.length === 0) continue
139
+ mine = i
140
+ claimed.add(i)
141
+ setSlot(i)
142
+ return
143
+ }
144
+ },
145
+ )
146
+ return dispose
147
+ })
148
+ if (getOwner()) {
149
+ onCleanup(() => {
150
+ dispose()
151
+ if (mine !== undefined) claimed.delete(mine)
152
+ })
153
+ }
154
+ let pads = (): GamepadState[] => {
155
+ let s = slot()
156
+ if (s === undefined) return []
157
+ let pad = read()[s]
158
+ return pad ? [pad] : []
159
+ }
160
+ return createGamepadDevice(pads, slot, "gamepad (joined)")
161
+ }
@@ -0,0 +1,24 @@
1
+ // The gamepad device over core's gamepads() snapshot: `gamepad(slot)`
2
+ // reads one pad (slots are stable per connection, so slot 0 is player
3
+ // one for the session); `gamepad()` sums every connected pad, the right
4
+ // default for a single-player app that does not care which pad is picked
5
+ // up; `gamepad.next()` claims the next pad that presses any button, for
6
+ // split screen that seats players in pick-up order. The sources and the
7
+ // join logic live in input-gamepad-device.ts, runtime-free.
8
+
9
+ import { gamepads } from "./gamepad"
10
+ import { createGamepadJoin, createGamepadSlot } from "./input-gamepad-device"
11
+ import type { GamepadDevice } from "./input-gamepad-device"
12
+
13
+ export type { GamepadDevice } from "./input-gamepad-device"
14
+
15
+ export function gamepad(slot?: number): GamepadDevice {
16
+ return createGamepadSlot(gamepads, slot)
17
+ }
18
+
19
+ /**
20
+ * A device with no pad until one no other joining device holds presses
21
+ * any button, then that pad for good (`slot` resolves, reactively). The
22
+ * claim is released when the creating scope is disposed.
23
+ */
24
+ gamepad.next = (): GamepadDevice => createGamepadJoin(gamepads)