As a front-end novice, I have always been hesitant to touch Canvas. Recently, I have done some superficial learning about Canvas operations, which has fulfilled a personal wish. I have simply organized some of my notes and learning insights.
The goal is to create a basic animation scene of Flappy Bird.
Using canvas can be really addictive (fog)
Part.1 Preparation#
I prepared some small materials for creating this simple animation in advance, as follows:
Bird: https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png
Ground: https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png
Sky (background): https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png
Part.2 Start#
PS: I feel a better approach would be to draw the background and ground using two separate Canvases, but in this article, all animation elements will be drawn on the same Canvas.
First, create a Canvas (Note: using CSS to modify the canvas may cause distortion, so try to define the canvas dimensions using the height
and width
attributes)
<canvas id="scene" height="640" width="481"></canvas>
Next, use its own getContext()
to get the Canvas context
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
Part.2-1 Load Image Resources#
There are many ways to load images, and I used the method below. I don't know how it's generally done, but this method is very convenient for maintenance and development.
// Define the paths for the required image resources
const images = {
bg: "https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png",
bird: "https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png",
ground: "https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png"
}
// Load all images
let completed = []; // Used to determine the number of images that have finished loading
let keys = Object.keys(images); // Used to get all image names
let keysLength = keys.length; // Get the number of images
for (let i = 0; i < keysLength; i++) {
let name = keys[i]; // Get the current image name
let image = new Image(); // Instantiate an Image object to load the image
image.src = images[name]; // Load the image
image.onload = function () {
images[name] = image; // Replace the specified image path in the images object with the corresponding image instance
completed.push(1); // Record the number of images loaded
if (completed.length === keysLength) {
// All images have finished loading
run();
}
}
}
// Canvas initialization method
const run = () => {
draw();
}
// Canvas drawing method
const draw = () => {
//TODO
}
At this point, the three image resources we need have all been loaded, and it's very simple to use them: images["bg"], images["bird"], images["ground"] can be used to retrieve the corresponding images.
Part.3 Draw Background#
This is the simplest part. Before drawing, let's understand the drawImage()
method of Canvas.
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
What each parameter corresponds to can be understood with a picture from MDN. For more details, MDN has a more detailed description, which I won't elaborate on here.
Now drawing the background is no longer a problem for us. Let's supplement the previous draw function.
const draw = () => {
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
}
Part.4 Draw Ground#
We will also draw the ground image into the Canvas using the same method. To limit the height of the ground to 80px, we need to scale it proportionally when drawing. (A better solution would be to preprocess the image to avoid scaling, but I’m lazy)
const draw = () => {
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// Draw the ground
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, 0, canvas.height - 80, images["ground"].width*(80/images["ground"].height), 80);
}
Then… you will find that the width of the ground is far from enough. How can we tile the image? The answer is: loop.
Estimate a value that can fill the entire ground. (To avoid exposing the animation, you can give a little more)
const draw = () => {
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// Draw the ground
let groundWidth = images["ground"].width * (80 / images["ground"].height); // Pre-calculate the drawing width of the ground image to reduce calculation times
for(let i = 0 ;i<22;i++){
// Use a for loop to repeatedly draw the ground to get a complete ground
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
}
The background and ground have been drawn, so how do we add a backward movement effect to the ground?
Part.4-1 Frame Update#
We might first think of using setTimeout()
or setInterval()
, but for Canvas, the browser's window global object provides requestAnimationFrame()
to update frame content more efficiently.
Why use requestAnimationFrame()
instead of setTimeout()
or setInterval()
?
requestAnimationFrame()
can more accurately keep the frame rate around 60fps, avoiding excessive drawing or animation stuttering.
requestAnimationFrame()
automatically stops drawing when elements are not visible, the browser is in the background, or the tab is not active, saving performance overhead. Compared to setTimeout()
or setInterval()
, requestAnimationFrame()
is optimized by the browser for its calling timing, making it more efficient and reliable.
Modify run()
and draw()
according to our needs, and don't forget to use clearRect()
at the beginning of draw()
to clear the Canvas.
// Canvas initialization method
const run = () => {
window.requestAnimationFrame(draw);
}
// Canvas drawing method
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// Draw the ground
let groundWidth = images["ground"].width * (80 / images["ground"].height); // Pre-calculate the drawing width of the ground image to reduce calculation times
for(let i = 0 ;i<22;i++){
// Use a for loop to repeatedly draw the ground to get a complete ground
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
window.requestAnimationFrame(draw);
}
Now the ground cannot move yet. To make the ground move, we need to change the position of the entire ground in each frame. The possibility of the ground moving infinitely backward is very small because we only drew 22 widths of the ground in this demo. However, we can simulate the illusion of the ground moving infinitely backward.
The simplest method is to let the ground move back 1/3 of a width (referring to one of the 22 parts) for the first three frames of each group, and move forward 1 width in the fourth frame of each group. This way, we can create the illusion of the ground moving infinitely backward.
Part.4-2 Implementing Movement#
So, how do we make these 22 pieces of "ground" move backward simultaneously? The first method is to directly modify the ground's x-axis coordinate each time we draw. The second method is to move the coordinate system before drawing the ground each time. We will use the second method.
This involves saving and restoring the rendering context's drawing state, corresponding to save()
and restore()
. According to MDN, the properties that can be saved include: strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled.
We use translate()
to move the ground coordinate system.
Well, of course, a speed of 1/3 grid/frame is too fast for the ground. After fine-tuning the speed, the drawing part of the code is supplemented as follows:
let groundAnimationCount = 0;
// Canvas drawing method
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// Draw the ground
let groundWidth = images["ground"].width * (80 / images["ground"].height); // Pre-calculate the drawing width of the ground image to reduce calculation times
if (groundAnimationCount == 12) {
groundAnimationCount = 0;
}
ctx.save(); // Save the original coordinate system
ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // Move the coordinate system
for (let i = 0; i < 22; i++) {
// Use a for loop to repeatedly draw the ground to get a complete ground
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
ctx.restore(); // Restore the original coordinate system
groundAnimationCount++;
window.requestAnimationFrame(draw);
};
Part.5 Draw Bird Animation#
The bird animation has 3 different states, which are processed into a sprite sheet stored in a PNG image. With the experience from before, handling this bird becomes much simpler. Remember the sx/sy/sWidth/sHeight parameters mentioned earlier regarding the drawImage() method? These four parameters are used to segment each frame of the image.
Don't forget to limit the bird's flapping rate, otherwise it will be mysteriously chaotic...
let groundAnimationCount = 0;
let birdAnimationCount = 0;
// Canvas drawing method
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
// Draw the background, reserving 80px height for the ground
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// Draw the ground
let groundWidth = images["ground"].width * (80 / images["ground"].height); // Pre-calculate the drawing width of the ground image to reduce calculation times
if (groundAnimationCount == 12) {
groundAnimationCount = 0;
}
ctx.save(); // Save the original coordinate system
ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // Move the coordinate system
for (let i = 0; i < 22; i++) {
// Use a for loop to repeatedly draw the ground to get a complete ground
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
ctx.restore(); // Restore the original coordinate system
groundAnimationCount++;
// Draw the bird
if (birdAnimationCount == 15) {
birdAnimationCount = 0;
}
// Limit the bird's flapping frame rate, changing to the next state every 5 frames
ctx.drawImage(images["bird"], images["bird"].width / 3 * ~~(birdAnimationCount / 5),
0,
images["bird"].width / 3,
images["bird"].height,
canvas.width / 2 - 45 / 2,
canvas.height / 2 ,
45,
images["bird"].height * (45 / images["bird"].width * 3)
);
birdAnimationCount++;
window.requestAnimationFrame(draw);
};