How to create a simple and efficient collision detection between paths in your HTML5 Canvas app or game? In this article we will create a maze, allow a player to navigate through the maze and detect when the player collides with the walls.
Table of Contents
The End Goal
Let’s build a simple game. The game will look kind of like this:
You can view the full code and play with it here:
See the Pen by Yonatan Kra (@yonatankra) on CodePen.
What is Path2D?
Path2D is a lightweight class that allows a user to generate or duplicate 2 dimensional paths and easily draw them on a canvas 2D context. Ok – so many phrases in one sentence. Let’s try to break it down.
Essentially, when you create a new Path2D()
you get a path. A path is a shape (can be a rectangle, a cirlce, an SVG path or a combination of them). So you could create whole drawings inside a Path2D
instance. Let’s see an example. You can also combine a few Path2D
instances by using the addPath
method.
Eventually, once you have a path, you can just draw it on any canvas like this:
function draw(pathInstance) {
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, CANVAS_HEIGHT, CANVAS_WIDTH);
ctx.fillStyle = COLORS[BLACK];
ctx.fill(pathInstance);
}
There’s a lot more to Path2D, which I might cover in a different article (if there’s a demand for it in the crowd 🙂 ).
In this article, we will use Path2D in order to create our background layer and see how we can detect collision between background and dynamic layers – on totally different canvases. More specifically, we’re going to use the useful isPointInPath
method to see if our player hits a wall.
Set paths for collision
The magic starts with the creation of the paths:
function generateBackgroundPaths(matrix) { | |
const wallsPath = new Path2D(); | |
const roadsPath = new Path2D(); | |
const mazePaths = { | |
wallsPath, | |
roadsPath, | |
}; | |
matrix.forEach((pixelsRow, rowIndex) => { | |
const y = rowIndex * PIXEL_RATIO; | |
pixelsRow.forEach((pixel, pixelIndex) => { | |
const x = pixelIndex * PIXEL_RATIO; | |
const currPath = pixel === BLACK ? roadsPath : wallsPath; | |
currPath.rect(x, y, PIXEL_RATIO, PIXEL_RATIO); | |
}); | |
}); | |
return mazePaths; | |
} |
The function generateBackgroundPaths
takes a matrix that represents a maze (created with the Cellular Automaton algorithm described here). It then creates two Path2D objects. The first is the walls and the second is the road (or wall free).
Now that we have the paths, we can actually draw them using the drawBackground
function that accepts the walls and road paths and just adds them to the background canvas.
The function generateBackground
(that uses the generateBackgroundPaths
and drawBackground
) are shown here:
function drawBackground(mazePaths) { | |
const ctx = canvas.getContext("2d"); | |
ctx.clearRect(0, 0, CANVAS_HEIGHT, CANVAS_WIDTH); | |
ctx.fillStyle = COLORS[BLACK]; | |
ctx.fill(mazePaths.roadsPath); | |
ctx.fillStyle = COLORS[WHITE]; | |
ctx.fill(mazePaths.wallsPath); | |
return mazePaths; | |
} | |
function generateBackground() { | |
const matrices = { | |
last: null, | |
current: null, | |
}; | |
matrices.current = new Array(MATRIX_DIMENSIONS.height) | |
.fill(0) | |
.map(() => { | |
return generateWhiteNoise(MATRIX_DIMENSIONS.width, WHITE_LEVEL); | |
}); | |
let count = 0; | |
const ITERATIONS_LIMIT = 100; | |
while ( | |
areMatricesDifferent(matrices.current, matrices.last) || | |
count > ITERATIONS_LIMIT | |
) { | |
matrices.last = matrices.current; | |
matrices.current = cellularAutomaton(matrices.last); | |
} | |
const backgroundPaths = generateBackgroundPaths(matrices.current); | |
return drawBackground(backgroundPaths); | |
} |
Start the game
The function start
starts the game. Because the background is static, we just leave it as it is. What’s changing is the player’s position.
function start(canvas, radius = 10) { | |
const mazesPaths = generateBackground(); | |
const { x, y } = findAStartingPoint(mazesPaths.wallsPath, radius); | |
renderPlayer(canvas, {currX: x, currY: y, playerRadius: radius}, mazesPaths.wallsPath); | |
} | |
function renderPlayer(canvas, {currX, currY, playerRadius}, wallsPath) { | |
let radius = playerRadius, x = currX, y = currY; | |
if (spaceClicked) { | |
radius = 2; | |
} | |
const ctx = canvas.getContext("2d"); | |
if (clickedKey) { | |
if (clickedKey.includes('Down') || clickedKey.includes('Up')) { | |
y = currY + KEYBOARD_KEYS[clickedKey] * PIXEL_RATIO / 2; | |
} else { | |
x = currX + KEYBOARD_KEYS[clickedKey] * PIXEL_RATIO / 2; | |
} | |
} | |
if (y - radius < 0 || y + radius >= CANVAS_HEIGHT || x - radius < 0 || x + radius >= CANVAS_WIDTH || isCircleInPath({radius, x, y}, wallsPath)) { | |
x = currX; | |
y = currY; | |
} else { | |
const playerPath = new Path2D(); | |
playerPath.arc(x, y, radius, 0, 2 * Math.PI); | |
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); | |
ctx.fillStyle = "blue"; | |
ctx.fill(playerPath); | |
} | |
requestAnimationFrame(() => renderPlayer(canvas, {currX: x, currY: y, playerRadius}, wallsPath)); | |
} |
Start uses generateBackground
in order to generate a background and get the walls and roads paths. It then uses some function that helps to find a wall-less spot as a starting point to the player. It finally calls renderPlayer
.
renderPlayer
responds to use keyboard presses for motion (lines 9-11 respond to spacebar press and lines 13-19 to arrows). It accepts the former x,y
coordinates and changes them according to the keys pressed. If no key pressed, the coordinates just remain the same.
The collision detection happens on line 21. We first check if the movement didn’t take us out of the canvas bounds:
y - radius < 0 || y + radius >= CANVAS_HEIGHT || x - radius < 0 || x + radius >= CANVAS_WIDTH
Which is a kind of wall.
We also call the function isCircleInPath
which accepts a radius, the coordinates and the walls path. This function compares the player’s path (a circle with a given radius and the center in x,y
coordinates) with the walls path:
function isCircleInPath(circleData, path) { | |
const ctx = document.createElement("canvas").getContext("2d"); | |
const radius = circleData.radius; | |
for ( | |
let x = circleData.x - radius; | |
x <= circleData.x + 2 * radius; | |
x += radius | |
) { | |
for ( | |
let y = circleData.y - radius; | |
y <= circleData.y + 2 * radius; | |
y += radius | |
) { | |
if (ctx.isPointInPath(path, x, y)) return true; | |
} | |
} | |
return false; | |
} |
This function is a bit tricky. What we do here is check 4 points on the circle in the possible motion direction (we can move only up/down/left/right – hence the fancy loop) and use the native isPointInPath
method on the context to see if one of the points on the circle is inside the wall’s path (line 14).
Back in renderPlayer
, if we had a collision (either with the wall or the canvas borders), we just change the x
and y
values to what they were before. This results in no movement (e.g. we hit a wall… we can’t move through it). If there was no collision, we just render the player in its new position (lines 26-29).
The render player calls itself recursively using requestAnimationFrame
, so on every frame, if a user clicked, we will see a motion on screen.
Summary
Path2D is an awesome addition to the Canvas toolkit. Here we demonstrated how we can create a simple collision detection mechanism that is quite efficient using Path2D. There are many more things you can do with Path2D… You can check it out its documentation and play around with it.
Thanks to Miki Ezra Stanger for the very kind and helpful review.
Featured Image by NASA-Imagery from Pixabay