Skip to content

Commit 58202ed

Browse files
committed
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.
1 parent c6c1558 commit 58202ed

12 files changed

Lines changed: 419 additions & 36 deletions

File tree

src/main/missing-types.d.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,52 @@ declare namespace JSX {
2929
inputmode?: string;
3030
}
3131
}
32+
33+
/**
34+
* This Gamepad API interface defines an individual gamepad or other controller, allowing access to
35+
* information such as button presses, axis positions, and id. Available only in secure contexts.
36+
*/
37+
interface Gamepad {
38+
readonly axes: ReadonlyArray<number>;
39+
readonly buttons: ReadonlyArray<GamepadButton>;
40+
readonly connected: boolean;
41+
readonly hapticActuators: ReadonlyArray<GamepadHapticActuator>;
42+
readonly id: string;
43+
readonly index: number;
44+
readonly mapping: GamepadMappingType;
45+
readonly timestamp: DOMHighResTimeStamp;
46+
}
47+
48+
declare var Gamepad: {
49+
prototype: Gamepad;
50+
new (): Gamepad;
51+
};
52+
53+
/**
54+
* An individual button of a gamepad or other controller, allowing access to the current state of
55+
* different types of buttons available on the control device. Available only in secure contexts.
56+
*/
57+
interface GamepadButton {
58+
readonly pressed: boolean;
59+
readonly touched: boolean;
60+
readonly value: number;
61+
}
62+
63+
declare var GamepadButton: {
64+
prototype: GamepadButton;
65+
new (): GamepadButton;
66+
};
67+
68+
/**
69+
* This Gamepad API interface contains references to gamepads connected to the system, which is what
70+
* the gamepad events Window.gamepadconnected and Window.gamepaddisconnected are fired in response to.
71+
* Available only in secure contexts.
72+
*/
73+
interface GamepadEvent extends Event {
74+
readonly gamepad: Gamepad;
75+
}
76+
77+
declare var GamepadEvent: {
78+
prototype: GamepadEvent;
79+
new (type: string, eventInitDict: GamepadEventInit): GamepadEvent;
80+
};

