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