iT邦幫忙

2021 iThome 鐵人賽

DAY 7
0
Modern Web

從零開始打造網頁遊戲-造輪子你也辦的到!系列 第 7

Chapter2 - Canvas動畫(I)玩轉路徑和位移 動畫原來這麼簡單

  • 分享至 

  • xImage
  •  

這個章節呢,同樣會以實作為主,在解決問題中帶大家學習,逐漸引入JS的語言特性,前面一樣會從簡單的開始,後面八成會開始越講越快,若有不明白的歡迎留言詢問!

路徑和位移

身為程式人,要懂得拆解和觀察,可以先想想看"什麼叫做動畫",一幅會動的畫?還是一個移動中的圖像?在Canvas上畫畫我們已經學會了,上個章節透過不斷更新頻譜數據,畫出來的直方圖,也是一種持續變化的動畫,因此要思考的是"動"這件事,要滿足動,也就是位移,只要能掌握路徑,便能掌握動畫,咦?最長在動的不就是咱們的滑鼠嗎,那今天就用滑鼠帶大家學動畫吧!

context.drawImage 方法

參數回顧:

  • RATIO: 畫布和實際畫面的比例
  • WIDTH、HEIGHT: 畫布的寬高 (= 實際畫面 * RATIO)

讓我們沿用上個章節的架構:
https://ithelp.ithome.com.tw/upload/images/20210914/20135197Ze6Ze0yyBz.jpg

let mouseImg = new Image();
mouseImg.src = "../mouse.png";
let mouseX = 0, mouseY = 0;
canvas.addEventListener('mousemove', function (e) {
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.pageX - Rect.left) * RATIO;
    mouseY = (e.pageY - Rect.top) * RATIO;
}, false);
function MouseAnime(){
    context.drawImage(MouseImg, mouseX, mouseY);
}

pageX, pageY 對應到的實際畫面大小,計算其在畫布上的相對位置,要乘上我們當時設計的 RATIO

若考量到RWD所用到的touchmove,給大家參考:

if(window.innerWidth < 992)
canvas.addEventListener('touchmove', function (e) {
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.touches[0].pageX - Rect.left) * RATIO;
    mouseY = (e.touches[0].pageY - Rect.top) * RATIO;
}, false);

用breakpoint來判斷,不是mousemove就是touchmove,才不會在移動端重覆執行

題外話:Image類名

這個坑實在有點不想踩,又要拿一些進階的名詞,是後面章節才會提到的,簡單來說就是 Image 實例化的物件(mouseImg)並不會在你賦予它圖片路徑後,就直接讀取圖片,要是圖片太大讀取太久,那程式不就會卡住嗎?因此它用到的是非同步的方式載入,等到圖片載入完畢後,就會觸發 load 事件,我們可以自行定義其內容,來決定「當圖片載入後,要做什麼反應」。

如果你正心想:「什麼是類名?什麼是實例化?什麼是非同步?」這些不知道沒關係,我們未來有機會再娓娓道來,這邊只需要知道「如果在設定圖片路徑後,立刻呼叫drawImage,則瀏覽器會拋出錯誤,彷彿mouseImg不存在一樣」,因此這邊最直覺的解決方案便是:

mouseImg.onload = () => {
    AnimationLoop();
}

在圖片載入完畢後,再呼叫動畫循環

這樣解決雖然很直白,但把整個動畫循環的開始,完全依賴於一個圖片的讀取,實在是不太靠譜,萬一這個檔案就丟了,讀取不到怎麼辦呢?因此較好的做法應該是以下這兩種:

function MouseAnime(){
    if(MouseImg.complete) context.drawImage(mouseImg, mouseX, mouseY);
}

圖片物件身上有complete屬性,圖片載入時為false,完成後為true,若MouseImg未讀取完畢,則暫時不進行繪製

不過有一種狀況例外,就是當圖片的路徑有問題時,由於是非同步載入,這個路徑的錯誤並不會中斷整個網頁的運行,這固然是一個優點(畢竟網路上的圖片總會經常弄丟),只是問題在於由於載入的過程被中斷了,MouseImg.complete仍然會是預設值true,並沒有進到載入中的狀態,直接用上面的代碼就會讓人有「摁?complete == true已經載入完成了,卻還是沒有圖片」的錯覺,於是我們還可以這麼做:

function MouseAnime(){
    if(mouseImg.width) return; // 若寬度為0,表示圖片來源未正確設定,直接中斷
    
    if(MouseImg.complete) context.drawImage(mouseImg, mouseX, mouseY);
    else context.drawImage(MouseImg, mouseX, mouseY);
}

圖片物件建立之初,會預設寬高屬性為0,因此我們可以選用width來檢查代碼

聰明的你是不是也想到了什麼呢?沒錯,上個章節用到的HTMLMediaElement(aduio),其實也是需要讀取的!相比load事件,它的事件名稱長了些,叫做loadeddata。那當初為什麼程式沒出錯?可以說我們運氣好,也可以說我們的架構設計的還不錯,這些細節的部份在最後的章節,將會一口氣對整個架構進行細節的調整。

回歸正傳

於是乎,這樣就有一個基本的圖形會隨時在滑鼠的位置了,不過它的貼圖位置是以坐標點為左上角,因此會發現滑鼠總是在圖案的左上角,因此若要置中,可以用第二種寫法來設定寬高,並把圖片偏移左上,使中心剛好為滑鼠座標,這邊為了方便,以長寬1:1的圖形舉例:

