@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.1
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 +9 -0
- package/LICENSE +21 -21
- package/README.md +191 -139
- package/dist/doctor/cli.mjs +49 -19
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/globals.js +8 -3
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/package.json +89 -89
- package/src/auto.ts +39 -39
- package/src/core/connection.ts +299 -299
- package/src/core/errors.ts +147 -147
- package/src/core/game-data.ts +38 -38
- package/src/core/hub-economy.ts +179 -179
- package/src/core/hub-progress.ts +205 -205
- package/src/core/hub.ts +131 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/local-store.ts +261 -261
- package/src/core/offline/queue.ts +171 -171
- package/src/core/page.ts +74 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +152 -152
- package/src/core/storage/json-guards.ts +74 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +161 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +67 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +78 -76
- package/src/doctor/cli.ts +98 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/globals.ts +115 -110
- package/src/index.ts +50 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +164 -164
- package/src/input/input-system.ts +393 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/nav-state.ts +191 -191
- package/src/input/navigator.ts +171 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +177 -177
- package/src/input.ts +22 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +208 -208
- package/src/overlay/styles.ts +9 -9
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +121 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +28 -28
- package/src/realtime/multiplayer-types.ts +52 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +125 -125
- package/src/realtime/rooms-forwarder.ts +79 -79
- package/src/realtime/runtime-api.ts +49 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +72 -72
- package/src/realtime/social-types.ts +78 -78
- package/src/realtime/types.ts +53 -53
- package/src/realtime/wire-types.ts +72 -72
- package/src/rooms.ts +10 -10
- package/src/social.ts +18 -18
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -1,393 +1,393 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The generic input system (`hub.input`): desktop, touch and gamepad. A game
|
|
3
|
-
* declares named inputs that each read as 0..1, grouped so they can be
|
|
4
|
-
* enabled together, and reads them every frame. The system handles edges,
|
|
5
|
-
* axes and vectors, device arbitration (last used wins; the keyboard is
|
|
6
|
-
* always on), on-screen controls, navigable UI, an on-screen keyboard,
|
|
7
|
-
* player remapping, and a zero-config fallback for games that never define
|
|
8
|
-
* inputs.
|
|
9
|
-
*/
|
|
10
|
-
import type { ControlsPanelHost } from '../overlay/controls-panel';
|
|
11
|
-
import { DeviceState } from './device-state';
|
|
12
|
-
import { RepeatingEdges, RisingEdges } from './edges';
|
|
13
|
-
import { isAnyPadButtonPressed, PAD_BUTTON } from './gamepad';
|
|
14
|
-
import { InputEventHub } from './input-events';
|
|
15
|
-
import { InputRegistry } from './input-registry';
|
|
16
|
-
import { parseLegacyControls } from './legacy-controls';
|
|
17
|
-
import type { LegacyControls } from './legacy-controls';
|
|
18
|
-
import { Navigator } from './navigator';
|
|
19
|
-
import type { NavigatorContext } from './navigator';
|
|
20
|
-
import { feedDeviceState, OnScreenControls } from './on-screen-controls';
|
|
21
|
-
import type { PageInputControl } from './page-control';
|
|
22
|
-
import { describeBinding, PlayerRemaps } from './player-remaps';
|
|
23
|
-
import type {
|
|
24
|
-
InputConfig, InputDef, InputEvents, InputHost, InputReading, InputSource, MenuBridge, NavTarget, Vector, VirtualOverride,
|
|
25
|
-
} from './types';
|
|
26
|
-
import { runZeroConfigFrame } from './zero-config';
|
|
27
|
-
|
|
28
|
-
/** Options for the input system. */
|
|
29
|
-
export interface InputSystemOptions {
|
|
30
|
-
/** Start as the page's input owner (default true). A private instance takes over when its game defines inputs. */
|
|
31
|
-
isPageOwner?: boolean;
|
|
32
|
-
/** Make this instance the page's registered input system (when a private instance takes over); returns the one replaced. */
|
|
33
|
-
claimPage?: (system: InputSystem) => PageInputControl | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The window flag that tells every copy's zero-config fallback to stand down. */
|
|
37
|
-
const INPUT_ACTIVE_FLAG = '__HUB_INPUT_ACTIVE__';
|
|
38
|
-
|
|
39
|
-
/** The page's input system. */
|
|
40
|
-
export class InputSystem implements ControlsPanelHost, PageInputControl {
|
|
41
|
-
private readonly host: InputHost;
|
|
42
|
-
private readonly options: InputSystemOptions;
|
|
43
|
-
private config: InputConfig | null = null;
|
|
44
|
-
private readonly remaps: PlayerRemaps;
|
|
45
|
-
private readonly registry = new InputRegistry();
|
|
46
|
-
private readonly devices = new DeviceState();
|
|
47
|
-
private readonly controls = new OnScreenControls(() => this.registry.enabledGroupNames());
|
|
48
|
-
private readonly navigator = new Navigator();
|
|
49
|
-
private readonly repeats = new RepeatingEdges();
|
|
50
|
-
private readonly presses = new RisingEdges();
|
|
51
|
-
private readonly events = new InputEventHub();
|
|
52
|
-
private menu: MenuBridge | null = null;
|
|
53
|
-
private legacyControls: LegacyControls = parseLegacyControls(null);
|
|
54
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page as given, kept for a handover
|
|
55
|
-
private legacyControlsSource: unknown = null;
|
|
56
|
-
private activeSource: InputSource = 'keyboard';
|
|
57
|
-
private isPageOwner: boolean;
|
|
58
|
-
private pageOwner: PageInputControl | null = null;
|
|
59
|
-
|
|
60
|
-
constructor(host: InputHost, options: InputSystemOptions = {}) {
|
|
61
|
-
this.host = host;
|
|
62
|
-
this.options = options;
|
|
63
|
-
this.isPageOwner = options.isPageOwner ?? true;
|
|
64
|
-
this.remaps = new PlayerRemaps(host);
|
|
65
|
-
this.controls.playerChoice = this.remaps.overrides.showVirtual ?? null;
|
|
66
|
-
this.devices.listen({
|
|
67
|
-
onMouseActivity: () => {
|
|
68
|
-
this.setSource('mouse');
|
|
69
|
-
},
|
|
70
|
-
onGamepadConnection: (isConnected) => {
|
|
71
|
-
this.controls.isGamepadConnected ||= isConnected;
|
|
72
|
-
this.controls.sync();
|
|
73
|
-
},
|
|
74
|
-
shouldCancelScrollKeys: () => this.config !== null,
|
|
75
|
-
});
|
|
76
|
-
if (typeof requestAnimationFrame === 'function') {
|
|
77
|
-
requestAnimationFrame(() => {
|
|
78
|
-
this.tick();
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Whether this page counts as a touch device. */
|
|
84
|
-
get touchDevice(): boolean {
|
|
85
|
-
return this.controls.isTouchDevice;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Declare the game's inputs (replacing any earlier declaration). The game owns input from now on. */
|
|
89
|
-
define(config: InputConfig): void {
|
|
90
|
-
if (!this.isPageOwner) {
|
|
91
|
-
const previous = this.options.claimPage?.(this) ?? null;
|
|
92
|
-
this.isPageOwner = true;
|
|
93
|
-
previous?.yieldPageTo?.(this);
|
|
94
|
-
}
|
|
95
|
-
this.config = config;
|
|
96
|
-
this.registry.declare(config, (name, declared) => this.remaps.withBinding(name, declared));
|
|
97
|
-
this.navigator.clearGroups();
|
|
98
|
-
for (const [group, groupDefinition] of Object.entries(config.groups)) {
|
|
99
|
-
if (groupDefinition.navigable !== undefined) {
|
|
100
|
-
this.navigator.addGroup(group, groupDefinition.navigable);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
this.buildControls();
|
|
104
|
-
Reflect.set(window, INPUT_ACTIVE_FLAG, true);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** True once the game has declared its inputs. */
|
|
108
|
-
get isDefined(): boolean {
|
|
109
|
-
return this.pageOwner?.isDefined ?? this.config !== null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Turn a group on. Held keys don't fire a phantom `down` (the pause key held as play resumes). */
|
|
113
|
-
enable(group: string): void {
|
|
114
|
-
this.registry.setGroupEnabled(group, true, this.devices, this.activeSource);
|
|
115
|
-
this.controls.sync();
|
|
116
|
-
this.navigator.refreshGroup(group, this.navigatorContext());
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Turn a group off (its inputs read 0). */
|
|
120
|
-
disable(group: string): void {
|
|
121
|
-
this.registry.setGroupEnabled(group, false, this.devices, this.activeSource);
|
|
122
|
-
this.controls.sync();
|
|
123
|
-
this.navigator.blurGroup(group, this.controls.existingOverlay);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Whether a group is on. */
|
|
127
|
-
isEnabled(group: string): boolean {
|
|
128
|
-
return this.registry.isEnabled(group);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** An input's value, 0..1. */
|
|
132
|
-
value(name: string): number {
|
|
133
|
-
return this.registry.value(name);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** An input's full reading this frame. */
|
|
137
|
-
get(name: string): InputReading {
|
|
138
|
-
return this.registry.reading(name);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Whether an input went down this frame. */
|
|
142
|
-
down(name: string): boolean {
|
|
143
|
-
return this.registry.wentDown(name);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Whether an input came up this frame. */
|
|
147
|
-
up(name: string): boolean {
|
|
148
|
-
return this.registry.wentUp(name);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Whether an input is held (≥ 0.5). */
|
|
152
|
-
pressed(name: string): boolean {
|
|
153
|
-
return this.registry.isPressed(name);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** A named axis, or an ad-hoc `axis(negative, positive)` pair, as -1..1. */
|
|
157
|
-
axis(name: string, positive?: string): number {
|
|
158
|
-
return this.registry.axis(name, positive);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** A named axis pair as a vector clamped to the unit circle. */
|
|
162
|
-
vector(name: string): Vector {
|
|
163
|
-
return this.registry.vector(name);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** The device the player is using right now. */
|
|
167
|
-
get source(): InputSource {
|
|
168
|
-
return this.activeSource;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Subscribe to an input event. Returns the unsubscribe function. */
|
|
172
|
-
on<Name extends keyof InputEvents>(event: Name, listener: (payload: InputEvents[Name]) => void): () => void {
|
|
173
|
-
return this.events.on(event, listener);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** Update a button's content at runtime (e.g. an SVG reflecting game state). */
|
|
177
|
-
setVirtualHtml(id: string, html: string): void {
|
|
178
|
-
this.controls.existingOverlay?.setControlHtml(id, html);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** The player's show/hide choice for the on-screen controls (persisted). */
|
|
182
|
-
showVirtual(isVisible: boolean): void {
|
|
183
|
-
this.controls.playerChoice = isVisible;
|
|
184
|
-
this.remaps.setShowVirtual(isVisible);
|
|
185
|
-
this.controls.sync();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Flip the player's show/hide choice. */
|
|
189
|
-
toggleVirtual(): void {
|
|
190
|
-
this.showVirtual(!this.showingVirtual);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Force the controls visible for layout editing, without changing the player's choice. */
|
|
194
|
-
setVirtualEditing(isEditing: boolean): void {
|
|
195
|
-
this.controls.setEditing(isEditing);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** This game's default visibility (the player's choice still wins); `null` = shown on touch devices without a pad. */
|
|
199
|
-
setVirtualDefault(isVisible: boolean | null): void {
|
|
200
|
-
this.controls.setGameDefault(isVisible);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Keep auto-placed controls clear of a band along a screen edge, e.g. your
|
|
205
|
-
* top HUD: `setInsets({ top: hud.getBoundingClientRect().bottom })`. Px from
|
|
206
|
-
* each edge, merged with earlier calls (0 clears one edge). Dragged controls
|
|
207
|
-
* and explicit `pos` ignore it.
|
|
208
|
-
*/
|
|
209
|
-
setInsets(insets: { top?: number; right?: number; bottom?: number; left?: number }): void {
|
|
210
|
-
this.controls.overlay().setInsets(insets);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** What the "Show on-screen controls" toggle reflects. */
|
|
214
|
-
get showingVirtual(): boolean {
|
|
215
|
-
return this.controls.isShowing;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Rebind an input (persisted). */
|
|
219
|
-
rebind(inputName: string, patch: Partial<InputDef>): void {
|
|
220
|
-
this.remaps.rebind(inputName, patch);
|
|
221
|
-
const declared = this.config?.groups[this.registry.groupOf(inputName) ?? '']?.inputs[inputName];
|
|
222
|
-
if (declared !== undefined) {
|
|
223
|
-
this.registry.redefine(inputName, this.remaps.withBinding(inputName, declared));
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Move or restyle an on-screen control (persisted). */
|
|
228
|
-
setVirtual(id: string, patch: VirtualOverride): void {
|
|
229
|
-
this.remaps.customiseControl(id, patch);
|
|
230
|
-
this.buildControls();
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** Open (or close) the Controls screen. */
|
|
234
|
-
openControls(): void {
|
|
235
|
-
if (this.pageOwner === null) {
|
|
236
|
-
this.controls.overlay().toggleControls(this.config, this);
|
|
237
|
-
} else {
|
|
238
|
-
this.pageOwner.openControls();
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Forget every remap and customisation. */
|
|
243
|
-
resetControls(): void {
|
|
244
|
-
this.remaps.reset();
|
|
245
|
-
this.controls.playerChoice = null;
|
|
246
|
-
if (this.config !== null) {
|
|
247
|
-
this.define(this.config);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** A readable label of an input's current binding. */
|
|
252
|
-
bindingLabel(inputName: string): string {
|
|
253
|
-
return describeBinding(this.registry.definitionOf(inputName));
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Called by the injected menu: Select toggles it and a gamepad navigates it. */
|
|
257
|
-
attachMenu(bridge: MenuBridge | null): void {
|
|
258
|
-
if (this.pageOwner === null) {
|
|
259
|
-
this.menu = bridge;
|
|
260
|
-
} else {
|
|
261
|
-
this.pageOwner.attachMenu(bridge);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Called by the injected menu with `game.controls`, for the zero-config fallback. */
|
|
266
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from window.__HUB__, parsed straight away
|
|
267
|
-
setLegacyControls(controls: unknown): void {
|
|
268
|
-
if (this.pageOwner === null) {
|
|
269
|
-
this.legacyControlsSource = controls;
|
|
270
|
-
this.legacyControls = parseLegacyControls(controls);
|
|
271
|
-
} else {
|
|
272
|
-
this.pageOwner.setLegacyControls(controls);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Make a game's own on-screen menu gamepad-navigable: a CSS selector (or a
|
|
278
|
-
* function returning elements). While any match is visible and a gamepad is
|
|
279
|
-
* in use, the stick moves a highlight, A clicks, B calls `options.back`.
|
|
280
|
-
* Re-queried every frame, so call it once.
|
|
281
|
-
*/
|
|
282
|
-
autoNavigate(selector: string | (() => NavTarget[]), options?: { back?: () => void }): void {
|
|
283
|
-
this.navigator.setAutoNavigate(Navigator.queryFor(selector), options?.back ?? null);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Stop navigating the game's own menu. */
|
|
287
|
-
clearAutoNavigate(): void {
|
|
288
|
-
this.navigator.clearAutoNavigate(this.controls.existingOverlay);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Stop driving the page because another copy's game owns it (plan §4.4):
|
|
293
|
-
* hand the menu and `game.controls` to `owner`, and forward the menu's calls to it.
|
|
294
|
-
*/
|
|
295
|
-
yieldPageTo(owner: PageInputControl): void {
|
|
296
|
-
this.isPageOwner = false;
|
|
297
|
-
this.pageOwner = owner;
|
|
298
|
-
owner.attachMenu(this.menu);
|
|
299
|
-
owner.setLegacyControls(this.legacyControlsSource);
|
|
300
|
-
this.menu = null;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
private buildControls(): void {
|
|
304
|
-
if (this.config === null) {
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
const callbacks = feedDeviceState(this.devices, {
|
|
308
|
-
onTouch: () => {
|
|
309
|
-
this.setSource('touch');
|
|
310
|
-
},
|
|
311
|
-
onDragged: (id, spot) => {
|
|
312
|
-
this.setVirtual(id, spot);
|
|
313
|
-
},
|
|
314
|
-
});
|
|
315
|
-
this.controls.build(this.config, this.remaps.overrides, callbacks);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
private setSource(source: InputSource): void {
|
|
319
|
-
if (source === this.activeSource) {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
this.activeSource = source;
|
|
323
|
-
this.events.emit('sourcechange', source);
|
|
324
|
-
this.controls.sync();
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
private navigatorContext(): NavigatorContext {
|
|
328
|
-
return {
|
|
329
|
-
gamepads: this.devices.gamepads,
|
|
330
|
-
repeats: this.repeats,
|
|
331
|
-
presses: this.presses,
|
|
332
|
-
listeners: {
|
|
333
|
-
onFocus: (event) => {
|
|
334
|
-
this.events.emit('focus', event);
|
|
335
|
-
},
|
|
336
|
-
onBlur: (event) => {
|
|
337
|
-
this.events.emit('blur', event);
|
|
338
|
-
},
|
|
339
|
-
onAct: (event) => {
|
|
340
|
-
this.events.emit('act', event);
|
|
341
|
-
},
|
|
342
|
-
},
|
|
343
|
-
highlighter: () => this.controls.overlay(),
|
|
344
|
-
isPressed: (name) => this.registry.isPressed(name),
|
|
345
|
-
wentDown: (name) => this.registry.wentDown(name),
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
private tick(): void {
|
|
350
|
-
this.devices.frame += 1;
|
|
351
|
-
const { isConnected, hasActivity } = this.devices.pollGamepads();
|
|
352
|
-
this.controls.isGamepadConnected = isConnected;
|
|
353
|
-
if (hasActivity) {
|
|
354
|
-
this.setSource('gamepad');
|
|
355
|
-
}
|
|
356
|
-
if (this.isPageOwner) {
|
|
357
|
-
this.driveFrame();
|
|
358
|
-
} else {
|
|
359
|
-
this.registry.sample(this.devices, this.activeSource);
|
|
360
|
-
}
|
|
361
|
-
this.devices.decay();
|
|
362
|
-
this.controls.existingOverlay?.afterFrame();
|
|
363
|
-
requestAnimationFrame(() => {
|
|
364
|
-
this.tick();
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** One frame as the page owner: the menu toggle and navigation, inputs, the zero-config fallback. */
|
|
369
|
-
private driveFrame(): void {
|
|
370
|
-
const context = this.navigatorContext();
|
|
371
|
-
const overlay = this.controls.existingOverlay;
|
|
372
|
-
const isGamepadActive = this.activeSource === 'gamepad';
|
|
373
|
-
const menu = this.menu;
|
|
374
|
-
// Select toggles the hub menu from anywhere.
|
|
375
|
-
if (menu !== null && this.presses.rose('select', isAnyPadButtonPressed(this.devices.gamepads, PAD_BUTTON.SELECT))) {
|
|
376
|
-
menu.toggle();
|
|
377
|
-
}
|
|
378
|
-
if (menu?.isOpen() === true) {
|
|
379
|
-
// The menu takes over input: the game's inputs read 0 while it is open.
|
|
380
|
-
this.registry.silence();
|
|
381
|
-
this.navigator.driveMenu(menu, isGamepadActive, context, overlay);
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
this.navigator.leaveMenu(overlay);
|
|
385
|
-
if (this.config !== null) {
|
|
386
|
-
this.registry.sample(this.devices, this.activeSource);
|
|
387
|
-
this.navigator.driveGroups((group) => this.registry.isEnabled(group), context);
|
|
388
|
-
} else if (Reflect.get(window, INPUT_ACTIVE_FLAG) !== true) {
|
|
389
|
-
runZeroConfigFrame({ gamepads: this.devices.gamepads, slug: this.host.slug, controls: this.legacyControls, navEdges: this.repeats });
|
|
390
|
-
}
|
|
391
|
-
this.navigator.driveAutoNavigate(isGamepadActive, context, overlay);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The generic input system (`hub.input`): desktop, touch and gamepad. A game
|
|
3
|
+
* declares named inputs that each read as 0..1, grouped so they can be
|
|
4
|
+
* enabled together, and reads them every frame. The system handles edges,
|
|
5
|
+
* axes and vectors, device arbitration (last used wins; the keyboard is
|
|
6
|
+
* always on), on-screen controls, navigable UI, an on-screen keyboard,
|
|
7
|
+
* player remapping, and a zero-config fallback for games that never define
|
|
8
|
+
* inputs.
|
|
9
|
+
*/
|
|
10
|
+
import type { ControlsPanelHost } from '../overlay/controls-panel';
|
|
11
|
+
import { DeviceState } from './device-state';
|
|
12
|
+
import { RepeatingEdges, RisingEdges } from './edges';
|
|
13
|
+
import { isAnyPadButtonPressed, PAD_BUTTON } from './gamepad';
|
|
14
|
+
import { InputEventHub } from './input-events';
|
|
15
|
+
import { InputRegistry } from './input-registry';
|
|
16
|
+
import { parseLegacyControls } from './legacy-controls';
|
|
17
|
+
import type { LegacyControls } from './legacy-controls';
|
|
18
|
+
import { Navigator } from './navigator';
|
|
19
|
+
import type { NavigatorContext } from './navigator';
|
|
20
|
+
import { feedDeviceState, OnScreenControls } from './on-screen-controls';
|
|
21
|
+
import type { PageInputControl } from './page-control';
|
|
22
|
+
import { describeBinding, PlayerRemaps } from './player-remaps';
|
|
23
|
+
import type {
|
|
24
|
+
InputConfig, InputDef, InputEvents, InputHost, InputReading, InputSource, MenuBridge, NavTarget, Vector, VirtualOverride,
|
|
25
|
+
} from './types';
|
|
26
|
+
import { runZeroConfigFrame } from './zero-config';
|
|
27
|
+
|
|
28
|
+
/** Options for the input system. */
|
|
29
|
+
export interface InputSystemOptions {
|
|
30
|
+
/** Start as the page's input owner (default true). A private instance takes over when its game defines inputs. */
|
|
31
|
+
isPageOwner?: boolean;
|
|
32
|
+
/** Make this instance the page's registered input system (when a private instance takes over); returns the one replaced. */
|
|
33
|
+
claimPage?: (system: InputSystem) => PageInputControl | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The window flag that tells every copy's zero-config fallback to stand down. */
|
|
37
|
+
const INPUT_ACTIVE_FLAG = '__HUB_INPUT_ACTIVE__';
|
|
38
|
+
|
|
39
|
+
/** The page's input system. */
|
|
40
|
+
export class InputSystem implements ControlsPanelHost, PageInputControl {
|
|
41
|
+
private readonly host: InputHost;
|
|
42
|
+
private readonly options: InputSystemOptions;
|
|
43
|
+
private config: InputConfig | null = null;
|
|
44
|
+
private readonly remaps: PlayerRemaps;
|
|
45
|
+
private readonly registry = new InputRegistry();
|
|
46
|
+
private readonly devices = new DeviceState();
|
|
47
|
+
private readonly controls = new OnScreenControls(() => this.registry.enabledGroupNames());
|
|
48
|
+
private readonly navigator = new Navigator();
|
|
49
|
+
private readonly repeats = new RepeatingEdges();
|
|
50
|
+
private readonly presses = new RisingEdges();
|
|
51
|
+
private readonly events = new InputEventHub();
|
|
52
|
+
private menu: MenuBridge | null = null;
|
|
53
|
+
private legacyControls: LegacyControls = parseLegacyControls(null);
|
|
54
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from the page as given, kept for a handover
|
|
55
|
+
private legacyControlsSource: unknown = null;
|
|
56
|
+
private activeSource: InputSource = 'keyboard';
|
|
57
|
+
private isPageOwner: boolean;
|
|
58
|
+
private pageOwner: PageInputControl | null = null;
|
|
59
|
+
|
|
60
|
+
constructor(host: InputHost, options: InputSystemOptions = {}) {
|
|
61
|
+
this.host = host;
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.isPageOwner = options.isPageOwner ?? true;
|
|
64
|
+
this.remaps = new PlayerRemaps(host);
|
|
65
|
+
this.controls.playerChoice = this.remaps.overrides.showVirtual ?? null;
|
|
66
|
+
this.devices.listen({
|
|
67
|
+
onMouseActivity: () => {
|
|
68
|
+
this.setSource('mouse');
|
|
69
|
+
},
|
|
70
|
+
onGamepadConnection: (isConnected) => {
|
|
71
|
+
this.controls.isGamepadConnected ||= isConnected;
|
|
72
|
+
this.controls.sync();
|
|
73
|
+
},
|
|
74
|
+
shouldCancelScrollKeys: () => this.config !== null,
|
|
75
|
+
});
|
|
76
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
77
|
+
requestAnimationFrame(() => {
|
|
78
|
+
this.tick();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether this page counts as a touch device. */
|
|
84
|
+
get touchDevice(): boolean {
|
|
85
|
+
return this.controls.isTouchDevice;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Declare the game's inputs (replacing any earlier declaration). The game owns input from now on. */
|
|
89
|
+
define(config: InputConfig): void {
|
|
90
|
+
if (!this.isPageOwner) {
|
|
91
|
+
const previous = this.options.claimPage?.(this) ?? null;
|
|
92
|
+
this.isPageOwner = true;
|
|
93
|
+
previous?.yieldPageTo?.(this);
|
|
94
|
+
}
|
|
95
|
+
this.config = config;
|
|
96
|
+
this.registry.declare(config, (name, declared) => this.remaps.withBinding(name, declared));
|
|
97
|
+
this.navigator.clearGroups();
|
|
98
|
+
for (const [group, groupDefinition] of Object.entries(config.groups)) {
|
|
99
|
+
if (groupDefinition.navigable !== undefined) {
|
|
100
|
+
this.navigator.addGroup(group, groupDefinition.navigable);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.buildControls();
|
|
104
|
+
Reflect.set(window, INPUT_ACTIVE_FLAG, true);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** True once the game has declared its inputs. */
|
|
108
|
+
get isDefined(): boolean {
|
|
109
|
+
return this.pageOwner?.isDefined ?? this.config !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Turn a group on. Held keys don't fire a phantom `down` (the pause key held as play resumes). */
|
|
113
|
+
enable(group: string): void {
|
|
114
|
+
this.registry.setGroupEnabled(group, true, this.devices, this.activeSource);
|
|
115
|
+
this.controls.sync();
|
|
116
|
+
this.navigator.refreshGroup(group, this.navigatorContext());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Turn a group off (its inputs read 0). */
|
|
120
|
+
disable(group: string): void {
|
|
121
|
+
this.registry.setGroupEnabled(group, false, this.devices, this.activeSource);
|
|
122
|
+
this.controls.sync();
|
|
123
|
+
this.navigator.blurGroup(group, this.controls.existingOverlay);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Whether a group is on. */
|
|
127
|
+
isEnabled(group: string): boolean {
|
|
128
|
+
return this.registry.isEnabled(group);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** An input's value, 0..1. */
|
|
132
|
+
value(name: string): number {
|
|
133
|
+
return this.registry.value(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An input's full reading this frame. */
|
|
137
|
+
get(name: string): InputReading {
|
|
138
|
+
return this.registry.reading(name);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Whether an input went down this frame. */
|
|
142
|
+
down(name: string): boolean {
|
|
143
|
+
return this.registry.wentDown(name);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether an input came up this frame. */
|
|
147
|
+
up(name: string): boolean {
|
|
148
|
+
return this.registry.wentUp(name);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Whether an input is held (≥ 0.5). */
|
|
152
|
+
pressed(name: string): boolean {
|
|
153
|
+
return this.registry.isPressed(name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A named axis, or an ad-hoc `axis(negative, positive)` pair, as -1..1. */
|
|
157
|
+
axis(name: string, positive?: string): number {
|
|
158
|
+
return this.registry.axis(name, positive);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A named axis pair as a vector clamped to the unit circle. */
|
|
162
|
+
vector(name: string): Vector {
|
|
163
|
+
return this.registry.vector(name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The device the player is using right now. */
|
|
167
|
+
get source(): InputSource {
|
|
168
|
+
return this.activeSource;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Subscribe to an input event. Returns the unsubscribe function. */
|
|
172
|
+
on<Name extends keyof InputEvents>(event: Name, listener: (payload: InputEvents[Name]) => void): () => void {
|
|
173
|
+
return this.events.on(event, listener);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Update a button's content at runtime (e.g. an SVG reflecting game state). */
|
|
177
|
+
setVirtualHtml(id: string, html: string): void {
|
|
178
|
+
this.controls.existingOverlay?.setControlHtml(id, html);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The player's show/hide choice for the on-screen controls (persisted). */
|
|
182
|
+
showVirtual(isVisible: boolean): void {
|
|
183
|
+
this.controls.playerChoice = isVisible;
|
|
184
|
+
this.remaps.setShowVirtual(isVisible);
|
|
185
|
+
this.controls.sync();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Flip the player's show/hide choice. */
|
|
189
|
+
toggleVirtual(): void {
|
|
190
|
+
this.showVirtual(!this.showingVirtual);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Force the controls visible for layout editing, without changing the player's choice. */
|
|
194
|
+
setVirtualEditing(isEditing: boolean): void {
|
|
195
|
+
this.controls.setEditing(isEditing);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** This game's default visibility (the player's choice still wins); `null` = shown on touch devices without a pad. */
|
|
199
|
+
setVirtualDefault(isVisible: boolean | null): void {
|
|
200
|
+
this.controls.setGameDefault(isVisible);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Keep auto-placed controls clear of a band along a screen edge, e.g. your
|
|
205
|
+
* top HUD: `setInsets({ top: hud.getBoundingClientRect().bottom })`. Px from
|
|
206
|
+
* each edge, merged with earlier calls (0 clears one edge). Dragged controls
|
|
207
|
+
* and explicit `pos` ignore it.
|
|
208
|
+
*/
|
|
209
|
+
setInsets(insets: { top?: number; right?: number; bottom?: number; left?: number }): void {
|
|
210
|
+
this.controls.overlay().setInsets(insets);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What the "Show on-screen controls" toggle reflects. */
|
|
214
|
+
get showingVirtual(): boolean {
|
|
215
|
+
return this.controls.isShowing;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Rebind an input (persisted). */
|
|
219
|
+
rebind(inputName: string, patch: Partial<InputDef>): void {
|
|
220
|
+
this.remaps.rebind(inputName, patch);
|
|
221
|
+
const declared = this.config?.groups[this.registry.groupOf(inputName) ?? '']?.inputs[inputName];
|
|
222
|
+
if (declared !== undefined) {
|
|
223
|
+
this.registry.redefine(inputName, this.remaps.withBinding(inputName, declared));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Move or restyle an on-screen control (persisted). */
|
|
228
|
+
setVirtual(id: string, patch: VirtualOverride): void {
|
|
229
|
+
this.remaps.customiseControl(id, patch);
|
|
230
|
+
this.buildControls();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Open (or close) the Controls screen. */
|
|
234
|
+
openControls(): void {
|
|
235
|
+
if (this.pageOwner === null) {
|
|
236
|
+
this.controls.overlay().toggleControls(this.config, this);
|
|
237
|
+
} else {
|
|
238
|
+
this.pageOwner.openControls();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Forget every remap and customisation. */
|
|
243
|
+
resetControls(): void {
|
|
244
|
+
this.remaps.reset();
|
|
245
|
+
this.controls.playerChoice = null;
|
|
246
|
+
if (this.config !== null) {
|
|
247
|
+
this.define(this.config);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** A readable label of an input's current binding. */
|
|
252
|
+
bindingLabel(inputName: string): string {
|
|
253
|
+
return describeBinding(this.registry.definitionOf(inputName));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Called by the injected menu: Select toggles it and a gamepad navigates it. */
|
|
257
|
+
attachMenu(bridge: MenuBridge | null): void {
|
|
258
|
+
if (this.pageOwner === null) {
|
|
259
|
+
this.menu = bridge;
|
|
260
|
+
} else {
|
|
261
|
+
this.pageOwner.attachMenu(bridge);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Called by the injected menu with `game.controls`, for the zero-config fallback. */
|
|
266
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: game.controls from window.__HUB__, parsed straight away
|
|
267
|
+
setLegacyControls(controls: unknown): void {
|
|
268
|
+
if (this.pageOwner === null) {
|
|
269
|
+
this.legacyControlsSource = controls;
|
|
270
|
+
this.legacyControls = parseLegacyControls(controls);
|
|
271
|
+
} else {
|
|
272
|
+
this.pageOwner.setLegacyControls(controls);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Make a game's own on-screen menu gamepad-navigable: a CSS selector (or a
|
|
278
|
+
* function returning elements). While any match is visible and a gamepad is
|
|
279
|
+
* in use, the stick moves a highlight, A clicks, B calls `options.back`.
|
|
280
|
+
* Re-queried every frame, so call it once.
|
|
281
|
+
*/
|
|
282
|
+
autoNavigate(selector: string | (() => NavTarget[]), options?: { back?: () => void }): void {
|
|
283
|
+
this.navigator.setAutoNavigate(Navigator.queryFor(selector), options?.back ?? null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Stop navigating the game's own menu. */
|
|
287
|
+
clearAutoNavigate(): void {
|
|
288
|
+
this.navigator.clearAutoNavigate(this.controls.existingOverlay);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Stop driving the page because another copy's game owns it (plan §4.4):
|
|
293
|
+
* hand the menu and `game.controls` to `owner`, and forward the menu's calls to it.
|
|
294
|
+
*/
|
|
295
|
+
yieldPageTo(owner: PageInputControl): void {
|
|
296
|
+
this.isPageOwner = false;
|
|
297
|
+
this.pageOwner = owner;
|
|
298
|
+
owner.attachMenu(this.menu);
|
|
299
|
+
owner.setLegacyControls(this.legacyControlsSource);
|
|
300
|
+
this.menu = null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private buildControls(): void {
|
|
304
|
+
if (this.config === null) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const callbacks = feedDeviceState(this.devices, {
|
|
308
|
+
onTouch: () => {
|
|
309
|
+
this.setSource('touch');
|
|
310
|
+
},
|
|
311
|
+
onDragged: (id, spot) => {
|
|
312
|
+
this.setVirtual(id, spot);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
this.controls.build(this.config, this.remaps.overrides, callbacks);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private setSource(source: InputSource): void {
|
|
319
|
+
if (source === this.activeSource) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
this.activeSource = source;
|
|
323
|
+
this.events.emit('sourcechange', source);
|
|
324
|
+
this.controls.sync();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private navigatorContext(): NavigatorContext {
|
|
328
|
+
return {
|
|
329
|
+
gamepads: this.devices.gamepads,
|
|
330
|
+
repeats: this.repeats,
|
|
331
|
+
presses: this.presses,
|
|
332
|
+
listeners: {
|
|
333
|
+
onFocus: (event) => {
|
|
334
|
+
this.events.emit('focus', event);
|
|
335
|
+
},
|
|
336
|
+
onBlur: (event) => {
|
|
337
|
+
this.events.emit('blur', event);
|
|
338
|
+
},
|
|
339
|
+
onAct: (event) => {
|
|
340
|
+
this.events.emit('act', event);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
highlighter: () => this.controls.overlay(),
|
|
344
|
+
isPressed: (name) => this.registry.isPressed(name),
|
|
345
|
+
wentDown: (name) => this.registry.wentDown(name),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private tick(): void {
|
|
350
|
+
this.devices.frame += 1;
|
|
351
|
+
const { isConnected, hasActivity } = this.devices.pollGamepads();
|
|
352
|
+
this.controls.isGamepadConnected = isConnected;
|
|
353
|
+
if (hasActivity) {
|
|
354
|
+
this.setSource('gamepad');
|
|
355
|
+
}
|
|
356
|
+
if (this.isPageOwner) {
|
|
357
|
+
this.driveFrame();
|
|
358
|
+
} else {
|
|
359
|
+
this.registry.sample(this.devices, this.activeSource);
|
|
360
|
+
}
|
|
361
|
+
this.devices.decay();
|
|
362
|
+
this.controls.existingOverlay?.afterFrame();
|
|
363
|
+
requestAnimationFrame(() => {
|
|
364
|
+
this.tick();
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** One frame as the page owner: the menu toggle and navigation, inputs, the zero-config fallback. */
|
|
369
|
+
private driveFrame(): void {
|
|
370
|
+
const context = this.navigatorContext();
|
|
371
|
+
const overlay = this.controls.existingOverlay;
|
|
372
|
+
const isGamepadActive = this.activeSource === 'gamepad';
|
|
373
|
+
const menu = this.menu;
|
|
374
|
+
// Select toggles the hub menu from anywhere.
|
|
375
|
+
if (menu !== null && this.presses.rose('select', isAnyPadButtonPressed(this.devices.gamepads, PAD_BUTTON.SELECT))) {
|
|
376
|
+
menu.toggle();
|
|
377
|
+
}
|
|
378
|
+
if (menu?.isOpen() === true) {
|
|
379
|
+
// The menu takes over input: the game's inputs read 0 while it is open.
|
|
380
|
+
this.registry.silence();
|
|
381
|
+
this.navigator.driveMenu(menu, isGamepadActive, context, overlay);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.navigator.leaveMenu(overlay);
|
|
385
|
+
if (this.config !== null) {
|
|
386
|
+
this.registry.sample(this.devices, this.activeSource);
|
|
387
|
+
this.navigator.driveGroups((group) => this.registry.isEnabled(group), context);
|
|
388
|
+
} else if (Reflect.get(window, INPUT_ACTIVE_FLAG) !== true) {
|
|
389
|
+
runZeroConfigFrame({ gamepads: this.devices.gamepads, slug: this.host.slug, controls: this.legacyControls, navEdges: this.repeats });
|
|
390
|
+
}
|
|
391
|
+
this.navigator.driveAutoNavigate(isGamepadActive, context, overlay);
|
|
392
|
+
}
|
|
393
|
+
}
|