Our hero can run and jump, but the game needs more excitement and danger. Let's make it more interesting by adding obstacles!
Since obstacles are different from basic objects, we'll give them their own obstacle.js file. We could make them stationary, but it's more thrilling to jump over moving obstacles.
We'll use the camera speed to decide how fast the obstacles should move.
export default class Obstacle {
constructor ({x, y, width, height, sprite}) {
this.x = x
this.y = y
this.width = width
this.height = height
this.sprite = sprite
this.speed = 0.5
}
update (deltaTime, cameraSpeed) {
this.x -= cameraSpeed * this.speed * deltaTime
}
}
Don't forget to update the obstacles in the scene's update loop.
update (deltaTime) {
// ... omitted for brevity
this.world.obstacles.forEach(obstacle => {
obstacle.update(deltaTime, this.camera)
})
}
We'll generate obstacles at random intervals based on the camera's speed.
But first, we need to add information about the time that has passed in the scene.
constructor (canvas) {
// ... omitted for brevity
this.elapsedTime = 0
}
update (deltaTime) {
// ... omitted for brevity
this.elapsedTime += deltaTime
}
First, we'll check how much time has passed since the last obstacle was made. If it's too soon to create a new obstacle, we'll wait until the next loop.
Next, we'll make a new obstacle with a random position on the y-axis, size, and image.
Finally, we'll set the time for the next obstacle based on the camera's speed.
generateObstacles () {
const {camera} = this
if (this.elapsedTime < this.nextObstacleAt) {
return
}
const scale = floatBetween([0.4, 0.8])
const obstacle = new Obstacle({
x: camera.x + camera.width + 1,
y: floatBetween([2.5, 2.9]),
width: scale,
height: scale,
sprite: randomPick([
'tech1',
'tech2',
'tech3',
'tech4',
'tech5',
'tech6',
'tech7'
])
})
this.add('obstacles', obstacle)
const nextObstacleDelay = (1 / this.camera.speed) * floatBetween(2.5, 4.5)
this.nextObstacleAt = this.elapsedTime + nextObstacleDelay
}
Don't forget to start the obstacle generation at the end of the generateWorld function.
generateWorld () {
// ... omitted for brevity
this.generateObstacles()
}
Collisions
The obstacles don't do anything when they hit the hero. To fix this, we need to do some math.
Fortunately, we can simplify the hero and obstacles shapes by using circles. Calculating collisions between two circles is easy.
But before we calculate collisions, we'll start by showing circles. This will make it easier to visualize the collisions.
Drawing a circle is a bit complicated because we need to draw an arc. I'll simplify this for you, but you can learn more by reading the arc documentation.
export function drawCircle (ctx, {x, y, radius, color}) {
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(x, y, radius, 0, 2 * Math.PI)
ctx.fill()
}
We have a problem: the hero and obstacles are squares for now. To calculate collisions, we need to find the center of the circle and its radius relative to the square.
The radius of the circle is half the size of the square.
We have to find the center of the circle by taking the position of the top-left corner of the square and adding the radius.
We'll make a new dynamic property to calculate the circle of our hitBox.
get hitBox () {
const semiWidth = this.width / 2
const semiHeight = this.height / 2
return {
x: this.x + semiWidth,
y: this.y + semiHeight,
radius: semiWidth
}
}
Now let's create a new method called drawHitBox that will draw the collision circle of an element if a hitbox exists on that element.
export function drawHitBox (ctx, scene, element) {
const {hitBox} = element
if (hitBox) {
drawCircle(ctx, {
x: hitBox.x - scene.camera.x,
y: hitBox.y - scene.camera.y,
radius: hitBox.radius,
color: 'black'
})
}
}
We can draw the circle hitboxes in the scene for debugging purpose.
Unfortunately, our drawScene function is starting to get a bit cluttered. This is an opportunity to do some refactoring to incorporate the display of the hitboxes more cleanly.
But the drawScene function is getting too cluttered. This is a chance to refactor.
We want to do two things in the drawScene function: show the world then show the hero.
export function drawScene (ctx, scene, images) {
drawWorld(ctx, scene, images)
drawHero(ctx, scene, images)
}
Now it's much cleaner. In the drawWorld function, we want to go through each element of the world and show it.
function drawWorld (ctx, scene, images) {
for (let category in scene.world) {
scene.world[category].forEach(element => {
drawSceneElement(ctx, scene, images, element)
})
}
}
Then, we'll make a function called drawSceneElement that will show an element of the scene. We'll add the hitBox here.
function drawSceneElement (ctx, scene, images, element) {
const drawParams = Object.assign({}, element)
drawParams.image = images[drawParams.sprite]
drawParams.x -= scene.camera.x
drawParams.y -= scene.camera.y
drawImage(ctx, drawParams)
drawHitBox(ctx, scene, element)
}
Now you can see the obstacles hitboxes. This step was a bit long, but it's important to refactor. Otherwise, the code will be hard to read.
Next, let's look at the hero's hitbox, which is a bit more complicated to calculate.
The hero's body doesn't take up the entire square that surrounds it. Also, it's slightly off-center because of the legs. We need to consider these factors to find the center of the circle and its radius.
get hitBox () {
const semiWidth = this.width / 2
const semiHeight = this.height / 2
return {
x: this.x + semiWidth,
y: this.y + semiHeight * 0.75,
radius: semiWidth * 0.5
}
}
Let's draw this hitBox when we display the hero.
function drawHero (ctx, scene, images) {
// ... omitted for brevity
drawImage(ctx, drawParams)
drawHitBox(ctx, scene, hero)
}
You should see a black hitBox around the hero. It's not perfect, but it's enough for our mini-game.
Collision detection
To find out if 2 circles touch each other, we will use the Pythagorean theorem.
I recommend you to take a look at this Pythagorean theorem progression by Silent Teacher. It's a fun way to understand how it works.
export function circleVsCircle (circleA, circleB) {
const distanceX = circleA.x - circleB.x
const distanceY = circleA.y - circleB.y
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY)
return distance < circleA.radius + circleB.radius
}
Now in the scene, we'll check if the hero collides with an obstacle on every frame.
checkCollisions () {
const {world, hero} = this
const collided = world.obstacles.some(obstacle => {
obstacle.collided = circleVsCircle(hero.hitBox, obstacle.hitBox)
return obstacle.collided
})
hero.collided = collided
}
Remember to call this function first in the update function.
update (deltaTime) {
this.checkCollisions()
// ... omitted for brevity
}
Collisions should be working now, but we can't see them yet. We'll update the drawHitBox function to show red when an element is colliding.
export function drawHitBox (ctx, scene, element) {
const {hitBox} = element
if (hitBox) {
const color = element.collided ? 'red' : 'black'
drawCircle(ctx, {
x: hitBox.x - scene.camera.x,
y: hitBox.y - scene.camera.y,
radius: hitBox.radius,
color
})
}
}
And we're done! Collisions are working. You'll see the obstacles turn red when they collide with the hero.
What's new ?
- Obstacle generation based on time
- Calculating collisions between 2 circles
- Showing a hitbox based on its state
We have everything we need to make a game now.