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