@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.
- package/AGENTS.md +109 -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-chord.ts +44 -0
- package/src/input-gamepad-device.ts +161 -0
- package/src/input-gamepad.ts +24 -0
- package/src/input-keyboard.ts +145 -0
- package/src/input-map.ts +468 -0
- package/src/input-pointer.ts +306 -0
- package/src/input.ts +12 -0
- package/src/transform.ts +4 -1
- package/src/types.d.ts +4 -3
|
@@ -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)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The keyboard as input sources: a key as a button, a key pair as an
|
|
2
|
+
// axis, four keys as a vec2 (the WASD composite), each holding its own
|
|
3
|
+
// pressed state from the key events an input map forwards to it
|
|
4
|
+
// (InputMap.handlers, spread on the window or the focused leaf). Keys
|
|
5
|
+
// match on the physical code ("KeyW", layout-independent) or the logical
|
|
6
|
+
// key ("w", what a synthetic event or an odd layout reports), and a
|
|
7
|
+
// letter code matches its letter either way, so "KeyW" and "w" both take
|
|
8
|
+
// a shifted "W".
|
|
9
|
+
//
|
|
10
|
+
// A spec may name modifiers ahead of the key ("Shift+Tab", "Ctrl+KeyS";
|
|
11
|
+
// the chord vocabulary of input-chord.ts, shared with the pointer feed):
|
|
12
|
+
// the down must carry them, while a bare spec ignores them (a shifted W
|
|
13
|
+
// still walks). Within one source the most specific matching spec wins
|
|
14
|
+
// a down, so `axis("Shift+Tab", "Tab")` reads -1, not 0, on Shift+Tab;
|
|
15
|
+
// and an up releases on the key alone, so a modifier let go first
|
|
16
|
+
// cannot leave the key stuck.
|
|
17
|
+
//
|
|
18
|
+
// Held state is a count behind a signal, so a source's rate is reactive
|
|
19
|
+
// (a held key wakes a control's frame loop) and a blur - the up that never
|
|
20
|
+
// arrives once focus has left - clears it. Composites clamp nothing: the
|
|
21
|
+
// map's combination does (W and D together read [1, -1] before the
|
|
22
|
+
// vec2 clamp to unit length, so diagonals walk at the same speed).
|
|
23
|
+
//
|
|
24
|
+
// Values use the screen convention (x right, y down): W reads [0, -1], as
|
|
25
|
+
// a stick pushed up does.
|
|
26
|
+
|
|
27
|
+
import { createSignal } from "@solidjs/signals"
|
|
28
|
+
import type { KeyEvent } from "./types"
|
|
29
|
+
import type { InputSource } from "./input-map"
|
|
30
|
+
import type { Vec2 } from "./input-axes"
|
|
31
|
+
import { mostSpecific, parseModifiers } from "./input-chord"
|
|
32
|
+
import type { Modifier } from "./input-chord"
|
|
33
|
+
|
|
34
|
+
// A parsed spec: the text as given (the held-state key and the label),
|
|
35
|
+
// the key it names, and the modifiers the down must carry.
|
|
36
|
+
type Spec = { text: string; key: string; mods: Modifier[] }
|
|
37
|
+
|
|
38
|
+
function parse(what: string, text: unknown): Spec {
|
|
39
|
+
if (typeof text !== "string" || text.length === 0) throw new Error(`keyboard.${what}: expected a key code or key name, got ${String(text)}`)
|
|
40
|
+
let parts = text.split("+")
|
|
41
|
+
let key = parts.pop()!
|
|
42
|
+
if (key.length === 0) throw new Error(`keyboard.${what}: "${text}" names no key`)
|
|
43
|
+
return { text, key, mods: parseModifiers(`keyboard.${what}`, text, parts) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A key matches its physical code or its logical key; a letter code
|
|
47
|
+
// ("KeyW") also matches the logical letter ("w", "W") and "Space" the
|
|
48
|
+
// logical " ", so a synthetic event without a code (an agent's
|
|
49
|
+
// send_input, an odd layout) still walks.
|
|
50
|
+
let matches = (event: KeyEvent, key: string): boolean => {
|
|
51
|
+
if (event.code === key || event.key === key) return true
|
|
52
|
+
if (key === "Space" && event.key === " ") return true
|
|
53
|
+
if (event.key.length !== 1) return false
|
|
54
|
+
if (key.length === 1) return event.key.toLowerCase() === key.toLowerCase()
|
|
55
|
+
return key.length === 4 && key.startsWith("Key") && event.key.toLowerCase() === key[3]!.toLowerCase()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A set of specs with a reactive "how many are held" count.
|
|
59
|
+
function held(specs: Spec[]) {
|
|
60
|
+
let down = new Set<string>()
|
|
61
|
+
// ownedWrite: a map forwards key events from wherever its handlers sit.
|
|
62
|
+
let [count, setCount] = createSignal(0, { ownedWrite: true })
|
|
63
|
+
return {
|
|
64
|
+
count,
|
|
65
|
+
key(event: KeyEvent, isDown: boolean) {
|
|
66
|
+
// Every spec on this key is released first: a down settles the key
|
|
67
|
+
// on its most specific matches (a repeat under a newly held Shift
|
|
68
|
+
// moves "Tab" to "Shift+Tab" and back), an up frees them all.
|
|
69
|
+
let onKey = specs.filter(s => matches(event, s.key))
|
|
70
|
+
for (let s of onKey) down.delete(s.text)
|
|
71
|
+
if (isDown) for (let s of mostSpecific(onKey, s => s.mods, event)) down.add(s.text)
|
|
72
|
+
setCount(down.size)
|
|
73
|
+
},
|
|
74
|
+
blur() {
|
|
75
|
+
down.clear()
|
|
76
|
+
setCount(0)
|
|
77
|
+
},
|
|
78
|
+
has(text: string) {
|
|
79
|
+
return down.has(text)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One key as a button source (pressed while held). */
|
|
85
|
+
function key(spec: string): InputSource<"button"> {
|
|
86
|
+
let state = held([parse("key", spec)])
|
|
87
|
+
return {
|
|
88
|
+
kind: "button",
|
|
89
|
+
label: `keyboard ${spec}`,
|
|
90
|
+
rate: () => state.count() > 0,
|
|
91
|
+
key: state.key,
|
|
92
|
+
blur: state.blur,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Two keys as an axis: `neg` reads -1, `pos` reads 1, both 0. */
|
|
97
|
+
function axis(neg: string, pos: string): InputSource<"axis"> {
|
|
98
|
+
let state = held([parse("axis", neg), parse("axis", pos)])
|
|
99
|
+
return {
|
|
100
|
+
kind: "axis",
|
|
101
|
+
label: `keyboard ${neg}/${pos}`,
|
|
102
|
+
rate: () => {
|
|
103
|
+
state.count()
|
|
104
|
+
return (state.has(pos) ? 1 : 0) - (state.has(neg) ? 1 : 0)
|
|
105
|
+
},
|
|
106
|
+
key: state.key,
|
|
107
|
+
blur: state.blur,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type KeyboardVec2Keys = { up: string; down: string; left: string; right: string }
|
|
112
|
+
|
|
113
|
+
/** Four keys as a vec2 in the screen convention: up reads [0, -1], right
|
|
114
|
+
* reads [1, 0]; opposite keys cancel. */
|
|
115
|
+
function vec2(keys: KeyboardVec2Keys): InputSource<"vec2"> {
|
|
116
|
+
let state = held((["up", "down", "left", "right"] as const).map(side => parse(`vec2 ${side}`, keys[side])))
|
|
117
|
+
return {
|
|
118
|
+
kind: "vec2",
|
|
119
|
+
label: `keyboard ${keys.up}/${keys.left}/${keys.down}/${keys.right}`,
|
|
120
|
+
rate: (): Vec2 => {
|
|
121
|
+
state.count()
|
|
122
|
+
return [(state.has(keys.right) ? 1 : 0) - (state.has(keys.left) ? 1 : 0), (state.has(keys.down) ? 1 : 0) - (state.has(keys.up) ? 1 : 0)]
|
|
123
|
+
},
|
|
124
|
+
key: state.key,
|
|
125
|
+
blur: state.blur,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The keyboard device: `keyboard.key("Space")`, `keyboard.axis("KeyQ",
|
|
131
|
+
* "KeyE")`, `keyboard.vec2({ up, down, left, right })`, and the two
|
|
132
|
+
* composites every game binds, `keyboard.wasd` and `keyboard.arrows`.
|
|
133
|
+
* A spec may carry modifiers ("Shift+Tab", "Ctrl+KeyS"). Each call makes
|
|
134
|
+
* an independent source with its own held state; the composites are
|
|
135
|
+
* shared singletons.
|
|
136
|
+
*/
|
|
137
|
+
export let keyboard = {
|
|
138
|
+
key,
|
|
139
|
+
axis,
|
|
140
|
+
vec2,
|
|
141
|
+
wasd: vec2({ up: "KeyW", down: "KeyS", left: "KeyA", right: "KeyD" }),
|
|
142
|
+
arrows: vec2({ up: "ArrowUp", down: "ArrowDown", left: "ArrowLeft", right: "ArrowRight" }),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type KeyboardDevice = typeof keyboard
|