@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
package/src/input-map.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// The input map: named, typed actions between devices and consumers -
|
|
2
|
+
// Godot's InputMap and Unity's action maps, in reactive form. An app
|
|
3
|
+
// declares its actions once (`look: "vec2"`, `jump: "button"`), binds
|
|
4
|
+
// device sources to them (a stick, a key composite, a pointer drag), and
|
|
5
|
+
// consumers read the action, never the device. Anything can then drive a
|
|
6
|
+
// consumer: a second pad on a second map for split screen, a debug
|
|
7
|
+
// command or a network peer through the by-name injection, a replay on
|
|
8
|
+
// the action stream, a settings screen over bindings().
|
|
9
|
+
//
|
|
10
|
+
// Each axis or vec2 action carries two channels, which is the part Godot
|
|
11
|
+
// (no mouse motion in actions) and Unity (mouse delta and stick share one
|
|
12
|
+
// value, consumers scale by dt themselves) each got half of:
|
|
13
|
+
// - the RATE: the sum of every bound source's current value, clamped (an
|
|
14
|
+
// axis to -1..1, a vec2 to unit length), reactive, sampled per frame by
|
|
15
|
+
// the consumer and integrated over dt at its own speed;
|
|
16
|
+
// - the DELTAS: immediate, device-free amounts a gesture produced (a drag
|
|
17
|
+
// in view heights, a wheel notch in octaves), delivered as they happen
|
|
18
|
+
// with begin/end brackets around a finger's gesture.
|
|
19
|
+
// Values use the screen convention: x right, y down.
|
|
20
|
+
//
|
|
21
|
+
// A button action reads pressed while any bound button source is, and
|
|
22
|
+
// may also bind to an axis action (1 while pressed). Kinds are checked at
|
|
23
|
+
// bind: a vec2 source on an axis action throws.
|
|
24
|
+
//
|
|
25
|
+
// Reactivity: rates are memos over reactive sources, so a consumer's
|
|
26
|
+
// active() gate wakes on a stick or a key. Create the map in an owned
|
|
27
|
+
// scope (a component body): onPress/onRelease run effects under it.
|
|
28
|
+
//
|
|
29
|
+
// Consumers with the Axes contract (createAxes; the camera controls) are
|
|
30
|
+
// connected by name with drive(): each axis takes the action of the same
|
|
31
|
+
// name, or the name given, for both channels.
|
|
32
|
+
//
|
|
33
|
+
// Contexts (a menu open, a cutscene) switch actions off and on by name:
|
|
34
|
+
// enable/disable. A disabled action reads neutral, drops its deltas and
|
|
35
|
+
// closes the gesture it had open, while its sources keep their state, so
|
|
36
|
+
// a key still held when the action comes back reads at once. A set is a
|
|
37
|
+
// list of names, which a preset's action object already is
|
|
38
|
+
// (`input.disable(...Object.keys(gameActions))`): Unity's per-map enable
|
|
39
|
+
// and Unreal's stacked contexts, on one map, with no extra concept.
|
|
40
|
+
|
|
41
|
+
import { createEffect, createRoot, createSignal, untrack } from "@solidjs/signals"
|
|
42
|
+
import type { KeyEvent } from "./types"
|
|
43
|
+
import { combineRates } from "./input-axes"
|
|
44
|
+
import type { Axes, AxesDecl, AxisValue, Vec2 } from "./input-axes"
|
|
45
|
+
|
|
46
|
+
export type ActionKind = "button" | "axis" | "vec2"
|
|
47
|
+
export type ActionValue<K extends ActionKind> = K extends "button" ? boolean : K extends "axis" ? number : Vec2
|
|
48
|
+
export type ActionsDecl = Record<string, ActionKind>
|
|
49
|
+
|
|
50
|
+
/** Where a delta source delivers: the gesture brackets and the deltas. */
|
|
51
|
+
export interface DeltaSink {
|
|
52
|
+
begin(): void
|
|
53
|
+
delta(value: number | Vec2, focal?: Vec2): void
|
|
54
|
+
end(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One device control as an input source. `rate` reads its current value
|
|
59
|
+
* (reactive); `deltas` subscribes a sink to the amounts a gesture
|
|
60
|
+
* produces; `key`/`blur` receive the key events a map forwards
|
|
61
|
+
* (InputMap.handlers). A source may carry any subset.
|
|
62
|
+
*/
|
|
63
|
+
export interface InputSource<K extends ActionKind = ActionKind> {
|
|
64
|
+
readonly kind: K
|
|
65
|
+
/** For bindings listings and settings screens: "keyboard W/A/S/D". */
|
|
66
|
+
readonly label: string
|
|
67
|
+
rate?: () => ActionValue<K>
|
|
68
|
+
deltas?: (sink: DeltaSink) => () => void
|
|
69
|
+
key?: (event: KeyEvent, down: boolean) => void
|
|
70
|
+
blur?: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type Binding = { action: string; source: InputSource }
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A source with its values negated - Unity's Invert processor. The one
|
|
77
|
+
* convention split every engine has: a drag moves the CONTENT under the
|
|
78
|
+
* finger, while keys and sticks move the CAMERA, so a preset binds the
|
|
79
|
+
* arrows or a stick to `pan` or `rotate` through invert() and the axis
|
|
80
|
+
* keeps one meaning.
|
|
81
|
+
*/
|
|
82
|
+
export function invert<K extends "axis" | "vec2">(source: InputSource<K>): InputSource<K> {
|
|
83
|
+
checkSource(source)
|
|
84
|
+
if ((source.kind as ActionKind) === "button") throw new Error(`invert: "${source.label}" is a button`)
|
|
85
|
+
let neg = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? -v : [-v[0], -v[1]])
|
|
86
|
+
return {
|
|
87
|
+
kind: source.kind,
|
|
88
|
+
label: `${source.label} (inverted)`,
|
|
89
|
+
rate: source.rate ? () => neg(source.rate!() as number | Vec2) as never : undefined,
|
|
90
|
+
deltas: source.deltas
|
|
91
|
+
? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(neg(value), focal) })
|
|
92
|
+
: undefined,
|
|
93
|
+
key: source.key,
|
|
94
|
+
blur: source.blur,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A source scaled by `factor` (rates and deltas alike) - Unity's Scale
|
|
99
|
+
* processor: a slower stick, a finer wheel. */
|
|
100
|
+
export function scale<K extends "axis" | "vec2">(source: InputSource<K>, factor: number): InputSource<K> {
|
|
101
|
+
checkSource(source)
|
|
102
|
+
if ((source.kind as ActionKind) === "button") throw new Error(`scale: "${source.label}" is a button`)
|
|
103
|
+
if (!Number.isFinite(factor)) throw new Error(`scale: factor must be a finite number, got ${factor}`)
|
|
104
|
+
let mul = (v: number | Vec2): number | Vec2 => (typeof v === "number" ? v * factor : [v[0] * factor, v[1] * factor])
|
|
105
|
+
return {
|
|
106
|
+
kind: source.kind,
|
|
107
|
+
label: `${source.label} (x${factor})`,
|
|
108
|
+
rate: source.rate ? () => mul(source.rate!() as number | Vec2) as never : undefined,
|
|
109
|
+
deltas: source.deltas
|
|
110
|
+
? sink => source.deltas!({ begin: sink.begin, end: sink.end, delta: (value, focal) => sink.delta(mul(value), focal) })
|
|
111
|
+
: undefined,
|
|
112
|
+
key: source.key,
|
|
113
|
+
blur: source.blur,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface GestureListener<K extends "axis" | "vec2" = "axis" | "vec2"> {
|
|
118
|
+
begin?: () => void
|
|
119
|
+
delta?: (value: AxisValue<K>, focal: Vec2 | undefined) => void
|
|
120
|
+
end?: () => void
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type AxisActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? never : N }[keyof A] & string
|
|
124
|
+
type ButtonActions<A extends ActionsDecl> = { [N in keyof A]: A[N] extends "button" ? N : never }[keyof A] & string
|
|
125
|
+
|
|
126
|
+
export interface InputMap<A extends ActionsDecl> {
|
|
127
|
+
readonly actions: A
|
|
128
|
+
/** Bind sources to an action (kinds checked), or a list of bindings (a
|
|
129
|
+
* preset's return). Returns the remover for what this call bound. */
|
|
130
|
+
bind<N extends keyof A & string>(action: N, ...sources: InputSource[]): () => void
|
|
131
|
+
bind(bindings: Binding[]): () => void
|
|
132
|
+
unbind(action: keyof A & string, source: InputSource): void
|
|
133
|
+
/** Current bindings, of one action or all, in binding order. */
|
|
134
|
+
bindings(action?: keyof A & string): Binding[]
|
|
135
|
+
/** The action's rate now (reactive): pressed for a button, -1..1 for an
|
|
136
|
+
* axis, a unit-clamped [x, y] for a vec2. */
|
|
137
|
+
value<N extends keyof A & string>(action: N): ActionValue<A[N]>
|
|
138
|
+
/** Reactive: whether a button action is pressed. */
|
|
139
|
+
pressed(action: ButtonActions<A>): boolean
|
|
140
|
+
/** Edge callbacks on a button action; return the unsubscribe. */
|
|
141
|
+
onPress(action: ButtonActions<A>, callback: () => void): () => void
|
|
142
|
+
onRelease(action: ButtonActions<A>, callback: () => void): () => void
|
|
143
|
+
/** Subscribe to an axis or vec2 action's delta channel. */
|
|
144
|
+
onGesture<N extends AxisActions<A>>(action: N, listener: GestureListener<A[N] extends "axis" ? "axis" : "vec2">): () => void
|
|
145
|
+
/** Injection by name, for scripts, debug commands, peers: `set` holds
|
|
146
|
+
* a rate until set again (a value source of the map's own); `press`/
|
|
147
|
+
* `release` do the same for a button; `nudge`, `begin` and `end` feed
|
|
148
|
+
* the delta channel. */
|
|
149
|
+
set<N extends keyof A & string>(action: N, value: ActionValue<A[N]>): void
|
|
150
|
+
press(action: ButtonActions<A>): void
|
|
151
|
+
release(action: ButtonActions<A>): void
|
|
152
|
+
nudge<N extends AxisActions<A>>(action: N, delta: ActionValue<A[N]>, focal?: Vec2): void
|
|
153
|
+
begin(action: AxisActions<A>): void
|
|
154
|
+
end(action: AxisActions<A>): void
|
|
155
|
+
/** Switch actions on and off by name - a context. A disabled action
|
|
156
|
+
* reads neutral (false, 0, [0, 0]), drops its deltas and closes the
|
|
157
|
+
* gesture it had open (its listeners see the end); its sources keep
|
|
158
|
+
* their state, so a key still held when the action comes back reads at
|
|
159
|
+
* once. Edge callbacks see the switch as a release or a press. All
|
|
160
|
+
* actions start enabled. */
|
|
161
|
+
enable(...actions: (keyof A & string)[]): void
|
|
162
|
+
disable(...actions: (keyof A & string)[]): void
|
|
163
|
+
/** Reactive: whether the action is enabled. */
|
|
164
|
+
enabled(action: keyof A & string): boolean
|
|
165
|
+
/** Key events for the bound keyboard sources: spread on the window
|
|
166
|
+
* (app-global) or on the leaf that should hold focus for them. */
|
|
167
|
+
handlers: {
|
|
168
|
+
onKeyDown(event: KeyEvent): void
|
|
169
|
+
onKeyUp(event: KeyEvent): void
|
|
170
|
+
onBlur(): void
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Drive a control's axes (createAxes) from this map: each axis reads
|
|
174
|
+
* the action of the same name, or the one `names` gives it (null skips
|
|
175
|
+
* an axis). Both channels: rates as a source, deltas and brackets as
|
|
176
|
+
* nudges. Returns the disconnect.
|
|
177
|
+
*/
|
|
178
|
+
drive<X extends AxesDecl>(axes: Axes<X>, names?: Partial<Record<keyof X & string, keyof A & string | null>>): () => void
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Per action: the bound sources and the script value are plain state (a
|
|
182
|
+
// write and a read in the same synchronous block must agree, which a
|
|
183
|
+
// signal's deferred write does not give); `version` notifies the reactive
|
|
184
|
+
// readers of a change.
|
|
185
|
+
type ActionState = {
|
|
186
|
+
kind: ActionKind
|
|
187
|
+
sources: InputSource[]
|
|
188
|
+
script: boolean | number | Vec2 | null
|
|
189
|
+
enabled: boolean
|
|
190
|
+
/** Gesture brackets delivered to the listeners and not yet closed. */
|
|
191
|
+
depth: number
|
|
192
|
+
version: () => number
|
|
193
|
+
bump: () => void
|
|
194
|
+
value: () => boolean | number | Vec2
|
|
195
|
+
gesture: Set<GestureListener>
|
|
196
|
+
/** Live delta subscriptions per bound source. */
|
|
197
|
+
live: Map<InputSource, () => void>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let compatible = (source: ActionKind, action: ActionKind): boolean => source === action || (source === "button" && action === "axis")
|
|
201
|
+
|
|
202
|
+
let neutral = (kind: ActionKind): boolean | number | Vec2 => (kind === "button" ? false : kind === "axis" ? 0 : [0, 0])
|
|
203
|
+
|
|
204
|
+
function checkValue(what: string, kind: ActionKind, v: unknown): void {
|
|
205
|
+
if (kind === "button") {
|
|
206
|
+
if (typeof v !== "boolean") throw new Error(`${what}: expected a boolean, got ${String(v)}`)
|
|
207
|
+
} else if (kind === "axis") {
|
|
208
|
+
if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what}: expected a finite number, got ${String(v)}`)
|
|
209
|
+
} else if (!Array.isArray(v) || v.length !== 2 || !Number.isFinite(v[0]) || !Number.isFinite(v[1])) {
|
|
210
|
+
throw new Error(`${what}: expected [x, y] finite numbers, got ${JSON.stringify(v)}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function checkSource(source: unknown): InputSource {
|
|
215
|
+
let s = source as InputSource
|
|
216
|
+
if (!s || typeof s !== "object" || (s.kind !== "button" && s.kind !== "axis" && s.kind !== "vec2") || typeof s.label !== "string") {
|
|
217
|
+
throw new Error(`createInputMap: not an input source: ${String(source)}`)
|
|
218
|
+
}
|
|
219
|
+
return s
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createInputMap<A extends ActionsDecl>(actions: A): InputMap<A> {
|
|
223
|
+
if (!actions || typeof actions !== "object") throw new Error("createInputMap: expected an object of action kinds")
|
|
224
|
+
let states = new Map<string, ActionState>()
|
|
225
|
+
for (let [name, kind] of Object.entries(actions)) {
|
|
226
|
+
if (kind !== "button" && kind !== "axis" && kind !== "vec2") throw new Error(`createInputMap: action "${name}" has kind "${String(kind)}", expected "button", "axis" or "vec2"`)
|
|
227
|
+
// ownedWrite: bind() and set() run from component bodies.
|
|
228
|
+
let [version, setVersion] = createSignal(0, { ownedWrite: true })
|
|
229
|
+
let state: ActionState = {
|
|
230
|
+
kind,
|
|
231
|
+
sources: [],
|
|
232
|
+
script: null,
|
|
233
|
+
enabled: true,
|
|
234
|
+
depth: 0,
|
|
235
|
+
version,
|
|
236
|
+
bump: () => setVersion(untrack(version) + 1),
|
|
237
|
+
value: (): boolean | number | Vec2 => {
|
|
238
|
+
version()
|
|
239
|
+
if (!state.enabled) return neutral(kind)
|
|
240
|
+
let s = state.script
|
|
241
|
+
if (kind === "button") {
|
|
242
|
+
if (s === true) return true
|
|
243
|
+
return state.sources.some(src => src.rate?.() === true)
|
|
244
|
+
}
|
|
245
|
+
let values: (number | Vec2)[] = []
|
|
246
|
+
if (s !== null) values.push(s as number | Vec2)
|
|
247
|
+
for (let src of state.sources) {
|
|
248
|
+
if (!src.rate) continue
|
|
249
|
+
let v = src.rate()
|
|
250
|
+
if (src.kind === "button") values.push(v ? 1 : 0)
|
|
251
|
+
else values.push(v as number | Vec2)
|
|
252
|
+
}
|
|
253
|
+
return combineRates(kind, values)
|
|
254
|
+
},
|
|
255
|
+
gesture: new Set(),
|
|
256
|
+
live: new Map(),
|
|
257
|
+
}
|
|
258
|
+
states.set(name, state)
|
|
259
|
+
}
|
|
260
|
+
let state = (name: string): ActionState => {
|
|
261
|
+
let s = states.get(name)
|
|
262
|
+
if (!s) throw new Error(`createInputMap: no action "${name}" (declared: ${[...states.keys()].join(", ")})`)
|
|
263
|
+
return s
|
|
264
|
+
}
|
|
265
|
+
let axisState = (name: string, what: string): ActionState => {
|
|
266
|
+
let s = state(name)
|
|
267
|
+
if (s.kind === "button") throw new Error(`createInputMap: ${what} needs an axis or vec2 action, "${name}" is a button`)
|
|
268
|
+
return s
|
|
269
|
+
}
|
|
270
|
+
let buttonState = (name: string, what: string): ActionState => {
|
|
271
|
+
let s = state(name)
|
|
272
|
+
if (s.kind !== "button") throw new Error(`createInputMap: ${what} needs a button action, "${name}" is a ${s.kind}`)
|
|
273
|
+
return s
|
|
274
|
+
}
|
|
275
|
+
// The delta channel fan-out for one action. Brackets are counted as
|
|
276
|
+
// delivered, so a disable can close what is open and an end whose begin
|
|
277
|
+
// was dropped (or never delivered) reaches no listener.
|
|
278
|
+
let closeGesture = (s: ActionState): void => {
|
|
279
|
+
s.depth--
|
|
280
|
+
s.gesture.forEach(g => g.end?.())
|
|
281
|
+
}
|
|
282
|
+
let sink = (s: ActionState): DeltaSink => ({
|
|
283
|
+
begin: () => {
|
|
284
|
+
if (!s.enabled) return
|
|
285
|
+
s.depth++
|
|
286
|
+
s.gesture.forEach(g => g.begin?.())
|
|
287
|
+
},
|
|
288
|
+
delta: (value, focal) => {
|
|
289
|
+
if (!s.enabled) return
|
|
290
|
+
s.gesture.forEach(g => g.delta?.(value as never, focal))
|
|
291
|
+
},
|
|
292
|
+
end: () => {
|
|
293
|
+
if (s.depth > 0) closeGesture(s)
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
let switchActions = (names: string[], on: boolean, what: string): void => {
|
|
297
|
+
if (names.length === 0) throw new Error(`createInputMap: ${what}() needs at least one action`)
|
|
298
|
+
for (let name of names) {
|
|
299
|
+
let s = state(name)
|
|
300
|
+
if (s.enabled === on) continue
|
|
301
|
+
s.enabled = on
|
|
302
|
+
while (!on && s.depth > 0) closeGesture(s)
|
|
303
|
+
s.bump()
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let bindOne = (name: string, source: InputSource): void => {
|
|
308
|
+
let s = state(name)
|
|
309
|
+
checkSource(source)
|
|
310
|
+
if (!compatible(source.kind, s.kind)) throw new Error(`createInputMap: cannot bind ${source.kind} source "${source.label}" to ${s.kind} action "${name}"`)
|
|
311
|
+
if (s.sources.includes(source)) return
|
|
312
|
+
s.sources.push(source)
|
|
313
|
+
s.bump()
|
|
314
|
+
if (source.deltas && s.kind !== "button") s.live.set(source, source.deltas(sink(s)))
|
|
315
|
+
}
|
|
316
|
+
let unbindOne = (name: string, source: InputSource): void => {
|
|
317
|
+
let s = state(name)
|
|
318
|
+
let i = s.sources.indexOf(source)
|
|
319
|
+
if (i < 0) return
|
|
320
|
+
s.sources.splice(i, 1)
|
|
321
|
+
s.bump()
|
|
322
|
+
let stop = s.live.get(source)
|
|
323
|
+
if (stop) {
|
|
324
|
+
s.live.delete(source)
|
|
325
|
+
stop()
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let edge = (name: string, want: boolean, callback: () => void): (() => void) => {
|
|
329
|
+
let s = buttonState(name, want ? "onPress" : "onRelease")
|
|
330
|
+
if (typeof callback !== "function") throw new Error(`createInputMap: ${want ? "onPress" : "onRelease"}("${name}") expects a function`)
|
|
331
|
+
return createRoot(dispose => {
|
|
332
|
+
// The callback is a side effect that may read state of its own (a
|
|
333
|
+
// focus, a pose): a snapshot, not a dependency, so untracked.
|
|
334
|
+
createEffect(
|
|
335
|
+
() => s.value() as boolean,
|
|
336
|
+
(pressed, prev) => {
|
|
337
|
+
if (pressed === want && prev !== want) untrack(callback)
|
|
338
|
+
},
|
|
339
|
+
{ defer: true },
|
|
340
|
+
)
|
|
341
|
+
return dispose
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let map: InputMap<A> = {
|
|
346
|
+
actions,
|
|
347
|
+
bind(actionOrList: string | Binding[], ...sources: InputSource[]) {
|
|
348
|
+
let list: Binding[] = Array.isArray(actionOrList) ? actionOrList : sources.map(source => ({ action: actionOrList, source }))
|
|
349
|
+
if (list.length === 0) throw new Error("createInputMap: bind() needs at least one source")
|
|
350
|
+
for (let b of list) bindOne(b.action, b.source)
|
|
351
|
+
return () => {
|
|
352
|
+
for (let b of list) unbindOne(b.action, b.source)
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
unbind: unbindOne,
|
|
356
|
+
bindings(action) {
|
|
357
|
+
let out: Binding[] = []
|
|
358
|
+
for (let [name, s] of states) {
|
|
359
|
+
if (action !== undefined && name !== action) continue
|
|
360
|
+
for (let source of s.sources) out.push({ action: name, source })
|
|
361
|
+
}
|
|
362
|
+
if (action !== undefined) state(action)
|
|
363
|
+
return out
|
|
364
|
+
},
|
|
365
|
+
value(action) {
|
|
366
|
+
return state(action).value() as never
|
|
367
|
+
},
|
|
368
|
+
pressed(action) {
|
|
369
|
+
return buttonState(action, "pressed").value() as boolean
|
|
370
|
+
},
|
|
371
|
+
onPress: (action, callback) => edge(action, true, callback),
|
|
372
|
+
onRelease: (action, callback) => edge(action, false, callback),
|
|
373
|
+
onGesture(action, listener) {
|
|
374
|
+
let s = axisState(action, "onGesture")
|
|
375
|
+
if (!listener || typeof listener !== "object") throw new Error(`createInputMap: onGesture("${action}") expects a listener object`)
|
|
376
|
+
s.gesture.add(listener as GestureListener)
|
|
377
|
+
return () => {
|
|
378
|
+
s.gesture.delete(listener as GestureListener)
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
set(action, value) {
|
|
382
|
+
let s = state(action)
|
|
383
|
+
checkValue(`set("${action}")`, s.kind, value)
|
|
384
|
+
s.script = value
|
|
385
|
+
s.bump()
|
|
386
|
+
},
|
|
387
|
+
press(action) {
|
|
388
|
+
let s = buttonState(action, "press")
|
|
389
|
+
s.script = true
|
|
390
|
+
s.bump()
|
|
391
|
+
},
|
|
392
|
+
release(action) {
|
|
393
|
+
let s = buttonState(action, "release")
|
|
394
|
+
s.script = false
|
|
395
|
+
s.bump()
|
|
396
|
+
},
|
|
397
|
+
nudge(action, delta, focal) {
|
|
398
|
+
let s = axisState(action, "nudge")
|
|
399
|
+
checkValue(`nudge("${action}")`, s.kind, delta)
|
|
400
|
+
if (focal !== undefined) checkValue(`nudge("${action}") focal`, "vec2", focal)
|
|
401
|
+
sink(s).delta(delta as number | Vec2, focal)
|
|
402
|
+
},
|
|
403
|
+
begin(action) {
|
|
404
|
+
sink(axisState(action, "begin")).begin()
|
|
405
|
+
},
|
|
406
|
+
end(action) {
|
|
407
|
+
sink(axisState(action, "end")).end()
|
|
408
|
+
},
|
|
409
|
+
enable: (...actions) => switchActions(actions, true, "enable"),
|
|
410
|
+
disable: (...actions) => switchActions(actions, false, "disable"),
|
|
411
|
+
enabled(action) {
|
|
412
|
+
let s = state(action)
|
|
413
|
+
s.version()
|
|
414
|
+
return s.enabled
|
|
415
|
+
},
|
|
416
|
+
handlers: {
|
|
417
|
+
onKeyDown: event => forwardKey(event, true),
|
|
418
|
+
onKeyUp: event => forwardKey(event, false),
|
|
419
|
+
onBlur: () => {
|
|
420
|
+
let seen = new Set<InputSource>()
|
|
421
|
+
for (let s of states.values()) {
|
|
422
|
+
for (let source of s.sources) {
|
|
423
|
+
if (seen.has(source)) continue
|
|
424
|
+
seen.add(source)
|
|
425
|
+
source.blur?.()
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
drive(axes, names) {
|
|
431
|
+
let stops: (() => void)[] = []
|
|
432
|
+
for (let [axis, kind] of Object.entries(axes.kinds)) {
|
|
433
|
+
let mapped = names && axis in names ? names[axis] : axis
|
|
434
|
+
if (mapped === null) continue
|
|
435
|
+
if (mapped === undefined || !states.has(mapped)) {
|
|
436
|
+
throw new Error(`createInputMap: drive() has no action for axis "${axis}" (declared: ${[...states.keys()].join(", ")}); declare it, map it with names, or skip it with null`)
|
|
437
|
+
}
|
|
438
|
+
let s = state(mapped)
|
|
439
|
+
if (s.kind !== kind) throw new Error(`createInputMap: drive() maps ${kind} axis "${axis}" to ${s.kind} action "${mapped}"`)
|
|
440
|
+
stops.push(axes.add(axis, () => s.value() as never))
|
|
441
|
+
stops.push(
|
|
442
|
+
map.onGesture(mapped as never, {
|
|
443
|
+
begin: () => axes.begin(axis),
|
|
444
|
+
delta: (value, focal) => axes.nudge(axis, value as never, focal),
|
|
445
|
+
end: () => axes.end(axis),
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
return () => {
|
|
450
|
+
for (let stop of stops) stop()
|
|
451
|
+
}
|
|
452
|
+
},
|
|
453
|
+
}
|
|
454
|
+
// A key event reaches each bound keyboard source once, however many
|
|
455
|
+
// actions it is bound to.
|
|
456
|
+
let forwardKey = (event: KeyEvent, down: boolean): void => {
|
|
457
|
+
let seen = new Set<InputSource>()
|
|
458
|
+
for (let s of states.values()) {
|
|
459
|
+
for (let source of s.sources) {
|
|
460
|
+
if (seen.has(source) || !source.key) continue
|
|
461
|
+
seen.add(source)
|
|
462
|
+
source.key(event, down)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return map
|
|
467
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// A pointer feed: one element's pointer events turned into gesture
|
|
2
|
+
// sources for an input map. The pointer is the one device that is not
|
|
3
|
+
// global - the tree holds UI and scene together, so a drag belongs to the
|
|
4
|
+
// element it started on (a slider beside a viewport must not turn the
|
|
5
|
+
// camera) - hence a feed per element: spread `feed.handlers` on the leaf
|
|
6
|
+
// (a Scene, View3d, SpriteLayer or View2d takes it as its `pointer` prop
|
|
7
|
+
// and spreads it on the built-in leaf) and bind its sources.
|
|
8
|
+
//
|
|
9
|
+
// The sources are gestures, not raw events, because a pointer only means
|
|
10
|
+
// something once a gesture is recognized: a move without a press is
|
|
11
|
+
// nothing, one finger dragging is `drag`, two fingers moving together is
|
|
12
|
+
// `pan`, spreading is `pinch`, turning is `twist`, and the wheel is its
|
|
13
|
+
// own thing. One merged recognizer (createTransform) serves all of them,
|
|
14
|
+
// so they arbitrate in the app-wide gesture arena as one - a viewport
|
|
15
|
+
// inside a scroller never double-handles - and they never fight each
|
|
16
|
+
// other over the same fingers. `mouseDelta` is the pointer-lock path:
|
|
17
|
+
// raw mouse motion while the pointer is locked, when the cursor does not
|
|
18
|
+
// move and no drag can arm.
|
|
19
|
+
//
|
|
20
|
+
// Units are device-free (the input map's contract): drag and pan travel
|
|
21
|
+
// in the element's own heights (a drag across the element is 1 whatever
|
|
22
|
+
// the window size - Three's OrbitControls convention), pinch and wheel
|
|
23
|
+
// in octaves (log2 of the scale change, one notch about a fifth), twist
|
|
24
|
+
// in turns, mouse motion in view-height-equivalents through a fixed
|
|
25
|
+
// reference. The focal point of a pinch or a wheel rides along as a
|
|
26
|
+
// fraction of the element (0..1), the anchor a zoom-about-the-cursor
|
|
27
|
+
// consumer maps to its own space. Screen convention: x right, y down.
|
|
28
|
+
//
|
|
29
|
+
// Gesture brackets: `drag` begins when the first pointer lands and ends
|
|
30
|
+
// when the last lifts (or when a second lands, which begins `pan`,
|
|
31
|
+
// `pinch` and `twist` until the count drops back to one); the wheel
|
|
32
|
+
// sends no brackets, so a consumer can tell a notch from a finger.
|
|
33
|
+
// Brackets fire on the press itself, before the recognizer's slop, so a
|
|
34
|
+
// glide stops the moment a finger lands.
|
|
35
|
+
//
|
|
36
|
+
// The element's laid-out box normalizes the travel: read at the press
|
|
37
|
+
// through getLayoutBox (the untransformed read, so a designSize fit or
|
|
38
|
+
// an ancestor transform never skews it), or from `layout` when given -
|
|
39
|
+
// required for a detached d-* leaf, which has no layout box (the feed
|
|
40
|
+
// throws rather than treat pixels as heights), and handy for a headless
|
|
41
|
+
// check with no tree. A zero-height box passes pixels through unscaled.
|
|
42
|
+
//
|
|
43
|
+
// Create a feed in an owned scope (a component body): the recognizer
|
|
44
|
+
// registers its cleanup with the owner.
|
|
45
|
+
|
|
46
|
+
import { createTransform } from "./transform"
|
|
47
|
+
import { getLayoutBox } from "./core"
|
|
48
|
+
import { pointerLocked } from "./window"
|
|
49
|
+
import type { PointerEvent, WheelEvent } from "./types"
|
|
50
|
+
import type { DeltaSink, InputSource } from "./input-map"
|
|
51
|
+
import type { Vec2 } from "./input-axes"
|
|
52
|
+
|
|
53
|
+
// Octaves per wheel-delta unit: the zoom exponent both cameras used
|
|
54
|
+
// (0.0015 per unit) taken in log2, so a 100-unit notch zooms about a
|
|
55
|
+
// fifth of an octave and a fast scroll of ten notches doubles.
|
|
56
|
+
const WHEEL_OCTAVES = 0.0015 / Math.LN2
|
|
57
|
+
// Mouse travel per delta unit under pointer lock, in logical pixels: one
|
|
58
|
+
// unit is what a drag across the element's height means to a consumer (a
|
|
59
|
+
// half turn of look in the first-person control), and 1500 px per half
|
|
60
|
+
// turn is the ~0.002 rad/px of Three's PointerLockControls.
|
|
61
|
+
const MOUSE_PX_PER_UNIT = 1500
|
|
62
|
+
|
|
63
|
+
export interface PointerFeed {
|
|
64
|
+
/** Spread on the element whose pointer events feed the sources. */
|
|
65
|
+
handlers: {
|
|
66
|
+
onPointerDown(event: PointerEvent): void
|
|
67
|
+
onPointerMove(event: PointerEvent): void
|
|
68
|
+
onPointerUp(event: PointerEvent): void
|
|
69
|
+
onPointerLeave(event: PointerEvent): void
|
|
70
|
+
onWheel(event: WheelEvent): void
|
|
71
|
+
}
|
|
72
|
+
/** One pointer's travel, in element heights; bracketed. */
|
|
73
|
+
drag: InputSource<"vec2">
|
|
74
|
+
/** Two or more pointers' focal travel, in element heights; bracketed. */
|
|
75
|
+
pan: InputSource<"vec2">
|
|
76
|
+
/** Span change of two pointers, in octaves (spreading positive), with
|
|
77
|
+
* the focal point; bracketed. */
|
|
78
|
+
pinch: InputSource<"axis">
|
|
79
|
+
/** Rotation of the pointer pair, in turns, with the focal point; bracketed. */
|
|
80
|
+
twist: InputSource<"axis">
|
|
81
|
+
/** Wheel notches in octaves (wheel up positive), with the cursor as the
|
|
82
|
+
* focal point; unbracketed. */
|
|
83
|
+
wheel: InputSource<"axis">
|
|
84
|
+
/** Raw mouse motion while the pointer is locked, in view-height
|
|
85
|
+
* equivalents (see MOUSE_PX_PER_UNIT); unbracketed. */
|
|
86
|
+
mouseDelta: InputSource<"vec2">
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface PointerFeedOptions {
|
|
90
|
+
/** The element's size, when the app knows it - required for a detached
|
|
91
|
+
* d-* leaf, which has no layout box; default reads getLayoutBox at each
|
|
92
|
+
* press. */
|
|
93
|
+
layout?: () => { width: number; height: number } | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
type Sinks = Set<DeltaSink>
|
|
97
|
+
|
|
98
|
+
function source<K extends "axis" | "vec2">(kind: K, label: string, sinks: Sinks): InputSource<K> {
|
|
99
|
+
return {
|
|
100
|
+
kind,
|
|
101
|
+
label,
|
|
102
|
+
deltas(sink) {
|
|
103
|
+
sinks.add(sink)
|
|
104
|
+
return () => {
|
|
105
|
+
sinks.delete(sink)
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed {
|
|
112
|
+
let sinks = {
|
|
113
|
+
drag: new Set<DeltaSink>(),
|
|
114
|
+
pan: new Set<DeltaSink>(),
|
|
115
|
+
pinch: new Set<DeltaSink>(),
|
|
116
|
+
twist: new Set<DeltaSink>(),
|
|
117
|
+
wheel: new Set<DeltaSink>(),
|
|
118
|
+
mouseDelta: new Set<DeltaSink>(),
|
|
119
|
+
}
|
|
120
|
+
let begin = (s: Sinks) => s.forEach(k => k.begin())
|
|
121
|
+
let end = (s: Sinks) => s.forEach(k => k.end())
|
|
122
|
+
let delta = (s: Sinks, value: number | Vec2, focal?: Vec2) => s.forEach(k => k.delta(value, focal))
|
|
123
|
+
|
|
124
|
+
// The element's box, read at each press (see the header). A detached
|
|
125
|
+
// leaf has no layout box and must bring `layout`: normalizing by
|
|
126
|
+
// nothing would turn a 100 px drag into 100 element heights, so that
|
|
127
|
+
// is a throw (the dev validation policy), while a zero-size box is a
|
|
128
|
+
// degenerate layout and passes pixels through.
|
|
129
|
+
let size = (event: { currentTarget: number }): { width: number; height: number } => {
|
|
130
|
+
let box = options.layout ? options.layout() : getLayoutBox({ id: event.currentTarget })
|
|
131
|
+
if (box === null && !options.layout) {
|
|
132
|
+
throw new Error("createPointerFeed: the element has no layout box (a detached d-* leaf, or before its first layout); create the feed with { layout: () => ({ width, height }) } for it")
|
|
133
|
+
}
|
|
134
|
+
return box && box.width > 0 && box.height > 0 ? { width: box.width, height: box.height } : { width: 0, height: 0 }
|
|
135
|
+
}
|
|
136
|
+
let box = { width: 0, height: 0 }
|
|
137
|
+
let unit = () => (box.height > 0 ? box.height : 1)
|
|
138
|
+
let focalOf = (x: number, y: number): Vec2 | undefined => (box.width > 0 && box.height > 0 ? [x / box.width, y / box.height] : undefined)
|
|
139
|
+
|
|
140
|
+
let transform = createTransform({
|
|
141
|
+
onTransformMove: t => {
|
|
142
|
+
let h = unit()
|
|
143
|
+
let travel: Vec2 = [t.dx / h, t.dy / h]
|
|
144
|
+
if (t.pointers >= 2) {
|
|
145
|
+
if (t.dx !== 0 || t.dy !== 0) delta(sinks.pan, travel)
|
|
146
|
+
let focal = focalOf(t.x, t.y)
|
|
147
|
+
if (t.scale !== 1) delta(sinks.pinch, Math.log2(t.scale), focal)
|
|
148
|
+
if (t.rotation !== 0) delta(sinks.twist, t.rotation / (2 * Math.PI), focal)
|
|
149
|
+
} else if (t.dx !== 0 || t.dy !== 0) {
|
|
150
|
+
delta(sinks.drag, travel)
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Pointers down on the element, for the gesture brackets: the first
|
|
156
|
+
// press opens drag, a second closes it and opens the two-pointer
|
|
157
|
+
// gestures, and the count coming back down reverses that.
|
|
158
|
+
let downs = new Set<number>()
|
|
159
|
+
let landed = (id: number) => {
|
|
160
|
+
if (downs.has(id)) return
|
|
161
|
+
downs.add(id)
|
|
162
|
+
if (downs.size === 1) begin(sinks.drag)
|
|
163
|
+
if (downs.size === 2) {
|
|
164
|
+
end(sinks.drag)
|
|
165
|
+
begin(sinks.pan)
|
|
166
|
+
begin(sinks.pinch)
|
|
167
|
+
begin(sinks.twist)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
let lifted = (id: number) => {
|
|
171
|
+
if (!downs.delete(id)) return
|
|
172
|
+
if (downs.size === 1) {
|
|
173
|
+
end(sinks.pan)
|
|
174
|
+
end(sinks.pinch)
|
|
175
|
+
end(sinks.twist)
|
|
176
|
+
begin(sinks.drag)
|
|
177
|
+
}
|
|
178
|
+
if (downs.size === 0) end(sinks.drag)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
handlers: {
|
|
183
|
+
onPointerDown(e) {
|
|
184
|
+
if (e.button != null && e.button !== 0) return
|
|
185
|
+
if (downs.size === 0) box = size(e)
|
|
186
|
+
landed(e.pointerId)
|
|
187
|
+
transform.handlers.onPointerDown(e)
|
|
188
|
+
},
|
|
189
|
+
onPointerMove(e) {
|
|
190
|
+
if (pointerLocked() && e.pointerType === "mouse" && (e.movementX !== 0 || e.movementY !== 0)) {
|
|
191
|
+
delta(sinks.mouseDelta, [e.movementX / MOUSE_PX_PER_UNIT, e.movementY / MOUSE_PX_PER_UNIT])
|
|
192
|
+
}
|
|
193
|
+
transform.handlers.onPointerMove(e)
|
|
194
|
+
},
|
|
195
|
+
onPointerUp(e) {
|
|
196
|
+
transform.handlers.onPointerUp(e)
|
|
197
|
+
lifted(e.pointerId)
|
|
198
|
+
},
|
|
199
|
+
onPointerLeave() {},
|
|
200
|
+
onWheel(e) {
|
|
201
|
+
if (e.deltaY === 0) return
|
|
202
|
+
box = size(e)
|
|
203
|
+
delta(sinks.wheel, -e.deltaY * WHEEL_OCTAVES, focalOf(e.localX, e.localY))
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
drag: source("vec2", "pointer drag", sinks.drag),
|
|
207
|
+
pan: source("vec2", "pointer two-finger pan", sinks.pan),
|
|
208
|
+
pinch: source("axis", "pointer pinch", sinks.pinch),
|
|
209
|
+
twist: source("axis", "pointer twist", sinks.twist),
|
|
210
|
+
wheel: source("axis", "pointer wheel", sinks.wheel),
|
|
211
|
+
mouseDelta: source("vec2", "mouse motion (pointer locked)", sinks.mouseDelta),
|
|
212
|
+
}
|
|
213
|
+
}
|