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