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();