用Canvas畫一隻會跟着鼠標走的小狗

之前常常看到這種效果:在網頁右下角放一我的,而後他的眼珠會跟着鼠標轉,效果以下:html

這個例子來自於CodePen,它是根據鼠標的位置設置兩個眼球的transform: rotate屬性作的效果。前端

這種跟着鼠標移動的小交互通常都比較好玩,因此我忽然想到,能不能作一隻會跟着鼠標走的小狗,最後的效果以下所示:算法

咱們一步步來實現這個效果。canvas

1. 小狗走的動畫

小狗走的動畫應該怎麼實現呢?若是用一張gif,而後根據鼠標的位置移動這張gif,那麼當鼠標停下來小狗不動的效果就作不了,由於gif一直在循環播放代碼控制不了這個行爲。因此這種簡單方案是不可行的。數組

而後又想到以前用CSS的animation作過這種逐幀動畫:瀏覽器

因此就有思路了,小狗的動畫也是使用逐幀的動畫,而且用JS控制它的播放。bash

在網上搜羅了一番,尚未人作過相似的動畫,不過找到了小狗的素材,這位老兄在教人怎麼畫行走的動物,恰好能夠拿來當作咱們的素材,把小狗摳出來:async

2. 畫一隻在原地踏步的小狗

動畫的第一步先讓小狗原地踏步,即先讓這個動畫能播放起來,而後再作移動的動畫。所謂逐幀動畫就是每隔一小會就播放一幀,這樣連起來就是在動了。函數

寫一個canvas標籤,而後把它固定到頁面的底部:優化

<canvas id="dog-walking" style="position:fixed;left:0;bottom:0"></canvas>複製代碼

而後設置寬度爲頁面的100%:

let canvas = document.querySelector("#dog-walking");
canvas.width = window.innerWidth;
canvas.height = 200;複製代碼

這樣咱們就有一個畫布了。接着要把圖片畫讓去,先要把圖片加載下來,上面咱們準備了9張png:0.png ~ 8.png,其中0.png是小狗停住不動的圖片,1.png ~ 8.png是小狗在走的圖片。

在JS裏面怎麼加載圖片呢,用新建一個Image實例的方式,以下代碼所示:

let img = new Image();
img.onload = function() {
    beginDraw(img);
};
img.src = "dog/0.png";複製代碼

因爲圖片比較多,咱們用類的方式組織咱們的代碼,把數據看成類的屬性,方便存取。以下代碼所示:

class DogAnimation {
    constructor(canvas) {
        canvas.width = window.innerWidth;
        canvas.height = 200;
        // 存放加載後狗的圖片
        this.dogPictures = [];
        // 圖片目錄
        this.RES_PATH = "./dog";
        this.IMG_COUNT = 8;
        this.start();
    }
    start() {
        this.loadResources();
    }
    loadResources() {

    }

}
let canvas = document.querySelector("#dog-walking");
let dogAnimation = new DogAnimation(canvas);
dogAnimation.start();複製代碼

把狗的圖片放到dogPictures數組裏面,在loadResources裏面進行加載,以下代碼所示:

// 加載圖片
loadResources() {
    let imagesPath = []; 
    // 準備圖片的src
    for (let i = 0; i <= this.IMG_COUNT; i++) {
        imagesPath.push(`${this.RES_PATH}/${i}.png`);
    }   

    let works = []; 
    imagesPath.forEach(imgPath => {
        // 圖片加載完以後觸發Promise的resolve
        works.push(new Promise(resolve => {
            let img = new Image();
            img.onload = () => resolve(img);
            img.src = imgPath;
        }));
    }); 

    return new Promise(resolve => {
        // 藉助Promise.all知道了全部圖片都加載好了
        Promise.all(works).then(dogPictures => {
            this.dogPictures = dogPictures;
            resolve();
        }); 
    }); // 這裏再套一個Promise是爲了讓調用者可以知道處理好了
}複製代碼

這段加載圖片的代碼藉助了Promise,把每張圖片的加載都看成一個Promise的任務,統一放到一個數組裏面,而後再借助Promise.all就知道全部的任務都完成了。這樣就拿到了全部已onload的img對象,而後就能夠拿來畫了。

在start函數裏面添加一個畫的函數walk的執行:

