Will Ridgers

asteroids, part 3 of n

· canvas typescript asteroids

This is the fourth in a multi-part series about building an Asteroids clone. If you haven’t already, you may wish to read the parts 1 and 2.

Let’s jump in.

Projectiles

As before, we start with the model.

const Projectile: Model = {
  vertices: [
    [0, -3],
    [2.15, -2.15],
    [3, 0],
    [2.15, 2.15],
    [0, 3],
    [-2.15, 2.15],
    [-3, 0],
    [-2.15, -2.15],
    [0, -3],
  ],
  fill: null,
  stroke: 'white',
}

… and add them to our scene …

let projectiles: SceneObject[] = [];

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

  // Projectiles
  projectiles.forEach(projectile => {
    update(projectile, frameDelta);
    draw(projectile);
  });

  // ...
}

We’ll need a method to fire our projectiles too, so let’s add this to our input system.

let input = {
  // ...

  fire: false,
};

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

  if (event.keyCode == 32) input.fire = true;
}, false);

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

  if (event.keyCode == 32) input.fire = false;
}, false);

In the processInput method we’ll check if the player is firing, and create a new projectile.

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

  if (input.fire) {
    projectiles.push({
      model: Projectile,
      position: [
        playerShip.position[0] + (radius(playerShip) + 10) * Math.sin(playerShip.rotation),
        playerShip.position[1] - (radius(playerShip) + 10) * Math.cos(playerShip.rotation),
      ],
      rotation: 0,
      scale: 1,
      direction: [300 * Math.sin(playerShip.rotation), - 300 * Math.cos(playerShip.rotation)],
      spin: 0,
      dead: false,
    });
  }
}

Here I am using some basic trigonometry to position the projectile just slightly in front of the player ship and give it a large direction vector based on it’s rotation.

If you’re following along, or maybe if you’ve got a keen eye for these things, you will notice a problem - the current implementation will spawn a new projectile every frame while the space key is depressed! There is a nice trick to solve this - we set a requiresReload variable when the projectile is created, and we only create the projectile if ! requiresReload.

let requiresReload = false;

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

  if (input.fire && ! requiresReload) {
    requiresReload = true;

    // ...
}

I put requiresReload = false; in the spacebar keyup event, but another idea would be to use a timeout to limit rate per second of firing.

// Fire once per second.
window.setTimeout(() => {
  requiresReload = false;
}, 1000)

One last problem is that our projectiles are wrapping around the edges of the screen just like our asteroids. An optional parameter added to the update function, so the wrapping behaviour can be turned off, will take care of this. I also added a filter to the drawFrame function that garbage collects projectiles not within the bounds of the canvas.

function update(obj: SceneObject, delta: number, wrap: boolean = true): void {
  // ...

  if (wrap) {
    // wrap code...
  }
}

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

  projectiles = projectiles.filter(projectile =>
    projectile.position[0] > 0
      && projectile.position[0] < canvas.width
      && projectile.position[1] > 0
      && projectile.position[1] < canvas.height
  );

  // ...
}

Asteroid destruction

Checking for projectile-asteroid collision is easy, the tricky part is what we do once a collision is detected. There are two main strategies:

  1. Splice the objects out of the array now.
  2. Mark the objects for garbage collection and remove them later.

The first option is tricky because it involves iterating through the arrays backwards or messing with the index as we are iterating. I chose the second solution because it feels cleaner and is more explicit.

First we add a dead property to the SceneObject interface.

interface SceneObject {
  // ...

  dead: boolean,
}

Remember to add this property anywhere we are creating SceneObjects - Typescript makes this easy by giving errors during compilation.

It’s also important to stop dead objects from being drawn or updated.

function draw(obj: SceneObject): void {
  if (obj.dead) {
    return;
  }

  // ...
}

Now we just check for projectile-asteroid collision and mark them both as dead. I’m using some again here because we only care about the first positive collision test.

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

  projectiles.forEach(projectile => {
    // ...

    asteroids.some(asteroid => {
      if (collisionTest(projectile, asteroid)) {
        asteroid.dead = true;
        projectile.dead = true;

        return true;
      }

      return false;
    });
  });

  // ...
}