src/main/services/preact-canvas/components/board/index.tsx

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ import { Animator } from "src/main/rendering/animator";
1515
import { Renderer } from "src/main/rendering/renderer";
1616
import { putCanvas } from "src/main/utils/canvas-pool";
1717
import { cellFocusMode } from "src/main/utils/constants";
18+
import { gamepad } from "src/main/utils/gamepad";
1819
import { isFeaturePhone } from "src/main/utils/static-display";
1920
import { bind } from "src/utils/bind";
2021
import { StateChange } from "src/worker/gamelogic";
21-
import { Cell } from "src/worker/gamelogic/types";
22+
import { Cell, PlayMode } from "src/worker/gamelogic/types";
2223
import { GameChangeCallback } from "../../index";
2324
import {
2425
board,
@@ -45,24 +46,17 @@ export interface Props {
4546
renderer: Renderer;
4647
animator: Animator;
4748
dangerMode: boolean;
49+
interactive: boolean;
4850
gameChangeSubscribe: (f: GameChangeCallback) => void;
4951
gameChangeUnsubscribe: (f: GameChangeCallback) => void;
5052
onDangerModeChange: (v: boolean) => void;
5153
}
5254

53-
interface State {
54-
keyNavigation: boolean;
55-
}
56-
5755
interface SetFocusOptions {
5856
preventScroll?: boolean;
5957
}
6058

61-
export default class Board extends Component<Props, State> {
62-
state: State = {
63-
keyNavigation: false
64-
};
65-
59+
export default class Board extends Component<Props, {}> {
6660
private _canvas?: HTMLCanvasElement;
6761
private _table?: HTMLTableElement;
6862
private _buttons: HTMLButtonElement[] = [];
@@ -101,12 +95,16 @@ export default class Board extends Component<Props, State> {
10195

10296
window.addEventListener("resize", this._onWindowResize);
10397
window.addEventListener("keyup", this._onGlobalKeyUp);
98+
gamepad.addButtonDownCallback(this._onGamepadButtonDown);
99+
gamepad.addButtonPressCallback(this._onGamepadButtonPress);
104100
}
105101

106102
componentWillUnmount() {
107103
document.documentElement.classList.remove("in-game");
108104
window.removeEventListener("resize", this._onWindowResize);
109105
window.removeEventListener("keyup", this._onGlobalKeyUp);
106+
gamepad.removeButtonDownCallback(this._onGamepadButtonDown);
107+
gamepad.removeButtonPressCallback(this._onGamepadButtonPress);
110108
this.props.gameChangeUnsubscribe(this._doManualDomHandling);
111109
this.props.renderer.stop();
112110
this.props.animator.stop();
@@ -144,6 +142,9 @@ export default class Board extends Component<Props, State> {
144142

145143
@bind
146144
private _onGlobalKeyUp(event: KeyboardEvent) {
145+
if (!this.props.interactive) {
146+
return;
147+
}
147148
// This returns the focus to the board when one of these keys is pressed (on feature phones
148149
// only). This means the user doesn't have to manually refocus the board.
149150
if (
@@ -161,6 +162,57 @@ export default class Board extends Component<Props, State> {
161162
}
162163
}
163164

165+
@bind
166+
private _onGamepadButtonDown(buttonId: number) {
167+
if (!this.props.interactive) {
168+
return;
169+
}
170+
switch (buttonId) {
171+
case 0: // A
172+
case 4: /* Left Shoulder Button */ {
173+
const button = document.activeElement as HTMLButtonElement;
174+
if (!button) {
175+
return;
176+
}
177+
this.simulateClick(button);
178+
break;
179+
}
180+
case 2: // X
181+
case 5: /* Right Shoulder Button */ {
182+
const button = document.activeElement as HTMLButtonElement;
183+
if (!button) {
184+
return;
185+
}
186+
this.simulateClick(button, true);
187+
break;
188+
}
189+
case 1: // B
190+
this._toggleDangerMode();
191+
break;
192+
}
193+
}
194+
195+
@bind
196+
private _onGamepadButtonPress(buttonId: number) {
197+
if (!this.props.interactive) {
198+
return;
199+
}
200+
switch (buttonId) {
201+
case 15: // Right
202+
this.moveFocusByKey(new Event(""), 1, 0);
203+
break;
204+
case 14: // Left
205+
this.moveFocusByKey(new Event(""), -1, 0);
206+
break;
207+
case 12: // Up
208+
this.moveFocusByKey(new Event(""), 0, -1);
209+
break;
210+
case 13: // Down
211+
this.moveFocusByKey(new Event(""), 0, 1);
212+
break;
213+
}
214+
}
215+
164216
@bind
165217
private _doManualDomHandling(stateChange: StateChange) {
166218
if (!stateChange.gridChanges) {
@@ -245,7 +297,8 @@ export default class Board extends Component<Props, State> {
245297
const showFocusStyle =
246298
button.classList.contains("focus-visible") ||
247299
isFeaturePhone ||
248-
cellFocusMode;
300+
cellFocusMode ||
301+
gamepad.isGamepadConnected;
249302

250303
if (!showFocusStyle) {
251304
this.props.renderer.setFocus(-1, -1);
@@ -318,7 +371,7 @@ export default class Board extends Component<Props, State> {
318371
}
319372

320373
@bind
321-
private moveFocusByKey(event: KeyboardEvent, h: number, v: number) {
374+
private moveFocusByKey(event: Event, h: number, v: number) {
322375
event.stopPropagation();
323376
event.preventDefault();
324377

src/main/services/preact-canvas/components/game/index.tsx

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { GameChangeCallback } from "src/main/services/preact-canvas";
1818
import { submitTime } from "src/main/services/state/best-times";
1919
import { supportsVibration } from "src/main/services/state/vibration-preference";
2020
import { vibrationLength } from "src/main/utils/constants";
21+
import { gamepad } from "src/main/utils/gamepad";
2122
import { isFeaturePhone } from "src/main/utils/static-display";
2223
import { bind } from "src/utils/bind";
2324
import { StateChange } from "src/worker/gamelogic";
@@ -33,6 +34,9 @@ import {
3334
exitRow,
3435
exitRowInner,
3536
game as gameClass,
37+
gamepadButton,
38+
gamepadButtonA,
39+
gamepadButtonB,
3640
mainButton,
3741
shortcutKey
3842
} from "./style.css";
@@ -50,6 +54,7 @@ export interface Props {
5054
useMotion: boolean;
5155
bestTime?: number;
5256
useVibration: boolean;
57+
isGamepadConnected: boolean;
5358
}
5459

5560
interface State {
@@ -96,7 +101,8 @@ export default class Game extends Component<Props, State> {
96101
gameChangeUnsubscribe,
97102
toRevealTotal,
98103
useMotion,
99-
bestTime: previousBestTime
104+
bestTime: previousBestTime,
105+
isGamepadConnected
100106
}: Props,
101107
{ playMode, toReveal, animator, renderer, completeTime, bestTime }: State
102108
) {
@@ -123,6 +129,7 @@ export default class Game extends Component<Props, State> {
123129
height={height}
124130
mines={mines}
125131
useMotion={this.props.useMotion}
132+
isGamepadConnected={isGamepadConnected}
126133
/>
127134
) : renderer && animator ? (
128135
[
@@ -132,6 +139,9 @@ export default class Game extends Component<Props, State> {
132139
dangerMode={dangerMode}
133140
animator={animator}
134141
renderer={renderer}
142+
interactive={
143+
playMode === PlayMode.Pending || playMode === PlayMode.Playing
144+
}
135145
gameChangeSubscribe={gameChangeSubscribe}
136146
gameChangeUnsubscribe={gameChangeUnsubscribe}
137147
onCellClick={this.onCellClick}
@@ -150,10 +160,24 @@ export default class Game extends Component<Props, State> {
150160
#
151161
</span>
152162
)}{" "}
163+
{isGamepadConnected ? (
164+
<span class={[gamepadButton, gamepadButtonA].join(" ")}>
165+
A
166+
</span>
167+
) : (
168+
""
169+
)}{" "}
153170
Try again
154171
</button>
155172
<button class={mainButton} onClick={this.onReset}>
156173
{isFeaturePhone ? <span class={shortcutKey}>*</span> : ""}{" "}
174+
{isGamepadConnected ? (
175+
<span class={[gamepadButton, gamepadButtonB].join(" ")}>
176+
B
177+
</span>
178+
) : (
179+
""
180+
)}{" "}
157181
Main menu
158182
</button>
159183
</div>
@@ -175,11 +199,13 @@ export default class Game extends Component<Props, State> {
175199
this.props.onDangerModeChange(true);
176200
}
177201
window.addEventListener("keyup", this.onKeyUp);
202+
gamepad.addButtonDownCallback(this.onGamepadButtonDown);
178203
}
179204

180205
componentWillUnmount() {
181206
this.props.gameChangeUnsubscribe(this.onGameChange);
182207
window.removeEventListener("keyup", this.onKeyUp);
208+
gamepad.removeButtonDownCallback(this.onGamepadButtonDown);
183209
}
184210

185211
componentDidUpdate(_: Props, previousState: State) {
@@ -230,12 +256,31 @@ export default class Game extends Component<Props, State> {
230256

231257
@bind
232258
private onKeyUp(event: KeyboardEvent) {
233-
if (event.key === "#") {
234-
if (this.state.playMode === PlayMode.Lost) {
259+
if (
260+
this.state.playMode === PlayMode.Won ||
261+
this.state.playMode === PlayMode.Lost
262+
) {
263+
if (event.key === "#") {
264+
this.onRestart();
265+
} else if (event.key === "*") {
266+
this.onReset();
267+
}
268+
}
269+
}
270+
271+
@bind
272+
private onGamepadButtonDown(buttonId: number) {
273+
if (
274+
this.state.playMode === PlayMode.Won ||
275+
this.state.playMode === PlayMode.Lost
276+
) {
277+
if (buttonId === 0) {
278+
// A
235279
this.onRestart();
280+
} else if (buttonId === 1) {
281+
// B
282+
this.onReset();
236283
}
237-
} else if (event.key === "*") {
238-
this.onReset();
239284
}
240285
}
241286

src/main/services/preact-canvas/components/game/style.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,15 @@
7878
/** PostCSS adding classes in wrong order :( */
7979
border-color: #000 !important;
8080
}
81+
82+
.gamepad-button {
83+
composes: gamepadbutton from "../../utils.css";
84+
}
85+
86+
.gamepad-button-a {
87+
color: var(--color-gamepad-a);
88+
}
89+
90+
.gamepad-button-b {
91+
color: var(--color-gamepad-b);
92+
}

0 commit comments

Comments
 (0)