Will Ridgers

asteroids, part 1 of n

· canvas typescript asteroids

This will be the first in a multi-part series about building an Asteroids (ish) clone - written as the project is developed so you can follow it from start to… wherever it ends up.

I’ll be using TypeScript - we’re going to have lots of things and static typing will significantly reduce cognitive load/errors. I will be building heavily on the ideas I wrote about in a previous post drawing a square. It may be handy to read this first!

These posts will not be a step by step guide but there should be enough information to follow along. I’ll try to include demonstrations and examples where it makes sense.

Notes and ideas will be annotated like this.

Scaffolding

I’ll start by setting up some simple scaffolding.

TypeScript configuration looks a little bit like this:

{
    "compilerOptions": {
        "target": "es5",
        "outDir": "public",
        "rootDir": "src"
    }
}

This way I can watch and compile the code with tsc -w and serve the project with zapp (or something similar). Nothing more complicated than this is required right now.

I really want to keep things simple here. We won’t worry about things like modules and bundling until it becomes a problem.

Drawing a ship

We begin by setting up a simple render loop using window.requestAnimationFrame. This draws us an empty black canvas.

const canvas = <HTMLCanvasElement> document.getElementById('asteroids');
const context: CanvasRenderingContext2D = canvas.getContext('2d');

function clearFrame(): void {
  context.fillStyle = 'black';
  context.fillRect(0, 0, canvas.width, canvas.height);
}

function drawFrame(): void {
  clearFrame();

  window.requestAnimationFrame(drawFrame);
}

drawFrame();

Drawing objects I’ve already covered here in great detail. I’m going to implement these ideas on top of TypeScript’s strong typing system.

// We're going to use this a lot...
type Vec2 = [number, number]

// A model.
interface Model {
  vertices: Vec2[],
}

// An object in our scene (a model, in some world location).
interface SceneObject {
  model: Model,

  position: Vec2,
  rotation: number,
  scale: number,
}

And a draw function (again, carbon copy of this with some types sprinkled over the top).

function draw(obj: SceneObject): void {
  const model = obj.model;

  const coords = model.vertices.map(([x, y]) => (
    <Vec2>[
      obj.scale * (x * Math.cos(obj.rotation) - y * Math.sin(obj.rotation)) + obj.position[0],
      obj.scale * (x * Math.sin(obj.rotation) + y * Math.cos(obj.rotation)) + obj.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]);
  }

  context.strokeStyle = 'white';
  context.stroke();
}

And finally we need something to draw. I’m making a model called a PlayerShip, and a SceneObject which is an instance of the PlayerShip model in the middle of our world.

const PlayerShip: Model = {
  vertices: [
    [0, 3],
    [10, 10],
    [0, -15],
    [-10, 10],
    [0, 3],
  ],
}

let playerShip: SceneObject = {
  model: PlayerShip,
  position: [canvas.width / 2, canvas.height / 2],
  rotation: 0,
  scale: 1,
};

Maybe at some point we can create a function with a signature like:

sceneObjectFactory(model: Model, position: Vec2): SceneObject

Put a draw(playerShip) in your drawFrame function and you will get something like this!

I rotated mine, and I also set a fill. With TypeScript 2 (as of writing in beta) you can use the strictNullChecks option and write something like this:

interface Model {
  // ...

  stroke: string | null, // This means string or null. Available in TypeScript 2.
  fill: string | null,
}

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

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

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

Movement

Static scenes are boring so we’ll add some movement; direction and spin …

interface SceneObject {
  // ...

  direction: Vec2,
  spin: number,
}

… a function to update our SceneObject (put this in drawFrame) …

function update(obj: SceneObject): void {
  obj.position[0] += obj.direction[0];
  obj.position[1] += obj.direction[1];
  obj.rotation += obj.spin;
}

… and make sure our playerShip is updated …

let playerShip: SceneObject = {
  // ...

  direction: [0, 0],
  spin: 2,
};

Important, make sure to update your SceneObjects before you draw them! Otherwise, what is shown on screen will be one frame in the past.

… easy! But there’s a problem - our update function is called once per frame, and our frames are rendered at some unknown rate. Right now our game is not particularly resource intensive so it’s probably running at a consistent rate - around 60 Frames Per Second (FPS) - but as it gets more complicated (or if we run it on a slower computer) that won’t be the case. Because our movement updates are dependent on the frame rate, the ship will spin faster a 60 FPS than it will at 30 FPS.

We’ll solve this problem by introducing a delta. The delta will be the number of seconds between each frame, and we’ll scale our updates accordingly.

function update(obj: SceneObject, delta: number): void {
  obj.position[0] += obj.direction[0] * delta;
  obj.position[1] += obj.direction[1] * delta;
  obj.rotation += obj.spin * delta;
}

And get the frame delta in our drawFrame function

let lastUpdate = Date.now();

function drawFrame(): void {
  const now = Date.now();
  const frameDelta = (now - lastUpdate) / 1000; // Convert Milliseconds to Seconds
  lastUpdate = now;

  // ...
}

And now our updates are scaled to be “per second”. For example, a spin value of 2 * Math.PI would be one full rotation every second.

Control

For control, the first thing I did was make an object to track input state …

let input = {
  left: false,
  right: false,
  thrust: false,
};

… some event listeners to modify the state based on key input …

window.addEventListener('keydown', function(event): void {
  if (event.keyCode == 37) input.left = true;
  if (event.keyCode == 38) input.thrust = true;
  if (event.keyCode == 39) input.right = true;
}, false);

window.addEventListener('keyup', function(event): void {
  if (event.keyCode == 37) input.left = false;
  if (event.keyCode == 38) input.thrust = false;
  if (event.keyCode == 39) input.right = false;
}, false);

Key code 37 is for the left arrow key, 38 is for the right arrow key, and 39 is for the up arrow key.

… and a function to process the input state

const ROTATION_PER_SECOND = 1 * (2 * Math.PI);

function processInput(): void {
  playerShip.spin = 0;

  if (input.left) {
    playerShip.spin = -ROTATION_PER_SECOND;
  }

  if (input.right) {
    playerShip.spin = ROTATION_PER_SECOND;
  }

  if (input.thrust) {
    playerShip.direction[0] +=  10 * Math.sin(playerShip.rotation);
    playerShip.direction[1] += -10 * Math.cos(playerShip.rotation);
  }
}

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

  processInput();

  // ...
}

The only magic going on here is the thrust, which uses trigonometry to modify the direction vector. Apart from that, control is really that easy…

Control would be a good candidate for abstraction. We might want to control our ship with touch events, mouse movements, or maybe over web sockets…

Bonus

If you’re following along you’ll have noticed that it’s easy to get the ship to fly off screen never to be found again. Fortunately we can easily solve that problem by making our ship wrap around the edges of the world.

function update(obj: SceneObject, delta: number): void {
  // ...

  // Wrap around the screen (canvas)
  obj.position[0] = obj.position[0] > 0 ? obj.position[0] % (canvas.width  + 1) : obj.position[0] + canvas.width;
  obj.position[1] = obj.position[1] > 0 ? obj.position[1] % (canvas.height + 1) : obj.position[1] + canvas.height;
}

A fun fact is that this wraparound approach means our game world is a kind of torus!

Demo

Here is a demo of what we’ve got so far (click to gain focus).

References