Sidequest

Check for Collision in the Game Loop

Game State

When a collision is detected we'll set the game state to game over. We're going to extend the Game class to track a new state enum to reflect one of three game states:

  • GameOver
  • Playing
  • Title
    • This refers to the state where the player must click to start the game. Like a title screen.

We're also going to store our config in the Game class as well. This will give a convenient place to access the config values, and only require us to pass a single new value to our entities.

+
import { config } from "#/config";
+
+
export enum GameState {
+
GameOver,
+
Playing,
+
Title,
+
}
8
9
export class Game {
+
config = config;
11
debug = false;
12
state = GameState.Title;
13
+
reset() {
+
this.state = GameState.Title;
+
}
17
}

We also added a reset method to the Game class to reset the game state when needed.

Collision Response

Let's detect the collision and react:

1
import spriteSheetUrl from "#/assets/image/spritesheet.png";
2
import { BoxCollider } from "#/components/box-collider";
3
import { CircleCollider } from "#/components/circle-collider";
4
import { SpriteAnimation } from "#/components/sprite-animation";
5
import { SpriteAnimationDetails } from "#/components/sprite-animation-details";
6
import { SpriteData } from "#/components/sprite-data";
7
import { Vector2d } from "#/components/vector2d";
8
import { config } from "#/config";
9
import { Bird } from "#/entities/bird";
10
import { Ground } from "#/entities/ground";
+
import { Game, GameState } from "#/game";
12
import { loadImage } from "#/lib/asset-loader";
+
import { circleRectangleIntersects } from "#/lib/collision";
14
import { spriteMap } from "#/sprite-map";
15
16
// ...
17
18
const frame = (hrt: DOMHighResTimeStamp) => {
19
const dt = Math.min(1000, hrt - last) / 1000;
20
21
context.clearRect(0, 0, canvas.width, canvas.height);
22
23
ground.update(dt);
24
bird.update(dt);
25
+
const didBirdHitGround = circleRectangleIntersects(
+
bird.position.x + bird.circleCollider.offsetX,
+
bird.position.y + bird.circleCollider.offsetY,
+
bird.circleCollider.radius,
+
ground.position.x + ground.boxCollider.offsetX,
+
ground.position.y + ground.boxCollider.offsetY,
+
ground.boxCollider.width,
+
ground.boxCollider.height
+
);
+
+
if (didBirdHitGround) {
+
bird.die();
+
ground.stop();
+
game.state = GameState.GameOver;
+
}
41
42
// Draw the background
43
context.drawImage(
44
spriteSheet,
45
spriteMap.background.sourceX,
46
spriteMap.background.sourceY,
47
spriteMap.background.width,
48
spriteMap.background.height,
49
0,
50
0,
51
spriteMap.background.width,
52
spriteMap.background.height
53
);
54
55
ground.draw(context);
56
bird.draw(context);
57
58
last = hrt;
59
60
requestAnimationFrame(frame);
61
};

We don't currently have bird.die() and ground.stop(). Let's add those now.

1
export class Bird {
2
// ...
3
+
public die() {
+
this.state = BirdState.Dead;
+
}
7
8
public setRotation() {
9
// ...
10
}
11
12
// ...
13
}

Now for ground. This has a few more changes. When the game is over we want the ground to stop moving, so we'll track whether or not we're currently scrolling.

1
export class Ground {
2
boxCollider: BoxCollider;
3
game: Game;
4
position: Vector2d;
+
scrolling = true;
6
spriteData: SpriteData;
7
spriteSheet: HTMLImageElement;
8
scrollSpeed: number;
9
10
// ...
11
+
public stop() {
+
this.scrolling = false;
+
}
15
+
public start() {
+
this.scrolling = true;
+
}
19
20
public update(delta: number) {
+
if (this.scrolling === false) {
+
return;
+
}
24
// ...
25
}
26
}

If we're not scrolling, we just return early in the update method.

Click Handler Refactor

Now back to main.ts - we need to update the click handler to reflect our new game states.

1
canvas.addEventListener("click", () => {
+
switch (game.state) {
+
case GameState.Title: {
+
game.state = GameState.Playing;
+
bird.flap();
+
+
break;
+
}
+
+
case GameState.Playing: {
+
bird.flap();
+
+
break;
+
}
+
+
case GameState.GameOver: {
+
game.reset();
+
bird.reset();
+
ground.start();
+
+
break;
+
}
+
}
24
});

This is pretty similar to the way we handled the flap method in the Bird class. The game starts in Title state, and when the player clicks we want to change the state to Playing. Playing is pretty basic, just react to clicks and call flap() like usual. When the game is over, we want to reset the game state and reset the entities.

You should see an error, we need to add the reset() method to the Bird class as well:

1
export class Bird {
2
// ...
3
4
public die() {
5
this.state = BirdState.Dead;
6
}
7
+
public reset() {
+
this.position.x = this.game.config.gameWidth / 4;
+
this.position.y = this.game.config.gameHeight / 2;
+
this.rotation = 0;
+
this.velocity.x = 0;
+
this.velocity.y = 0;
+
this.state = BirdState.Idle;
+
}
16
17
// ...
18
}

This will allow us to reset the bird when restarting the game.

Bird and Ground Draw Order

Before we had collision detection, it was handy to see the bird fall in front of the ground. This way we knew where it was. Now that we have collision detection, let's draw the bird before the ground so it appears to sink into it.

1
const frame = (hrt: DOMHighResTimeStamp) => {
2
// ...
3
+
bird.draw(context);
+
ground.draw(context);
6
7
last = hrt;
8
9
requestAnimationFrame(frame);
10
};

We did this purely cause we thought it looked better. It's not necessary if you don't agree.

There you have it! We've got collision detection and a nice complete game flow in place. Next up, the pipes.