Basic HTML5 canvas operations for a Javascript / Typescript game

- 5 mins
Hello1
ViewSource code

What are we doing

We are going to focus on properties of the canvas that we are likely to use when creating a 2D game. These are listed below.

  • Drawing a background gradient
  • Drawing text
  • Drawing shapes
  • Drawing images
  • Clearing the canvas

We will use these properties to create some example art for a scene. You can click the example button to see the final canvas or keep it a mystery and proceed.

Setup

This project uses the basic-typescript-env project as a base. Before continuing make sure you have downloaded the simple-typescript-canvas-game-project-setup.

Throughout the project we will build and run the project using the commands in the previous basic typescript env tutorial linked below.

Selecting the canvas context

The canvas is a HTML element that we can use to draw on. The canvas context is a CanvasRenderingContext2D that allows us to draw objects onto the canvas. Add the code below to index.ts to define references to the canvas and context.

const canvas = document.getElementById('gameCanvas') as HTMLCanvasElement;
const context = canvas.getContext('2d');
canvas.width = 800
canvas.height = 3 * canvas.width / 4.0

The as keyword allows us to try and cast the element with id gameCanvas as a HTMLCanvasElement. If this id doesn't exist or is not a <canvas> element we will see an error in the console.

The getContext('2d') method returns a CanvasRenderingContext2D | null so before we can do much with the context we must check that the context is not null. Add the following lines to index.ts.

We are also setting the canvas width to be 800px and making the height conform to a 4:3 aspect ratio.

const draw = (context: CanvasRenderingContext2D) => {
    context.fillRect(0, 0, 100, 100)
}

if (context) {
  draw(context)
}

Build and run the app, you should see a black square in the top left corner of the canvas. This indicates that the context is not null!

Canvas coordinate system

The canvas uses a grid coordinate system with x and y coordinates starting at the top left corner of the canvas (position: x: 0, y: 0). The x coordinates increase right. The y coordinates increase down.

Clearing the canvas

In order to create the illusion of moving objects that games rely on we require a way of clearing the canvas and re-drawing the objects in their updated positions.

The clearRect method on the canvas context can be used to clear a rectangle with a specified position, width and height of the canvas.

To clear the whole canvas we provide the clearRect method with the width and height of the canvas. A portion of the canvas can be cleared by adjusting the position, width and height.

To demonstrate how to clear the canvas we will clear the black square we have created.

Add the following line to the index.ts's draw method to clear the canvas.

ctx.clearRect(0, 0, canvas.width, canvas.height);

Build and run the app and check that the canvas is blank.

Gradient background

To begin we will give our scene a gradient background.

Add the following drawGradientBackground method and call it from within the draw method.

const drawGradientBackground = (context: CanvasRenderingContext2D) => {
  
  let gradient = context.createLinearGradient(0, 0, 0, canvas.height);
  gradient.addColorStop(0, 'lightBlue');
  gradient.addColorStop(.5, 'blue');
  gradient.addColorStop(1, 'lightBlue');

  context.fillStyle = gradient;
  context.fillRect(0, 0, canvas.width, canvas.height);
}

const draw = (context: CanvasRenderingContext2D) => {
  ...
  drawGradientBackground(context)
}

Here we are creating a vertical gradient. The createLinearGradient method takes the following arguments, x, xWidth, y, yWidth. For a vertical gradient covering the canvas we specify 0 for horizontal values and 0, height for vertical values.

The addColorStop method allows us to specify different colours and the interval where the gradient will have changed to that colour. In our case we are creating a vertical gradient that starts a lightBlue then from 0 to 50% (0.5) it transitions into a blue and then from 50% to 100% it transitions back to lightBlue.

Next we set the fillStyle to the gradient we have chosen. This will apply our gradient to any object we draw onto the context using the fill property.

Finally we use fillRect to draw a full canvas width and height rectangle of our gradient.

Build and run the app and check that the canvas has a blue gradient background.

Drawing the road

