Command pattern standard input
The previous tutorial covered the theory behind the command design pattern and why it is used prolifically for handling input in games. A high level example of how a game handles input was presented and in this tutorial we will have a go at implementing this for our javascript / typescript input framework.
We will use the diagram presented previously below to refactor our current code to implement our own version of the command pattern.
Storing HTML keyboard input event state
As we can see above we need a way to store the low level keyboard input events.
Extend the input types file by adding a new ButtonState
and KeyboardState
type.
export type ButtonState = 'pressed' | 'not-pressed'
export type KeyboardState = Record<SupportedKeys, ButtonState>
The ButtonState
is a simple string union type representing the state of a button and whether or not it is pressed.
The KeyboardState
represents the current state of the keyboard by storing whether each one of its SupportedKey
's are pressed or not. It uses typescript's record type which is a key value data structure, in this case the key is of type SupportedKey
and the value is of type ButtonState
.
Define a default KeybardStateNeutral
constant in the input Constants.ts
file as below.
export const KeyboardStateNeutral: KeyboardState = {
w: 'not-pressed',
a: 'not-pressed',
s: 'not-pressed',
d: 'not-pressed',
f: 'not-pressed',
g: 'not-pressed',
h: 'not-pressed',
j: 'not-pressed'
}
Now we need to ensure the keyboard state is kept up to date. In the KeyboardManager.ts
add a new static keyboardState
constant as below.
static keyboardState: KeyboardState = KeyboardStateNeutral
We want to take a HTML keyboard event.key
which is of type string and try convert it to a SupportedKey
type so that we can update the keyboard state.
The below code leverages typescript's input is type
syntax to check whether an input meets some conditional criteria by returning a boolean. If the below method returns true
then typescript will allow us to treat the key
input as a SupportedKey
.
The condition below checks if key is one of the SupportedKey
types. Add the following method to the KeybaordManager
as below.
private static isSupportedKey(key: string): key is SupportedKeys {
return (key === 'w' || key === 'a' || key === 's' || key === 'd' || key === 'f' || key === 'g' || key === 'h' || key === 'j' || key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowRight' || key === 'ArrowLeft')
}
At the bottom of the keyDown
method add the following code to update the keyboard state.
if (KeyboardManager.isSupportedKey(event.key)) {
KeyboardManager.keyboardState[event.key] = 'pressed'
}
At the bottom of the keyUp
method add the following code to update the keyboard state.
if (KeyboardManager.isSupportedKey(event.key)) {
KeyboardManager.keyboardState[event.key] = 'not-pressed'
}
These two if statements will take care of checking if the current event.key
can be cast to a SupportedKey
type. If it can we can then update the respective key's value in the keyboardState
to match the event that has occurred, pressed
for keyDown
and not-pressed
for keyUp
.
In GameRuntime.ts
's draw method just under the last context debug log
add the following log to show the current keyboard state.
context.fillText(`KS: ${JSON.stringify(KeyboardManager.keyboardState)}`, 0, 60)
Run the game and confirm that when you press a SupportedKey
on the keyboard the keyboard state updates the respective key's value to pressed
, confirm that letting go switches the value back to not-pressed
.
Adding an Input State Manager
The keyboardState
provides us a way to access the low level keyboard state.
Because we want to support input types other than keyboard lets encapsulate retrieval of this low level state in a new InputStateManager
.
Extend the input types.ts
file to include a new InputState
type as below.
export type InputState = {
keyboard: KeyboardState
}
Create a new InputStateManager.ts
file in the input directory and add the following code.
export class InputStateManager {
static getInputs = (): InputState => {
const keyboardState = KeyboardManager.keyboardState
return {
keyboard: keyboardState
}
}
}
Defining Standard Game Input
As shown in the diagram above we need a type to represent the high level game action state.
Create a new StandardGameInput
type in the input types.ts
file as below.
export type DirectionState = -1 | 0 | 1
export type StandardGameInput = {
axis: {
discrete: {
x: DirectionState,
y: DirectionState
}
},
actions: {
primary: ButtonState,
secondary: ButtonState,
button3: ButtonState,
button4: ButtonState
}
}
export type StandardGameInputFourPlayer = {
player1: StandardGameInput
}
We have copied the DirectionState
type added in an earlier tutorial, this is okay, the type is required in both the input framework
and PlayerState
.
The StandardGameInput
type represents all the possible action states our game input supports. The direction in both horizontal and vertical can be negative (-1), neutral (0) or positive (1). The actions are either enabled (pressed) or not (not-pressed).
For the axis
I have chosen to add a discrete
field which will hold integer
values to represent direction. In a following tutorial we will add a new continuous
field to represent a typical continuous joystick
direction.
The StandardGameInputFourPlayer
is an extra wrapping type that will allow us to extend the input
system to support up to 4 players in a later tutorial. Even though it only contains one player's input we have named the type StandardGameInputFourPlayer
to make refactoring easier.
-2Next add a default StandardGameInputStateNeutral
to the input Constants.ts
file as below to represent a dormant input.
export const StandardGameInputStateNeutral: StandardGameInput = {
axis: {
discrete: {
x: 0,
y: 0
},
},
actions: {
primary: 'not-pressed',
secondary: 'not-pressed',
button3: 'not-pressed',
button4: 'not-pressed'
}
}
In the games types.ts
file update the GameState
type's inputs
field to include a standardGameInputFourPlayer
field set to the StandardGameInputFourPlayer
type, the GameState
type should match below.
export type GameState = {
player1: PlayerState,
inputs: {
standardGameInputFourPlayer: StandardGameInputFourPlayer,
playerInputMappings: PlayerInputs
}
}
Finally update the GameConstants.ts
InitialGameState
constant to include a field of standardGameInputFourPlayer
set to StandardGameInputStateNeutral
. The InitialGameState
should match below.
export const InitialGameState: GameState = {
player1: InitialPlayerState,
inputs: {
standardGameInputFourPlayer: {
player1: StandardGameInputStateNeutral
},
playerInputMappings: {
player1: {
keyboardMapping: KeyboardMappingNeutral
}
}
}
}
Transforming low level keyboard events
Now given we have a keyboardState
and the player's keyboard mappings it should be possible to transform the low level keyboardState into a high level StandardGameInput
.
Create a new KeyboardTransformer.ts
class in the input directory with the below code.
export class KeyboardTransformer {
private static getButtonState = (keys: SupportedKeys[], keyboard: KeyboardState): ButtonState => {
const keyStates: ButtonState[] = keys.map(key => {
const keyState = keyboard[key]
return keyState
})
return keyStates.find(key => key === 'pressed') ? 'pressed' : 'not-pressed'
}
private static resolveAxisState = (negativeDirection: ButtonState, positiveDirection: ButtonState): 0 | -1 | 1 => {
if (negativeDirection === 'pressed' && positiveDirection === 'not-pressed') {
return -1
} else if (positiveDirection ==='pressed' && negativeDirection === 'not-pressed') {
return 1
} else {
return 0
}
}
static transform = (keyboard: KeyboardState, playerKeyMappings: GameKeyboardMapping): StandardGameInput => {
const upActive = KeyboardTransformer.getButtonState(playerKeyMappings.direction.up, keyboard)
const downActive = KeyboardTransformer.getButtonState(playerKeyMappings.direction.down, keyboard)
const leftActive = KeyboardTransformer.getButtonState(playerKeyMappings.direction.left, keyboard)
const rightActive = KeyboardTransformer.getButtonState(playerKeyMappings.direction.right, keyboard)
const primaryActive = KeyboardTransformer.getButtonState(playerKeyMappings.buttons.primary, keyboard)
const secondaryActive = KeyboardTransformer.getButtonState(playerKeyMappings.buttons.secondary, keyboard)
const button3Active = KeyboardTransformer.getButtonState(playerKeyMappings.buttons.button3, keyboard)
const button4Active = KeyboardTransformer.getButtonState(playerKeyMappings.buttons.button4, keyboard)
const x = KeyboardTransformer.resolveAxisState(leftActive, rightActive)
const y = KeyboardTransformer.resolveAxisState(upActive, downActive)
return {
axis: {
discrete: {
x: x,
y: y
}
},
actions: {
primary: primaryActive,
secondary: secondaryActive,
button3: button3Active,
button4: button4Active
}
}
}
}
This is a bit of code but hopefully with some explanation it will become clear what the KeyboardTransformer
's responsibility is.
The getButtonState
method given an array of SupportedKey
and a KeyboardState
will determine if at least one of the keys is in the supported keys array is pressed.
The resolveAxisState
method given a negative and positive direction (ie left and right, up and down) will return a DirectionState
.
The transform
method builds up a buttonState for every action we care about, the four directions and four different buttons. It then resolves the axis DirectionState
for the horizontal and vertical directions. Finally it returns the results as a StandardGameInput
type.
Adding an Input Manager
As shown in the diagram above we need an InputManager
that will be responsible for taking the low level InputState
and converting it to high level game actions.
Add a new InputManager.ts
file in the input directory with the following code.
export class InputManager {
static getInputs = (playerInput: PlayerInput): StandardGameInput => {
const inputState: InputState = InputStateManager.getInputs()
return KeyboardTransformer.transform(inputState.keyboard, playerInput.keyboardMapping)
}
}
The getInputs
method takes a PlayerInput
which holds the players keyboard mappings and returns a StandardGameInput
.
It retrieves the low level InputState
via the InputStateManager
and then uses that along with the player keyboard mappings to transform into a StandardGameInput
with the help of the KeyboardTransformer
.
Updating player state
We can now use our StandardGameInput
and current player state to determine the new player state.
Update the Player
's getPlayerState
method to retrieve the player's new updated position as below.
static getPlayerState = (gameInput: StandardGameInput, currentState: PlayerState): PlayerState => {
return {
position: Player.updatePlayerPosition(currentState.position, currentState.direction),
direction: {
horizontal: gameInput.axis.discrete.x,
vertical: gameInput.axis.discrete.y
},
bottom: gameInput.actions.primary === 'pressed',
right: gameInput.actions.secondary === 'pressed',
left: gameInput.actions.button3 === 'pressed',
top: gameInput.actions.button4 === 'pressed',
}
}
Using the new input
From GameRuntime
's loop
method lets retrieve the input from the InputManager
, use it to retrieve the player1State
and then update the relevant GameState
fields with the result. The loop
method should match below.
static loop = (context: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number) => {
const inputs = InputManager.getInputs(GameRuntime.gameState.inputs.playerInputMappings.player1)
const player1State = Player.getPlayerState(inputs, GameRuntime.gameState.player1)
GameRuntime.gameState.player1 = player1State
GameRuntime.gameState.inputs.standardGameInputFourPlayer.player1 = inputs
GameRuntime.draw(context, canvasWidth, canvasHeight, GameRuntime.gameState)
}
Run the game and confirm that the square moves when the current keyboard direction keys are pressed.
Remove old keyboard code
Now that everything is working with our new InputManager
we can remove the old code that was directly updating the GameState
in the KeybaordManager
.
Remove all the player1KeyboardMapping
code from the keyDown
and keyUp
methods. The KeyboardManager
's keyDown
and keyUp
methods should look like below.
private static keyDown = (event: KeyboardEvent) => {
if (KeyboardManager.isSupportedKey(event.key)) {
KeyboardManager.keyboardState[event.key] = 'pressed'
}
}
private static keyUp = (event: KeyboardEvent) => {
if (KeyboardManager.isSupportedKey(event.key)) {
KeyboardManager.keyboardState[event.key] = 'not-pressed'
}
}
Run the app and confirm everything still works. Notice that when we press multiple direction keys quickly the jittering effect is no longer present. This effect was caused because we were updating the state directly and not taking into account the directions equivalent opposite direction when doing so. This means that the gameState.player1.direction.vertical
state could be written to multiple times undoing the previous update. Now instead of updating the gameState straight away we are using the resolveAxisState
method in the KeyboardTransformer
to determine the overall horizontal
and vertical
, taking into account the left and right
and up and down
states respectively. This creates a smoother transition for when we hold down multiple directions at once. Note that depending on the game we might want this jitter effect or some other effect, ie old school pacman allows a player to press record key strokes even when pacman can't move in that direction and pacman will move in the desired direction when it next can, it's up to us as game developers to interpret the input state in a way that is conformant to the games desired restrictions.
Summary
Hopefully you made it this far, I admit this tutorial is a bit messy and I think that represents how intertwined and coupled the original code was. The code is in a good generic state now though and the rest of this course is smooth sailing I assure you!
Now that we have our StandardGameInput
pattern in place we will explore in the next tutorial how easy it is to adapt this pattern to fit supporting the Gamepad
as an input.