@typeonce/ritmo-browser 0.9.0 → 0.11.0
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/CHANGELOG.md +285 -0
- package/README.md +6 -3
- package/dist/Controls.d.ts +495 -0
- package/dist/Controls.d.ts.map +1 -0
- package/dist/Controls.js +343 -0
- package/dist/Controls.js.map +1 -0
- package/dist/Gamepad.d.ts +375 -0
- package/dist/Gamepad.d.ts.map +1 -0
- package/dist/Gamepad.js +452 -0
- package/dist/Gamepad.js.map +1 -0
- package/dist/InputCapture.d.ts +47 -14
- package/dist/InputCapture.d.ts.map +1 -1
- package/dist/InputCapture.js +1 -1
- package/dist/InputCapture.js.map +1 -1
- package/dist/Keyboard.d.ts +184 -21
- package/dist/Keyboard.d.ts.map +1 -1
- package/dist/Keyboard.js +257 -91
- package/dist/Keyboard.js.map +1 -1
- package/dist/Pointer.d.ts +231 -62
- package/dist/Pointer.d.ts.map +1 -1
- package/dist/Pointer.js +205 -145
- package/dist/Pointer.js.map +1 -1
- package/dist/Touch.d.ts +73 -27
- package/dist/Touch.d.ts.map +1 -1
- package/dist/Touch.js +25 -30
- package/dist/Touch.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/buttons.d.ts +52 -0
- package/dist/internal/buttons.d.ts.map +1 -0
- package/dist/internal/buttons.js +211 -0
- package/dist/internal/buttons.js.map +1 -0
- package/dist/internal/introspect.d.ts +14 -0
- package/dist/internal/introspect.d.ts.map +1 -0
- package/dist/internal/introspect.js +19 -0
- package/dist/internal/introspect.js.map +1 -0
- package/dist/internal/used.d.ts +8 -0
- package/dist/internal/used.d.ts.map +1 -0
- package/dist/internal/used.js +14 -0
- package/dist/internal/used.js.map +1 -0
- package/dist/internal/vectors.d.ts +24 -0
- package/dist/internal/vectors.d.ts.map +1 -0
- package/dist/internal/vectors.js +22 -0
- package/dist/internal/vectors.js.map +1 -0
- package/package.json +5 -3
- package/src/Controls.ts +799 -0
- package/src/Gamepad.ts +747 -0
- package/src/InputCapture.ts +44 -22
- package/src/Keyboard.ts +395 -68
- package/src/Pointer.ts +392 -164
- package/src/Touch.ts +111 -54
- package/src/index.ts +10 -1
- package/src/internal/buttons.ts +222 -0
- package/src/internal/introspect.ts +23 -0
- package/src/internal/used.ts +14 -0
- package/src/internal/vectors.ts +36 -0
package/src/Keyboard.ts
CHANGED
|
@@ -30,12 +30,22 @@
|
|
|
30
30
|
*
|
|
31
31
|
* // Once per update:
|
|
32
32
|
* const snapshot = input.snapshot()
|
|
33
|
-
* if (snapshot.jump.pressed) jump()
|
|
33
|
+
* if (snapshot.actions.jump.pressed) jump()
|
|
34
34
|
*
|
|
35
35
|
* // On teardown:
|
|
36
36
|
* input.dispose()
|
|
37
37
|
* ```
|
|
38
38
|
*
|
|
39
|
+
* Movement is one vector action: `move: Keyboard.vector({ up, down, left,
|
|
40
|
+
* right })` reports `vectors.move` with length at most 1 (opposite keys
|
|
41
|
+
* cancel, diagonals are not faster). Timelines name it by direction,
|
|
42
|
+
* `"move.right"`.
|
|
43
|
+
*
|
|
44
|
+
* For a controls settings screen, `presses(...)` reports every key that
|
|
45
|
+
* goes down (bound or not) so the screen can wait for the key to bind,
|
|
46
|
+
* `rebind(...)` swaps the keys of the same actions, and `parseRebinding(...)`
|
|
47
|
+
* validates bindings saved as JSON.
|
|
48
|
+
*
|
|
39
49
|
* For tests, replays, and headless runs, `scripted(...)` plays a timeline of
|
|
40
50
|
* presses and releases instead of listening to a host, and `recording(...)`
|
|
41
51
|
* wraps live actions to capture the timeline a session produced.
|
|
@@ -53,8 +63,17 @@
|
|
|
53
63
|
* }
|
|
54
64
|
* ```
|
|
55
65
|
*/
|
|
66
|
+
import type * as System from "@typeonce/ritmo/System"
|
|
56
67
|
import * as Result from "@typeonce/ritmo/Result"
|
|
57
68
|
|
|
69
|
+
import { boundKeys } from "./internal/introspect.ts"
|
|
70
|
+
import type { ReportsBoundKeys } from "./internal/introspect.ts"
|
|
71
|
+
import { anyUsed } from "./internal/used.ts"
|
|
72
|
+
import { digital, directionName, directions, isVectorBinding, zero } from "./internal/vectors.ts"
|
|
73
|
+
import type { Direction, Vector } from "./internal/vectors.ts"
|
|
74
|
+
|
|
75
|
+
export type { Direction, Vector }
|
|
76
|
+
|
|
58
77
|
/**
|
|
59
78
|
* The subset of `KeyboardEvent` the module reads. `code` (the physical key)
|
|
60
79
|
* is optional so simple test hosts can omit it; browsers always provide it.
|
|
@@ -97,11 +116,53 @@ export interface PhysicalKey {
|
|
|
97
116
|
*/
|
|
98
117
|
export type KeyBinding = string | PhysicalKey
|
|
99
118
|
|
|
119
|
+
/** The keys of one action: at least one. */
|
|
120
|
+
export type Keys = readonly [KeyBinding, ...Array<KeyBinding>]
|
|
121
|
+
|
|
100
122
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
123
|
+
* Keys for the four directions of a vector action (movement), built with
|
|
124
|
+
* {@link vector}.
|
|
103
125
|
*/
|
|
104
|
-
export
|
|
126
|
+
export interface VectorBinding {
|
|
127
|
+
readonly _tag: "Vector"
|
|
128
|
+
readonly up: Keys
|
|
129
|
+
readonly down: Keys
|
|
130
|
+
readonly left: Keys
|
|
131
|
+
readonly right: Keys
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Action name to the keys that trigger it (at least one), or to a
|
|
136
|
+
* {@link vector} binding for a direction such as movement.
|
|
137
|
+
*/
|
|
138
|
+
export type Bindings = Readonly<Record<string, Keys | VectorBinding>>
|
|
139
|
+
|
|
140
|
+
/** The bound button actions: the names bound to keys. */
|
|
141
|
+
export type ButtonNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? never : K }[keyof B] & string
|
|
142
|
+
|
|
143
|
+
/** The bound vector actions: the names bound with {@link vector}. */
|
|
144
|
+
export type VectorNames<B> = { [K in keyof B]: B[K] extends VectorBinding ? K : never }[keyof B] & string
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Binds a vector action to keys for its four directions. The snapshot's
|
|
148
|
+
* `vectors` reports it with length at most 1: opposite keys cancel and a
|
|
149
|
+
* diagonal is not faster than a straight line.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* const bindings = {
|
|
154
|
+
* move: Keyboard.vector({ up: ["w", "ArrowUp"], down: ["s", "ArrowDown"], left: ["a", "ArrowLeft"], right: ["d", "ArrowRight"] }),
|
|
155
|
+
* jump: [" "]
|
|
156
|
+
* } as const
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export const vector = (keys: { readonly up: Keys; readonly down: Keys; readonly left: Keys; readonly right: Keys }): VectorBinding => ({
|
|
160
|
+
_tag: "Vector",
|
|
161
|
+
up: keys.up,
|
|
162
|
+
down: keys.down,
|
|
163
|
+
left: keys.left,
|
|
164
|
+
right: keys.right
|
|
165
|
+
})
|
|
105
166
|
|
|
106
167
|
/**
|
|
107
168
|
* A binding to a physical key by its `KeyboardEvent.code`.
|
|
@@ -117,32 +178,71 @@ export type Bindings = Readonly<Record<string, readonly [KeyBinding, ...Array<Ke
|
|
|
117
178
|
export const code = <const Code extends string>(code: Code): PhysicalKey & { readonly code: Code } => ({ code })
|
|
118
179
|
|
|
119
180
|
/**
|
|
120
|
-
* One action's state in a snapshot
|
|
181
|
+
* One action's state in a snapshot, the same on every device (`Gamepad`,
|
|
182
|
+
* `Pointer`, and `Touch` buttons too).
|
|
121
183
|
*/
|
|
122
184
|
export interface ActionState {
|
|
123
|
-
/** At least one bound key is down now. */
|
|
185
|
+
/** At least one bound key or button is down now. */
|
|
124
186
|
readonly held: boolean
|
|
125
|
-
/** A bound key went down since the previous snapshot, even if already released. */
|
|
187
|
+
/** A bound key or button went down since the previous snapshot, even if already released. */
|
|
126
188
|
readonly pressed: boolean
|
|
127
|
-
/** A bound key went up since the previous snapshot and none is held now. */
|
|
189
|
+
/** A bound key or button went up since the previous snapshot and none is held now. */
|
|
128
190
|
readonly released: boolean
|
|
129
191
|
}
|
|
130
192
|
|
|
131
|
-
|
|
132
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Every action's state, with the same core as the other devices' snapshots
|
|
195
|
+
* (`actions`, `vectors`, and `used`) so `Controls` can merge them.
|
|
196
|
+
*/
|
|
197
|
+
export interface Snapshot<B extends Bindings> {
|
|
198
|
+
readonly actions: { readonly [Action in ButtonNames<B>]: ActionState }
|
|
199
|
+
/** Each vector action's direction, length at most 1, `y` growing down. */
|
|
200
|
+
readonly vectors: { readonly [Action in VectorNames<B>]: Vector }
|
|
201
|
+
/** Some key bound to an action was pressed in this window or is held: the player is using the keyboard. */
|
|
202
|
+
readonly used: boolean
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* New keys for the same actions, for {@link Actions.rebind}: keys for a
|
|
207
|
+
* button action, a {@link vector} for a vector action. Load saved ones with
|
|
208
|
+
* {@link parseRebinding}.
|
|
209
|
+
*/
|
|
210
|
+
export type Rebinding<B extends Bindings> = { readonly [Action in keyof B]: B[Action] extends VectorBinding ? VectorBinding : Keys }
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Rejects actions a rebinding names that the bindings do not have, so
|
|
214
|
+
* {@link Actions.rebind} takes exactly the actions it was made with.
|
|
215
|
+
*/
|
|
216
|
+
export type OnlyActionsOf<R, B extends Bindings> = {
|
|
217
|
+
readonly [Name in Exclude<keyof R, keyof B>]: `${Name & string} is not an action of these bindings`
|
|
133
218
|
}
|
|
134
219
|
|
|
135
|
-
|
|
220
|
+
/**
|
|
221
|
+
* A keyboard bound to actions, built with {@link actions} (live),
|
|
222
|
+
* {@link scripted}, or {@link recording}. Capture it with
|
|
223
|
+
* `InputCapture.system(...)`, or merge it with `Controls`.
|
|
224
|
+
*/
|
|
225
|
+
export interface Actions<B extends Bindings> extends System.Consumes<"snapshot"> {
|
|
136
226
|
/**
|
|
137
227
|
* Returns every action's state and starts a new press/release window.
|
|
138
228
|
*/
|
|
139
229
|
snapshot(): Snapshot<B>
|
|
230
|
+
/**
|
|
231
|
+
* Binds the same actions to new keys, from the next snapshot, for a
|
|
232
|
+
* settings screen where players remap controls (capture the key to bind
|
|
233
|
+
* with {@link presses}). An action held only through keys it lost reports
|
|
234
|
+
* `released`. A key held across the rebind holds a newly bound action only
|
|
235
|
+
* once it is pressed again, so an action never turns held without a
|
|
236
|
+
* press. It takes exactly the actions the source was made with.
|
|
237
|
+
*/
|
|
238
|
+
rebind<const R extends Rebinding<B>>(bindings: R & OnlyActionsOf<R, B>): void
|
|
140
239
|
/**
|
|
141
240
|
* Removes the event listeners.
|
|
142
241
|
*/
|
|
143
242
|
dispose(): void
|
|
144
243
|
}
|
|
145
244
|
|
|
245
|
+
/** Options for {@link actions}. */
|
|
146
246
|
export interface ActionsOptions {
|
|
147
247
|
/**
|
|
148
248
|
* Call `preventDefault()` for bound keys, so arrows and space do not scroll
|
|
@@ -156,11 +256,42 @@ export interface ActionsOptions {
|
|
|
156
256
|
* before the first capture.
|
|
157
257
|
*/
|
|
158
258
|
export const idle = <const B extends Bindings>(bindings: B): Snapshot<B> => {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
259
|
+
const idleState: ActionState = { held: false, pressed: false, released: false }
|
|
260
|
+
return assemble(bindings, () => idleState, false) as Snapshot<B>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Each action's keys, with every vector action split into its four directions (`move.up`, ...). */
|
|
264
|
+
const flatten = (bindings: Bindings): Readonly<Record<string, Keys>> => {
|
|
265
|
+
const flat: Record<string, Keys> = {}
|
|
266
|
+
for (const [name, entry] of Object.entries(bindings)) {
|
|
267
|
+
if (isVectorBinding(entry)) {
|
|
268
|
+
for (const direction of directions) flat[directionName(name, direction)] = (entry as VectorBinding)[direction]
|
|
269
|
+
} else flat[name] = entry as Keys
|
|
270
|
+
}
|
|
271
|
+
return flat
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The snapshot for `bindings` from the state of each flat action (button actions and vector directions). */
|
|
275
|
+
const assemble = (bindings: Bindings, state: (name: string) => ActionState, used: boolean): Snapshot<Bindings> => {
|
|
276
|
+
const actions: Record<string, ActionState> = {}
|
|
277
|
+
const vectors: Record<string, Vector> = {}
|
|
278
|
+
for (const [name, entry] of Object.entries(bindings)) {
|
|
279
|
+
if (isVectorBinding(entry)) {
|
|
280
|
+
const held = (direction: Direction): boolean => state(directionName(name, direction)).held
|
|
281
|
+
vectors[name] = digital(held("up"), held("down"), held("left"), held("right"))
|
|
282
|
+
} else actions[name] = state(name)
|
|
162
283
|
}
|
|
163
|
-
return
|
|
284
|
+
return { actions, vectors, used } as Snapshot<Bindings>
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The flat action states behind the last snapshot of a live or scripted
|
|
289
|
+
* keyboard, so `recording` sees each direction of a vector action.
|
|
290
|
+
*/
|
|
291
|
+
const flatStates = Symbol("Keyboard/flatStates")
|
|
292
|
+
|
|
293
|
+
interface WithFlatStates {
|
|
294
|
+
readonly [flatStates]?: () => Readonly<Record<string, ActionState>>
|
|
164
295
|
}
|
|
165
296
|
|
|
166
297
|
const normalize = (key: string): string => key.length === 1 ? key.toLowerCase() : key
|
|
@@ -170,35 +301,57 @@ const keyToken = (key: string): string => `key:${normalize(key)}`
|
|
|
170
301
|
const codeToken = (code: string): string => `code:${code}`
|
|
171
302
|
const bindingToken = (binding: KeyBinding): string => typeof binding === "string" ? keyToken(binding) : codeToken(binding.code)
|
|
172
303
|
|
|
304
|
+
/** How a binding reads in a lint: the character quoted, a physical key by its code. */
|
|
305
|
+
const bindingLabel = (binding: KeyBinding): string => typeof binding === "string" ? JSON.stringify(normalize(binding)) : binding.code
|
|
306
|
+
|
|
307
|
+
const labelsOf = (flat: Readonly<Record<string, Keys>>): ReadonlyArray<string> =>
|
|
308
|
+
[...new Set(Object.values(flat).flatMap((keys) => keys.map(bindingLabel)))]
|
|
309
|
+
|
|
173
310
|
/**
|
|
174
|
-
* Starts tracking bound keys on `host`.
|
|
311
|
+
* Starts tracking bound keys on `host`, usually `window`, until `dispose()`.
|
|
312
|
+
* When the window loses focus every held action is released. Each call is
|
|
313
|
+
* its own device: build one per source (one `Controls` merge, one player
|
|
314
|
+
* of a pool). Use {@link scripted} for tests and headless runs, and
|
|
315
|
+
* {@link presses} to capture an unbound key on a settings screen.
|
|
175
316
|
*/
|
|
176
317
|
export const actions = <const B extends Bindings>(
|
|
177
318
|
host: KeyboardHost,
|
|
178
|
-
|
|
319
|
+
initial: B,
|
|
179
320
|
options: ActionsOptions = {}
|
|
180
321
|
): Actions<B> => {
|
|
181
322
|
const preventDefault = options.preventDefault ?? true
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
323
|
+
// Fixed at creation: a rebind swaps keys, never the actions. Vector
|
|
324
|
+
// actions are tracked as their four directions.
|
|
325
|
+
const names = Object.keys(flatten(initial))
|
|
326
|
+
let bindings = flatten(initial)
|
|
327
|
+
const actionsByToken = new Map<string, Array<string>>()
|
|
328
|
+
const index = (): void => {
|
|
329
|
+
actionsByToken.clear()
|
|
330
|
+
for (const name of names) {
|
|
331
|
+
for (const binding of bindings[name]!) {
|
|
332
|
+
const token = bindingToken(binding)
|
|
333
|
+
const bound = actionsByToken.get(token)
|
|
334
|
+
if (bound) bound.push(name)
|
|
335
|
+
else actionsByToken.set(token, [name])
|
|
336
|
+
}
|
|
190
337
|
}
|
|
191
338
|
}
|
|
339
|
+
index()
|
|
192
340
|
|
|
193
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Each held token and the actions it holds: those bound to it when it went
|
|
343
|
+
* down that are still bound to it. A token stays here while held even if a
|
|
344
|
+
* rebind unbound it, so its release is still seen.
|
|
345
|
+
*/
|
|
346
|
+
const heldTokens = new Map<string, Set<string>>()
|
|
194
347
|
/**
|
|
195
348
|
* The character token each physical key produced when it went down, so its
|
|
196
349
|
* release matches even if a modifier changed the character meanwhile
|
|
197
350
|
* (W pressed, Option pressed, W released as "∑").
|
|
198
351
|
*/
|
|
199
352
|
const keyTokenByCode = new Map<string, string>()
|
|
200
|
-
const pressed = new Set<
|
|
201
|
-
const released = new Set<
|
|
353
|
+
const pressed = new Set<string>()
|
|
354
|
+
const released = new Set<string>()
|
|
202
355
|
|
|
203
356
|
const onKeyDown = (event: KeyEvent): void => {
|
|
204
357
|
const tokens = [keyToken(event.key)]
|
|
@@ -212,7 +365,7 @@ export const actions = <const B extends Bindings>(
|
|
|
212
365
|
if (!bound) continue
|
|
213
366
|
handled = true
|
|
214
367
|
if (event.repeat || heldTokens.has(token)) continue
|
|
215
|
-
heldTokens.
|
|
368
|
+
heldTokens.set(token, new Set(bound))
|
|
216
369
|
for (const name of bound) pressed.add(name)
|
|
217
370
|
}
|
|
218
371
|
if (handled && preventDefault) event.preventDefault()
|
|
@@ -227,15 +380,16 @@ export const actions = <const B extends Bindings>(
|
|
|
227
380
|
tokens.push(codeToken(event.code))
|
|
228
381
|
}
|
|
229
382
|
for (const token of tokens) {
|
|
230
|
-
const
|
|
231
|
-
if (!
|
|
232
|
-
|
|
383
|
+
const holding = heldTokens.get(token)
|
|
384
|
+
if (!holding) continue
|
|
385
|
+
heldTokens.delete(token)
|
|
386
|
+
for (const name of holding) released.add(name)
|
|
233
387
|
}
|
|
234
388
|
}
|
|
235
389
|
|
|
236
390
|
const onBlur = (): void => {
|
|
237
|
-
for (const
|
|
238
|
-
for (const name of
|
|
391
|
+
for (const holding of heldTokens.values()) {
|
|
392
|
+
for (const name of holding) released.add(name)
|
|
239
393
|
}
|
|
240
394
|
heldTokens.clear()
|
|
241
395
|
keyTokenByCode.clear()
|
|
@@ -245,19 +399,21 @@ export const actions = <const B extends Bindings>(
|
|
|
245
399
|
host.addEventListener("keyup", onKeyUp)
|
|
246
400
|
host.addEventListener("blur", onBlur)
|
|
247
401
|
|
|
248
|
-
const isHeld = (name:
|
|
402
|
+
const isHeld = (name: string): boolean => {
|
|
249
403
|
for (const binding of bindings[name]!) {
|
|
250
|
-
if (heldTokens.
|
|
404
|
+
if (heldTokens.get(bindingToken(binding))?.has(name)) return true
|
|
251
405
|
}
|
|
252
406
|
return false
|
|
253
407
|
}
|
|
254
408
|
|
|
255
|
-
|
|
409
|
+
let last: Readonly<Record<string, ActionState>> = {}
|
|
410
|
+
const live: Actions<B> & WithFlatStates & ReportsBoundKeys = {
|
|
411
|
+
[boundKeys]: () => labelsOf(bindings),
|
|
256
412
|
snapshot() {
|
|
257
|
-
const
|
|
413
|
+
const states: Record<string, ActionState> = {}
|
|
258
414
|
for (const name of names) {
|
|
259
415
|
const held = isHeld(name)
|
|
260
|
-
|
|
416
|
+
states[name] = {
|
|
261
417
|
held,
|
|
262
418
|
pressed: pressed.has(name),
|
|
263
419
|
released: released.has(name) && !held
|
|
@@ -265,7 +421,20 @@ export const actions = <const B extends Bindings>(
|
|
|
265
421
|
}
|
|
266
422
|
pressed.clear()
|
|
267
423
|
released.clear()
|
|
268
|
-
|
|
424
|
+
last = states
|
|
425
|
+
return assemble(initial, (name) => states[name]!, anyUsed(states)) as Snapshot<B>
|
|
426
|
+
},
|
|
427
|
+
[flatStates]: () => last,
|
|
428
|
+
rebind(next) {
|
|
429
|
+
const before = names.filter(isHeld)
|
|
430
|
+
bindings = flatten(next as Bindings)
|
|
431
|
+
index()
|
|
432
|
+
// A held key keeps only the actions still bound to it; new ones wait for a fresh press.
|
|
433
|
+
for (const [token, holding] of heldTokens) {
|
|
434
|
+
const bound = actionsByToken.get(token) ?? []
|
|
435
|
+
for (const name of holding) if (!bound.includes(name)) holding.delete(name)
|
|
436
|
+
}
|
|
437
|
+
for (const name of before) if (!isHeld(name)) released.add(name)
|
|
269
438
|
},
|
|
270
439
|
dispose() {
|
|
271
440
|
host.removeEventListener("keydown", onKeyDown)
|
|
@@ -273,20 +442,79 @@ export const actions = <const B extends Bindings>(
|
|
|
273
442
|
host.removeEventListener("blur", onBlur)
|
|
274
443
|
}
|
|
275
444
|
}
|
|
445
|
+
return live
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** One key that went down, in the two forms a {@link KeyBinding} can name it. */
|
|
449
|
+
export interface KeyPress {
|
|
450
|
+
/** The character typed (`KeyboardEvent.key`), for a character binding. */
|
|
451
|
+
readonly key: string
|
|
452
|
+
/** The physical key (`KeyboardEvent.code`), for `Keyboard.code(...)`; `null` when the host gave none. */
|
|
453
|
+
readonly code: string | null
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Every key press on a host, bound or not; see {@link presses}. */
|
|
457
|
+
export interface Presses extends System.Consumes<"snapshot"> {
|
|
458
|
+
/** The keys that went down since the previous snapshot, in order, without auto-repeats. */
|
|
459
|
+
snapshot(): ReadonlyArray<KeyPress>
|
|
460
|
+
/** Removes the event listener. */
|
|
461
|
+
dispose(): void
|
|
276
462
|
}
|
|
277
463
|
|
|
278
464
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
465
|
+
* Starts collecting every key press on `host`, whatever the bindings, for a
|
|
466
|
+
* settings screen that waits for the key to bind. Use {@link actions} for
|
|
467
|
+
* gameplay. It never calls `preventDefault()`.
|
|
468
|
+
*
|
|
469
|
+
* @example
|
|
470
|
+
* ```ts
|
|
471
|
+
* const presses = Keyboard.presses(window)
|
|
472
|
+
*
|
|
473
|
+
* // While the settings screen waits for the new jump key:
|
|
474
|
+
* const press = presses.snapshot()[0]
|
|
475
|
+
* if (press !== undefined) {
|
|
476
|
+
* input.rebind({ ...current, jump: [press.code === null ? press.key : Keyboard.code(press.code)] })
|
|
477
|
+
* }
|
|
478
|
+
* ```
|
|
479
|
+
*/
|
|
480
|
+
export const presses = (host: KeyboardHost): Presses => {
|
|
481
|
+
let pending: Array<KeyPress> = []
|
|
482
|
+
const onKeyDown = (event: KeyEvent): void => {
|
|
483
|
+
if (event.repeat) return
|
|
484
|
+
pending.push({ key: event.key, code: event.code ?? null })
|
|
485
|
+
}
|
|
486
|
+
host.addEventListener("keydown", onKeyDown)
|
|
487
|
+
return {
|
|
488
|
+
snapshot() {
|
|
489
|
+
const snapshot = pending
|
|
490
|
+
pending = []
|
|
491
|
+
return snapshot
|
|
492
|
+
},
|
|
493
|
+
dispose() {
|
|
494
|
+
host.removeEventListener("keydown", onKeyDown)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Input changes applied just before one snapshot. `frame` is the snapshot's
|
|
501
|
+
* index, counting from 0 at the first `snapshot()` call. Entries name
|
|
502
|
+
* actions, and a vector action by direction (`"move.right"`). An action in both
|
|
503
|
+
* `press` and `release` changed twice within the frame: a tap shorter than a
|
|
504
|
+
* frame when it was up, a release and a new press when it was held.
|
|
283
505
|
*/
|
|
284
506
|
export interface TimelineEntry<B extends Bindings> {
|
|
285
507
|
readonly frame: number
|
|
286
|
-
readonly press?: ReadonlyArray<
|
|
287
|
-
readonly release?: ReadonlyArray<
|
|
508
|
+
readonly press?: ReadonlyArray<TimelineName<B>>
|
|
509
|
+
readonly release?: ReadonlyArray<TimelineName<B>>
|
|
288
510
|
}
|
|
289
511
|
|
|
512
|
+
/**
|
|
513
|
+
* What a timeline presses and releases: a button action, or one direction
|
|
514
|
+
* of a vector action (`"move.right"`).
|
|
515
|
+
*/
|
|
516
|
+
export type TimelineName<B extends Bindings> = ButtonNames<B> | `${VectorNames<B>}.${Direction}`
|
|
517
|
+
|
|
290
518
|
/**
|
|
291
519
|
* Input changes by snapshot index. Plain data, so it round-trips through
|
|
292
520
|
* JSON; load it back with `parseTimeline`.
|
|
@@ -294,7 +522,8 @@ export interface TimelineEntry<B extends Bindings> {
|
|
|
294
522
|
export type Timeline<B extends Bindings> = ReadonlyArray<TimelineEntry<B>>
|
|
295
523
|
|
|
296
524
|
/**
|
|
297
|
-
* Actions driven by a timeline instead of a host.
|
|
525
|
+
* Actions driven by a timeline instead of a host. The timeline names
|
|
526
|
+
* actions, not keys, so `rebind` changes nothing.
|
|
298
527
|
*/
|
|
299
528
|
export interface Scripted<B extends Bindings> extends Actions<B> {
|
|
300
529
|
/** Number of snapshots taken so far, which is the next frame index. */
|
|
@@ -307,41 +536,56 @@ export interface Scripted<B extends Bindings> extends Actions<B> {
|
|
|
307
536
|
* release, like keys; pressing an action already held is not a new press.
|
|
308
537
|
*/
|
|
309
538
|
export const scripted = <const B extends Bindings>(bindings: B, timeline: Timeline<B>): Scripted<B> => {
|
|
310
|
-
const names = Object.keys(bindings)
|
|
539
|
+
const names = Object.keys(flatten(bindings))
|
|
311
540
|
const byFrame = new Map<number, Array<TimelineEntry<B>>>()
|
|
312
541
|
for (const entry of timeline) {
|
|
313
542
|
const entries = byFrame.get(entry.frame)
|
|
314
543
|
if (entries) entries.push(entry)
|
|
315
544
|
else byFrame.set(entry.frame, [entry])
|
|
316
545
|
}
|
|
317
|
-
const held = new Set<
|
|
546
|
+
const held = new Set<string>()
|
|
318
547
|
let frame = 0
|
|
319
|
-
|
|
548
|
+
let last: Readonly<Record<string, ActionState>> = {}
|
|
549
|
+
const played: Scripted<B> & WithFlatStates & ReportsBoundKeys = {
|
|
550
|
+
[boundKeys]: () => labelsOf(flatten(bindings)),
|
|
320
551
|
snapshot() {
|
|
321
|
-
const pressed = new Set<
|
|
322
|
-
const released = new Set<
|
|
552
|
+
const pressed = new Set<string>()
|
|
553
|
+
const released = new Set<string>()
|
|
323
554
|
for (const entry of byFrame.get(frame) ?? []) {
|
|
555
|
+
const releases = new Set(entry.release ?? [])
|
|
324
556
|
for (const name of entry.press ?? []) {
|
|
557
|
+
if (held.has(name) && releases.has(name)) {
|
|
558
|
+
// Released and pressed again within the frame.
|
|
559
|
+
releases.delete(name)
|
|
560
|
+
released.add(name)
|
|
561
|
+
pressed.add(name)
|
|
562
|
+
continue
|
|
563
|
+
}
|
|
325
564
|
// A press of a held action is not a new press, as with a live
|
|
326
565
|
// keyboard's auto-repeat.
|
|
327
566
|
if (!held.has(name)) pressed.add(name)
|
|
328
567
|
held.add(name)
|
|
329
568
|
}
|
|
330
|
-
for (const name of
|
|
569
|
+
for (const name of releases) {
|
|
331
570
|
if (held.delete(name)) released.add(name)
|
|
332
571
|
}
|
|
333
572
|
}
|
|
334
573
|
frame += 1
|
|
335
|
-
const
|
|
574
|
+
const states: Record<string, ActionState> = {}
|
|
336
575
|
for (const name of names) {
|
|
337
576
|
const isHeld = held.has(name)
|
|
338
|
-
|
|
577
|
+
states[name] = { held: isHeld, pressed: pressed.has(name), released: released.has(name) && !isHeld }
|
|
339
578
|
}
|
|
340
|
-
|
|
579
|
+
last = states
|
|
580
|
+
return assemble(bindings, (name) => states[name]!, anyUsed(states)) as Snapshot<B>
|
|
341
581
|
},
|
|
582
|
+
[flatStates]: () => last,
|
|
583
|
+
// A timeline names actions, not keys, so new keys change nothing.
|
|
584
|
+
rebind() {},
|
|
342
585
|
dispose() {},
|
|
343
586
|
frames: () => frame
|
|
344
587
|
}
|
|
588
|
+
return played
|
|
345
589
|
}
|
|
346
590
|
|
|
347
591
|
/**
|
|
@@ -358,17 +602,25 @@ export interface Recording<B extends Bindings> extends Actions<B> {
|
|
|
358
602
|
*/
|
|
359
603
|
export const recording = <B extends Bindings>(actions: Actions<B>): Recording<B> => {
|
|
360
604
|
const entries: Array<TimelineEntry<B>> = []
|
|
605
|
+
let wasHeld = new Set<TimelineName<B>>()
|
|
361
606
|
let frame = 0
|
|
362
|
-
|
|
607
|
+
const recorded: Recording<B> & WithFlatStates & ReportsBoundKeys = {
|
|
608
|
+
[boundKeys]: () => (actions as ReportsBoundKeys)[boundKeys]?.() ?? [],
|
|
363
609
|
snapshot() {
|
|
364
610
|
const snapshot = actions.snapshot()
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
611
|
+
// Each direction of a vector action, from the keyboard this wraps.
|
|
612
|
+
const states = (actions as WithFlatStates)[flatStates]?.() ?? (snapshot.actions as Readonly<Record<string, ActionState>>)
|
|
613
|
+
const press: Array<TimelineName<B>> = []
|
|
614
|
+
const release: Array<TimelineName<B>> = []
|
|
615
|
+
const held = new Set<TimelineName<B>>()
|
|
616
|
+
for (const name of Object.keys(states) as Array<TimelineName<B>>) {
|
|
617
|
+
const state = states[name]!
|
|
618
|
+
if (state.held) held.add(name)
|
|
369
619
|
if (state.pressed) press.push(name)
|
|
370
|
-
|
|
620
|
+
// A press of an action held at the previous snapshot means it was released in between.
|
|
621
|
+
if (state.released || (state.pressed && wasHeld.has(name))) release.push(name)
|
|
371
622
|
}
|
|
623
|
+
wasHeld = held
|
|
372
624
|
if (press.length > 0 || release.length > 0) {
|
|
373
625
|
entries.push({
|
|
374
626
|
frame,
|
|
@@ -379,11 +631,16 @@ export const recording = <B extends Bindings>(actions: Actions<B>): Recording<B>
|
|
|
379
631
|
frame += 1
|
|
380
632
|
return snapshot
|
|
381
633
|
},
|
|
634
|
+
[flatStates]: () => (actions as WithFlatStates)[flatStates]?.() ?? {},
|
|
635
|
+
rebind(bindings) {
|
|
636
|
+
actions.rebind(bindings)
|
|
637
|
+
},
|
|
382
638
|
dispose() {
|
|
383
639
|
actions.dispose()
|
|
384
640
|
},
|
|
385
641
|
timeline: () => [...entries]
|
|
386
642
|
}
|
|
643
|
+
return recorded
|
|
387
644
|
}
|
|
388
645
|
|
|
389
646
|
/**
|
|
@@ -399,7 +656,8 @@ export interface InvalidTimeline {
|
|
|
399
656
|
/**
|
|
400
657
|
* Validates untrusted data, such as a parsed JSON file, as a timeline for
|
|
401
658
|
* `bindings`: an array of entries with a non-negative integer `frame` and
|
|
402
|
-
* optional `press`/`release` arrays of bound action names
|
|
659
|
+
* optional `press`/`release` arrays of bound action names (a vector action
|
|
660
|
+
* by direction, `"move.right"`).
|
|
403
661
|
*/
|
|
404
662
|
export const parseTimeline = <const B extends Bindings>(
|
|
405
663
|
bindings: B,
|
|
@@ -407,6 +665,7 @@ export const parseTimeline = <const B extends Bindings>(
|
|
|
407
665
|
): Result.Result<Timeline<B>, InvalidTimeline> => {
|
|
408
666
|
const invalid = (path: string, message: string) => Result.failure<InvalidTimeline>({ _tag: "InvalidTimeline", path, message })
|
|
409
667
|
if (!Array.isArray(data)) return invalid("", "expected an array of entries")
|
|
668
|
+
const flat = flatten(bindings)
|
|
410
669
|
const entries: Array<TimelineEntry<B>> = []
|
|
411
670
|
for (let index = 0; index < data.length; index++) {
|
|
412
671
|
const entry: unknown = data[index]
|
|
@@ -416,16 +675,16 @@ export const parseTimeline = <const B extends Bindings>(
|
|
|
416
675
|
if (typeof frame !== "number" || !Number.isInteger(frame) || frame < 0) {
|
|
417
676
|
return invalid(`${path}.frame`, "expected a non-negative integer")
|
|
418
677
|
}
|
|
419
|
-
const actionsAt = (value: unknown, field: string): Result.Result<Array<
|
|
678
|
+
const actionsAt = (value: unknown, field: string): Result.Result<Array<TimelineName<B>> | undefined, InvalidTimeline> => {
|
|
420
679
|
if (value === undefined) return Result.success(undefined)
|
|
421
680
|
if (!Array.isArray(value)) return invalid(`${path}.${field}`, "expected an array of action names")
|
|
422
|
-
const names: Array<
|
|
681
|
+
const names: Array<TimelineName<B>> = []
|
|
423
682
|
for (let offset = 0; offset < value.length; offset++) {
|
|
424
683
|
const name: unknown = value[offset]
|
|
425
|
-
if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(
|
|
426
|
-
return invalid(`${path}.${field}[${offset}]`, `expected one of ${Object.keys(
|
|
684
|
+
if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(flat, name)) {
|
|
685
|
+
return invalid(`${path}.${field}[${offset}]`, `expected one of ${Object.keys(flat).join(", ")}`)
|
|
427
686
|
}
|
|
428
|
-
names.push(name)
|
|
687
|
+
names.push(name as TimelineName<B>)
|
|
429
688
|
}
|
|
430
689
|
return Result.success(names)
|
|
431
690
|
}
|
|
@@ -441,3 +700,71 @@ export const parseTimeline = <const B extends Bindings>(
|
|
|
441
700
|
}
|
|
442
701
|
return Result.success(entries)
|
|
443
702
|
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Why loaded data is not a rebinding for the bindings. `path` points at the
|
|
706
|
+
* offending value, for example `jump[1]`.
|
|
707
|
+
*/
|
|
708
|
+
export interface InvalidRebinding {
|
|
709
|
+
readonly _tag: "InvalidRebinding"
|
|
710
|
+
readonly path: string
|
|
711
|
+
readonly message: string
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Validates untrusted data, such as saved settings parsed from JSON, as new
|
|
716
|
+
* keys for `bindings`: an object with exactly the same action names, each
|
|
717
|
+
* mapped to a non-empty array of characters or `{ code }` physical keys
|
|
718
|
+
* (for a vector action, an object with such keys for `up`, `down`, `left`,
|
|
719
|
+
* and `right`, as `Keyboard.vector` saves).
|
|
720
|
+
* Pass the result to {@link Actions.rebind}; on failure, keep the defaults.
|
|
721
|
+
*
|
|
722
|
+
* @example
|
|
723
|
+
* ```ts
|
|
724
|
+
* const saved = Keyboard.parseRebinding(bindings, JSON.parse(stored))
|
|
725
|
+
* if (saved.ok) input.rebind(saved.value)
|
|
726
|
+
* ```
|
|
727
|
+
*/
|
|
728
|
+
export const parseRebinding = <const B extends Bindings>(
|
|
729
|
+
bindings: B,
|
|
730
|
+
data: unknown
|
|
731
|
+
): Result.Result<Rebinding<B>, InvalidRebinding> => {
|
|
732
|
+
const invalid = (path: string, message: string) => Result.failure<InvalidRebinding>({ _tag: "InvalidRebinding", path, message })
|
|
733
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return invalid("", "expected an object of action names")
|
|
734
|
+
const fields = data as Readonly<Record<string, unknown>>
|
|
735
|
+
const names = Object.keys(bindings)
|
|
736
|
+
for (const name of Object.keys(fields)) {
|
|
737
|
+
if (!names.includes(name)) return invalid(name, `unknown action, expected one of ${names.join(", ")}`)
|
|
738
|
+
}
|
|
739
|
+
const keysAt = (value: unknown, path: string): Result.Result<Keys, InvalidRebinding> => {
|
|
740
|
+
if (!Array.isArray(value) || value.length === 0) return invalid(path, "expected a non-empty array of keys")
|
|
741
|
+
const keys: Array<KeyBinding> = []
|
|
742
|
+
for (let offset = 0; offset < value.length; offset++) {
|
|
743
|
+
const key: unknown = value[offset]
|
|
744
|
+
if (typeof key === "string" && key.length > 0) keys.push(key)
|
|
745
|
+
else if (typeof key === "object" && key !== null && typeof (key as { readonly code?: unknown }).code === "string" && (key as PhysicalKey).code.length > 0) {
|
|
746
|
+
keys.push({ code: (key as PhysicalKey).code })
|
|
747
|
+
} else return invalid(`${path}[${offset}]`, "expected a key character or { code }")
|
|
748
|
+
}
|
|
749
|
+
return Result.success(keys as unknown as Keys)
|
|
750
|
+
}
|
|
751
|
+
const rebinding: Record<string, Keys | VectorBinding> = {}
|
|
752
|
+
for (const name of names) {
|
|
753
|
+
const value = fields[name]
|
|
754
|
+
if (isVectorBinding(bindings[name])) {
|
|
755
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalid(name, "expected { up, down, left, right } keys for a vector action")
|
|
756
|
+
const parts: Partial<Record<Direction, Keys>> = {}
|
|
757
|
+
for (const direction of directions) {
|
|
758
|
+
const keys = keysAt((value as Readonly<Record<string, unknown>>)[direction], `${name}.${direction}`)
|
|
759
|
+
if (!keys.ok) return keys
|
|
760
|
+
parts[direction] = keys.value
|
|
761
|
+
}
|
|
762
|
+
rebinding[name] = vector(parts as Record<Direction, Keys>)
|
|
763
|
+
continue
|
|
764
|
+
}
|
|
765
|
+
const keys = keysAt(value, name)
|
|
766
|
+
if (!keys.ok) return keys
|
|
767
|
+
rebinding[name] = keys.value
|
|
768
|
+
}
|
|
769
|
+
return Result.success(rebinding as unknown as Rebinding<B>)
|
|
770
|
+
}
|