Intro
In the previous two tutorials we explored the command pattern and then we implemented a lightweight version to handle keyboard input for our Javascript / Typescript game project. In this tutorial we are going to reap the rewards the command design pattern gives us by adding support for a gamepad. To complete this tutorial you will need a HTML supported gamepad, most USB gamepad controller's should be recognised by the gamepad API.
By the end of this tutorial you will be able to use the Gamepad to play our mini Game
.
Connecting to a gamepad using the HTML gamepad API
The great news is the browser already supports gamepads out of the box, no further libraries are required!
Follow the steps below to connect a gamepad.
- Plug the gamepad into a USB slot on your device.
- Ensure the gamepad is turned on
- Open Chrome browser to any page
- Right click and select
inspect
, this will open up the Chrome dev tools - Select the
Console
tab - Press a few buttons or wiggle the joystick on the gamepad
- Type
navigator.getGamepads()
into the browser console
If everything worked you should see an array of optional gamepads returned.
Something similar to this [null, Gamepad, null, null]
.
The Gamepad
in the array shows that the Gamepad
was detected correctly, if you were to connect another Gamepad
and run the command above then the Array would contain two Gamepad
's.
If you select the dropdown next to the console Array result and then select the Gamepad
dropdown you will see various information about the Gamepad
, such as the current axes
(Joystick / D-pad) and buttons
state.
Try holding down some buttons or the joystick to the left
and then running the command above again. If you look at the Gamepad state you should see in the buttons
Array some of the buttons will have a pressed
state and the axes
should have a value other than 0. We will use these states when we poll for our Gamepad
input.
If you don't see the Gamepad
in the Array then you may need to go over the above steps. If you still don't see it and you have your gamepads model number you can Google Connecting Model number
to Gamepad API and see if anyone else is able to get that Gamepad to work, from my experience the Browser finds most Gamepad's I've tried to plugin.
Here is the MDN Gamepad API docs if you want to learn more about the API in detail.
Now that we can connect the gamepad it's time to incorporate Gamepad input into our game via the command pattern.
Supporting multiple input devices
Before we add Gamepad
support we will first add a new InputType
to the PlayerInput
type. This will allow us to set the player's current input device and allow them to switch between Keyboard
and Gamepad
.
Add the following new types to the input
types.ts
file.
export type Gamepad = {id: 0} | {id: 1} | {id: 2} | {id: 3}
export type InputType = 'keyboard' | Gamepad
The we only have one keyboard
to support it is represented by a simple typescript string
type. We will support 4 Gamepads (This is the amount of Gamepads the browser supports) and so we will represent them by an object with an Id set to 0, 1, 2 or 3.
Next add the current
of type InputType
to the PlayerInput
type which will look as below.
export type PlayerInput = {
current: InputType,
keyboardMapping: GameKeyboardMapping
}
In the inputs.playerInputMappings.player1
field of the InitialGameState
constant in the GameConstants.ts
file add a current field set to keyboard
as below.
current: 'keyboard'
In the InputManager
add a new method called getStandardGameInput
and add the following code.
private static getStandardGameInput = (playerInput: PlayerInput, inputState: InputState): StandardGameInput => {
if (playerInput.current === 'keyboard') {
return KeyboardTransformer.transform(inputState.keyboard, playerInput.keyboardMapping)
} else {
return StandardGameInputStateNeutral
}
}
The above code check the playerInput
's current
value, if it's keyboard it will call the KeyboardTransformer
as before to transform the low level input to the high level StandardGameInput
, if it's not it will return the StandardGameInputStateNeutral
state for now.
Finally update the InputManager
's getInputs
method to call the getStandardGameInput
method instead of calling the KeyboardTransformer
directly, it should look like the code below.
static getInputs = (playerInput: PlayerInput): StandardGameInput => {
const inputState: InputState = InputStateManager.getInputs()
return InputManager.getStandardGameInput(playerInput, inputState)
}
Now run the app and check that the keyboard input is still working, direction keys should move the square and the f, g, h, j
keys should activate the different actions (coloured squares).
Adding gamepad mappings
Okay I promise that was the last bit of setup code, we're now going to add the Gamepad!
We'll start by introducing a few more Gamepad
specific types to the input types.ts
file as below.
export type SupportedButtons = 0 | 1 | 2 | 3
export type AxisDirections = 'horizontal' | 'vertical'
export type GamepadMapping = {
direction: {
horizontal: AxisDirections,
vertical: AxisDirections
},
actions: {
primary: SupportedButtons[],
secondary: SupportedButtons[],
button3: SupportedButtons[],
button4: SupportedButtons[]
}
}
The HTML Gamepad API maps the buttons of a Gamepad to a number as you experienced when we tested the Gamepad. We'll use the SupportedButtons
type to represent these numbers, we'll only support the first 4 buttons but it should be easy to add support for the rest if you wish.
the AxisDirections
will be used to support joystick
inversion.
The GamepadMapping
type allows a player to map their desired Gamepad input. Similar to the keyboard. The four actions can be mapped to one or more of the SupportedButtons
and each axes
can be mapped to an AxisDirection
.
Update the PlayerInput
type to include the gamepadMapping
field as below.
export type PlayerInput = {
current: InputType,
gamepadMapping: GamepadMapping,
keyboardMapping: GameKeyboardMapping
}
This will allow a player to set their desired gamepadMapping.
Add a new constant called GamepadMappingNeutral
to the Constants.ts
file in the input directory as below.
export const GamepadMappingNeutral: GamepadMapping = {
direction: {
horizontal: 'horizontal',
vertical: 'vertical'
},
actions: {
primary: [0],
secondary: [1],
button3: [2],
button4: [3]
}
}
This will be our standard GamepadMapping
.
In the GameConstants.ts
file update the InitialGameState
inputs.playerInputMappings.player1
to include the new field gamepadMapping
and set it to GamepadMappingNeutral
as below.
gamepadMapping: GamepadMappingNeutral
Run the app and check that everything still works. We have added gamepad support to our GameState
but we aren't using them yet.
Adding a GamepadTransformer
Similar to the keyboard we will need a GamepadTransformer
to transform the low level gamepad input into high level StandardGameInput
. Instead of creating our own GamepadManager
to store the low level gamepad inputs we will rely on the HTML Gamepad API for simplicity.
Add a new GamepadTransformer.ts
file to the inputs folder and add the code below.
import { ButtonState, DirectionState, GamepadMapping, StandardGameInput, SupportedButtons } from "./types.js"
export class GamepadTransformer {
static transform = (gamepad: Gamepad, playerGamepadMappings: GamepadMapping): StandardGameInput => {
const primaryActive = GamepadTransformer.getButtonState(playerGamepadMappings.actions.primary, gamepad)
const secondaryActive = GamepadTransformer.getButtonState(playerGamepadMappings.actions.secondary, gamepad)
const button3Active = GamepadTransformer.getButtonState(playerGamepadMappings.actions.button3, gamepad)
const button4Active = GamepadTransformer.getButtonState(playerGamepadMappings.actions.button4, gamepad)
const x = playerGamepadMappings.direction.horizontal === 'horizontal' ? gamepad.axes[0] : gamepad.axes[1]
const y = playerGamepadMappings.direction.vertical === 'horizontal' ? gamepad.axes[0] : gamepad.axes[1]
const discreteX = GamepadTransformer.getAxisDirection(x)
const discreteY = GamepadTransformer.getAxisDirection(y)
return {
axis: {
discrete: {
x: discreteX,
y: discreteY
}
},
actions: {
primary: primaryActive,
secondary: secondaryActive,
button3: button3Active,
button4: button4Active
}
}
}
private static getButtonState = (keys: SupportedButtons[], gamepad: Gamepad): ButtonState => {
const keyStates: ButtonState[] = keys.map(key => {
const keyState = gamepad.buttons[key].pressed ? 'pressed' : 'not-pressed'
return keyState
})
return keyStates.find(key => key === 'pressed') ? 'pressed' : 'not-pressed'
}
static getAxisDirection = (n: number): DirectionState => {
if (n < 0) {
return -1
} else if (n > 0) {
return 1
} else {
return 0
}
}
}
The getButtonState
method is very similar to the KeyboardTransformer
's getButtonState
method, given a Gamepad
and some SupportedKey
Array it checks if any of the SupportedKeys
are pressed.
The getAxisDirection
method, converts the gamepad's continuous axis values into discrete ones.
The transform
method computes the relevant button
states and axis
values and uses them to construct a StandardGameInput
which is returned. It is also mapping the direction by checking the players direction mappings which is what enables the inversion support.
Update the InputManager
's getStandardGameInput
method with the following code to support Gamepad
.
private static getStandardGameInput = (playerInput: PlayerInput, inputState: InputState, gamepads: (Gamepad | null)[]): StandardGameInput => {
if (playerInput.current === 'keyboard') {
return KeyboardTransformer.transform(inputState.keyboard, playerInput.keyboardMapping)
} else {
const gamepad = gamepads[playerInput.current.id]
if (gamepad !== null) {
return GamepadTransformer.transform(gamepad, playerInput.gamepadMapping)
} else {
return StandardGameInputStateNeutral
}
}
}
Because the Gamepad can be unplugged and become unavailable the Gamepad API returns either a Gamepad
or null
. If the Gamepad
is null we will return the StandardGameInputStateNeutral
. In a real game we would want to model this using some error flag to let the game know a gamepad cannot be found. The Gamepad
api also has a gamepadDisconnected
handler we could listen to and show a menu asking for the Gamepad to be reconnected but this is out of scope for this tutorial. If this happens in our game the player will stop moving as the neutral Gamepad state has the axis set to 0 in both directions, which is also a pretty standard game response when a controller is unplugged.
Next we need to update where we call getStandardGameInput
to include the new gamepads
argument. In getInputs
method retrieve the gamepads
via the navigator
and pass it into the getStandardGameInput
method call as an argument the getInputs
method should match below.
static getInputs = (playerInput: PlayerInput): StandardGameInput => {
const inputState: InputState = InputStateManager.getInputs()
const gamepads = navigator.getGamepads()
return InputManager.getStandardGameInput(playerInput, inputState, gamepads)
}
Testing the Gameapad
In the GameConstants.ts
file update the InitialGameState
's inputs.playerInputMappings.player1.current
to a Gamepad with id
equal to zero as below.
current: {id: 0}
Run the app and try using the Gamepad. You should be able to control our square friend with the Joystick and trigger the actions (coloured squares) with the first four buttons of your controller. Note if your gamepad is set to a different index in the Gamepad
Array you logged in the browser console then make sure that index is used instead of id: 0
.
The first time I connected a Gamepad to one of the web games I was working on was amazing! The Games just feel instantly more responsive and experimenting with physics (which we'll do in a future tutorial) has many possibilities with a gamepad and joystick input.
Adding a controller joystick threshold
You may have noticed that when you let go on your gamepad the player might continue to move. This is because the joystick is not perfectly centred, it will still have some small x
and y
axis value that causes the square to gradually move. If you flick the joystick to the left
and let go the square probably moves to the right
and vice versa. We can add a threshold
to treat small values (values under the threshold as 0).
Update GamepadTransformer
's getAxisDirection
method as below.
static getAxisDirection = (n: number, threshold: number): DirectionState => {
if (Math.abs(n) <= threshold) {
return 0
} else if (n > threshold) {
return 1
} else {
return -1
}
}
We have added a new threshold
parameter and are using it instead of 0 to check the DirectionState
. In the first condition we are taking the absolute value of n
and returning 0 if it is below the threshold.
In the transform method add a new constant threshold
of value 0.15
and update the code calling the getAxisDirection
method to include a new threshold as below.
const threshold = 0.15
const discreteX = GamepadTransformer.getAxisDirection(x, threshold)
const discreteY = GamepadTransformer.getAxisDirection(y, threshold)
Run the app again and see if the square will stop dead in its tracks now when you let go of the joystick
, that's better! You can tweak the threshold to your liking.
Adding continuous
Currently we only support a finite number of discrete
axis values -1, 0, 1
, this works for our game but it would be nice to support continuous direction supplied by the joystick
as well, we'll go ahead and add that support now.
Inside the axis
object of the StandardGameInput
add a new continuous
field, it should be on the same level as the discrete
field as below.
continuous: {
x: number,
y: number
}
In the Constants.ts
file in the inputs directory add the following continuous
object aside the discrete field in the StandardGameInputStateNeutral
.
continuous: {
x: 0,
y: 0
}
In the GamepadTransformer
's transform
method add a continuous object aside the discrete field setting the x and y values to the computed constants x and y.
continuous: {
x: x,
y: y
}
In KeyboardTransformer
's transform
method add a continuous object aside the discrete field setting the x and y values to the computed constants x and y.
continuous: {
x: x,
y: y
}
That's it, we're not going to do anything with these continuous values in this tutorial though you could log them to the canvas to confirm they are being set if you wanted to. In future tutorials though we will make use of them as they will come in handy in dealing with realistic joystick movement and 2D physics!
Summary
That's everything for this tutorial. Even though it was another long one I hope you can appreciate that the majority of the code is to do with how we handle the Gamepad inputs and not how we support inputs in general. Hopefully this is starting to demonstrate the power of the command design pattern!
In the next tutorial we will extend the framework to support 4 players all using different inputs.