asteroids, part 4 of n

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, 2, and 3.

Let’s jump in.

Ship-asteroid collision

One thing we haven’t addressed yet is ship-asteroid collision. We’ve already added the dead property so we’ll just reuse this.

You’ll remember from the last part that any SceneObject marked as dead will not be drawn or updated.

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

  asteroids.forEach(asteroid => {
    // ...

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

  // ...
}

I also created a respawn function that moves the player ship back to the spawn point, resets the drift/spin, and sets the dead flag to false so it is rendered and updated once again. Calling this with setTimeout gives us a nice timed respawn mechanism.

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

// Respawn player ship 2.5 seconds after death
window.setTimeout(spawnPlayerShip, 2500);

A great enhancement here would be to check that the spawn point is free of asteroids - this prevents the player from spawning in a losing situation.

Score and lives

Tracking lives and score is not particularly painful either - add some variables and updated them when the appropriate events happen.

let lives = 3;
let score = 0;

In the asteroid-projectile collision test code, you can add something like this:

score += Math.floor((1 / asteroid.scale) * 600);

Here I’m multiplying the reciprocal of the struck asteroids scale. It’s a naive algorithm, but it means that destroying the smaller asteroids will score the player more points than destroying the larger asteroids - which seems fair. You may choose to use a more sophisticated algorithm.

Similarly, update the player-asteroid collision test code to decrement lives. I’m also checking here if the player has sufficient lives to respawn.

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

  if (lives > 0) {
    lives -= 1;
    window.setTimeout(spawnPlayerShip, 2500);
  }
}

Interface

The canvas context gives us access to a method called fillText and we’ll use this to display the score.

function drawUI(): void {
  context.font = '24px monospace';
  context.fillStyle = 'white';
  context.textAlign = 'right';
  context.fillText(score.toString(), canvas.width - 10, 25);
}

Put a call to drawUI somewhere inside your drawFrame function.

For the lives I’d like to draw some ships but that requires a bit of refactoring. We’re going to split out part of our draw function into a second function called project.

function project(model: Model, position: Vec2, rotation: number, scale: number) {
  const coords = model.vertices.map(([x, y]) =>
    <Vec2>[
      scale * (x * Math.cos(rotation) - y * Math.sin(rotation)) + position[0],
      scale * (x * Math.sin(rotation) + y * Math.cos(rotation)) + position[1],
    ]
  );

  context.beginPath();
  context.moveTo(coords[0][0], coords[0][1]);

  for (let i = 1; i < coords.length; i++) {
    context.lineTo(coords[i][0], coords[i][1]);
  }

  if (model.stroke) {
    context.strokeStyle = model.stroke;
    context.stroke();
  }

  if (model.fill) {
    context.fillStyle = model.fill;
    context.fill();
  }
}

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

  project(obj.model, obj.position, obj.rotation, obj.scale);

  if (debug) {
    // ...
  }
}

The function is called project because we are ‘projecting’ our models onto the canvas - mapping our data structure to the 2D coordinate system of the canvas, our world coordinate system.

Now we can draw a Model in an arbitrary position. Use this to draw ships that represent lives.

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

  for (let i = 0; i < lives; i++) {
    project(PlayerShip, [15 + (i * 25), 20], 0, 1);
  }
}

I just fiddled with these numbers until I found something that looked nice.

Game state

There are three distinct states we can be in:

We’ll use a TypeScript enum to define the states …

enum GameState { INSERT_COINS, PLAYING, GAME_OVER };

… and we’ll set the initial state:

let gameState = GameState.INSERT_COINS;

Then we’ll update the drawUI function to draw the correct interface based on the gameState variable. There’s nothing new here, I just rearranged the code a little and added some different bits of text.

function drawUI(): void {
  context.font = '24px monospace';
  context.fillStyle = 'white';

  switch (gameState) {
    case GameState.INSERT_COINS:
      if (Math.floor(Date.now() / 500) % 2 === 0) {
        context.textAlign = 'center';
        context.fillText('Press space to start', canvas.width / 2, canvas.height / 3);
      }

      break;

    case GameState.PLAYING:
      context.textAlign = 'right';
      context.fillText(score.toString(), canvas.width - 10, 25);

      for (let i = 0; i < lives; i++) {
        project(PlayerShip, [15 + (i * 25), 20], 0, 1);
      }

      break;

    case GameState.GAME_OVER:
      context.textAlign = 'center';
      context.fillText('GAME OVER', canvas.width / 2, canvas.height / 3);

      break;
  }
}

One neat thing I’m doing here is using Math.floor(Date.now() / 500) % 2 which will alternate between 0 and 1 over a second. By only drawing the text if === 0 we’ll get nothing for 0.5 seconds and text for the other 0.5 seconds. This gives a nice blinking effect.

We need to make a few tweaks to tie this all together:

Demo

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

References