async start() {
    // 等待資源加載完
    await this.loadResources();
    this.walk(); 
}
walk() {

}複製代碼

實際上爲了畫逐幀動畫,咱們要使用window.requestAnimationFrame,這個函數在瀏覽器畫它本身的動畫的下一幀以前會先調一下這個函數,理想狀況下,1s有60幀,即幀率爲60 fps。由於不論是播放視頻仍是瀏覽網頁它們都是逐幀的,例如往下滾動網頁的時候就是一個滾動的動畫,因此瀏覽器自己也是在不斷地在畫動畫,只是當你的網頁中止不動時(且頁面沒有動畫元素),它可能會下降幀率減小資源消耗。

因此代碼改爲這樣:

async start() {
    await this.loadResources();
    // 給下一幀註冊一個函數
    window.requestAnimationFrame(this.walk.bind(this));
}
walk() {
    // 繪製狗的圖片 
    
    // 繼續給下一幀註冊一個函數
    window.requestAnimationFrame(this.walk.bind(this));
}複製代碼

咱們使用了一個bind(this),它的做用是讓walk函數的執行上下文仍是指向當前類的實例。

如今怎麼讓狗動起來呢?最簡單的咱們能夠每隔0.1s就畫一幀,這樣就會連起來,造成一個動畫,爲此咱們須要記錄上一次畫的時間,而後判斷當前時間與上一次的時間是否大於0.1s,若是是的話就畫下一幀,不然什麼也不用幹。由於上文提過,1s最多有60幀,每一幀間隔 1s / 60 = 16.67ms。以下代碼所示,先在constructor添加幾個變量,包括一個記錄上一幀時間的變量:

constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext("2d");
    // 記錄上一幀的時間
    this.lastWalkingTime = Date.now(); 
    // 記錄當前畫的圖片索引
    this.keyFrameIndex = -1; 
    this.start();
}複製代碼

而後在walk函數裏面進行繪製,在畫的時候每次畫都取下張圖片,即下一幀的圖片,不斷循環:

walk() {
    // 繪製狗的圖片,每過100ms就畫一張 
    let now = Date.now();
    if (now - this.lastWalkingTime > 100) {
        // 先清掉上一次畫的內容
        this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
        // 獲取下一張圖片的索引
        let keyFrameIndex = ++this.keyFrameIndex % this.IMG_COUNT;
        let img = this.dogPictures[keyFrameIndex + 1]; 
                        // img, sx, sy, swidth, sheight
        this.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
                // dx = 20, dy, dwidth, dheight
                20, 20, 186, 162); 
        this.lastWalkingTime = now;
    }   
    // 繼續給下一幀註冊一個函數
    window.requestAnimationFrame(this.walk.bind(this));
}複製代碼

這樣咱們就有了一隻在原地踏步的小狗:

而後讓它往前走。

3. 讓小狗往前走

上面在drawImage的傳參固定dx = 20,若是不斷加大這個dx,那麼它就往前走了。爲此在構造函數裏面添加一個變量記錄當前的位移,並設置小狗的速度:

constructor(canvas) {
    // 小狗的速度
    this.dogSpeed = 0.1;
    // 小狗當前的位移
    this.currentX = 0;
}複製代碼

而後在walk函數裏面計算當前累加的位移:

// 計算位移 = 時間 * 速度
let distance = (now - this.lastWalkingTime) * this.dogSpeed;
this.currentX += distance;
this.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
        // dx, dy, dwidth, dheight
        this.currentX, 20, 186, 162);複製代碼

可是這樣咱們發現小狗走起路來一卡一卡的,不是很連貫:

這個是由於每0.1s畫一幀,幀率只有10fps,因此一走起來就不太行了。方法一是讓它走慢點,這樣能夠減緩,可是若是想保持速度甚至提升速度的話,咱們得想辦法優化一下。

4. 算法優化

考慮到狗的控制參數比較集中,把它們寫到一個dog的Object裏面:

constructor (canvas) {
    this.dog = {
        // 一步10px
        stepDistance: 10,
        // 狗的速度
        speed: 0.15,
        // 鼠標的x座標
        mouseX: -1
    };
}複製代碼

主要有兩個參數,一個是狗的速度另外一個是每一步走的位移,而後計算距離方式變成:

