作為一隻前端白菜,一直都不太敢碰 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);
};