Our scene will have a road that comprises of a gray rectangle and dashed orange line.

const drawRoad = (context: CanvasRenderingContext2D) => {
  context.fillStyle = 'gray'
  context.fillRect(0, canvas.height - 50, canvas.width, 50)

  context.beginPath();
  context.lineWidth = 5
  context.setLineDash([10, 40]);
  context.strokeStyle = 'orange';
  context.moveTo(0, canvas.height - 15);
  context.lineTo(canvas.width, canvas.height - 15);
  context.stroke();
  context.setLineDash([])
}

const draw = (context: CanvasRenderingContext2D) => {
  ...
  drawRoad(context)
}

The only new part about this is the orange dashed line. To draw a line on the canvas we need to.

The beginPath method lets the context know we will be drawing a line. The lineWidth method sets the width of the line. The setLineDash method ??? The moveTo method sets a pointer on the context to a particular position. The lineTo method moves from where the pointer currently is to the new position. The stroke method actually draws the described line.

Setting our constants

To make life easier when drawing a object comprised of multiple shapes, it's best to set up some variables to keep track of the relative positioning and size of the various objects.

Add the following variables to index.ts.

const carPosX = 200
const baseHeight = 540
const roofHeight = baseHeight - 150
const BonnetHeight = baseHeight - 75
const carWidth = 450
const wheel1CenterX = carPosX + 75
const wheel1CenterY = baseHeight - 15
const wheel2CenterX = carPosX + carWidth - 75
const wheel2CenterY = baseHeight - 15

Drawing car body

Add the following line to index.ts's draw method.

  context.fillStyle = 'red'
  context.fillRect(carPosX, BonnetHeight, carWidth, baseHeight - BonnetHeight)

Drawing a circle

const drawFillCircle = (context: CanvasRenderingContext2D, x: number, y: number, radius: number, color: string) => {
  context.beginPath();
  context.fillStyle = color
  context.arc(x, y, radius, 0, Math.PI*2);
  context.fill();
  context.closePath();
}

The above method will draw a circle filled in with a specified colour.

The arc method takes the following arguments. x, y position of center of the circle, radius of the circle, starting degrees to final degrees. This allows us to create different kinds of circles such as a semi-circle.

We will use this circle method to draw the wheels on our car next.

Drawing the wheels

Add the following lines to index.ts's draw method.

  drawFillCircle(context, wheel1CenterX, wheel1CenterY, 50, 'black')
  drawFillCircle(context, wheel1CenterX, wheel1CenterY, 40, 'gray')

  drawFillCircle(context, wheel2CenterX, wheel2CenterY, 50, 'black')
  drawFillCircle(context, wheel2CenterX, wheel2CenterY, 40, 'gray')

This will draw the car's two wheels comprised of an outer black and inner gray circle.

Drawing the roof

The roof of our car is a trapezium and uses methods such as moveTo and lineTo that we have already seen to form a line path.

Add the following method to index.ts.

const drawRoof = (context: CanvasRenderingContext2D, color: string, delta: number) => {
  context.fillStyle = color

  context.beginPath();
  context.moveTo(carPosX + 25 + delta, BonnetHeight);
  context.lineTo(carPosX + 100, roofHeight + delta);
  context.lineTo(carPosX + carWidth - 150, roofHeight + delta);
  context.lineTo(carPosX + carWidth - 75 - delta, BonnetHeight);
  context.fill()
}

Our roof will be red and will have an inner trapezium to represent the windows.

Add the following lines to index.ts's draw method.

  drawRoof(context, 'red', 0)
  drawRoof(context, '#99f6ff', 15)

To finish lets add a window gap. Add the following method to index.ts.

const drawWindowGap = (context: CanvasRenderingContext2D) => {
  const windowGapCenterX = carPosX + carWidth / 2 - 15
  context.fillStyle = 'red'
  context.beginPath();
  context.moveTo(windowGapCenterX - 5, roofHeight);
  context.lineTo(windowGapCenterX + 5, roofHeight);
  context.lineTo(windowGapCenterX + 10, BonnetHeight);
  context.lineTo(windowGapCenterX - 10, BonnetHeight);
  context.closePath();
  context.fill() 
}