function MouseAnime(){
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, mouseX-size/2, mouseY-size/2, size, size);
}

考量到RWD,大小也是用WIDTH決定(畫布總寬度)

context.drawImage(mouseImg, 0, 0, mouseImg.width, mouseImg.height,
                            mouseX-size/2, mouseY-size/2, size, size);

↑這邊也同步提供第三種格式給大家參考,前面四個參數可以讓你對圖片進行裁切,不過我個人是不建議把事情做得這麼複雜,最好的方式是在製作素材的時候就先設計好寬高的比例,然後取得寬高比,這樣,不論是什麼圖片,都不用重新做設定:

function MouseAnime(){
    let imgRatio = mouseImg.height / mouseImg.width;
    let w = WIDTH * 0.1;
    let h = WIDTH * 0.1 * imgRatio;
    context.drawImage(mouseImg, mouseX-w/2, mouseY-h/2, w, h);
}

那麼,終於讓滑鼠總是待在圖片的正中心了,不過,單純只有這樣的話,你會發現,隨著鼠標的移動,圖片有非常明顯的瞬移現象,動畫的品質是低劣的,因此我們要來設計一個緩衝的方案了,一開始談數學公式太複雜了,我們就先思考「希望圖案不要立刻到滑鼠的位置」這件事,那是不是就不要馬上衝過去,可以讓位移變少?

如果這樣做...

  • 位移 = 距離 - 緩衝
  • 位移 = 每次移動5格

這幾種方案是不是就馬上浮現腦袋了,接下來分析他們會遇到的問題,用減法的話,比方說緩衝設為100,那是不是圖案最後只會停在距離滑鼠100的地方?那顯然不行,如果用固定每次移動5格呢?欸這辦法似乎挺不錯的,只是當距離超過500,那是不是要移動100次才夠呢?似乎再稍微修改一下,就可以得到答案了。

因應上面的問題,我們需要一個方案:「在距離小的時候,移動變少;在距離大的時候,移動變多」,巧了,這意思不就是距離和位移呈正比嗎,並且位移要比距離還小,那麼只要這麼設計就大功告成了:

位移 = 距離 / 緩衝

function MouseAnime(){
    cursorX+= (mouseX - cursorX) / 5;
    cursorY+= (mouseY - cursorY) / 5;
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

如果有格式控覺得不太好看的話,還有另一種相同效果的版本

cursorX = (cursorX * 4 + mouseX) / 5;
cursorY = (cursorY * 4 + mouseY) / 5;

此時我們發現,圖案就像是跟隨著滑鼠一樣,有自己的運動軌跡,看起來也更加順暢了,嚴格來說,它已經不是鼠標了,就是個小跟班

接下來就可以練習嘗試做個函式,在加入時間這個概念前,先來製作一個基本的互動模型,讓滑鼠點擊後,讓動畫暫停,釋放滑鼠後,再讓動畫繼續進行:

let isPathing = false;
canvas.addEventListener('mousedown', () => isPathing = true);
canvas.addEventListener('mousemove', GetMouse);
canvas.addEventListener('mouseup', () => isPathing = false);
canvas.addEventListener('mouseout', () => isPathing = false);
function GetMouse(e){
    let Rect = canvas.getBoundingClientRect();
    mouseX = (e.pageX - Rect.left) * RATIO;
    mouseY = (e.pageY - Rect.top) * RATIO;
}
function MouseAnime(){
    if(!isPathing){
        cursorX+= (mouseX - cursorX) / 5;
        cursorY+= (mouseY - cursorY) / 5;
    }
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

在滑鼠按下的時候,先鎖住游標圖案的座標,讓它保持不變,等放開滑鼠時,再一次釋放它!有沒有很像要喊三二一讓賽車衝出去的感覺呢?

接下來我們給它一定的時間,單位為偵數,60偵大約為1秒

let period = 0, timer = 0;
let distanceX = 0, distanceY = 0;
canvas.addEventListener('mouseup', GetDistance);
canvas.addEventListener('mouseout', GetDistance);
function GetDistance(){
    distanceX = (mouseX - cursorX);
    distanceY = (mouseY - cursorY);
    period = 90;
    timer = 90;
    isPathing = false;
}
function MouseAnime(){
    if(!isPathing){
        if(timer > 0){
            cursorX+= distanceX / period;
            cursorY+= distanceY / period;
            timer--;
        }
        else{
            cursorX+= (mouseX - cursorX) / 5;
            cursorY+= (mouseY - cursorY) / 5;
        }
    }
    let size = WIDTH * 0.1;
    context.drawImage(mouseImg, cursorX-size/2, cursorY-size/2, size, size);
}

這樣就完成一個等速動畫了,可以參考 Demo,接下來幾天會陸續更新!

時間也不早了,出個思考題吧

思考題

如果想要讓圖案啟動時,慢慢出發,一邊加速一邊抵達鼠標的位置,可以怎麼做呢?


上一篇
Chapter1 - 補充 CORS + autoplay政策 + requestAnimeFrame致命缺點
下一篇
Chapter2 - Canvas動畫(II)用國中數學拆解Ease-out和Ease-in
系列文
從零開始打造網頁遊戲-造輪子你也辦的到!31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言