Keyboard game input map
In the previous tutorial we setup a basic 2D keyboard player controller. The result was us being able to control a square witht the w, a, s and d
keys and fire four actions via the f, g, h and j
keys.
In this tutorial we will extend our Javascript / Typescript html game by allowing custom keyboard input key mappings for the player controls.
This means that pressing the same key could fire two different actions or one action could be fired by multiple keys. This is a standard feature of most games input systems allowing the player to customise their controls to suit their style.
Power of a game input framework
I mentioned by the end of the course we would have an isolated framework for handling input but what does that actually mean?
Handling input is a general concern for most games. The key is to separate and generalise the logic and responsibility for handling input from the logic that decides what effect should occur within the game given that input. If this can be achieved then the input handling logic can be reused across many different games.
In order to help make this distinction clear we are going to store all the input
specific code in its own directory.
Add a new input
directory to the src
directory.
Updating the game input types
Lets also add a types.ts
file inside the input
directory to store our input related types.
Add the following code to the input types.ts
file.
export type SupportedKeys = 'w' | 'a' | 's' | 'd' | 'f' | 'g' | 'h' | 'j'
export type GameKeyboardMapping = {
direction: {
up: SupportedKeys[],
down: SupportedKeys[],
left: SupportedKeys[],
right: SupportedKeys[]
},
buttons: {
primary: SupportedKeys[],
secondary: SupportedKeys[],
button3: SupportedKeys[],
button4: SupportedKeys[],
}
}
export type PlayerInput = {
keyboardMapping: GameKeyboardMapping
}
export type PlayerInputs = {
player1: PlayerInput
}
The SupportedKeys
type represents all the different HTML keyboard event key codes our game will support.
The GameKeyboardMapping
type connects the games different actions to an array of SupportedKeys
, it has a key mapping for each of the 4 directions and actions.
The PlayerInput
type holds a GameKeyboardMapping
and the PlayerInputs
sets a player1
field to PlayerInput
, this will be extended later to support multiple players.
Updating the gamestate
Add a Constants.ts
file to the input
directory to hold the input related variables.
Add a KeyboardMappingNeutral
type to the constants
file to set a default GameKeyboardMapping
. The direction values are set to the standard w, a, s, d
keys and the actions to f, g, h, j
.
export const KeyboardMappingNeutral: GameKeyboardMapping = {
direction: {
up: ['w'],
down: ['s'],
right: ['d'],
left: ['a']
},
buttons: {
primary: ['f'],
secondary: ['g'],
button3: ['h'],
button4: ['j']
}
}
We want to use the new mapping type in the GameState
. Modify the GameState
type as below.
export type GameState = {
player1: PlayerState,
inputs: {
playerInputMappings: PlayerInputs
}
}
Be careful to add the PlayerInputs
import and ensure it is importing the .js
file suffix as below.
import { PlayerInputs } from "../input/types.js"
If we miss the .js
suffix in the import after building and running the app we will encounter a similar error as below if we try and refresh the page.
"GET /input/types" Error (404): "Not found"
In GameConstants.ts
update the GameState as below by adding the inputs field.
export const InitialGameState: GameState = {
player1: InitialPlayerState,
inputs: {
playerInputMappings: {
player1: {
keyboardMapping: KeyboardMappingNeutral
}
}
}
}
Updating the keyboard controller
The KeyboardManager
code is specific to our input framework, lets move it to the input folder and update the imports.
Now we can use the player keyboard mappings to check if certain directions or actions are being pressed or un-pressed when a keyboard event occurs.
Modify the keyDown
and keyUp
methods of the KeyboardManager
as below.
import GameRuntime from "../GameRuntime.js"
export class KeyboardManager {
private static keyDown = (event: KeyboardEvent) => {
const player1KeyboardMapping = GameRuntime.gameState.inputs.playerInputMappings.player1.keyboardMapping
const up = player1KeyboardMapping.direction.up.find(key => key === event.key)
if (up) {
GameRuntime.gameState.player1.direction.vertical = -1
}
const down = player1KeyboardMapping.direction.down.find(key => key === event.key)
if (down) {
GameRuntime.gameState.player1.direction.vertical = 1
}
const left = player1KeyboardMapping.direction.left.find(key => key === event.key)
if (left) {
GameRuntime.gameState.player1.direction.horizontal = -1
}
const right = player1KeyboardMapping.direction.right.find(key => key === event.key)
if (right) {
GameRuntime.gameState.player1.direction.horizontal = 1
}
const bottomAction = player1KeyboardMapping.buttons.primary.find(key => key === event.key)
if (bottomAction) {
GameRuntime.gameState.player1.bottom = true
}
const rightAction = player1KeyboardMapping.buttons.secondary.find(key => key === event.key)
if (rightAction) {
GameRuntime.gameState.player1.right = true
}
const leftAction = player1KeyboardMapping.buttons.button3.find(key => key === event.key)
if (leftAction) {
GameRuntime.gameState.player1.left = true
}
const topAction = player1KeyboardMapping.buttons.button4.find(key => key === event.key)
if (topAction) {
GameRuntime.gameState.player1.top = true
}
}
private static keyUp = (event: KeyboardEvent) => {
const player1KeyboardMapping = GameRuntime.gameState.inputs.playerInputMappings.player1.keyboardMapping
const up = player1KeyboardMapping.direction.up.find(key => key === event.key)
if (up) {
GameRuntime.gameState.player1.direction.vertical = 0
}
const down = player1KeyboardMapping.direction.down.find(key => key === event.key)
if (down) {
GameRuntime.gameState.player1.direction.vertical = 0
}
const left = player1KeyboardMapping.direction.left.find(key => key === event.key)
if (left) {
GameRuntime.gameState.player1.direction.horizontal = 0
}
const right = player1KeyboardMapping.direction.right.find(key => key === event.key)
if (right) {
GameRuntime.gameState.player1.direction.horizontal = 0
}
const bottomAction = player1KeyboardMapping.buttons.primary.find(key => key === event.key)
if (bottomAction) {
GameRuntime.gameState.player1.bottom = false
}
const rightAction = player1KeyboardMapping.buttons.secondary.find(key => key === event.key)
if (rightAction) {
GameRuntime.gameState.player1.right = false
}
const leftAction = player1KeyboardMapping.buttons.button3.find(key => key === event.key)
if (leftAction) {
GameRuntime.gameState.player1.left = false
}
const topAction = player1KeyboardMapping.buttons.button4.find(key => key === event.key)
if (topAction) {
GameRuntime.gameState.player1.top = false
}
}
constructor() {
window.addEventListener('keydown', KeyboardManager.keyDown)
window.addEventListener('keyup', KeyboardManager.keyUp)
}
}
The code is similar to before but this time we are using the dynamic player keyboard mappings when deciding what player state changes to make for a given key event.
In both methods we are first getting a reference to the keyboard mappings. When a keyboard event is fired we compare the event code to any of the keys mapped to a particular aciton. If the keyDown
method is called and we get a match for a given action we set the respective player state to true
for actions.
Run the app and check that the player can be controlled by the w, a, s, d
keys and f, g, h, j
action keys.
Change the key input mappings
Modify KeyboardMappingNeutral
and set the right
direction to ['w','d']
. Now start the app and press w
, notice how the player moves diagonally upwards and to the right. This is because we have set the key mapping for w
to be both the up and right key. If you wanted to make w
make the player go down and s
make the player go up, or extend our SupportedKeys
this is now easy to do by changing the player's input mappings.
Summary
Having the ability to map custom keys is great but you may notice that the player still stalls when the direction keys are pressed in quick succession. Also doesn't the square look a little lonely and what if we wanted to plug in a gamepad?
We'll tackle all of these concerns throughout the rest of the course!