Now we just have to garbage collect dead objects at the end of our frame.

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

  asteroids = asteroids.filter(asteroid => ! asteroid.dead);

  projectiles = projectiles.filter(projectile =>
    projectile.position[0] > 0
      && projectile.position[0] < canvas.width
      && projectile.position[1] > 0
      && projectile.position[1] < canvas.height
      && ! projectile.dead
  );

  // ...
}

Great - now we can destroy the asteroids, but in the original game asteroids break into smaller asteroids when they’re hit. In part 2 we made an asteroid factory - here’s an updated version that takes position and scale as arguments.

function asteroidFactory(position: Vec2, scale: number): void {
  const MAX_ASTEROID_VELOCITY = 75;

  asteroids.push({
    model: Asteroid,
    position: <Vec2> position.slice(),
    rotation: 2 * Math.PI * Math.random(),
    scale,
    direction: [
      MAX_ASTEROID_VELOCITY * (2 * Math.random() - 1),
      MAX_ASTEROID_VELOCITY * (2 * Math.random() - 1),
    ],
    spin: Math.random(),
    dead: false,
  });
}

Use this to spawn two replacement asteroids when a projectile-asteroid collision is detected.

if (collisionTest(projectile, asteroid)) {
  asteroid.dead = true;
  projectile.dead = true;

  if (asteroid.scale > 1.5) {
    asteroidFactory(asteroid.position, asteroid.scale / 2);
    asteroidFactory(asteroid.position, asteroid.scale / 2);
  }

  // ...
}

The use of 1.5 here is not arbitrary. Since we started with large asteroids (scale 6) and their scale is halved each time they are successfully shot, the third child asteroids will have scale (6 / 2) / 2 = 6 * 0.5 ^ 2 = 1.5. This way the small asteroids will just be destroyed and will not split.

Further, since when an asteroid is shot it either halves or dies, the sum of all asteroid scale values will never increase. It will only stay the same (6 = 3 + 3, 3 = 1.5 + 1.5) or decrease. We can use this information to figure out when we need to spawn a new.

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

  const mass = asteroids.reduce((mass, asteroid) => mass + asteroid.scale, 0);

  if (mass <= 30) {
    asteroidFactory([Math.random() * canvas.width, Math.random() * canvas.height], 6);
  }

  // ...
}

You can remove the asteroid creating code in setupScene now because the above neatly solves the problem.

This way new asteroids will spawn as old ones are destroyed. The total ‘mass’ of the system is bounded between 30 and 36.

You can go a step further and abstract these values to constants.

const LARGE_ASTEROID_SCALE = 6;
const MAX_ASTEROIDS = 6;
const ASTEROID_HALVINGS = 2;

The mass check becomes

if (mass <= (MAX_ASTEROIDS - 1) * LARGE_ASTEROID_SCALE) {

and the scale check becomes

if (asteroid.scale > LARGE_ASTEROID_SCALE * Math.pow(0.5, ASTEROID_HALVINGS)) {

FPS Counter

This is just nice to have. We already have the frame delta (in seconds) so the frame rate (per second) is just the reciprocal.

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

  const fps = 1 / frameDelta;

  if (debug) {
    context.font = '10px monospace';
    context.fillText(Math.round(fps).toString(), 2, 10);
  }

  // ...
}

Refined wrapping

Finally, lets fix the pop when objects wrap from one canvas edge to another. A neat way to do this is use the radius we calculated for collision checking to ensure our object is fully obscured before it wraps.

function update(obj: SceneObject, delta: number, wrap: boolean = true): void {
  // ...

  if (wrap) {
    const r = radius(obj);

    obj.position[0] = obj.position[0] > -r ? obj.position[0] : obj.position[0] + canvas.width  + r + r;
    obj.position[1] = obj.position[1] > -r ? obj.position[1] : obj.position[1] + canvas.height + r + r;

    obj.position[0] = obj.position[0] > canvas.width  + r ? obj.position[0] - (canvas.width  + r + r) : obj.position[0];
    obj.position[1] = obj.position[1] > canvas.height + r ? obj.position[1] - (canvas.height + r + r) : obj.position[1];
  }
}

Demo

Arrow keys for movement, space to shoot, and P/X for debug functions. Click for focus.

References