From 58202ede3b2c67143eaafe8ba327de69d36f67dc Mon Sep 17 00:00:00 2001 From: Luca Casonato Date: Tue, 10 May 2022 21:20:11 +0200 Subject: [PATCH] Add gamepad support This commit adds support for playing PROXX with a gamepad. The gamepad can be used both on the game board, and in the menus. On the game board it can be used to clear (A or LB) or flag (X or RB) tiles, navigate the board using the D-Pad, and switch into alt mode (B). On the menu, A can be used to confirm (Start or Restart) and B can be used to go back or cancel. I tried to keep this relatively light-weight, but it does add a bit of extra code to the initial bundle (the Gamepad handling specifically). If the user does not use a game pad, the code will not cause any extra CPU usage. --- src/main/missing-types.d.ts | 49 ++++++ .../preact-canvas/components/board/index.tsx | 77 ++++++++-- .../preact-canvas/components/game/index.tsx | 55 ++++++- .../preact-canvas/components/game/style.css | 12 ++ .../preact-canvas/components/intro/index.tsx | 23 ++- .../preact-canvas/components/intro/style.css | 5 + .../preact-canvas/components/win/index.tsx | 41 +++-- .../preact-canvas/components/win/style.css | 12 ++ src/main/services/preact-canvas/index.tsx | 20 ++- src/main/services/preact-canvas/utils.css | 14 ++ src/main/style.css | 5 + src/main/utils/gamepad.ts | 142 ++++++++++++++++++ 12 files changed, 419 insertions(+), 36 deletions(-) create mode 100644 src/main/utils/gamepad.ts diff --git a/src/main/missing-types.d.ts b/src/main/missing-types.d.ts index aa712e3f..303c7e15 100644 --- a/src/main/missing-types.d.ts +++ b/src/main/missing-types.d.ts @@ -29,3 +29,52 @@ declare namespace JSX { inputmode?: string; } } + +/** + * This Gamepad API interface defines an individual gamepad or other controller, allowing access to + * information such as button presses, axis positions, and id. Available only in secure contexts. + */ +interface Gamepad { + readonly axes: ReadonlyArray; + readonly buttons: ReadonlyArray; + readonly connected: boolean; + readonly hapticActuators: ReadonlyArray; + readonly id: string; + readonly index: number; + readonly mapping: GamepadMappingType; + readonly timestamp: DOMHighResTimeStamp; +} + +declare var Gamepad: { + prototype: Gamepad; + new (): Gamepad; +}; + +/** + * An individual button of a gamepad or other controller, allowing access to the current state of + * different types of buttons available on the control device. Available only in secure contexts. + */ +interface GamepadButton { + readonly pressed: boolean; + readonly touched: boolean; + readonly value: number; +} + +declare var GamepadButton: { + prototype: GamepadButton; + new (): GamepadButton; +}; + +/** + * This Gamepad API interface contains references to gamepads connected to the system, which is what + * the gamepad events Window.gamepadconnected and Window.gamepaddisconnected are fired in response to. + * Available only in secure contexts. + */ +interface GamepadEvent extends Event { + readonly gamepad: Gamepad; +} + +declare var GamepadEvent: { + prototype: GamepadEvent; + new (type: string, eventInitDict: GamepadEventInit): GamepadEvent; +}; diff --git a/src/main/services/preact-canvas/components/board/index.tsx b/src/main/services/preact-canvas/components/board/index.tsx index e191e7fb..47642eb2 100644 --- a/src/main/services/preact-canvas/components/board/index.tsx +++ b/src/main/services/preact-canvas/components/board/index.tsx @@ -15,10 +15,11 @@ import { Animator } from "src/main/rendering/animator"; import { Renderer } from "src/main/rendering/renderer"; import { putCanvas } from "src/main/utils/canvas-pool"; import { cellFocusMode } from "src/main/utils/constants"; +import { gamepad } from "src/main/utils/gamepad"; import { isFeaturePhone } from "src/main/utils/static-display"; import { bind } from "src/utils/bind"; import { StateChange } from "src/worker/gamelogic"; -import { Cell } from "src/worker/gamelogic/types"; +import { Cell, PlayMode } from "src/worker/gamelogic/types"; import { GameChangeCallback } from "../../index"; import { board, @@ -45,24 +46,17 @@ export interface Props { renderer: Renderer; animator: Animator; dangerMode: boolean; + interactive: boolean; gameChangeSubscribe: (f: GameChangeCallback) => void; gameChangeUnsubscribe: (f: GameChangeCallback) => void; onDangerModeChange: (v: boolean) => void; } -interface State { - keyNavigation: boolean; -} - interface SetFocusOptions { preventScroll?: boolean; } -export default class Board extends Component { - state: State = { - keyNavigation: false - }; - +export default class Board extends Component { private _canvas?: HTMLCanvasElement; private _table?: HTMLTableElement; private _buttons: HTMLButtonElement[] = []; @@ -101,12 +95,16 @@ export default class Board extends Component { window.addEventListener("resize", this._onWindowResize); window.addEventListener("keyup", this._onGlobalKeyUp); + gamepad.addButtonDownCallback(this._onGamepadButtonDown); + gamepad.addButtonPressCallback(this._onGamepadButtonPress); } componentWillUnmount() { document.documentElement.classList.remove("in-game"); window.removeEventListener("resize", this._onWindowResize); window.removeEventListener("keyup", this._onGlobalKeyUp); + gamepad.removeButtonDownCallback(this._onGamepadButtonDown); + gamepad.removeButtonPressCallback(this._onGamepadButtonPress); this.props.gameChangeUnsubscribe(this._doManualDomHandling); this.props.renderer.stop(); this.props.animator.stop(); @@ -144,6 +142,9 @@ export default class Board extends Component { @bind private _onGlobalKeyUp(event: KeyboardEvent) { + if (!this.props.interactive) { + return; + } // This returns the focus to the board when one of these keys is pressed (on feature phones // only). This means the user doesn't have to manually refocus the board. if ( @@ -161,6 +162,57 @@ export default class Board extends Component { } } + @bind + private _onGamepadButtonDown(buttonId: number) { + if (!this.props.interactive) { + return; + } + switch (buttonId) { + case 0: // A + case 4: /* Left Shoulder Button */ { + const button = document.activeElement as HTMLButtonElement; + if (!button) { + return; + } + this.simulateClick(button); + break; + } + case 2: // X + case 5: /* Right Shoulder Button */ { + const button = document.activeElement as HTMLButtonElement; + if (!button) { + return; + } + this.simulateClick(button, true); + break; + } + case 1: // B + this._toggleDangerMode(); + break; + } + } + + @bind + private _onGamepadButtonPress(buttonId: number) { + if (!this.props.interactive) { + return; + } + switch (buttonId) { + case 15: // Right + this.moveFocusByKey(new Event(""), 1, 0); + break; + case 14: // Left + this.moveFocusByKey(new Event(""), -1, 0); + break; + case 12: // Up + this.moveFocusByKey(new Event(""), 0, -1); + break; + case 13: // Down + this.moveFocusByKey(new Event(""), 0, 1); + break; + } + } + @bind private _doManualDomHandling(stateChange: StateChange) { if (!stateChange.gridChanges) { @@ -245,7 +297,8 @@ export default class Board extends Component { const showFocusStyle = button.classList.contains("focus-visible") || isFeaturePhone || - cellFocusMode; + cellFocusMode || + gamepad.isGamepadConnected; if (!showFocusStyle) { this.props.renderer.setFocus(-1, -1); @@ -318,7 +371,7 @@ export default class Board extends Component { } @bind - private moveFocusByKey(event: KeyboardEvent, h: number, v: number) { + private moveFocusByKey(event: Event, h: number, v: number) { event.stopPropagation(); event.preventDefault(); diff --git a/src/main/services/preact-canvas/components/game/index.tsx b/src/main/services/preact-canvas/components/game/index.tsx index f5826569..d92c119c 100644 --- a/src/main/services/preact-canvas/components/game/index.tsx +++ b/src/main/services/preact-canvas/components/game/index.tsx @@ -18,6 +18,7 @@ import { GameChangeCallback } from "src/main/services/preact-canvas"; import { submitTime } from "src/main/services/state/best-times"; import { supportsVibration } from "src/main/services/state/vibration-preference"; import { vibrationLength } from "src/main/utils/constants"; +import { gamepad } from "src/main/utils/gamepad"; import { isFeaturePhone } from "src/main/utils/static-display"; import { bind } from "src/utils/bind"; import { StateChange } from "src/worker/gamelogic"; @@ -33,6 +34,9 @@ import { exitRow, exitRowInner, game as gameClass, + gamepadButton, + gamepadButtonA, + gamepadButtonB, mainButton, shortcutKey } from "./style.css"; @@ -50,6 +54,7 @@ export interface Props { useMotion: boolean; bestTime?: number; useVibration: boolean; + isGamepadConnected: boolean; } interface State { @@ -96,7 +101,8 @@ export default class Game extends Component { gameChangeUnsubscribe, toRevealTotal, useMotion, - bestTime: previousBestTime + bestTime: previousBestTime, + isGamepadConnected }: Props, { playMode, toReveal, animator, renderer, completeTime, bestTime }: State ) { @@ -123,6 +129,7 @@ export default class Game extends Component { height={height} mines={mines} useMotion={this.props.useMotion} + isGamepadConnected={isGamepadConnected} /> ) : renderer && animator ? ( [ @@ -132,6 +139,9 @@ export default class Game extends Component { dangerMode={dangerMode} animator={animator} renderer={renderer} + interactive={ + playMode === PlayMode.Pending || playMode === PlayMode.Playing + } gameChangeSubscribe={gameChangeSubscribe} gameChangeUnsubscribe={gameChangeUnsubscribe} onCellClick={this.onCellClick} @@ -150,10 +160,24 @@ export default class Game extends Component { # )}{" "} + {isGamepadConnected ? ( + + A + + ) : ( + "" + )}{" "} Try again @@ -175,11 +199,13 @@ export default class Game extends Component { this.props.onDangerModeChange(true); } window.addEventListener("keyup", this.onKeyUp); + gamepad.addButtonDownCallback(this.onGamepadButtonDown); } componentWillUnmount() { this.props.gameChangeUnsubscribe(this.onGameChange); window.removeEventListener("keyup", this.onKeyUp); + gamepad.removeButtonDownCallback(this.onGamepadButtonDown); } componentDidUpdate(_: Props, previousState: State) { @@ -230,12 +256,31 @@ export default class Game extends Component { @bind private onKeyUp(event: KeyboardEvent) { - if (event.key === "#") { - if (this.state.playMode === PlayMode.Lost) { + if ( + this.state.playMode === PlayMode.Won || + this.state.playMode === PlayMode.Lost + ) { + if (event.key === "#") { + this.onRestart(); + } else if (event.key === "*") { + this.onReset(); + } + } + } + + @bind + private onGamepadButtonDown(buttonId: number) { + if ( + this.state.playMode === PlayMode.Won || + this.state.playMode === PlayMode.Lost + ) { + if (buttonId === 0) { + // A this.onRestart(); + } else if (buttonId === 1) { + // B + this.onReset(); } - } else if (event.key === "*") { - this.onReset(); } } diff --git a/src/main/services/preact-canvas/components/game/style.css b/src/main/services/preact-canvas/components/game/style.css index 40dbb098..99b1724a 100644 --- a/src/main/services/preact-canvas/components/game/style.css +++ b/src/main/services/preact-canvas/components/game/style.css @@ -78,3 +78,15 @@ /** PostCSS adding classes in wrong order :( */ border-color: #000 !important; } + +.gamepad-button { + composes: gamepadbutton from "../../utils.css"; +} + +.gamepad-button-a { + color: var(--color-gamepad-a); +} + +.gamepad-button-b { + color: var(--color-gamepad-b); +} diff --git a/src/main/services/preact-canvas/components/intro/index.tsx b/src/main/services/preact-canvas/components/intro/index.tsx index 553de677..53f3728d 100644 --- a/src/main/services/preact-canvas/components/intro/index.tsx +++ b/src/main/services/preact-canvas/components/intro/index.tsx @@ -17,12 +17,14 @@ import { PresetName, presets } from "src/main/services/state/grid-presets"; +import { gamepad } from "src/main/utils/gamepad"; import { isFeaturePhone } from "src/main/utils/static-display"; import { bind } from "src/utils/bind"; import deferred from "../deferred"; import { Arrow } from "../icons/initial"; import { field as fieldStyle, + gamepadButton as gamepadButtonStyle, intro as introStyle, label as labelStyle, labelText as labelTextStyle, @@ -120,6 +122,7 @@ export interface Props { onStartGame: (width: number, height: number, mines: number) => void; defaults?: GridType; motion: boolean; + isGamepadConnected: boolean; } interface State { @@ -146,10 +149,12 @@ export default class Intro extends Component { componentDidMount() { window.scrollTo(0, 0); window.addEventListener("keyup", this._onKeyUp); + gamepad.addButtonDownCallback(this._onGamepadButtonDown); } componentWillUnmount() { window.removeEventListener("keyup", this._onKeyUp); + gamepad.removeButtonDownCallback(this._onGamepadButtonDown); } componentWillReceiveProps({ defaults }: Props) { @@ -158,7 +163,10 @@ export default class Intro extends Component { } } - render({ motion }: Props, { width, height, mines, presetName }: State) { + render( + { motion, isGamepadConnected }: Props, + { width, height, mines, presetName }: State + ) { return (
@@ -233,6 +241,11 @@ export default class Intro extends Component {
@@ -248,6 +261,14 @@ export default class Intro extends Component { } } + @bind + private _onGamepadButtonDown(buttonId: number) { + if (buttonId === 0) { + // A + this._startGame(new Event("")); + } + } + @bind private _onSelectChange() { const presetName = this._presetSelect!.value as PresetName | "custom"; diff --git a/src/main/services/preact-canvas/components/intro/style.css b/src/main/services/preact-canvas/components/intro/style.css index 62a1f8ce..adaae523 100644 --- a/src/main/services/preact-canvas/components/intro/style.css +++ b/src/main/services/preact-canvas/components/intro/style.css @@ -161,3 +161,8 @@ /** PostCSS adding classes in wrong order :( */ border-color: #000 !important; } + +.gamepad-button { + composes: gamepadbutton from "../../utils.css"; + color: var(--color-gamepad-a); +} diff --git a/src/main/services/preact-canvas/components/win/index.tsx b/src/main/services/preact-canvas/components/win/index.tsx index 5fcd5ffa..449f85b8 100644 --- a/src/main/services/preact-canvas/components/win/index.tsx +++ b/src/main/services/preact-canvas/components/win/index.tsx @@ -11,6 +11,7 @@ * limitations under the License. */ import { Component, h } from "preact"; +import { gamepad } from "src/main/utils/gamepad"; import { bind } from "../../../../../utils/bind"; import { minSec } from "../../../../utils/format"; import { isFeaturePhone } from "../../../../utils/static-display"; @@ -19,6 +20,9 @@ import { Timer } from "../icons/additional"; import { againButton, againShortcutKey, + gamepadButton, + gamepadButtonA, + gamepadButtonB, gridName as gridNameStyle, mainButton, noMotion, @@ -43,6 +47,7 @@ interface Props { height: number; mines: number; useMotion: boolean; + isGamepadConnected: boolean; } interface State { @@ -95,15 +100,17 @@ export default class End extends Component { window.scrollTo(0, 0); }, 0); this._playAgainBtn!.focus(); - window.addEventListener("keyup", this.onKeyUp); - } - - componentWillUnmount() { - window.removeEventListener("keyup", this.onKeyUp); } render( - { onRestart, onMainMenu, time, bestTime, useMotion }: Props, + { + onRestart, + onMainMenu, + time, + bestTime, + useMotion, + isGamepadConnected + }: Props, { gridName }: State ) { const timeStr = minSec(time); @@ -146,22 +153,24 @@ export default class End extends Component { {isFeaturePhone && ( # )}{" "} + {isGamepadConnected ? ( + A + ) : ( + "" + )}{" "} Play again
); } - - @bind - private onKeyUp(event: KeyboardEvent) { - if (event.key === "#") { - this.props.onRestart(); - } else if (event.key === "*") { - this.props.onMainMenu(); - } - } } diff --git a/src/main/services/preact-canvas/components/win/style.css b/src/main/services/preact-canvas/components/win/style.css index 9900de03..cf828f05 100644 --- a/src/main/services/preact-canvas/components/win/style.css +++ b/src/main/services/preact-canvas/components/win/style.css @@ -176,3 +176,15 @@ /** PostCSS adding classes in wrong order :( */ border-color: #000 !important; } + +.gamepad-button { + composes: gamepadbutton from "../../utils.css"; +} + +.gamepad-button-a { + color: var(--color-gamepad-a); +} + +.gamepad-button-b { + color: var(--color-gamepad-b); +} diff --git a/src/main/services/preact-canvas/index.tsx b/src/main/services/preact-canvas/index.tsx index c56667ff..3a6fb882 100644 --- a/src/main/services/preact-canvas/index.tsx +++ b/src/main/services/preact-canvas/index.tsx @@ -14,6 +14,7 @@ import workerURL from "chunk-name:./../../../worker"; import nebulaSafeDark from "consts:nebulaSafeDark"; import prerender from "consts:prerender"; import { Component, h, VNode } from "preact"; +import { gamepad } from "src/main/utils/gamepad"; import toRGB from "src/main/utils/to-rgb"; import { bind } from "src/utils/bind"; import { PlayMode } from "src/worker/gamelogic/types"; @@ -107,6 +108,7 @@ interface State { gameInPlay: boolean; allowIntroAnim: boolean; vibrationPreference: boolean; + isGamepadConnected: boolean; } export type GameChangeCallback = (stateChange: GameStateChange) => void; @@ -133,7 +135,8 @@ export default class Root extends Component { motionPreference: true, gameInPlay: false, allowIntroAnim: true, - vibrationPreference: true + vibrationPreference: true, + isGamepadConnected: gamepad.isGamepadConnected }; private previousFocus: HTMLElement | null = null; @@ -234,6 +237,11 @@ export default class Root extends Component { if (prerender) { prerenderDone(); } + gamepad.addConnectedCallback(this._onGamepadConnected); + } + + componentWillUnmount() { + gamepad.removeConnectedCallback(this._onGamepadConnected); } render( @@ -248,7 +256,8 @@ export default class Root extends Component { gameInPlay, bestTime, allowIntroAnim, - vibrationPreference + vibrationPreference, + isGamepadConnected }: State ) { let mainComponent: VNode; @@ -280,6 +289,7 @@ export default class Root extends Component { onStartGame={this._onStartGame} defaults={prerender ? undefined : gridDefaults} motion={motionPreference && allowIntroAnim} + isGamepadConnected={isGamepadConnected} /> ); } @@ -303,6 +313,7 @@ export default class Root extends Component { useMotion={motionPreference} bestTime={bestTime} useVibration={vibrationPreference} + isGamepadConnected={isGamepadConnected} /> )} /> @@ -345,6 +356,11 @@ export default class Root extends Component { ); } + @bind + private _onGamepadConnected() { + this.setState({ isGamepadConnected: gamepad.isGamepadConnected }); + } + private _nebulaLightColor() { if (this.state.settingsOpen) { return nebulaSettingLight; diff --git a/src/main/services/preact-canvas/utils.css b/src/main/services/preact-canvas/utils.css index 249f0910..c21efd81 100644 --- a/src/main/services/preact-canvas/utils.css +++ b/src/main/services/preact-canvas/utils.css @@ -66,6 +66,20 @@ box-sizing: border-box; } +.gamepadbutton { + border: solid 1px #fff; + background-color: #000; + font-weight: bold; + border-radius: 100%; + display: inline-block; + width: calc(var(--gamepadbtn-size) + 2px); + height: calc(var(--gamepadbtn-size) + 2px); + line-height: var(--gamepadbtn-size); + text-align: center; + letter-spacing: 0; + box-sizing: border-box; +} + .button { mix-blend-mode: screen; } diff --git a/src/main/style.css b/src/main/style.css index 404e84ab..a45fb5b5 100644 --- a/src/main/style.css +++ b/src/main/style.css @@ -20,10 +20,14 @@ html { --cell-padding: 2px; --side-margin: 10px; --icon-size: 20px; + --gamepadbtn-size: 20px; --circlebtn-size: 24px; --bar-avoid: 40px; --bar-padding: 5px; --border-radius: 7px; + + --color-gamepad-a: #3cdb4e; + --color-gamepad-b: #d04242; } /* We're assuming (totally safe, right?) that widths below 320 are feature phones */ @@ -34,6 +38,7 @@ html { --cell-padding: 5px; --side-margin: 24px; --icon-size: 24px; + --gamepadbtn-size: 28px; --circlebtn-size: 48px; --bar-avoid: 78px; --bar-padding: 18px; diff --git a/src/main/utils/gamepad.ts b/src/main/utils/gamepad.ts new file mode 100644 index 00000000..8a6fe1c7 --- /dev/null +++ b/src/main/utils/gamepad.ts @@ -0,0 +1,142 @@ +/** + * Copyright 2019 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { bind } from "src/utils/bind"; + +// WARNING: This module is part of the main bundle. Avoid adding to it if possible. + +export interface GamepadState { + // For each button, how many ticks it has been held down for. + buttonsHeldTicks: number[]; +} + +export class GamepadController { + get isGamepadConnected(): boolean { + return this._hasConnectedGamepads; + } + private _state: GamepadState[] = []; + + private _hasConnectedGamepads = false; + private _tickScheduled = false; + + private _buttonDownCallbacks = new Set<(button: number) => void>(); + private _buttonPressCallbacks = new Set<(button: number) => void>(); + private _connectedCallbacks = new Set<() => void>(); + + constructor() { + // @ts-ignore This version of TS is not aware of this event. + window.addEventListener("gamepadconnected", this._onGamepadConnected); + this._scheduleTick(); + } + + addButtonDownCallback(callback: (button: number) => void) { + this._buttonDownCallbacks.add(callback); + } + + removeButtonDownCallback(callback: (button: number) => void) { + this._buttonDownCallbacks.delete(callback); + } + + addButtonPressCallback(callback: (button: number) => void) { + this._buttonPressCallbacks.add(callback); + } + + removeButtonPressCallback(callback: (button: number) => void) { + this._buttonPressCallbacks.delete(callback); + } + + addConnectedCallback(callback: () => void) { + this._connectedCallbacks.add(callback); + } + + removeConnectedCallback(callback: () => void) { + this._connectedCallbacks.delete(callback); + } + + @bind + private _onGamepadConnected() { + this._scheduleTick(); + } + + private _scheduleTick() { + if (!this._tickScheduled) { + this._tickScheduled = true; + requestAnimationFrame(this._tick); + } + } + + @bind + private _tick() { + this._tickScheduled = false; + if (typeof navigator.getGamepads === "undefined") { + return; + } + const gamepads = navigator.getGamepads(); + + // Iterate over all gamepads, and update the state. + const currentlyIsConnected = this._hasConnectedGamepads; + this._hasConnectedGamepads = false; + for (let i = 0; i < gamepads.length; i++) { + const gamepad = gamepads[i]; + if (gamepad !== null) { + this._hasConnectedGamepads = true; + let state = this._state[i]; + if (state === undefined) { + state = this._state[i] = { + buttonsHeldTicks: new Array(gamepad.buttons.length).fill(0) + }; + } + + for (let j = 0; j < gamepad.buttons.length; j++) { + const button = gamepad.buttons[j]; + if (button.pressed) { + state.buttonsHeldTicks[j]++; + } else { + state.buttonsHeldTicks[j] = 0; + } + } + } + } + + if (currentlyIsConnected !== this._hasConnectedGamepads) { + this._connectedCallbacks.forEach(callback => callback()); + } + + // Iterate over all gamepads, and emit "buttonPress" and "buttonup" events. + for (const state of this._state) { + for (let j = 0; j < state.buttonsHeldTicks.length; j++) { + // If the button just started being pressed we emit a "down" and a "hold" + // event. + const tick = state.buttonsHeldTicks[j]; + if (tick === 1) { + this._buttonDownCallbacks.forEach(callback => callback(j)); + } + // If the button is being held down, we emit an event every 5 ticks + // (16.6 * 5 =~ 80ms) after an initial wait of 18 ticks + // (16.6 * 18 =~ 300ms). We also emit + if (tick === 1 || (tick >= 18 && (tick - 18) % 5 === 0)) { + this._buttonPressCallbacks.forEach(callback => { + callback(j); + }); + } + } + } + + // Schedule the next tick if there are still gamepads connected. + if (this._hasConnectedGamepads) { + this._scheduleTick(); + } + } +} + +export const gamepad = new GamepadController();