HTML5 game controllers: keyboard and virtual joystick in ExcaliburJS - 3/11
This is an open-source project on game development with a focus on the HTML5 Excalibur game engine. Check out this feature implementation on this commit in our Github repository!
You can read here the list of features we are going to develop for our Excalibur game, such as scene transitions. If you're interested in game development, follow us on Twitter and Instagram to be caught up on our latest news!
Table of Contents
In this tutorial
In this tutorial, we set up a motion controller for our game character.
In the last tutorial we've added an animated character to our game. Now it's time to make it move! We'll move the player using keyboard inputs or using a nipplejs virtual joystick for touch-capable interfaces (e.g. smartphones).
Keyboard controller
Set up keyboard events listeners
We'll start by setting up keyboard listeners to move the player with the keyboard. From Excalibur documentation and from the source code of their gallery, we know that this is as easy as writing a couple of lines of code:
player.scene.engine.input.keyboard.on(Event.EventTypes.Release, () => {
player.vel.setTo(0, 0);
player.graphics.use(ActorAnimations.IDLE);
});
player.scene.engine.input.keyboard.on(Event.EventTypes.Hold, (evt) => {
let dir = Vector.Down;
let horizontalFlip = false;
switch (evt.key) {
case Input.Keys.A:
case Input.Keys.Left:
dir = Vector.Left;
horizontalFlip = true;
break;
case Input.Keys.D:
case Input.Keys.Right:
dir = Vector.Right;
break;
case Input.Keys.S:
case Input.Keys.Down:
dir = Vector.Down;
break;
case Input.Keys.W:
case Input.Keys.Up:
dir = Vector.Up;
break;
default:
return;
}
player.vel.setTo(dir.x, dir.y);
player.graphics.use(ActorAnimations.RUN);
We've taken these lines from the Excalibur gallery example. The code is quite easy to follow: the keyboard listener of the engine is set to listen for the Hold and Release events. Depending on the event, the player will stay still with zero velocity or move in a direction determined by the keyboard input. Here the engine listens to a WASD controller.
The only thing that we've added here is a module called ActorAnimations
which is a simple collector of strings:
export module ActorAnimations {
export const IDLE: AnimationName = 'idle';
export const RUN: AnimationName = 'run';
export const HURT: GraphicName = 'hurt';
}
export type AnimationName = 'idle' | 'run';
export type GraphicName = 'hurt';
Set up a MotionService to move the player
Now that we know what it's needed to listen to keyboard events, it's time to register them. This is the kind of task that seems right to be placed in a dedicated class. In our game, there will be only one controlled character, so we can use a service that keeps a reference to the only Excalibur actor which can be controlled by the user. Let's call this service MotionService
.
@Service()
export class MotionService {
private player: Actor;
private speedMultiplier: number = 80;
public setPlayer(actor: Actor) {
this.player = actor;
}
public registerKeyboardListeners(on: boolean): void {
const {Hold, Release} = Events.EventTypes;
if (!on) {
[Hold, Release].forEach(event => {
this.player.scene.engine.input.keyboard.off(event);
})
}
if (on) {
this.player.scene.engine.input.keyboard.on(Release, () => {
this.player.vel.setTo(0, 0);
this.player.graphics.use(ActorAnimations.IDLE);
});
this.player.scene.engine.input.keyboard.on(Hold, (evt) => {
let dir = Vector.Down;
let horizontalFlip = false;
switch (evt.key) {
case Input.Keys.A:
case Input.Keys.Left:
dir = Vector.Left;
horizontalFlip = true;
break;
case Input.Keys.D:
case Input.Keys.Right:
dir = Vector.Right;
break;
case Input.Keys.S:
case Input.Keys.Down:
dir = Vector.Down;
break;
case Input.Keys.W:
case Input.Keys.Up:
dir = Vector.Up;
break;
default:
return;
}
const speed: Speed = {x: dir.x * this.speedMultiplier, y: dir.y * this.speedMultiplier};
this.run(speed, horizontalFlip);
});
}
}
private run({x, y}: Speed, horizontalFlip: boolean) {
this.player.vel.setTo(x, y);
this.player.graphics.use(ActorAnimations.RUN);
this.player.graphics.getGraphic(ActorAnimations.RUN).flipHorizontal = horizontalFlip;
}
}
The MotionService
can be configured to register or unregister keyboard events (by calling registerKeyboardListeners(on: boolean)
) and keeps a reference to the Actor
that will be moved (the private player:Actor
) with the keyboard inputs.
The last detail that needs to be taken care of is the horizontal flip to apply to the player graphics if the user is moving it towards the left. This is accomplished in the run
method, which changes the actor graphics to the RUN one, and if necessary flips it. We've registered actor graphics in one of the previous tutorials, you can read about it here!
Last but not least, we need to set the player to the MotionService
, and enable the keyboard listeners by calling registerKeyboardListeners(true)
. In order to do so, let's reopen our Player.ts
class, which represents the Excalibur actor we're going to move. We'll add a couple of lines in its onInitialize()
method:
export class Player extends ExcaliburActor {
public type: ActorType = 'player';
private motionService: MotionService = Container.get(MotionService);
public onInitialize(_engine: Engine) {
super.onInitialize(_engine);
this.graphics.use(ActorAnimations.IDLE);
this.motionService.setPlayer(this);
this.motionService.registerKeyboardListeners(true);
}
}
And this is it! Now we can move the player using a WASD keyboard controller.
Set up a virtual joystick with nipplejs
We've set up the keyboard listeners for moving the player with the keyboard. But in the future, we'll release our cross-platform game on smartphones too, and on those devices, it would be impossible to play. We need to set up a different kind of motion controller. We've got more than luck on our side since the nipplejs the library does exactly what we need, and it does it well!
Let's start by installing the nipplejs
dependency. Run in the terminal:
npm i nipplejs
You should see the new dependency in your package.json
.
From the nipplejs documentation we know that we need an HTMLElement
with a set position:relative
in our DOM
. Open then the index.html
file and add a div
element with id="joystick"
in it:
<body>
...
<div class="joystick" id="joystick"></div>
<canvas id="game" style="display: block; margin: auto auto"></canvas>
...
</body>
The joystick
CSS class is defined as:
.joystick {
position: absolute;
bottom: 200px;
right: 200px;
}
Now we'll create a dedicated class to manage the virtual joystick. In the src/services
folder create a JoystickFactory
defined as:
@Service()
export class JoystickFactory {
private domService: DOMService = Container.get(DOMService);
private joystickManager: JoystickManager;
public getJoystick(): JoystickManager {
if (Util.isDefined(this.joystickManager)) {
return this.joystickManager;
}
const position: { bottom: string; right: string } = {bottom: '35%', right: '20%'};
const joystickDomElement: HTMLElement = this.domService.getElement('joystick');
this.joystickManager = nipplejs.create({
zone: joystickDomElement,
mode: 'static',
position,
color: 'black',
size: 150,
shape: 'circle',
});
return this.joystickManager;
}
public destroy(): void {
this.joystickManager?.destroy();
this.joystickManager = undefined;
}
}
As you can see, the JoystickFactory
service is responsible for creating, if not already done, the nipplejs
virtual joystick. The DOMService
, whose responsibility is to manage DOM elements, retrieves the div
we've added in the index.html
. That div
hosts the virtual joystick.
Now that we're able to create a virtual joystick, we need to make some adjustments to the MotionService
so that to be able to use the keyboard controller or the virtual joystick.
Let's back to the MotionService
and add a setMotionType
public method to choose between the keyboard controller and the virtual joystick one:
@Service()
export class MotionService {
...
public setMotionType(motionType: MotionTypes): void {
this.motionType = motionType;
if (this.motionType === 'keyboard') {
this.registerKeyboardListeners(true);
this.registerJoystickListener(false);
}
if (this.motionType === 'joystick') {
this.registerKeyboardListeners(false);
this.registerJoystickListener(true);
}
}
...
}
export type MotionTypes = 'keyboard' | 'joystick';
The registerKeyboardListeners(on:boolean)
method is the same method we've written at the beginning of this tutorial. Now it's time to write the registerJoystickListener(on:boolean)
one!
After some reading of the nipplejs documentation, we know that the joystick listeners look very similar to the keyboard ones:
private registerJoystickListener(on: boolean): void {
if (!on) {
this.joystickFactory.destroy();
}
if (on) {
const joystickManager: JoystickManager = this.joystickFactory.getJoystick();
joystickManager.on('move', (event, data) => {
const {vector, direction} = data;
if (direction?.x && direction?.y) {
const yMapper: number = this.yMapping[direction.y];
const speed: Speed = {x: vector.x * this.speedMultiplier, y: Math.abs(vector.y) * yMapper * this.speedMultiplier};
const horizontalFlip: boolean = direction.angle === 'left';
this.run(speed, horizontalFlip);
}
});
joystickManager.on('end', () => {
this.player.rotation = toRadians(0);
this.player.vel.setTo(0, 0);
this.player.graphics.use(ActorAnimations.IDLE);
});
}
}
where the yMapping
is a private field of the MotionService
to map the nipplejs
up and down directions to the Excalibur plus or minus sign:
private yMapping = {up: -1, down: 1};
Both the registerListeners
methods can be private since from outside we'll call only the setMotionType
of the MotionService
.
Let's have a closer look at the registerJoystickListener(on:boolean)
method.
When called with on=true
, we ask for the joystick to the JoystickFactory
we've seen before. After that it's all on the nipplejs
joystick: the move
event is triggered every time the user move the virtual joystick on the screen. The data
object we received contains the direction
information (up, left, down, right) and the vector
information, which represents the force unit vector. We can use this data to move the player accordingly, as it's done in the handler functions of the joystick manager listeners.
The very last thing we miss is to adjust the Player
class:
export class Player extends ExcaliburActor {
public type: ActorType = 'player';
private motionService: MotionService = Container.get(MotionService);
public onInitialize(_engine: Engine) {
super.onInitialize(_engine);
this.graphics.use(ActorAnimations.IDLE);
this.motionService.setPlayer(this);
this.motionService.setMotionType('keyboard');
}
}
As you can see, now we call the public setMotionType
MotionService
method instead of the registerKeyboardListeners
which has become private. In this moment we don't know on which device the game is running on, so we've chosen to set up the keyboard listeners by default, since we are developing using a browser. To check if the virtual joystick is working, we've added some development buttons to switch between the controllers. You can see the development buttons in the upper toolbar, which naturally won't be displayed in the release version of our game:
Did you like this article and wish there were more? Donate on Paypal so that I can write more!
Conclusions
In this tutorial, we've set up a motion controller to move our game character. We can configure the controller to use both the WASD keyboard inputs and a nipplejs
virtual joystick when using touch-capable interfaces. Since in the future we'll release our game also on the Google Play Store, this is all that we needed!
Next step now is to write some tests for the MotionService
we've just created. As you will see in the next tutorial, we'll need to set up a karma
runner
. We can't use jest as done with the other tests since to mock the Excalibur engine inputs would be quite a lot of work. An integration test is the best way to go, even though Karma requires some amount of configuration.
No more idle talk: let's set up Karma tests!