In the last post, we set up our react-app, added an InputManager
class to handle user input and added a first component to render a title-screen.
In this part, we will implement state management to switch from the title screen to the playing-screen, add a player-controlled ship
-class and finally some invaders for the player to fight!
State Management
To easily switch between the different components of our game, we need some sort of game state management. In a more complex game, it makes sense to create a separate class for that purpose. To keep things simple, we will keep all state management logic inside our App.js class. First, we will add a new enum
that represents the different game states above the class definition:
const GameState = {
StartScreen : 0,
Playing : 1,
GameOver : 2
};
Now, we can add a member variable of this enum
to our state in the constructor of App.js. We will set it to StartScreen
initially:
constructor() {
super();
this.state = {
input: new InputManager(),
screen: {
width: width,
height: height,
ratio: ratio
},
gameState: GameState.StartScreen
};
}
Finally, we can update our render()
method, to only draw the TitleScreen
in the initial state:
render() {
return (
<div>
{ this.state.gameState === GameState.StartScreen && <TitleScreen /> }
...
</div>
);
}
As you can see, I prepended the gameState
condition to the jsx-code
for drawing the title-screen, thus the title screen will be rendered only in the initial state.
The Game Loop
To actually switch from the initial state to the Playing
state, we have to wait for the user to press the Enter
button. Since we have to react to user-input continuously, we will create a game loop that runs as long as the application itself is running. To do so, we will add an update
method, that continuously calls itself after each run:
update(currentDelta) {
requestAnimationFrame(() => {this.update()});
}
As you can see, we simply call the requestAnimationFrame()
method to call the update()
method again after completion.
To start the requestAnimationFrame
loop, we have to call it once our component is loaded. The best place for this is the componentDidMount
method provided by React:
componentDidMount() {
this.state.input.bindKeys();
requestAnimationFrame(() => {this.update()});
}
As you can see, we simply added the last line from update
to the end of this method.
Now we can finally start reacting to user input:
update() {
const keys = this.state.input.pressedKeys;
if (this.state.gameState === GameState.StartScreen && keys.enter) {
this.startGame();
}
requestAnimationFrame(() => {this.update()});
}
First, we get the keys from our input-manager and store it in a local variable for easy access. Next, we check if the game is in StartScreen
state and the user pressed the enter button. If so, we will call a new helper method to start the game:
startGame() {
this.setState({
gameState: GameState.Playing
});
}
We will revisit this method later to add more initialization code. For now, it is only responsible for changing to the playing state.
Now, if you start the application and press the Enter key, the title screen should disappear.
Adding the Player
Now it’s time to add our first game object, the player! To keep all our game objects in the same location, we will first create a new folder in the src directory called GameComponents
. In there, we will define a new class Ship.js:
export default class Ship {
constructor(args){
this.position = args.position;
this.speed = args.speed;
this.radius = args.radius;
this.delete = false;
this.onDie = args.onDie;
}
}
Most of the properties should be self-explanatory. When the ship is destroyed, delete
will be set to true
and onDie
will be called.
As in App.js, we will now create and implement the update
method, which is responsible for updating the position of the ship according to user input:
update(keys) {
if (keys.right) {
this.position.x += this.speed;
} else if (keys.left) {
this.position.x -= this.speed;
}
}
As you can see, we simply increment/decrement the x coordinate of the position. This way, the ship will move left or right depending on the pressed keys, which are passed as a parameter.
Now we only have to implement the render
method to actually draw the ship
:
render(state) {
const context = state.context;
context.save();
context.translate(this.position.x, this.position.y);
context.strokeStyle = '#ffffff';
context.fillStyle = '#ffffff';
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, -25);
context.lineTo(15, 15);
context.lineTo(5, 7);
context.lineTo(-5, 7);
context.lineTo(-15, 15);
context.closePath();
context.fill();
context.stroke();
context.restore();
}
Here, we are using the basic functionality of HTML Canvas to draw some lines in the shape of the ship
and change its color to white to distinguish it from the black canvas.
Finally, we have to ensure our ship
stays within the frame. To do so, we will simply set the ship
's position.x
to 0
when it leaves the screen on the right side and to the width of the screen when it leaves on the left side:
if(this.position.x > state.screen.width) {
this.position.x = 0;
} else if(this.position.x < 0) {
this.position.x = state.screen.width;
}
Now we are ready to integrate our new Ship.js class into App.js. First, we have to add the context
property in the constructor, so it can be used by the Ship
's render
method:
...
context: null
};
this.ship = null;
I also added a new property to hold our ship
instance, which I also set to null
for now.
Then, we can initialize the context in the componentDidMount
method:
componentDidMount() {
this.state.input.bindKeys();
const context = this.refs.canvas.getContext('2d');
this.setState({ context: context });
requestAnimationFrame(() => {this.update()});
}
and the ship
in the startGame
method:
startGame() {
let ship = new Ship({
radius: 15,
speed: 2.5,
position: {
x: this.state.screen.width/2,
y: this.state.screen.height - 50
}});
this.ship = ship;
this.setState({
gameState: GameState.Playing
});
}
I set x
and y
positions so that the ship
will be drawn in the lower middle of the screen. Feel free to play around with these parameters.
We will provide the onDie
callback later. For now, let's add the missing calls to our ship
's update
and render
methods. Let’s take a look at the adjusted update
method:
update(currentDelta) {
const keys = this.state.input.pressedKeys;
if (this.state.gameState === GameState.StartScreen && keys.enter) {
this.startGame();
}
if (this.state.gameState === GameState.Playing) {
clearBackground();
if (this.ship !== undefined && this.ship !== null) {
this.ship.update(keys);
this.ship.render(this.state);
}
}
requestAnimationFrame(() => {this.update()});
}
As you can see, I added a new condition for the Playing
state because only in that case, we want to update and render our game components. To keep things simple, we will call both the ship
's update
and render
methods here.
Finally, take a look at the clearBackground
method, which cleans up the canvas
before draw new content onto it:
clearBackground() {
const context = this.state.context;
context.save();
context.scale(this.state.screen.ratio, this.state.screen.ratio);
context.fillRect(0, 0, this.state.screen.width, this.state.screen.height);
context.globalAlpha = 1;
}
Now, when you run the application and navigate to the Playing screen, you should see our ship
and be able to control it with the left and right arrow buttons (OR A and D keys).
Adding Enemies
In the last section of Part 2, I will show you how to add some simple invaders to our game. First, we will add a new Invader.js class inside the GameComponents directory. Since it is very similar to the Ship.js class, I will show you the entire code first and then walk you through it:
export const Direction = {
Left: 0,
Right: 1,
};
export default class Invader {
constructor (args) {
this.direction = Direction.Right;
this.position = args.position;
this.speed = args.speed;
this.radius = args.radius;
this.delete = false;
this.onDie = args.onDie;
}
reverse() {
if (this.direction === Direction.Right) {
this.position.x -= 10;
this.direction = Direction.Left;
} else {
this.direction = Direction.Right;
this.position.x += 10;
}
}
update() {
if (this.direction === Direction.Right) {
this.position.x += this.speed;
} else {
this.position.x -= this.speed;
}
}
render(state) {
const context = state.context;
context.save();
context.translate(this.position.x, this.position.y);
context.strokeStyle = '#F00';
context.fillStyle = '#F00';
context.lineWidth = 2;
context.beginPath();
context.moveTo(-5, 25);
context.arc(0, 25, 5, 0, Math.PI);
context.lineTo(5, 25);
context.lineTo(5, 0);
context.lineTo(15, 0);
context.lineTo(15, -15);
context.lineTo(-15, -15);
context.lineTo(-15, 0);
context.lineTo(-5, 0);
context.closePath();
context.fill();
context.stroke();
context.restore();
}
}
The render
works in the same way the ships render
method works, except that it draws a differently shaped spaceship facing down the screen instead of up. The update
again increments or decrements the positions x
coordinate. However, instead of reacting to user input, we check the Direction
-enum
to see, where the invader should move. This way, instead of jumping to the other side of the screen, they will simply turn around when they reach one edge of the screen.
Back in App.js, we will first add a new property to hold an array of invaders to our constructor:
...
this.ship = null;
this.invaders = [];
Now, we will add three new methods: createInvaders
, renderInvaders
and reverseInvaders
:
createInvaders(count) {
const newPosition = { x: 100, y: 20 };
let swapStartX = true;
for (var i = 0; i < count; i++) {
const invader = new Invader({
position: { x: newPosition.x, y: newPosition.y },
speed: 1,
radius: 50
});
newPosition.x += invader.radius + 20;
if (newPosition.x + invader.radius + 50 >= this.state.screen.width) {
newPosition.x = swapStartX ? 110 : 100;
swapStartX = !swapStartX;
newPosition.y += invader.radius + 20;
}
this.invaders.push(invader);
}
}
renderInvaders(state) {
let index = 0;
let reverse = false;
for (let invader of this.invaders) {
if (invader.delete) {
this.invaders.splice(index, 1);
}
else if (invader.position.x + invader.radius >= this.state.screen.width ||
invader.position.x - invader.radius <= 0) {
reverse = true;
}
else {
this.invaders[index].update();
this.invaders[index].render(state);
}
index++;
}
if (reverse) {
this.reverseInvaders();
}
}
reverseInvaders() {
let index = 0;
for (let invader of this.invaders) {
this.invaders[index].reverse();
this.invaders[index].position.y += 50;
index++;
}
}
createInvaders
initializes our invader's array and sets their positions. It places each invader on the right side of the previous one. If there is no space, the next invader will be drawn on a new row. renderInvaders
contains the logic for drawing our invaders and keeping them inbound while moving. If an invader is deleted (delete === true
), it will be removed from the array.
Finally, if any of our invaders reaches either edge of the screen, we call the reverseInvaders
method which in turn calls the reverse
method of each invader, thus changing its direction.
We will call createInvaders
from our startGame
method:
startGame() {
...
this.createInvaders(27);
this.setState({
...
}
Feel free to play around with the number of invaders you want to create.
Now, we only have to add a call to renderInvaders
to the update
method:
update() {
...
if (this.state.gameState === GameState.Playing) {
...
this.renderInvaders(this.state);
}
}
That’s it! Re-run the application and you should now see a bunch of red invader-spaceships slowly moving down towards the player. They won’t interact with each other yet, but we will work on that in the next part.
Again, thank you for reading this article. :)
If you have any questions, problems or feedback, please let me know in the comments.