戴兜

戴兜的小屋

Coding the world.
github
bilibili
twitter

HTML5 Canvas學習筆記

image

作為一隻前端白菜,一直都不太敢碰 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 可能會導致畫面扭曲,盡量使用 heightwidth 屬性定義 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 有較為詳細的描述,這裡就不再贅述。

image

現在繪製背景對於我們來說就不在話下了。我們來補充之前的 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);
};
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。