Sidequest

Add Flap Animation

To animate the bird we'll need two new components. One to represent the sprite animation details, and another to manage the animation. These will be SpriteAnimationDetails and SpriteAnimation respectively.

Sprite Animation Details

SpriteAnimationDetails is exactly the same as the SpriteData class, with two new properties: frameWidth and frameHeight. Once we know the width and height of each frame within the animation, we can calculate how many images we have in that animation. We use the prefix frame because the images that make up an animation are commonly referred to as frames.

We've talked about sprite data to represent something like a single complete image in the sprite sheet. Like the background. There's no reason the sample itself couldn't be made up of multiple images. This is exactly what we're going to do. We'll extend the sprite data object structure we know to represent a single sample of the sprite sheet, which itself has multiple images denoted by frameWidth and frameHeight.

Here's a diagram to help you visualize this:

Animation frame diagram
Animation frame diagram

Create a new SpriteAnimationDetails component:

1
export class SpriteAnimationDetails {
2
constructor(
3
public sourceX: number,
4
public sourceY: number,
5
public width: number,
6
public height: number,
7
public frameWidth: number,
8
public frameHeight: number
9
) {}
10
}

Sprite Animation

We also need a class to manage the animation. We'll call this SpriteAnimation.

Here's what this class will need to know at a high level:

  • The duration (in seconds) of the entire animation
  • The sprite animation details to determine how many frames there are, and what the duration of each frame will be
  • Track the progress of the current frame, so we know when to move to the next frame
  • The sprite data of the current frame we're on so we can render it
1
import { SpriteAnimationDetails } from "#/components/sprite-animation-details";
2
import { SpriteData } from "#/components/sprite-data";
3
4
export class SpriteAnimation {
5
/**
6
* The elapsed time of the current frame
7
*/
8
public elapsedFrameTime = 0;
9
10
/**
11
* The frame rate of the animation in seconds.
12
*/
13
public frameRate = 0;
14
15
/**
16
* Sprite data for each frame of the animation
17
*/
18
public frames: SpriteData[] = [];
19
20
public currentFrameIndex = 0;
21
22
constructor(
23
public animationDetails: SpriteAnimationDetails,
24
public durationInSeconds: number
25
) {
26
const horizontalFrames =
27
animationDetails.width / animationDetails.frameWidth;
28
29
for (let i = 0; i < horizontalFrames; i++) {
30
const sourceX =
31
animationDetails.sourceX + i * animationDetails.frameWidth;
32
33
this.frames.push(
34
new SpriteData(
35
sourceX,
36
animationDetails.sourceY,
37
animationDetails.frameWidth,
38
animationDetails.frameHeight
39
)
40
);
41
}
42
43
// Determine the frame rate based on the duration of the animation
44
// and the number of frames.
45
this.frameRate = this.durationInSeconds / this.frames.length;
46
}
47
48
public update(delta: number) {
49
this.elapsedFrameTime += delta;
50
51
if (this.elapsedFrameTime >= this.frameRate) {
52
this.elapsedFrameTime = 0;
53
54
this.currentFrameIndex =
55
(this.currentFrameIndex + 1) % this.frames.length;
56
}
57
}
58
59
public getCurrentFrame() {
60
return this.frames[this.currentFrameIndex];
61
}
62
}

We're assuming that animations are only laid out horizontally, because that's all we need to handle our bird. If you want to support animations that are laid out both vertically and horizontally, you'll need to add some logic to handle that. We also assume the animation will loop.

Adding Animation to the Bird

We now need to:

  • Update the Bird constructor to take a SpriteAnimation instance
  • Add an update() method to the bird to update the animation
  • Draw the current animations frame in the draw() method
  • Start calling the update() method in the game loop
+
import { SpriteAnimation } from "#/components/sprite-animation";
2
import { SpriteData } from "#/components/sprite-data";
3
import { Vector2d } from "#/components/vector2d";
4
5
type BirdOptions = {
6
spriteSheet: HTMLImageElement;
7
spriteData: SpriteData;
8
position: Vector2d;
+
flapAnimation: SpriteAnimation;
10
};
11
12
export class Bird {
13
spriteSheet: HTMLImageElement;
14
spriteData: SpriteData;
15
position: Vector2d;
+
flapAnimation: SpriteAnimation;
17
18
constructor(options: BirdOptions) {
19
this.spriteSheet = options.spriteSheet;
20
this.spriteData = options.spriteData;
21
this.position = options.position;
+
this.flapAnimation = options.flapAnimation;
23
}
24
+
public update(delta: number) {
+
this.flapAnimation.update(delta);
+
}
28
29
public draw(context: CanvasRenderingContext2D) {
+
const currentFrame = this.flapAnimation.getCurrentFrame();
31
32
context.drawImage(
33
this.spriteSheet,
+
currentFrame.sourceX,
+
currentFrame.sourceY,
+
currentFrame.width,
+
currentFrame.height,
38
this.position.x,
39
this.position.y,
+
currentFrame.width,
+
currentFrame.height
42
);
43
}
44
}

Now update the instance created in main.ts to use the new SpriteAnimation class:

1
import spriteSheetUrl from "#/assets/image/spritesheet.png";
+
import { SpriteAnimation } from "#/components/sprite-animation";
+
import { SpriteAnimationDetails } from "#/components/sprite-animation-details";
4
import { SpriteData } from "#/components/sprite-data";
5
import { Vector2d } from "#/components/vector2d";
6
import { config } from "#/config";
7
import { Bird } from "#/entities/bird";
8
import { Ground } from "#/entities/ground";
9
import { loadImage } from "#/lib/asset-loader";
10
import { spriteMap } from "#/sprite-map";
11
12
// ...
13
14
const bird = new Bird({
15
spriteSheet,
16
position: new Vector2d(config.gameWidth / 4, config.gameHeight / 2),
17
spriteData: new SpriteData(
18
spriteMap.bird.idle.sourceX,
19
spriteMap.bird.idle.sourceY,
20
spriteMap.bird.idle.width,
21
spriteMap.bird.idle.height
22
),
+
flapAnimation: new SpriteAnimation(
+
new SpriteAnimationDetails(
+
spriteMap.bird.animations.flap.sourceX,
+
spriteMap.bird.animations.flap.sourceY,
+
spriteMap.bird.animations.flap.width,
+
spriteMap.bird.animations.flap.height,
+
spriteMap.bird.animations.flap.frameWidth,
+
spriteMap.bird.animations.flap.frameHeight
+
),
+
0.3
+
),
34
});

We're leveraging the preconfigured spriteMap here again to pull the frame data we need from the sprite sheet. We also set our animation duration to 0.3 seconds.

Then add bird.update() within frame() in main.ts:

1
const frame = (hrt: DOMHighResTimeStamp) => {
2
const dt = Math.min(1000, hrt - last) / 1000;
3
4
context.clearRect(0, 0, canvas.width, canvas.height);
5
6
ground.update(dt);
+
bird.update(dt);
8
9
// ...
10
};

Now we're animating!

Next, we'll get the bird to flap upward on click.