@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 +99 -2
- package/examples/README.md +3 -0
- package/examples/gamepad.tsx +94 -0
- package/package.json +3 -2
- package/src/index.ts +12 -0
- package/src/input-axes.ts +180 -0
- package/src/input-gamepad-device.ts +161 -0
- package/src/input-gamepad.ts +24 -0
- package/src/input-keyboard.ts +156 -0
- package/src/input-map.ts +467 -0
- package/src/input-pointer.ts +213 -0
- package/src/input.ts +12 -0
- package/src/types.d.ts +4 -3
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
|
-
|
|
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
|
package/examples/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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)
|