I believe it’s high time we attempted to build a game with the engine we have so far. Wouldn’t you agree? We’ll be making Asteroids, which first made its debut in November 1979; released by Atari Inc.
There are so many ways we could implement the game:
- we could use sprite sheets, as we do support them now
- we could use code to draw the game elements
- we can even add sounds as the AssetsLoader class supports this
I believe there’s more learning value in trying to draw the game elements using JavaScript, so things like the player, bullets, asteroid, and star fields will all be individual classes.
We don’t have fancy things like collision yet, so the game will be pretty dull at first. But don’t worry, collision is definitely on the to-do list.
We’ll start with the player… we’ll need some sort of ship, that moves according to the VerletModel we previously implemented. The canvas cartesian coordinate system begins with x = 0 and y = 0 at the top-left corner, so anything we draw is relative to that; we want this ship:
The instructions necessary to draw this are: moveTo() point A then lineTo() points B, C, D and back to A. The line from D to A is optional, as we could simply call closePath(). We’ll reuse points B, C and D, with some offsets, to create the ship thruster.
We could create a utility method for drawing. It would take two parameters: a set of actions and a set of coordinates for each action. This method should be part of the DisplayObject class as anything that’s drawn, extends it.
We’re only using moveTo() and lineTo() for now, so that’s all our engine will be instructed to use:
import Scene from './Scene';
const allowedActions = ['moveTo', 'lineTo'];
export default class DisplayObject extends Scene {
constructor(x=0, y=0) { /* */ }
add(elem) { /* */ }
draw(actions, coordinates) {
for(let a of actions.keys()) {
if(allowedActions[actions[a]]) {
this.ctx[
allowedActions[actions[a]]
](...coordinates[a]);
}
}
}
remove(elem) { /* */ }
update() { /* */ }
};
The draw() method can be used like this:
// normal approach // moveTo(10, 10) // lineTo(10, 20) // lineTo(20, 20)
// draw method approach draw([0, 1, 1], [[10, 10], [10, 20], [20, 20]]);
You could argue this method does more harm than good and without proper documentation I admit, it can be confusing… but it’s a helper method, we don’t have to use it. For the sake of clarity, I won’t be using it in these posts.
Our ship needs to rotate around its center point so that fact has to be taken into account when we calculate the coordinates needed to draw it.
import { Scene, DisplayObject, AssetsLoader, KeyboardEvents } from '../../engine';
export default class Player extends DisplayObject {
constructor(model) {
super();
let assets = new AssetsLoader().assets;
this.model = model;
this.assets = assets;
let onKeyUp = (e) => {
if(this.LEFT || this.RIGHT || this.A || this.D) {
model.rotationSpeed = 0;
}
if(this.UP || this.W) {
model.acceleration = 0;
model.friction = 0.96;
model.thrusterOn = false;
}
if(this.SPACE) {
model.fire = false;
assets.laserThum.pause();
assets.laserThum.currentTime = 0;
}
}
this.on(KeyboardEvents.KEY_UP, onKeyUp);
}
update() {
this.model.scene = this.scene;
// update the model
this.model.update();
if(this.LEFT || this.A) {
this.model.rotationSpeed = -10;
}
if(this.RIGHT || this.D) {
this.model.rotationSpeed = 10;
}
if(this.UP || this.W) {
this.model.acceleration = 0.2;
this.model.friction = 1;
this.model.thrusterOn = true;
}
if(this.SPACE) {
this.model.fire = true;
this.assets.laserThum.play();
}
// wrap the square to the scene bounds
Scene.wrap(this.model);
}
render() {
const { x, y, angle, fivePerc, halfSize, thirdSize, color } = this.model;
this.ctx.save();
this.ctx.beginPath();
this.ctx.translate(x, y);
// rotate the ship based on the angle, and offset it by pi/2
this.ctx.rotate(angle + Math.PI*0.5);
this.ctx.fillStyle = color;
this.ctx.moveTo(-halfSize, halfSize);
this.ctx.lineTo(0, -halfSize);
this.ctx.lineTo(halfSize, halfSize);
this.ctx.lineTo(0, halfSize-thirdSize);
this.ctx.fill();
this.ctx.closePath();
this.ctx.restore();
if(this.model.thrusterOn) {
this.ctx.save();
this.ctx.beginPath();
this.ctx.translate(x, y);
this.ctx.rotate(angle + Math.PI*0.5);
this.ctx.fillStyle = '#A30';
this.ctx.moveTo(halfSize - fivePerc, halfSize);
this.ctx.lineTo(-halfSize + fivePerc, halfSize);
this.ctx.lineTo(0, halfSize - thirdSize + fivePerc);
this.ctx.fill();
this.ctx.closePath();
this.ctx.restore();
}
}
}
As you can see, our Player class is both a view and a controller. You can control the ship using the arrow keys or (W, A, D), and the SPACE key to play a shooting sound (although we’re don’t have bullets yet). We’re missing the PlayerModel… let’s add that as well:
This one is similar to what we’ve implemented in our previous examples, but with a few extra features. The update function of the PlayerModel also treats rotation and adds a speed trap which slows down the ship if the thrusters are off. We could add all of these features directly into the VerletModel and just enable them if/when we need them, but this is fine for now.
The next thing we could do is create a Map class. This would draw some indicators of all the objects rendered on the scene, like the player and asteroids.
It’s basically an in-game map and it will take two parameters:
- the marker (indicator) size and
- the map scale factor.
The latter will represent the percentage of the map size, relative to the scene size. The Map class looks like this:
import { Game, Scene, AssetsLoader } from '../engine';
import Player from './objects/Player';
import StarField from './objects/StarField';
import Map from './objects/Map';
import PlayerModel from './models/PlayerModel';
require('../../scss/styles.scss');
let assetsLoader = new AssetsLoader();
let scene = new Scene();
let game = new Game(scene);
let playerModel = new PlayerModel();
let player = new Player(playerModel);
// generate a starfield with 300 stars
let starField = new StarField(playerModel, 300);
// create the game map
let map = new Map();
// track the game objects
map.add([
player
]);
let initGame = () => {
// center player
playerModel.x = (scene.width - playerModel.size) * 0.5;
playerModel.y = (scene.height - playerModel.size) * 0.5;
// render starfield, player and map
scene.add([
starField,
player,
map
]);
};
// load the laser sound
assetsLoader.load([
'./assets/laser-thum.mp3'
]).then(initGame);
Next time we’ll learn how to generate random asteroids, so that all of the rocks look a bit different, add them to the map for tracking and bullet generation logic.