let now = Date.now(); 
let distance = (now - this.lastWalkingTime) * this.dog.speed;
if (distance < this.dog.stepDistance) {
    window.requestAnimationFrame(this.walk.bind(this));
    return;
}複製代碼

每一步至少走10px,若是小於這個數的話就不走了。經過每步的位移和速度這兩個參數能夠很方便地控制狗走的快慢和幀率,例如把stepDistance改小點,speed提升就會走得比較頻繁,能提升幀率,上面設置的幀率是14 fps. 不過幀率低的根本緣由仍是在於小狗走路的圖片較少。

5. 走到鼠標的位置停下

給小狗添加一個停留的位置,包括往前走和日後走的,由於一個是鼠標在圖片前面,一個是鼠標在圖片的後面,須要區分:

this.dog = {
    // 往前走停留的位置
    frontStopX: -1,
    // 往回走停留的位置,
    backStopX: window.innerWidth,
};複製代碼

而後添加一個記錄鼠標位置的函數,主要是監聽mousemove事件:

async start () {
    await this.loadResources();
    this.pictureWidth = this.dogPictures[0].naturalWidth / 2;
    this.recordMousePosition();
    window.requestAnimationFrame(this.walk.bind(this));
}
// 記錄鼠標位置
recordMousePosition() {
    window.addEventListener("mousemove", event => {
        // 若是沒減掉圖片的寬度,小狗就跑到鼠標後面去了,由於圖片的寬度還要佔去空間
        this.dog.frontStopX = event.clientX - this.pictureWidth;
        this.dog.backStopX = event.clientX;
    });
}複製代碼

而後在walk函數裏面用一個變量stopWalking表示小狗是否停下來,和一個direct表示小狗的方向:

this.keyFrameIndex = ++this.keyFrameIndex % this.IMG_COUNT;
let direct = -1,
    stopWalking = false;
// 若是鼠標在狗的前面則往前走
if (this.dog.frontStopX > this.dog.mouseX) {
    direct = 1; 
} 
// 若是鼠標在狗的後面則往回走
else if (this.dog.backStopX < this.dog.mouseX) {
    direct = -1;
}
// 若是鼠標在狗在位置
else {
    stopWalking = true;
    // 若是停住的話用0.png(後面還會加1)
    this.keyFrameIndex = -1;
}複製代碼

若是小狗沒有停,計算位置的時候乘以direct:

// 計算位置
if (!stopWalking) {
    this.dog.mouseX += this.dog.stepDistance * direct;
}複製代碼

若是小狗停了,則mouseX仍是上次的值。

鼠標停留在小狗位置的那段代碼能夠作個優化,若是鼠標在小狗中間的右邊,則方向調整爲正,不然爲負:

// 若是鼠標在狗在位置
else {
    stopWalking = true;
    // 若是鼠標在小狗圖片中間的右邊,則direct爲正,不然爲負
    direct = this.dog.backStopX - this.dog.mouseX 
                    > this.pictureWidth / 2 ? 1 : -1; 
    this.keyFrameIndex = -1;
}複製代碼

這樣鼠標在小狗左右來回移動時,小狗會轉頭。

獲得小狗的位置和方向以後就是畫上去,正方向的還好,反方向的因爲沒圖片,咱們經過canvas的翻轉flip進行繪製,以下代碼所示:

ctx.save();
if (direct === -1) {
    // 左右翻轉繪製
    ctx.scale(direct, 1);
}
let img = this.dogPictures[this.keyFrameIndex + 1];
let drawX = 0;
// 左右翻轉繪製的位置須要計算一下
drawX = this.dog.mouseX * direct -  
            (direct === -1 ? this.pictureWidth : 0);
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight,
                drawX, 20, 186, 162);  
ctx.restore();複製代碼

這樣基本上就完成了,最後一個問題是小狗初始化位置的擺放,若是你要把它擺在右邊的話,那須要把它的方向反轉一下,擺在最左邊也須要。否則你會發現小狗擺在左邊,但它的頭朝左了,須要轉一下放在右邊。

一個完整的Demo:Walking Dog.

圖片的素材和繪製過程已說得很詳細,讀者能夠自行實現,或者想其它一些跟着鼠標動的交互效果。


相關閱讀:

用Canvas + WASM畫一個迷宮


【號外】《高效前端》已經開始預售,很快就上市,在京東和早讀課均可以買到。

相關文章
相關標籤/搜索