asteroids, part 2 of n

This is the second in a multi-part series about building an Asteroids (ish) clone. If you haven’t already, you may wish to read the first part here.

Let’s jump in.

Adding asteroids

We can’t have an Asteroids clone without asteroids. Fortunately, we’ve got a lot of the machinery we’re going to need already. Start by making a new Asteroid model.

const Asteroid: Model = {
  vertices: [
    [-2.8, -5],
    [0, -3],
    [2.6, -5.4],
    [5.4, -3.2],
    [3.6, -0.2],
    [5, 2.6],
    [1, 6],
    [-3.4, 5.4],
    [-5.8, 3.4],
    [-5.6, -2.6],
    [-2.8, -5.2]
  ],
  fill: null,
  stroke: 'white',
}

Figuring out the vertex coordinates was tricky, but I figured out a quick method which might be useful later:

  1. Draw the shape you want in a simple drawing package.
  2. Write down the pixel coordinates of each vertex.
  3. Calculate the average of these coordinates, this will give you the actual centre.
  4. Subtract the actual centre from each coordinate, so the actual becomes the origin.

Now you model will rotate nicely around it’s actual center.

Next we’ll make an array of all the asteroids in our scene, and draw/update them.

let asteroids: SceneObject[] = [];

function drawFrame(): void {
  // ...

  asteroids.forEach(asteroid => {
    update(asteroid, frameDelta);
    draw(asteroid);
  });
}

Now all we need is something to draw. We’ll make an asteroid factory that spawns asteroids randomly in our world. I also did a bit of refactoring here and moved most of the scene setup code into a setupScene function.

let playerShip: SceneObject;
let asteroids: SceneObject[] = [];

function setupScene(): void {
  playerShip = {
    model: PlayerShip,
    position: [canvas.width / 2, canvas.height / 2],
    rotation: 0,
    scale: 1,
    direction: [0, 0],
    spin: 0,
  };

  for (let i = 0; i < 6; i++) {
    asteroidFactory();
  }
}

function asteroidFactory(): void {
  const MAX_ASTEROID_VELOCITY = 75;

  asteroids.push({
    model: Asteroid,
    position: [Math.random() * canvas.width, Math.random() * canvas.height],
    rotation: Math.random(),
    scale: 6,
    direction: [
      2 * MAX_ASTEROID_VELOCITY * Math.random() - (MAX_ASTEROID_VELOCITY),
      2 * MAX_ASTEROID_VELOCITY * Math.random() - (MAX_ASTEROID_VELOCITY),
    ],
    spin: Math.random(),
  });
}

The only slightly tricky thing going on here is the asteroid direction. You have to remember to double MAX_ASTEROID_VELOCITY and then subtract otherwise all your asteroids will travel in roughly the same direction - down and to the right. Everything else is just built using what we wrote before.

Now space should be getting busy…

You may notice that the asteroids seem to ‘jump’ when they wrap around the edges of the canvas. This is because the wrapping code does not take the size of our model into account, it just operates on the centre point. We’ll fix this later.

Debug view

As our game gets more complicated it will be useful to have a debug view that gives a little more feedback on the scene. This is easy to add.

let debug = false;

window.addEventListener('keydown', function(event): void {
  // ...

  if (event.keyCode == 88) debug = true;
}, false);

window.addEventListener('keyup', function(event): void {
  // ...

  if (event.keyCode == 88) debug = false;
}, false);

Key code 88 is for the X button on your keyboard.

And we update the draw function …

function draw(obj: SceneObject): void {
  // ...

  if (debug) {
    context.beginPath();
    context.arc(obj.position[0], obj.position[1], 1, 0, 2 * Math.PI, false);
    context.strokeStyle = 'yellow';
    context.stroke();

    context.beginPath();
    context.moveTo(obj.position[0], obj.position[1]);
    context.lineTo(obj.position[0] + obj.direction[0], obj.position[1] + obj.direction[1]);
    context.strokeStyle = 'blue';
    context.stroke();
  }
}

This will draw a small yellow circle at the centre of each SceneObject, and a blue line indicating the directional vector. Now we can see visually the point around which our objects are rotating, and the direction and magnitude of their movement.

I also added this snippet which lets me pause object updating, effectively stopping time.

let paused = false;

window.addEventListener('keydown', function(event): void {
  // ...

  if (event.keyCode == 80) paused = true;
}, false);

window.addEventListener('keyup', function(event): void {
  // ...

  if (event.keyCode == 80) paused = false;
}, false);

function update(obj: SceneObject, delta: number): void {
  if (paused) {
    return;
  }

  // ...
}

Key code 80 is for the P button on your keyboard. Note, updates will only be paused while the key is depressed.

Collision detection

A crude method of detecting if two of our objects are colliding is by drawing circles around them and checking if these circles intersect. An easy (and cheap) way to test if these two circles are intersecting is to check if the distance between their centres is less than the sum of their radii.

First we need a way to approximate the radius of a bounding circle for our SceneObjects. I did this by finding the largest magnitude of the vertices - the vertex that is furthest away from [0, 0] - and multiplying by the object scale.

function radius(obj: SceneObject): number {
  const radii = obj.model.vertices.map(([x, y]) => Math.sqrt(x*x + y*y));

  return obj.scale * Math.max.apply(Math, radii);
}

This function would be a good target for some optimisation or precalculation, since we’ll be using it a lot.

… add this to the debug view so it can be visualised …

function draw(obj: SceneObject): void {
  // ...

  if (debug) {
    // ...

    context.beginPath();
    context.arc(obj.position[0], obj.position[1], radius(obj), 0, 2 * Math.PI, false);
    context.strokeStyle = 'green';
    context.stroke();
  }
}

… and you should get something like this:

All we need now is function that returns the distance between two objects - which is just the magnitude of the vector between their positions!

function distance(a: SceneObject, b: SceneObject): number {
  const dx = a.position[0] - b.position[0];
  const dy = a.position[1] - b.position[1];

  return Math.sqrt(dx*dx + dy*dy);
}

Now a collision test is now trivial

function collisionTest(a: SceneObject, b: SceneObject): boolean {
  if (a === b) {
    // Remember to return false if the objects are the same...
    return false;
  }

  return distance(a, b) <= radius(a) + radius(b);
}

A great way to see this in action is add it to our debug view.

function draw(obj: SceneObject): void {
  // ...

  if (debug) {
    // ...

    // Check if obj is colliding with the player ship, or an asteroid.
    const colliding = collisionTest(obj, playerShip) || asteroids.some(asteroid => (collisionTest(obj, asteroid)));

    context.beginPath();
    context.arc(obj.position[0], obj.position[1], radius(obj), 0, 2 * Math.PI, false);
    context.strokeStyle = colliding ? 'red' : 'green';
    context.stroke();
  }
}

Use of Array.prototype.some() here prevents unnecessary collision testing. We only care that the object is colliding with something, not what or how many.

This is type of collision testing is called a broad match and it is not particularly accurate. It will suffice for now, but we will probably need improving this later.

A quick word on project organisation

So far I have not paid much attention to the structure of this project. I believe that right now it is more important to focus on fleshing out the project and that a structure will emerge.

I have a few .ts files that are compiled to the public/ directory as .js files and included (in order) in index.html.

How you split code between the files is really arbitrary at this early stage, since everything is basically in global scope anyway.

In later posts I will address the strucutre properly, as well as issues such as modularisation and bundling.

Demo

Arrow keys for movement and P/X for debug functions. Click for focus.

References