作为一只前端白菜,一直都不太敢碰 Canvas。最近粗浅的学习了一下 canvas 的操作,也算是了结了自己的一个心愿。简单整理了一点自己的笔记和学习心得。
目的是创建一个 Flappy Bird 的基本动画场景。
用 canvas 是真的会上头的(雾
Part.1 准备#
提前准备了一些用于制作这个简单动画的小素材,如下:
鸟:https://i.loli.net/2019/08/14/yRFjQEdoSpx9YDa.png
地面:https://i.loli.net/2019/08/14/QyhMSrTwul2H7A9.png
天空(背景):https://i.loli.net/2019/08/14/YHtj95f2MxOduKh.png
Part.2 开始#
PS:感觉更好的做法是将背景和地面用两个 Canvas 分开绘制,但在这篇文章中,会将所有动画元素全部绘制到同一个 Canvas 中。
首先,创建一个 Canvas(注意:使用 css 修改 canvas 可能会导致画面扭曲,尽量使用 height
和 width
属性定义 canvas 的宽高)
<canvas id="scene" height="640" width="481"></canvas>
接着使用其自身的 getContext()
以获取 Canvas 的上下文
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
Part.2-1 载入图片资源#
载入图片有很多种方式,我采用的是下面这种。不知道一般情况下是如何实现的,但这种方法维护和开发时非常方便。
// 定义需要的图片资源路径
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"
}
// 载入所有图片
let completed = []; // 用于确定已经完成加载的图片数量
let keys = Object.keys(images); // 用于取出所有图片的名称
let keysLength = keys.length; // 获取图片数量
for (let i = 0; i < keysLength; i++) {
let name = keys[i]; // 获取当前图片名称
let image = new Image(); // 实例化一个Image对象用于加载图片
image.src = images[name]; //加载图片
image.onload = function () {
images[name] = image; // 将images对象的指定图片路径替换成对应image实例
completed.push(1); // 记录加载完成的图片个数
if (completed.length === keysLength) {
// 图片全部加载完成
run();
}
}
}
// 画布的初始化方法
const run = () => {
draw();
}
// 画布的绘制方法
const draw = () => {
//TODO
}
此时我们需要的三个图片资源已经全部载入完毕了,需要使用时也非常简单,images ["bg"]、 images ["bird"] 、 images ["ground"] 即可取出对应的图片。
Part.3 绘制背景#
这是最简单的一个部分,在绘制之前,我们先来了解一下 Canvas 的 drawImage()
方法。
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
每个参数分别对应着什么,我放张 MDN 上的图就明白了。想了解更多内容,MDN 有较为详细的描述,这里就不再赘述。
现在绘制背景对于我们来说就不在话下了。我们来补充之前的 draw 函数
const draw = () => {
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
}
Part.4 绘制地面#
地面的图片我们也用同样的方法绘制进 Canvas 中。为了将地面的高度限制在 80px,我们需要在绘制时进行等比缩放。(更好的处理方案是对图片进行预处理避免缩放操作,但我懒)
const draw = () => {
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// 绘制地面
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, 0, canvas.height - 80, images["ground"].width*(80/images["ground"].height), 80);
}
然后… 就会发现地面的宽度远远不够,如何让图像平铺呢?答案是:循环。
大概估摸着给个值,能铺满整个地面就可以了。 (为了做动画不露馅可以稍微多给一两个)
const draw = () => {
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// 绘制地面
let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
for(let i = 0 ;i<22;i++){
// 使用for循环让地面重复绘制多次,从而得到完整的地面
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
}
背景和地面都已经绘制完了,那么如何给地面添加一个向后运动的位移效果呢?
Part.4-1 帧的更新#
我们可能首先会想到使用 setTimeout()
或 setInterval()
,但其实对于 Canvas,浏览器的 window 全局对象提供了 requestAnimationFrame()
以更加高效的方式来更新帧内容。
为什么要使用 requestAnimationFrame()
而不是 setTimeout()
或 setInterval()
呢?
requestAnimationFrame()
能够更加精确地让帧率保持在 60fps 左右,避免过度绘制或动画卡顿。
requestAnimationFrame()
会在元素不可见、浏览器处于后台、标签页未激活等情况时自动停止绘制,节省性能开销。相比于 setTimeout()
或 setInterval()
,requestAnimationFrame()
由浏览器优化其调用时机,更加高效可靠。
根据我们的需求修改 run()
和 draw()
,不要忘了在 draw()
的开头使用 clearRect()
来清空 Canvas。
// 画布的初始化方法
const run = () => {
window.requestAnimationFrame(draw);
}
// 画布的绘制方法
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// 绘制地面
let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
for(let i = 0 ;i<22;i++){
// 使用for循环让地面重复绘制多次,从而得到完整的地面
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
window.requestAnimationFrame(draw);
}
现在的地面还不能动起来,为了让地面能够动起来,我们需要在每一帧对整个地面的位置进行改变。让地面无限后退的可能性是极小的,因为我们在这个 demo 中,只绘制了 22 份宽度的地面。但是,我们可以模拟出一种地面在无限后退的错觉。
最简单的方法,每组的前 3 帧,让地面后退 1/3 个宽度(指 22 份中一份的宽度),每组的第 4 帧,让地面前进 1 份的宽度。这样,就能制造出一种地面在无限后退的错觉了。
Part.4-2 位移的实现#
那么,如何实现这 22 份 “地面” 同时向后退呢?第一种方法,直接在每次绘制时修改地面 x 轴坐标。第二种方法,每次绘制地面前,移动坐标系。我们采用第二种。
这就涉及到了渲染上下文绘制状态的保存与恢复,分别对应 save()
和 restore()
,按照MDN的描述,能够被保存的属性有:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shadowColor, globalCompositeOperation, font, textAlign, textBaseline, direction, imageSmoothingEnabled。
我们使用 translate()
来移动地面坐标系。
emmm,当然,1/3 格 / 帧的速度对于地面来说实在是太快了,在对速度进行微调后,绘制部分代码补充如下:
let groundAnimationCount = 0;
// 画布的绘制方法
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// 绘制地面
let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
if (groundAnimationCount == 12) {
groundAnimationCount = 0;
}
ctx.save(); // 保存原坐标系
ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系
for (let i = 0; i < 22; i++) {
// 使用for循环让地面重复绘制多次,从而得到完整的地面
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
ctx.restore(); // 恢复原坐标系
groundAnimationCount++;
window.requestAnimationFrame(draw);
};
Part.5 绘制鸟的动画#
鸟的动画有 3 帧不同的状态,被处理成了雪碧图存放到了 PNG 图片中。有了前面的经验,处理这只鸟就简单多了。还记得之前讲到 drawImage () 方法时,sx/sy/sWidth/sHeight 参数么?使用这四个参数来分割每一帧的画面。
不要忘了限制鸟扇动翅膀的速率,否则会谜之鬼畜的…
let groundAnimationCount = 0;
let birdAnimationCount = 0;
// 画布的绘制方法
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布
// 绘制背景,预留80px的高度给地面
ctx.drawImage(images["bg"], 0, 0, images["bg"].width, images["bg"].height, 0, 0, canvas.width, canvas.height - 80);
// 绘制地面
let groundWidth = images["ground"].width * (80 / images["ground"].height); // 提前计算地面图片的绘制宽度,减少计算次数
if (groundAnimationCount == 12) {
groundAnimationCount = 0;
}
ctx.save(); // 保存原坐标系
ctx.translate(-groundAnimationCount * (groundWidth / 12), 0); // 移动坐标系
for (let i = 0; i < 22; i++) {
// 使用for循环让地面重复绘制多次,从而得到完整的地面
ctx.drawImage(images["ground"], 0, 0, images["ground"].width, images["ground"].height, groundWidth * i, canvas.height - 80, groundWidth, 80);
}
ctx.restore(); // 恢复原坐标系
groundAnimationCount++;
// 绘制鸟
if (birdAnimationCount == 15) {
birdAnimationCount = 0;
}
// 限制鸟扇动翅膀的帧率,每5帧才变成下一个状态
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);
};