The closePath method automatically joins where the context position currently is to where it started moveTo.

Add the following line to index.ts's draw method.

  drawWindowGap(context)

Drawing an image

It's handy to know how to draw basic geometric shapes onto our canvas but most games require more complicated images to be drawn onto the canvas.

We will be adding some clouds to our scene. Download the cloud-large.png.

Add the following lines to index.ts


let image = new Image();
image.src = "cloud-large.png"

const drawClouds = (context: CanvasRenderingContext2D) => {
  context.drawImage(image, 600, 100, 128, 128);
  context.drawImage(image, 450, 125, 96, 96);
  context.drawImage(image, 600, 175, 128, 128);
}

Add the following line to index.ts's draw method.

const draw = (context: CanvasRenderingContext2D) => {
  ...
  drawClouds(context)
}

The drawImage method takes 5 arguments, an image, positionX, positionY, width and height.

Build and run the app. Do you see any clouds? ... What's going on?

This is because the cloud image hasn't loaded by the time we try to draw it on the canvas.

In general we should wait for the window to load before interacting with the canvas.

Update index.ts context check code block to the following.

window.onload = function() {
  if (context) {
    draw(context)
  }
}

We have wrapped our context check and draw method in the window.onload method. This method will only run once the window has loaded.

Build and run the app again and you should see 3 clouds in the background.

Drawing text

Being able to draw text onto a canvas is crucial to create game dialogue and menu screens etc.

Add the following lines to index.ts's draw method to give the scene a title.

const draw = (context: CanvasRenderingContext2D) => {
  ...
  context.font = '80px Arial';
  context.fillStyle = 'white';
  context.textAlign = "center";
  context.fillText("Masterpiece", canvas.width / 2, 75);
}

Drawing the cloud driver

The drawImage method can take more arguments to allow us to draw only a portion of a particular image onto the canvas. This allows us to work effectively with sprite-sheets and create sprite animation.

If you are not familiar with sprites and sprite-sheets I plan to cover using the drawImage method to create animated sprites in a future post.

For now add the following line to index.ts's draw method to cut out a square portion of the cloud image and place it in the drivers seat.

const draw = (context: CanvasRenderingContext2D) => {
  ...
  context.drawImage(image, 40, 45, 50, 90, 450, 410, 50, 90)
}

Flipping the problem cloud

You may have noticed a problem. One of the clouds appears to be looking the other way. For this scene we want all eyes to be on the car and cloud driver.

To achieve this we will flip the problem cloud vertically.

Update the drawClouds method in index.ts with the lines below.

const drawClouds = (context: CanvasRenderingContext2D) => {
  context.save();
  context.translate(canvas.width, 0);
  context.scale(-1, 1);
  context.drawImage(image, 600, 100, 128, 128);
  context.restore()
  context.drawImage(image, 450, 125, 96, 96);
  context.drawImage(image, 600, 175, 128, 128);
}

Before drawing the first cloud we are flipping it by using the canvas scale feature. The scale method allows us to magnify what we draw onto the canvas by some scale factor. We have applied a negative x scale which flips anything drawn across the vertical axis. We need to also translate the whole context to see the scaled output.

We are also making use of the save and restore context feature. This allows us to take a snapshot of the context before we alter it, ie by using the scale method. The restore method allows us to reset the context to the state of when it was saved. Using save and restore can be cpu intensive and should only be used when necessary (Such as flipping a cloud). Rather than scaling and translating we could of added a new cloud sprite facing the other direction but where's the fun in that.

Finish

I hope this post has given you some ideas about what you can accomplish with the canvas and context.

As a bonus challenge make some updates to the scene or create a totally new scene and upload your final masterpiece in the comments below.