@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/Controls.ts
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One input source merged from several devices, so gameplay reads each
|
|
3
|
+
* action once whatever the player uses.
|
|
4
|
+
*
|
|
5
|
+
* Declare the game's actions once with `Controls.actions({ buttons: [...] })`, bind them
|
|
6
|
+
* on every device (`Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`,
|
|
7
|
+
* `Touch.actions`), and merge the devices with the declaration's `merge`.
|
|
8
|
+
* Merged devices
|
|
9
|
+
* behave like one device with all their bindings: an action is `held` while
|
|
10
|
+
* any device holds it, `pressed` when any device pressed it, and `released`
|
|
11
|
+
* when a device released it and none holds it now. A vector action (movement)
|
|
12
|
+
* is the longest direction any device reports, so a stick and keys never add
|
|
13
|
+
* up past length 1 or cancel each other. Device-specific data
|
|
14
|
+
* (sticks, triggers, touch aim) stays in `devices`, under the names given to
|
|
15
|
+
* `merge`; `lastUsed` names the device the player used last, for button
|
|
16
|
+
* prompts.
|
|
17
|
+
*
|
|
18
|
+
* Devices the player uses together (keyboard and mouse) go in one
|
|
19
|
+
* `group(...)`: the group is one entry of the merge, so walking with the
|
|
20
|
+
* keyboard while aiming with the mouse keeps `lastUsed` on the group, and
|
|
21
|
+
* `available` follows what the group binds between its devices. Grouped
|
|
22
|
+
* devices keep their own names in `devices` and `rebind`.
|
|
23
|
+
*
|
|
24
|
+
* Every entry binds every action. A device that cannot (a touch layout
|
|
25
|
+
* without a pause button) is passed as `actions.lacking(device, [...])`,
|
|
26
|
+
* which names exactly the actions it lacks. Reading a lacking action on that
|
|
27
|
+
* device does not compile; `available` reports whether the last used device
|
|
28
|
+
* has each action, and `describe()` lists every gap as a `service-lint`.
|
|
29
|
+
*
|
|
30
|
+
* Capture the merged source with `InputCapture.system(...)` and build the
|
|
31
|
+
* devices inside the `merge(...)` call, so nothing else takes their
|
|
32
|
+
* snapshots. A game with one device captures that device directly instead.
|
|
33
|
+
*
|
|
34
|
+
* A merge is one player on every device. For local multiplayer, where each
|
|
35
|
+
* device belongs to the player who joined with it, declare
|
|
36
|
+
* `Controls.players(Actions, { slots, join, leave })` and capture its
|
|
37
|
+
* `pool(...)` instead.
|
|
38
|
+
*
|
|
39
|
+
* @module Controls
|
|
40
|
+
* @docGroup browser
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* const Actions = Controls.actions({ buttons: ["jump", "pause"], vectors: ["move"] })
|
|
45
|
+
*
|
|
46
|
+
* const keyBindings = {
|
|
47
|
+
* move: Keyboard.vector({ up: ["ArrowUp", "w"], down: ["ArrowDown", "s"], left: ["ArrowLeft", "a"], right: ["ArrowRight", "d"] }),
|
|
48
|
+
* jump: [" "],
|
|
49
|
+
* pause: ["Escape"]
|
|
50
|
+
* } as const
|
|
51
|
+
* const padBindings = {
|
|
52
|
+
* move: Gamepad.vector({ stick: "left", up: ["dpadUp"], down: ["dpadDown"], left: ["dpadLeft"], right: ["dpadRight"] }),
|
|
53
|
+
* jump: ["south"],
|
|
54
|
+
* pause: ["start"]
|
|
55
|
+
* } as const
|
|
56
|
+
*
|
|
57
|
+
* // One function builds the controls, for the browser and for tests.
|
|
58
|
+
* export const makeControls = (devices: {
|
|
59
|
+
* readonly keyboard: Keyboard.Actions<typeof keyBindings>
|
|
60
|
+
* readonly gamepad: Gamepad.Actions<typeof padBindings>
|
|
61
|
+
* }) => Actions.merge(devices)
|
|
62
|
+
*
|
|
63
|
+
* const ControlsInput = Descriptor.Service<ReturnType<typeof makeControls>>()("Game/Controls")
|
|
64
|
+
* const Input = Descriptor.TransientResource<InputCapture.SnapshotOf<typeof ControlsInput>>()("Game/Input")
|
|
65
|
+
* const CaptureInput = InputCapture.system(Game, { name: "Game/CaptureInput", source: ControlsInput, resource: Input })
|
|
66
|
+
*
|
|
67
|
+
* const runtime = Game.Runtime.make({
|
|
68
|
+
* services: Game.Runtime.services(Game.Runtime.service(ControlsInput, makeControls({
|
|
69
|
+
* keyboard: Keyboard.actions(window, keyBindings),
|
|
70
|
+
* gamepad: Gamepad.actions(window, padBindings)
|
|
71
|
+
* }))),
|
|
72
|
+
* resources: { Input: Actions.idle({ keyboard: Keyboard.idle(keyBindings), gamepad: Gamepad.idle(padBindings) }) }
|
|
73
|
+
* }).value
|
|
74
|
+
*
|
|
75
|
+
* // In a system:
|
|
76
|
+
* const input = resources.input.get()
|
|
77
|
+
* walk(input.vectors.move) // length at most 1: keys, d-pad, or stick
|
|
78
|
+
* if (input.actions.jump.pressed) jump()
|
|
79
|
+
* const prompt = input.lastUsed === "gamepad" ? "Ⓐ" : "Space"
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
import type * as System from "@typeonce/ritmo/System"
|
|
83
|
+
import * as Debug from "@typeonce/ritmo/Debug"
|
|
84
|
+
|
|
85
|
+
import type { ActionState } from "./internal/state.ts"
|
|
86
|
+
import { boundKeys, claim, ownersOf } from "./internal/introspect.ts"
|
|
87
|
+
import type { ReportsBoundKeys } from "./internal/introspect.ts"
|
|
88
|
+
import { longer, zero } from "./internal/vectors.ts"
|
|
89
|
+
import type { Vector } from "./internal/vectors.ts"
|
|
90
|
+
|
|
91
|
+
export type { ActionState, Vector }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The part of a device's snapshot `Controls` merges: every button action's
|
|
95
|
+
* state, every vector action's direction, and whether the player used the
|
|
96
|
+
* device in this window.
|
|
97
|
+
*/
|
|
98
|
+
export interface DeviceSnapshot<A extends string, V extends string = never> {
|
|
99
|
+
readonly actions: { readonly [K in A]: ActionState }
|
|
100
|
+
readonly vectors: { readonly [K in V]: Vector }
|
|
101
|
+
readonly used: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A device `Controls` can merge: `Keyboard.actions`, `Gamepad.actions`, `Pointer.actions`, `Touch.actions`, or their scripted forms. */
|
|
105
|
+
export interface Device<A extends string = string, V extends string = string> extends System.Consumes<"snapshot"> {
|
|
106
|
+
snapshot(): DeviceSnapshot<A, V>
|
|
107
|
+
dispose(): void
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Devices the player uses together as one (keyboard and mouse), built with
|
|
112
|
+
* {@link ActionSet.group}. The group is one entry of the merge: it binds
|
|
113
|
+
* what its devices bind between them, and `lastUsed` and `available` follow
|
|
114
|
+
* it as a whole. Its devices keep their own names in `devices` and `rebind`.
|
|
115
|
+
*/
|
|
116
|
+
export interface Group<Members> {
|
|
117
|
+
readonly _tag: "Group"
|
|
118
|
+
readonly members: Members
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every pad on its own, for a player pool: `Gamepad.pads(...)`. Each pad
|
|
123
|
+
* joins as its own player. Reading it drains the pads' presses, like every
|
|
124
|
+
* input source.
|
|
125
|
+
*/
|
|
126
|
+
export interface Pads extends System.Consumes<"snapshot"> {
|
|
127
|
+
readonly _tag: "Pads"
|
|
128
|
+
readonly max: number
|
|
129
|
+
snapshot(): ReadonlyArray<DeviceSnapshot<string, string> & { readonly id: string | null; readonly connected: boolean }>
|
|
130
|
+
dispose(): void
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A device passed with the actions it lacks; build it with {@link ActionSet.lacking}. */
|
|
134
|
+
export interface Lacking<D extends Unit | Pads, Missing extends string> {
|
|
135
|
+
readonly _tag: "Lacking"
|
|
136
|
+
readonly device: D
|
|
137
|
+
readonly lacking: ReadonlyArray<Missing>
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One entry of a merge before `lacking`: a device or a group of devices. */
|
|
141
|
+
type Unit = Device | Group<Readonly<Record<string, Device>>>
|
|
142
|
+
|
|
143
|
+
type Entry = Unit | Lacking<Unit, string>
|
|
144
|
+
|
|
145
|
+
/** One entry of a player pool: a device, or every pad, maybe with the actions it lacks. */
|
|
146
|
+
type PoolEntry = Device | Pads | Lacking<Device | Pads, string>
|
|
147
|
+
|
|
148
|
+
/** The button actions a device, or the devices of a group, bind. */
|
|
149
|
+
type ActionsOf<D> = D extends Group<infer M> ? { [K in keyof M]: ActionsOf<M[K]> }[keyof M]
|
|
150
|
+
: D extends { readonly _tag: "Pads"; snapshot(): ReadonlyArray<{ readonly actions: infer Actions }> } ? keyof Actions & string
|
|
151
|
+
: D extends { snapshot(): { readonly actions: infer Actions } } ? keyof Actions & string
|
|
152
|
+
: never
|
|
153
|
+
|
|
154
|
+
/** The vector actions a device, or the devices of a group, bind. */
|
|
155
|
+
type VectorsOf<D> = D extends Group<infer M> ? { [K in keyof M]: VectorsOf<M[K]> }[keyof M]
|
|
156
|
+
: D extends { readonly _tag: "Pads"; snapshot(): ReadonlyArray<{ readonly vectors: infer Vectors }> } ? keyof Vectors & string
|
|
157
|
+
: D extends { snapshot(): { readonly vectors: infer Vectors } } ? keyof Vectors & string
|
|
158
|
+
: never
|
|
159
|
+
|
|
160
|
+
/** Every action a device binds, of either kind. */
|
|
161
|
+
type BoundOf<D> = ActionsOf<D> | VectorsOf<D>
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Why a device's bindings do not fit the declared buttons `A` and vectors
|
|
165
|
+
* `V` (an action of the wrong kind, or one not declared), or `never`.
|
|
166
|
+
*/
|
|
167
|
+
type KindIssue<A extends string, V extends string, Name extends string, D> = [ActionsOf<D> & V] extends [never]
|
|
168
|
+
? [VectorsOf<D> & A] extends [never]
|
|
169
|
+
? [Exclude<BoundOf<D>, A | V>] extends [never] ? never
|
|
170
|
+
: `${Name} binds ${Exclude<BoundOf<D>, A | V>}, which is not one of the actions`
|
|
171
|
+
: `${Name} binds ${VectorsOf<D> & A} as a vector, but it is a button action: bind it to keys or buttons`
|
|
172
|
+
: `${Name} binds ${ActionsOf<D> & V} as a button, but it is a vector action: bind it with vector(...)`
|
|
173
|
+
|
|
174
|
+
type DeviceOf<E> = E extends Lacking<infer D, string> ? D : E
|
|
175
|
+
|
|
176
|
+
type SnapshotOf<D> = D extends { snapshot(): infer S } ? S : never
|
|
177
|
+
|
|
178
|
+
/** The devices one merge entry contributes, by name: a group's members, or the device under the entry's name. */
|
|
179
|
+
type MembersOf<K, U> = U extends Group<infer M> ? M : { readonly [P in K & string]: U }
|
|
180
|
+
|
|
181
|
+
/** Every device name of a merge, with groups flattened. */
|
|
182
|
+
type DeviceNames<R> = { [K in keyof R]: keyof MembersOf<K, DeviceOf<R[K]>> }[keyof R] & string
|
|
183
|
+
|
|
184
|
+
/** The device a merge names `N`, wherever it is. */
|
|
185
|
+
type DeviceNamed<R, N> = {
|
|
186
|
+
[K in keyof R]: MembersOf<K, DeviceOf<R[K]>> extends infer M ? N extends keyof M ? M[N] : never : never
|
|
187
|
+
}[keyof R]
|
|
188
|
+
|
|
189
|
+
/** Each device's full snapshot, by device name (group members under their own names). */
|
|
190
|
+
type DevicesOf<R> = { readonly [N in DeviceNames<R>]: SnapshotOf<DeviceNamed<R, N>> }
|
|
191
|
+
|
|
192
|
+
/** The `rebind` of each merged device that has one, by device name. */
|
|
193
|
+
type RebindOf<R> = {
|
|
194
|
+
readonly [N in DeviceNames<R> as DeviceNamed<R, N> extends { readonly rebind: unknown } ? N : never]: DeviceNamed<R, N> extends { readonly rebind: infer Rebind } ? Rebind : never
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The names one entry takes: its own, and its members' for a group. */
|
|
198
|
+
type NamesOf<K, U> = U extends Group<infer M> ? K | keyof M : K
|
|
199
|
+
|
|
200
|
+
/** A device or group name the merge uses twice. */
|
|
201
|
+
type Repeated<R> = {
|
|
202
|
+
[K in keyof R]:
|
|
203
|
+
| (NamesOf<K, DeviceOf<R[K]>> & { [J in Exclude<keyof R, K>]: NamesOf<J, DeviceOf<R[J]>> }[Exclude<keyof R, K>])
|
|
204
|
+
| (DeviceOf<R[K]> extends Group<infer M> ? K & keyof M : never)
|
|
205
|
+
}[keyof R] & string
|
|
206
|
+
|
|
207
|
+
/** Rejects a merge that uses a name twice: `devices`, `rebind`, and `lastUsed` need one meaning per name. */
|
|
208
|
+
type UniqueNames<R> = [Repeated<R>] extends [never]
|
|
209
|
+
? unknown
|
|
210
|
+
: `the name ${Repeated<R>} is used twice: give every device and group its own name`
|
|
211
|
+
|
|
212
|
+
/** A group member when it is a plain device binding only declared actions, otherwise an error naming the fix. */
|
|
213
|
+
type CheckMember<A extends string, V extends string, Name extends string, D> = D extends Lacking<Unit, string>
|
|
214
|
+
? "lacking applies to the whole group: pass lacking(group(...), [...])"
|
|
215
|
+
: D extends Group<unknown> ? "groups do not nest: list every device in one group"
|
|
216
|
+
: [KindIssue<A, V, Name, D>] extends [never] ? D
|
|
217
|
+
: KindIssue<A, V, Name, D>
|
|
218
|
+
|
|
219
|
+
/** Rejects a group of fewer than two devices. */
|
|
220
|
+
type TwoOrMore<M> = [keyof M] extends [never]
|
|
221
|
+
? "a group needs at least two devices"
|
|
222
|
+
: { [K in keyof M]: [Exclude<keyof M, K>] extends [never] ? K : never }[keyof M] extends infer Alone extends string
|
|
223
|
+
? [Alone] extends [never] ? unknown : `a group needs at least two devices: pass ${Alone} to merge directly`
|
|
224
|
+
: unknown
|
|
225
|
+
|
|
226
|
+
/** The entry when it covers exactly the declared actions, otherwise an error naming the device and the fix. */
|
|
227
|
+
type CheckEntry<A extends string, V extends string, Name extends string, E> = E extends Lacking<infer D, infer Missing>
|
|
228
|
+
? [KindIssue<A, V, Name, D>] extends [never]
|
|
229
|
+
? [Exclude<A | V, BoundOf<D> | Missing>] extends [never]
|
|
230
|
+
? E
|
|
231
|
+
: `${Name} has no action ${Exclude<A | V, BoundOf<D> | Missing>}: bind it on ${Name}, or list it in its lacking(...)`
|
|
232
|
+
: KindIssue<A, V, Name, D>
|
|
233
|
+
: [KindIssue<A, V, Name, E>] extends [never]
|
|
234
|
+
? [Exclude<A | V, BoundOf<E>>] extends [never]
|
|
235
|
+
? E
|
|
236
|
+
: `${Name} has no action ${Exclude<A | V, BoundOf<E>>}: bind it on ${Name}, or pass lacking(${Name}, [...]) from the action set`
|
|
237
|
+
: KindIssue<A, V, Name, E>
|
|
238
|
+
|
|
239
|
+
/** The actions some merged device binds. */
|
|
240
|
+
type BoundBy<R> = { [K in keyof R]: BoundOf<DeviceOf<R[K]>> }[keyof R]
|
|
241
|
+
|
|
242
|
+
/** Rejects a merge where no device binds an action. */
|
|
243
|
+
type EveryActionBound<A extends string, R> = [Exclude<A, BoundBy<R>>] extends [never]
|
|
244
|
+
? unknown
|
|
245
|
+
: `no device binds ${Exclude<A, BoundBy<R>>}: bind it on one device, or remove it from Controls.actions`
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The merged input. `actions` merges every device; `devices` holds each
|
|
249
|
+
* device's own snapshot under the name it was merged under.
|
|
250
|
+
*/
|
|
251
|
+
export interface Snapshot<A extends string, V extends string, Devices, Used extends string = keyof Devices & string> {
|
|
252
|
+
readonly actions: { readonly [K in A]: ActionState }
|
|
253
|
+
/**
|
|
254
|
+
* Each vector action (movement) as a direction with length at most 1, `y`
|
|
255
|
+
* growing down: the longest any device reports, so devices never add up
|
|
256
|
+
* past 1 or cancel each other.
|
|
257
|
+
*/
|
|
258
|
+
readonly vectors: { readonly [K in V]: Vector }
|
|
259
|
+
/**
|
|
260
|
+
* Whether the last used device binds each action: `false` for the actions
|
|
261
|
+
* it was merged as lacking, so the game can offer them another way (an
|
|
262
|
+
* on-screen pause button). Every action is available before any device is
|
|
263
|
+
* used.
|
|
264
|
+
*/
|
|
265
|
+
readonly available: { readonly [K in A | V]: boolean }
|
|
266
|
+
readonly devices: Devices
|
|
267
|
+
/**
|
|
268
|
+
* The device (or group) that reported input most recently, or `null`
|
|
269
|
+
* before any did. When several report input in one window it stays on the
|
|
270
|
+
* current one if it is among them, otherwise it takes the first in merge
|
|
271
|
+
* order.
|
|
272
|
+
*/
|
|
273
|
+
readonly lastUsed: Used | null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The merged source, for `InputCapture.system(...)`. */
|
|
277
|
+
export interface Controls<A extends string, V extends string, R> extends System.Consumes<"snapshot"> {
|
|
278
|
+
/** Takes every device's snapshot, starting a new press window on each, and merges them. */
|
|
279
|
+
snapshot(): Snapshot<A, V, DevicesOf<R>, keyof R & string>
|
|
280
|
+
/** Each merged device's `rebind`, for a settings screen; devices without one (touch) are not listed. */
|
|
281
|
+
readonly rebind: RebindOf<R>
|
|
282
|
+
/** Disposes every merged device. */
|
|
283
|
+
dispose(): void
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The game's actions, declared once. Every merge, gap, and idle snapshot is
|
|
288
|
+
* built from it, so action names are checked against one list.
|
|
289
|
+
*/
|
|
290
|
+
export interface ActionSet<A extends string, V extends string = never> {
|
|
291
|
+
/** The declared button actions, in order, for listing them on a settings screen. */
|
|
292
|
+
readonly buttons: ReadonlyArray<A>
|
|
293
|
+
/** The declared vector actions, in order. */
|
|
294
|
+
readonly vectors: ReadonlyArray<V>
|
|
295
|
+
/**
|
|
296
|
+
* Marks the actions `device` cannot report, for {@link merge}. List
|
|
297
|
+
* exactly the declared actions it does not bind: autocomplete offers them,
|
|
298
|
+
* and a missing or extra name does not compile.
|
|
299
|
+
*/
|
|
300
|
+
lacking<D extends Unit | Pads, const Missing extends ReadonlyArray<Exclude<A | V, BoundOf<D>>>>(
|
|
301
|
+
device: D & (
|
|
302
|
+
[KindIssue<A, V, "This device", D>] extends [never]
|
|
303
|
+
? [Exclude<A | V, BoundOf<D>>] extends [never] ? "This device binds every action: pass it to merge without lacking" : unknown
|
|
304
|
+
: KindIssue<A, V, "This device", D>
|
|
305
|
+
),
|
|
306
|
+
lacking: Missing & ([Exclude<A | V, BoundOf<D> | Missing[number]>] extends [never] ? unknown : `This device also lacks ${Exclude<A | V, BoundOf<D> | Missing[number]>}: bind it, or list it here`)
|
|
307
|
+
): Lacking<D, Missing[number]>
|
|
308
|
+
/**
|
|
309
|
+
* Groups devices the player uses together (keyboard and mouse), for
|
|
310
|
+
* {@link merge}: the group binds what its devices bind between them, and
|
|
311
|
+
* `lastUsed` and `available` follow it as a whole, so walking with the
|
|
312
|
+
* keyboard while aiming with the mouse is one device in use. A group needs
|
|
313
|
+
* two or more plain devices, each binding only declared actions; pass the
|
|
314
|
+
* group to {@link lacking} for the actions none of them binds.
|
|
315
|
+
*/
|
|
316
|
+
group<const M extends Readonly<Record<string, Entry | Group<unknown>>>>(
|
|
317
|
+
members: { readonly [K in keyof M]: CheckMember<A, V, K & string, M[K]> } & TwoOrMore<M>
|
|
318
|
+
): Group<M>
|
|
319
|
+
/**
|
|
320
|
+
* Merges `devices` into one source. Each entry (a device or a
|
|
321
|
+
* {@link group}) binds every declared action, or is passed through
|
|
322
|
+
* {@link lacking}; every action is bound by at least one device. Entry
|
|
323
|
+
* names key `lastUsed`; device names (a group's members under their own)
|
|
324
|
+
* key `devices` and `rebind`.
|
|
325
|
+
*/
|
|
326
|
+
merge<const R extends Readonly<Record<string, Entry>>>(
|
|
327
|
+
devices: { readonly [K in keyof R]: CheckEntry<A, V, K & string, R[K]> } & EveryActionBound<A | V, R> & UniqueNames<R>
|
|
328
|
+
): Controls<A, V, R>
|
|
329
|
+
/**
|
|
330
|
+
* A merged snapshot with nothing held and no device used yet, for
|
|
331
|
+
* initializing the input resource: pass each device's idle snapshot
|
|
332
|
+
* (`Keyboard.idle(...)`, ...) under its device name.
|
|
333
|
+
*/
|
|
334
|
+
idle<const Devices extends Readonly<Record<string, DeviceSnapshot<string, string>>>>(devices: Devices): Snapshot<A, V, Devices, never>
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const idleState: ActionState = { held: false, pressed: false, released: false }
|
|
338
|
+
|
|
339
|
+
const isLacking = (entry: unknown): entry is Lacking<Unit | Pads, string> => (entry as Partial<Lacking<Unit, string>>)._tag === "Lacking"
|
|
340
|
+
|
|
341
|
+
const isGroup = (unit: Unit): unit is Group<Readonly<Record<string, Device>>> =>
|
|
342
|
+
(unit as Partial<Group<unknown>>)._tag === "Group"
|
|
343
|
+
|
|
344
|
+
/** A merge entry's devices by name: a group's members, or the device under the entry's name. */
|
|
345
|
+
const membersOf = (name: string, unit: Unit): ReadonlyArray<{ readonly name: string; readonly device: Device }> =>
|
|
346
|
+
isGroup(unit)
|
|
347
|
+
? Object.entries(unit.members).map(([member, device]) => ({ name: member, device }))
|
|
348
|
+
: [{ name, device: unit }]
|
|
349
|
+
|
|
350
|
+
/** Rejects a name declared both as a button and as a vector. */
|
|
351
|
+
type DistinctKinds<A extends string, V extends string> = [A & V] extends [never]
|
|
352
|
+
? unknown
|
|
353
|
+
: `${A & V} is declared as both a button and a vector action: give it one kind`
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Declares the game's actions: `buttons` (pressed and held: jump, pause)
|
|
357
|
+
* and `vectors` (a direction with length at most 1: move), each bound on
|
|
358
|
+
* every device. Build the merged source with the returned set's `merge`.
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* ```ts
|
|
362
|
+
* const Actions = Controls.actions({ buttons: ["jump", "pause"], vectors: ["move"] })
|
|
363
|
+
* ```
|
|
364
|
+
*/
|
|
365
|
+
export const actions = <const Buttons extends readonly [string, ...Array<string>], const Vectors extends ReadonlyArray<string> = readonly []>(
|
|
366
|
+
names: { readonly buttons: Buttons; readonly vectors?: Vectors } & DistinctKinds<Buttons[number], Vectors[number]>
|
|
367
|
+
): ActionSet<Buttons[number], Vectors[number]> => {
|
|
368
|
+
type A = Buttons[number]
|
|
369
|
+
type V = Vectors[number]
|
|
370
|
+
const list: ReadonlyArray<A> = [...new Set(names.buttons)]
|
|
371
|
+
const vectorList: ReadonlyArray<V> = [...new Set(names.vectors ?? [])]
|
|
372
|
+
const everyAvailable = (): Record<string, boolean> => Object.fromEntries([...list, ...vectorList].map((name) => [name, true]))
|
|
373
|
+
return {
|
|
374
|
+
buttons: list,
|
|
375
|
+
vectors: vectorList,
|
|
376
|
+
lacking: (device, lacking) => ({ _tag: "Lacking", device, lacking }),
|
|
377
|
+
group: (members) => ({ _tag: "Group", members }) as never,
|
|
378
|
+
merge(devices) {
|
|
379
|
+
const entries = Object.entries(devices as Readonly<Record<string, Entry>>).map(([name, entry]) => {
|
|
380
|
+
const unit = isLacking(entry) ? entry.device : entry
|
|
381
|
+
return { name, members: membersOf(name, unit), lacking: new Set<string>(isLacking(entry) ? entry.lacking : []) }
|
|
382
|
+
})
|
|
383
|
+
const all = entries.flatMap((entry) => entry.members)
|
|
384
|
+
for (const { device } of all) claim(device, "a Controls merge")
|
|
385
|
+
let lastUsed: string | null = null
|
|
386
|
+
const rebind: Record<string, (bindings: never) => void> = {}
|
|
387
|
+
for (const { name, device } of all) {
|
|
388
|
+
const candidate = (device as Device & { readonly rebind?: unknown }).rebind
|
|
389
|
+
if (typeof candidate === "function") rebind[name] = (bindings) => candidate.call(device, bindings)
|
|
390
|
+
}
|
|
391
|
+
const controls = {
|
|
392
|
+
snapshot() {
|
|
393
|
+
const snapshots: Record<string, DeviceSnapshot<string, string>> = {}
|
|
394
|
+
for (const { name, device } of all) snapshots[name] = device.snapshot()
|
|
395
|
+
const used = entries.filter(({ members }) => members.some(({ name }) => snapshots[name]!.used))
|
|
396
|
+
if (used.length > 0 && !used.some(({ name }) => name === lastUsed)) lastUsed = used[0]!.name
|
|
397
|
+
const current = entries.find(({ name }) => name === lastUsed)
|
|
398
|
+
const merged: Record<string, ActionState> = {}
|
|
399
|
+
const available: Record<string, boolean> = {}
|
|
400
|
+
for (const action of list) {
|
|
401
|
+
let held = false
|
|
402
|
+
let pressed = false
|
|
403
|
+
let released = false
|
|
404
|
+
for (const { name } of all) {
|
|
405
|
+
const state = (snapshots[name]!.actions as Readonly<Record<string, ActionState | undefined>>)[action]
|
|
406
|
+
if (state === undefined) continue
|
|
407
|
+
held ||= state.held
|
|
408
|
+
pressed ||= state.pressed
|
|
409
|
+
released ||= state.released
|
|
410
|
+
}
|
|
411
|
+
merged[action] = held || pressed || released ? { held, pressed, released: released && !held } : idleState
|
|
412
|
+
available[action] = current === undefined || !current.lacking.has(action)
|
|
413
|
+
}
|
|
414
|
+
const vectors: Record<string, Vector> = {}
|
|
415
|
+
for (const action of vectorList) {
|
|
416
|
+
let longest = zero
|
|
417
|
+
for (const { name } of all) {
|
|
418
|
+
const vector = (snapshots[name]!.vectors as Readonly<Record<string, Vector | undefined>>)[action]
|
|
419
|
+
if (vector !== undefined) longest = longer(longest, vector)
|
|
420
|
+
}
|
|
421
|
+
vectors[action] = longest
|
|
422
|
+
available[action] = current === undefined || !current.lacking.has(action)
|
|
423
|
+
}
|
|
424
|
+
return { actions: merged, vectors, available, devices: snapshots, lastUsed }
|
|
425
|
+
},
|
|
426
|
+
rebind,
|
|
427
|
+
dispose() {
|
|
428
|
+
for (const { device } of all) device.dispose()
|
|
429
|
+
},
|
|
430
|
+
[Debug.serviceLints]: () => [
|
|
431
|
+
...entries.flatMap(({ name, lacking }) =>
|
|
432
|
+
[...lacking].map((action) => ({
|
|
433
|
+
severity: "info" as const,
|
|
434
|
+
message: `${name} lacks ${action}, so while ${name} is the last used device the player cannot ${action}`,
|
|
435
|
+
fix: `fine if the game offers ${action} another way while ${name} is in use (read available.${action} from the input); otherwise bind ${action} on ${name}`
|
|
436
|
+
}))),
|
|
437
|
+
...all.flatMap(({ name, device }) => sharedLint(name, device))
|
|
438
|
+
]
|
|
439
|
+
}
|
|
440
|
+
// The checked signature carries the device types; the implementation is the same for every merge.
|
|
441
|
+
return controls as never
|
|
442
|
+
},
|
|
443
|
+
idle: (devices) => ({
|
|
444
|
+
actions: Object.fromEntries(list.map((name) => [name, idleState])) as Snapshot<A, V, never>["actions"],
|
|
445
|
+
vectors: Object.fromEntries(vectorList.map((name) => [name, zero])) as Snapshot<A, V, never>["vectors"],
|
|
446
|
+
available: everyAvailable() as Snapshot<A, V, never>["available"],
|
|
447
|
+
devices,
|
|
448
|
+
lastUsed: null
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** A device handed to more than one source: each takes its snapshots, so each sees only some presses. */
|
|
454
|
+
const sharedLint = (name: string, device: object): ReadonlyArray<Debug.ServiceLint> =>
|
|
455
|
+
ownersOf(device).length > 1
|
|
456
|
+
? [{
|
|
457
|
+
severity: "warning",
|
|
458
|
+
message: `${name} is taken by ${ownersOf(device).join(" and ")}: each takes its snapshots, so a press reaches one of them`,
|
|
459
|
+
fix: `build ${name} once for each source (each Keyboard.actions(...) call is its own device), or merge it in one place`
|
|
460
|
+
}]
|
|
461
|
+
: []
|
|
462
|
+
|
|
463
|
+
// ── Players ─────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/** A slot's input without the device: what every player snapshot has, joined or not. */
|
|
466
|
+
export interface SlotInput<A extends string, V extends string> {
|
|
467
|
+
readonly actions: { readonly [K in A]: ActionState }
|
|
468
|
+
readonly vectors: { readonly [K in V]: Vector }
|
|
469
|
+
/** Whether the slot's device binds each action: every one while the slot is empty. */
|
|
470
|
+
readonly available: { readonly [K in A | V]: boolean }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A slot nobody holds: its input is idle. */
|
|
474
|
+
export interface EmptySlot<A extends string, V extends string> extends SlotInput<A, V> {
|
|
475
|
+
readonly joined: false
|
|
476
|
+
readonly device: null
|
|
477
|
+
readonly connected: false
|
|
478
|
+
/** `"left"` in the snapshot right after its player left, otherwise `null`. */
|
|
479
|
+
readonly event: "left" | null
|
|
480
|
+
readonly devices: { readonly [K in never]: never }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A slot held by a player, by the device they joined with. Check `device` to read that device's own snapshot. */
|
|
484
|
+
export type JoinedSlot<A extends string, V extends string, Devices> = {
|
|
485
|
+
readonly [D in keyof Devices & string]: SlotInput<A, V> & {
|
|
486
|
+
readonly joined: true
|
|
487
|
+
readonly device: D
|
|
488
|
+
/** `false` while the player's pad is unplugged: the slot is kept, and the same pad coming back returns to it. */
|
|
489
|
+
readonly connected: boolean
|
|
490
|
+
/** `"joined"` in the snapshot where the player joined (their input is idle there, so the press that joins does nothing else), otherwise `null`. */
|
|
491
|
+
readonly event: "joined" | null
|
|
492
|
+
readonly devices: { readonly [P in D]: Devices[D] }
|
|
493
|
+
}
|
|
494
|
+
}[keyof Devices & string]
|
|
495
|
+
|
|
496
|
+
/** One player slot: empty, or held through one of the pool's devices. */
|
|
497
|
+
export type Slot<A extends string, V extends string, Devices> = EmptySlot<A, V> | JoinedSlot<A, V, Devices>
|
|
498
|
+
|
|
499
|
+
/** Every slot by name, and whether joining is open. */
|
|
500
|
+
export type PlayersSnapshot<A extends string, V extends string, S extends string, Devices> =
|
|
501
|
+
& { readonly joining: boolean }
|
|
502
|
+
& { readonly [K in S]: Slot<A, V, Devices> }
|
|
503
|
+
|
|
504
|
+
/** What one pool entry hands a player: a pad's snapshot for `Gamepad.pads`, the device's otherwise. */
|
|
505
|
+
type UnitSnapshotOf<D> = D extends { readonly _tag: "Pads"; snapshot(): ReadonlyArray<infer S> } ? S : SnapshotOf<D>
|
|
506
|
+
|
|
507
|
+
type PoolDevicesOf<R> = { readonly [K in keyof R]: UnitSnapshotOf<DeviceOf<R[K]>> }
|
|
508
|
+
|
|
509
|
+
/** The entry when it fits the actions and binds the join action, otherwise an error naming the fix. */
|
|
510
|
+
type CheckPoolEntry<A extends string, V extends string, J extends string, Name extends string, E> = E extends Lacking<infer _D, infer Missing>
|
|
511
|
+
? [Missing & J] extends [never]
|
|
512
|
+
? CheckEntry<A, V, Name, E>
|
|
513
|
+
: `${Name} lacks ${Missing & J}: every pooled device binds ${J}, or no one can join with it`
|
|
514
|
+
: CheckEntry<A, V, Name, E>
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* A pooled device to seat a player on: its pool name, or `{ [name]: index }`
|
|
518
|
+
* for one pad of a `Gamepad.pads` entry (its place in the pad list, from 0).
|
|
519
|
+
*/
|
|
520
|
+
export type SeatDevice<R> =
|
|
521
|
+
| { readonly [K in keyof R & string]: DeviceOf<R[K]> extends Pads ? never : K }[keyof R & string]
|
|
522
|
+
| { readonly [K in keyof R & string]: DeviceOf<R[K]> extends Pads ? { readonly [P in K]: number } : never }[keyof R & string]
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Whether {@link Pool.seat} seated the player: `slot-taken` when someone
|
|
526
|
+
* holds the slot, `device-taken` when another slot holds the device, and
|
|
527
|
+
* `no-such-pad` when the index is not a place in the pad list.
|
|
528
|
+
*/
|
|
529
|
+
export type SeatResult =
|
|
530
|
+
| { readonly ok: true }
|
|
531
|
+
| { readonly ok: false; readonly reason: "slot-taken" | "device-taken" | "no-such-pad" }
|
|
532
|
+
|
|
533
|
+
/** The pooled input source, for `InputCapture.system(...)`; systems call the rest through a plain service. */
|
|
534
|
+
export interface Pool<A extends string, V extends string, S extends string, R> extends System.Consumes<"snapshot"> {
|
|
535
|
+
/** Takes every device's snapshot, applies joins, leaves, and reconnects, and reports every slot. */
|
|
536
|
+
snapshot(): PlayersSnapshot<A, V, S, PoolDevicesOf<R>>
|
|
537
|
+
/** Opens or closes joining: open on a join screen or for drop-in play, closed during a round. Closed at the start. */
|
|
538
|
+
setJoining(open: boolean): void
|
|
539
|
+
/**
|
|
540
|
+
* Gives `slot` to `device` without a press, joining or not: a player who
|
|
541
|
+
* is in from the start (call it from a setup system), or one picked on a
|
|
542
|
+
* menu. The next snapshot reports `event: "joined"`, as a join does. A
|
|
543
|
+
* pad not plugged in yet holds the slot with `connected: false`.
|
|
544
|
+
*/
|
|
545
|
+
seat(slot: S, device: SeatDevice<R>): SeatResult
|
|
546
|
+
/** Frees a slot (a pause menu's "Leave", "Continue without P2"); its device goes back to the pool. */
|
|
547
|
+
leave(slot: S): void
|
|
548
|
+
/** Each pooled device's `rebind`, by its pool name (`Gamepad.pads` rebinds every pad). */
|
|
549
|
+
readonly rebind: {
|
|
550
|
+
readonly [K in keyof R as DeviceOf<R[K]> extends { readonly rebind: unknown } ? K : never]: DeviceOf<R[K]> extends { readonly rebind: infer Rebind } ? Rebind : never
|
|
551
|
+
}
|
|
552
|
+
/** Disposes every pooled device. */
|
|
553
|
+
dispose(): void
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The players of a local multiplayer game, declared once. */
|
|
557
|
+
export interface PlayerSet<A extends string, V extends string, S extends string, J extends string> {
|
|
558
|
+
/** The slots, in join order. */
|
|
559
|
+
readonly slots: ReadonlyArray<S>
|
|
560
|
+
/**
|
|
561
|
+
* Pools `devices`: none belongs to a player until someone presses join on
|
|
562
|
+
* it. Each binds every action (or is passed through `lacking`), and the
|
|
563
|
+
* join action always: `Gamepad.pads(...)` lets every pad join on its own,
|
|
564
|
+
* and two `Keyboard.actions` over different keys are two keyboard players.
|
|
565
|
+
*/
|
|
566
|
+
pool<const R extends Readonly<Record<string, PoolEntry>>>(
|
|
567
|
+
devices: { readonly [K in keyof R]: CheckPoolEntry<A, V, J, K & string, R[K]> }
|
|
568
|
+
): Pool<A, V, S, R>
|
|
569
|
+
/**
|
|
570
|
+
* Every slot empty and joining closed, for initializing the input
|
|
571
|
+
* resource: pass each device's idle snapshot (`Keyboard.idle(...)`,
|
|
572
|
+
* `Gamepad.idle(...)` for pads) under its pool name.
|
|
573
|
+
*/
|
|
574
|
+
idle<const Devices extends Readonly<Record<string, DeviceSnapshot<string, string>>>>(devices: Devices): PlayersSnapshot<A, V, S, Devices>
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Rejects a slot named like the snapshot's own field. */
|
|
578
|
+
type SlotNames<S extends string> = [S & "joining"] extends [never] ? unknown : "joining is the snapshot's own field: name the slot something else"
|
|
579
|
+
|
|
580
|
+
/** Rejects a button action named like `join: "any"`, which would read as any button. */
|
|
581
|
+
type NoAnyAction<A extends string> = [A & "any"] extends [never] ? unknown : "an action named any reads as join: \"any\" (any button): rename the action"
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Declares the players of a local multiplayer game over `actions`: the
|
|
585
|
+
* `slots` (the most players at once, in join order), the button action
|
|
586
|
+
* that joins (`"any"` for any button action but `leave`, or any vector
|
|
587
|
+
* moved), and the optional one that leaves while joining is open. Build the
|
|
588
|
+
* input source with the returned set's `pool`. For one player on several
|
|
589
|
+
* devices, use the action set's `merge` instead.
|
|
590
|
+
*
|
|
591
|
+
* Pressing join on a device nobody holds gives it the first free slot; that
|
|
592
|
+
* snapshot reports `event: "joined"` with the player's input idle, so the
|
|
593
|
+
* press does nothing else. A slot keeps its name when another empties. A
|
|
594
|
+
* pad that disconnects keeps its slot (`connected: false`) and returns to
|
|
595
|
+
* it when it comes back (two pads of one model share an id, so they are
|
|
596
|
+
* told apart by their place in the pad list: each returns to the slot
|
|
597
|
+
* holding that place). Leaving by button works only while joining is
|
|
598
|
+
* open; otherwise `leave` is an ordinary action (a pause menu), and
|
|
599
|
+
* `pool.leave(slot)` frees the slot. `pool.seat(slot, device)` puts a
|
|
600
|
+
* player in without a press: one who is in from the start.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* ```ts
|
|
604
|
+
* const Players = Controls.players(Actions, { slots: ["p1", "p2"], join: "join", leave: "back" })
|
|
605
|
+
* const makePlayers = (devices: {
|
|
606
|
+
* readonly left: Keyboard.Actions<typeof leftKeys>
|
|
607
|
+
* readonly right: Keyboard.Actions<typeof rightKeys>
|
|
608
|
+
* readonly pads: Gamepad.Pads<typeof padBindings>
|
|
609
|
+
* }) => Players.pool(devices)
|
|
610
|
+
*
|
|
611
|
+
* // In a system:
|
|
612
|
+
* const input = resources.input.get()
|
|
613
|
+
* if (input.p2.event === "joined") spawnSecondPlayer()
|
|
614
|
+
* const move = input.p2.vectors.move
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
export const players = <
|
|
618
|
+
A extends string,
|
|
619
|
+
V extends string,
|
|
620
|
+
const S extends readonly [string, ...Array<string>],
|
|
621
|
+
const J extends A | "any",
|
|
622
|
+
const L extends A = never
|
|
623
|
+
>(
|
|
624
|
+
actions: ActionSet<A, V> & NoAnyAction<A>,
|
|
625
|
+
options: { readonly slots: S & SlotNames<S[number]>; readonly join: J; readonly leave?: L }
|
|
626
|
+
): PlayerSet<A, V, S[number], Exclude<J, "any">> => {
|
|
627
|
+
type Snap = DeviceSnapshot<string, string>
|
|
628
|
+
const slots: ReadonlyArray<S[number]> = [...new Set(options.slots as S)]
|
|
629
|
+
const buttons = actions.buttons as ReadonlyArray<string>
|
|
630
|
+
const vectors = actions.vectors as ReadonlyArray<string>
|
|
631
|
+
const join = options.join as string
|
|
632
|
+
const leaveAction = options.leave as string | undefined
|
|
633
|
+
const idleInput = (): SlotInput<string, string> => ({
|
|
634
|
+
actions: Object.fromEntries(buttons.map((name) => [name, idleState])),
|
|
635
|
+
vectors: Object.fromEntries(vectors.map((name) => [name, zero])),
|
|
636
|
+
available: Object.fromEntries([...buttons, ...vectors].map((name) => [name, true]))
|
|
637
|
+
})
|
|
638
|
+
const empty = (event: "left" | null) => ({ ...idleInput(), joined: false, device: null, connected: false, event, devices: {} })
|
|
639
|
+
return {
|
|
640
|
+
slots,
|
|
641
|
+
idle: () => ({ joining: false, ...Object.fromEntries(slots.map((slot) => [slot, empty(null)])) }) as never,
|
|
642
|
+
pool(devices) {
|
|
643
|
+
const entries = Object.entries(devices as Readonly<Record<string, PoolEntry>>).map(([name, entry]) => {
|
|
644
|
+
const unit = isLacking(entry) ? entry.device : entry
|
|
645
|
+
claim(unit, "a player pool")
|
|
646
|
+
return { name, unit, lacking: new Set<string>(isLacking(entry) ? entry.lacking : []) }
|
|
647
|
+
})
|
|
648
|
+
const isPads = (unit: Device | Pads | Unit): unit is Pads => (unit as Partial<Pads>)._tag === "Pads"
|
|
649
|
+
/** Who holds each slot: the pool entry, the pad's place in its list, and the pad's id to find it again. */
|
|
650
|
+
const held = new Map<string, { readonly device: string; key: string; id: string | null }>()
|
|
651
|
+
const events = new Map<string, "joined" | "left">()
|
|
652
|
+
let joining = false
|
|
653
|
+
let everOpened = false
|
|
654
|
+
let everSeated = false
|
|
655
|
+
const wantsJoin = (snapshot: Snap): boolean => {
|
|
656
|
+
const pressed = (name: string) => (snapshot.actions as Readonly<Record<string, ActionState | undefined>>)[name]?.pressed === true
|
|
657
|
+
if (join !== "any") return pressed(join)
|
|
658
|
+
return buttons.some((name) => name !== leaveAction && pressed(name)) ||
|
|
659
|
+
Object.values(snapshot.vectors as Readonly<Record<string, Vector>>).some((vector) => vector.x !== 0 || vector.y !== 0)
|
|
660
|
+
}
|
|
661
|
+
const rebind: Record<string, (bindings: never) => void> = {}
|
|
662
|
+
for (const { name, unit } of entries) {
|
|
663
|
+
const candidate = (unit as { readonly rebind?: unknown }).rebind
|
|
664
|
+
if (typeof candidate === "function") rebind[name] = (bindings) => candidate.call(unit, bindings)
|
|
665
|
+
}
|
|
666
|
+
const pool = {
|
|
667
|
+
snapshot() {
|
|
668
|
+
const units: Array<{ readonly key: string; readonly device: string; readonly id: string | null; readonly connected: boolean; readonly snapshot: Snap }> = []
|
|
669
|
+
for (const { name, unit } of entries) {
|
|
670
|
+
if (isPads(unit)) unit.snapshot().forEach((snapshot, index) => units.push({ key: `${name}#${index}`, device: name, id: snapshot.id, connected: snapshot.connected, snapshot }))
|
|
671
|
+
else units.push({ key: name, device: name, id: null, connected: true, snapshot: (unit as Device).snapshot() })
|
|
672
|
+
}
|
|
673
|
+
const byKey = new Map(units.map((unit) => [unit.key, unit]))
|
|
674
|
+
// A pad seated before it was plugged in is known by its id from the first snapshot it shows up in.
|
|
675
|
+
for (const holder of held.values()) {
|
|
676
|
+
const unit = byKey.get(holder.key)
|
|
677
|
+
if (holder.id === null && unit?.connected === true) holder.id = unit.id
|
|
678
|
+
}
|
|
679
|
+
const taken = new Set([...held.values()].map((holder) => holder.key))
|
|
680
|
+
// A pad that comes back (here or at another index) returns to the slot it holds.
|
|
681
|
+
for (const holder of held.values()) {
|
|
682
|
+
if (holder.id === null || byKey.get(holder.key)?.connected === true) continue
|
|
683
|
+
const back = units.find((unit) => unit.device === holder.device && unit.id === holder.id && unit.connected && !taken.has(unit.key))
|
|
684
|
+
if (back === undefined) continue
|
|
685
|
+
taken.delete(holder.key)
|
|
686
|
+
holder.key = back.key
|
|
687
|
+
taken.add(back.key)
|
|
688
|
+
}
|
|
689
|
+
if (joining && leaveAction !== undefined) {
|
|
690
|
+
for (const [slot, holder] of held) {
|
|
691
|
+
// A slot seated since the last snapshot reports its join first; its input is idle there.
|
|
692
|
+
if (events.get(slot) === "joined") continue
|
|
693
|
+
const unit = byKey.get(holder.key)
|
|
694
|
+
const pressed = (unit?.snapshot.actions as Readonly<Record<string, ActionState | undefined>> | undefined)?.[leaveAction]?.pressed
|
|
695
|
+
if (unit?.connected === true && pressed === true) {
|
|
696
|
+
held.delete(slot)
|
|
697
|
+
taken.delete(holder.key)
|
|
698
|
+
events.set(slot, "left")
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (joining) {
|
|
703
|
+
for (const unit of units) {
|
|
704
|
+
if (taken.has(unit.key) || !unit.connected || !wantsJoin(unit.snapshot)) continue
|
|
705
|
+
const free = slots.find((slot) => !held.has(slot))
|
|
706
|
+
if (free === undefined) break
|
|
707
|
+
held.set(free, { device: unit.device, key: unit.key, id: unit.id })
|
|
708
|
+
taken.add(unit.key)
|
|
709
|
+
events.set(free, "joined")
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const result: Record<string, unknown> = { joining }
|
|
713
|
+
for (const slot of slots) {
|
|
714
|
+
const holder = held.get(slot)
|
|
715
|
+
const event = events.get(slot) ?? null
|
|
716
|
+
if (holder === undefined) {
|
|
717
|
+
result[slot] = empty(event === "left" ? "left" : null)
|
|
718
|
+
continue
|
|
719
|
+
}
|
|
720
|
+
const unit = byKey.get(holder.key)
|
|
721
|
+
const lacking = entries.find(({ name }) => name === holder.device)?.lacking ?? new Set<string>()
|
|
722
|
+
const input = idleInput()
|
|
723
|
+
// The snapshot that joins is idle, so the join press does nothing else.
|
|
724
|
+
if (unit !== undefined && event !== "joined") {
|
|
725
|
+
const actionsNow = unit.snapshot.actions as Readonly<Record<string, ActionState | undefined>>
|
|
726
|
+
const vectorsNow = unit.snapshot.vectors as Readonly<Record<string, Vector | undefined>>
|
|
727
|
+
for (const name of buttons) (input.actions as Record<string, ActionState>)[name] = actionsNow[name] ?? idleState
|
|
728
|
+
for (const name of vectors) (input.vectors as Record<string, Vector>)[name] = vectorsNow[name] ?? zero
|
|
729
|
+
}
|
|
730
|
+
for (const name of [...buttons, ...vectors]) (input.available as Record<string, boolean>)[name] = !lacking.has(name)
|
|
731
|
+
result[slot] = {
|
|
732
|
+
...input,
|
|
733
|
+
joined: true,
|
|
734
|
+
device: holder.device,
|
|
735
|
+
connected: unit?.connected ?? false,
|
|
736
|
+
event: event === "joined" ? "joined" : null,
|
|
737
|
+
devices: unit === undefined ? {} : { [holder.device]: unit.snapshot }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
events.clear()
|
|
741
|
+
return result
|
|
742
|
+
},
|
|
743
|
+
setJoining(open: boolean) {
|
|
744
|
+
joining = open
|
|
745
|
+
if (open) everOpened = true
|
|
746
|
+
},
|
|
747
|
+
seat(slot: string, device: string | Readonly<Record<string, number>>): SeatResult {
|
|
748
|
+
const [name, index] = typeof device === "string" ? [device, null] : Object.entries(device)[0] ?? ["", 0]
|
|
749
|
+
const unit = entries.find((entry) => entry.name === name)?.unit
|
|
750
|
+
if (unit === undefined || (index !== null && !(isPads(unit) && Number.isInteger(index) && index >= 0 && index < unit.max))) {
|
|
751
|
+
return { ok: false, reason: "no-such-pad" }
|
|
752
|
+
}
|
|
753
|
+
const key = index === null ? name : `${name}#${index}`
|
|
754
|
+
if (held.has(slot)) return { ok: false, reason: "slot-taken" }
|
|
755
|
+
if ([...held.values()].some((holder) => holder.key === key)) return { ok: false, reason: "device-taken" }
|
|
756
|
+
held.set(slot, { device: name, key, id: null })
|
|
757
|
+
events.set(slot, "joined")
|
|
758
|
+
everSeated = true
|
|
759
|
+
return { ok: true }
|
|
760
|
+
},
|
|
761
|
+
leave(slot: string) {
|
|
762
|
+
if (!held.delete(slot)) return
|
|
763
|
+
events.set(slot, "left")
|
|
764
|
+
},
|
|
765
|
+
rebind,
|
|
766
|
+
dispose() {
|
|
767
|
+
for (const { unit } of entries) unit.dispose()
|
|
768
|
+
},
|
|
769
|
+
[Debug.serviceLints]: (): ReadonlyArray<Debug.ServiceLint> => {
|
|
770
|
+
const keyed = entries.flatMap(({ name, unit }) => {
|
|
771
|
+
const keys = (unit as ReportsBoundKeys)[boundKeys]?.()
|
|
772
|
+
return keys === undefined ? [] : [{ name, keys: new Set(keys) }]
|
|
773
|
+
})
|
|
774
|
+
const shared = keyed.flatMap((first, index) => keyed.slice(index + 1).flatMap((second) =>
|
|
775
|
+
[...first.keys].filter((key) => second.keys.has(key)).map((key) => ({
|
|
776
|
+
severity: "warning" as const,
|
|
777
|
+
message: `${first.name} and ${second.name} both bind ${key}: one key press reaches two players`,
|
|
778
|
+
fix: `bind ${key} on one of them only, so each player has their own keys`
|
|
779
|
+
}))))
|
|
780
|
+
return [
|
|
781
|
+
...shared,
|
|
782
|
+
...entries.flatMap(({ name, unit }) => sharedLint(name, unit)),
|
|
783
|
+
...entries.flatMap(({ name, lacking }) => [...lacking].map((action) => ({
|
|
784
|
+
severity: "info" as const,
|
|
785
|
+
message: `${name} lacks ${action}, so a player on ${name} cannot ${action}`,
|
|
786
|
+
fix: `fine if the game offers ${action} another way (read available.${action} from the player's input); otherwise bind ${action} on ${name}`
|
|
787
|
+
}))),
|
|
788
|
+
...(everOpened || everSeated ? [] : [{
|
|
789
|
+
severity: "info" as const,
|
|
790
|
+
message: "joining was never opened in this session, so nobody could join",
|
|
791
|
+
fix: "call setJoining(true) from a system (on the join screen, or once at setup for drop-in play)"
|
|
792
|
+
}])
|
|
793
|
+
]
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// The checked signature carries the device types; the implementation is the same for every pool.
|
|
797
|
+
return pool as never
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|