iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Modern Web

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

Chapter4 - Canvas背景動畫(IV)把紛飛的落葉,通通抓回來當作收藏吧!

今天挑戰半小時寫完一篇文章(被打,其實我寫完程式了,把文章撰寫出來就好嚕。

https://ithelp.ithome.com.tw/upload/images/20210930/20135197NvhXlIYh72.png

https://jerry-the-potato.github.io/Chapter4-demo4/

什麼互動?

很多框架或引擎的教學在建造物理世界時,都是從物體的碰撞開始講起,因為他們會設計重力加速度、風力、對流等概念進去,一般來說,如果想干擾一個「系統」,放一個東西進去就好,比如說把阿基米德弄進浴缸、把碳酸氫鈉丟進水裡、把jojo的寵物犬丟到焚化爐阿透漏年紀了,總之水會滿溢出來、不同物體放進去還會產生化學變化,這種紮實的回饋感,給予了我們不斷觀察並實驗,並樂此不疲的琢磨著背後的原理,對於真實世界的五感便是如此直接自然,應該很難想像阿基米德直接掉進水裡,一點兒水花都沒濺起來吧,這種情況,就很「平面」,或者說不真實。

因此,我們可以思考如何與畫面產生互動,比方說我們前幾天設定的一種動畫模式自由,你說他算不算互動呢?說實在,若以上面的定義來講,我應該要能清楚的掌握著把東西抓著、放下、掉進水裡一系列的過程,然而自由模式似乎就還不夠符合這一標準,有時候它是直觀的,能讓使用者想操縱畫面怎麼動就怎麼動,有時候它是令人挫折的,使用者不太能明白這個自由模式,我移動滑鼠到底意味著什麼?為什麼畫面會產生些微的變化?就好比說,試著把手舉高到頭頂,結果卻看到手指舉到了你的眼前,這種失控感,無法完全肯定這隻手是自己在控制。

有意義的行動

綜上所述,互動即「讓使用者透過回饋感受到自主的行動具有意義」,這不是維基百科寫的,這是我所認為的定義,尤其是在和電子產品介面的溝通上,並且也值得我們做去做研究。

那麼,我們今天到底要做什麼呢?對於路上紛飛的落葉、亂飄的羽毛等等,不知道大家直覺想做什麼呢?
A. 很想抓住它們
B. 很想收集起來
C. 很想控制風的流向
D. 其他想法?歡迎留言

我想,大家想法都會不同,對我來說最直覺的就是把他們捉下來了,如同把星星摘下來那般,等摘到一定的數量,便能收集成一群,然後再一次拋出去!好不快樂,那麼,今天就來完成「補捉的動作檢查」和「收集的動畫」吧!

碰撞檢測

這個領域有個詞叫collision,不過今天也不想著墨太多,我們就把眼光放在「我以滑鼠控制著某物去捕捉物件」,其實就是要判斷會不會碰觸到,只要去比較兩點的距離兩物件半徑的相加大小,那麼,我會這麼寫:

  1. 計算兩個物件中心點距離的平方: dx^2 + dy^2
  2. 計算兩個物件半徑相加的平方: (r1 + r2)^2

一般算距離都會用畢氏定理來開根號,不過我沒有開根號,這算我的迷信吧,我認為乘法的效能比Math.sqrt好多了!一樣寫在我們用來跑動畫的NextFrame方法中

animeObject.prototype.NextFrame = function(){

    // 檢查是否與滑鼠碰撞
    let distanceP2 = Math.pow(this.pointX - myMouse.pointX, 2) + Math.pow(this.pointY - myMouse.pointY, 2);
    let mouseWidth = WIDTH * 0.05;
    let thisWidth = this.sizeNow;
    if(distanceP2 < Math.pow((mouseWidth * 0.4 + thisWidth * 0.5), 2)){
        // 觸發收集的動畫
        // ......
    }
    
    // 以下略
    // ......
}

let myMouse = new mouseTrail(0, 0, true);滑鼠幾乎每篇都有用到,這邊還是提醒一下,它是將第二章的學的滑鼠動畫,以第三章的觀念改寫成物件建構式,因為覺得沒有很重要,就沒拿來水文章了,若覺得困惑可以留言詢問。

為求方便性,會把物件當作一個圓,用半徑來檢測碰撞,因此一個方形的png圖片,將其寬度乘上0.5,表示一個內切圓的半徑,這邊demo我用的是一個愛心當作滑鼠游標,比內切圓來的更小,因此乘上0.4。

另外暫時預設這個愛心(滑鼠)的寬度是整個畫面寬度的0.05倍,且先前為了節省記憶體,沒有存下this.width、this.height,而是直接計算後使用,所以才再定義一個thisWidth變數,幫大家回憶一下:

let width = this.sizeNow;
let height = this.sizeNow * this.img.height / this.img.width;
context.drawImage(this.img, -width/2, -height/2, width, height);

以上,應該沒啥問題吧!那滑鼠碰到了物件要幹嘛呢?收集起來囉!

收集的動畫

我們可以先假定有一個收集點在正中間,思考的方向是要如何讓這個動畫物件,中斷原本的動畫,接著開始移動前往正中間的位置,這是一個從起點到終點的動畫,基本的思路有:

  1. 改變這個物件本身的狀態
  2. 提供這個物件一股推力

然而,以上兩個各有缺點,第一個想法是改變物件的動畫名稱,之前原本是這樣寫的:

animeObject.prototype.NextFrame = function(){
    // 以上略
    if(this.animeName == "Floating") this.Floating(dT);
    else if(this.animeName == "Falling") this.Falling(dT);
    else if(this.animeName == "Staring") this.Staring(dT);
    // 以下略
}

由於我們的動畫物件,是透過this.animeName來作為在NextFrame方法中分別演算座標的判斷依據,那麼,今天只要落葉再也不是落下的狀態,就不會動了,換言之,可以改成另一種狀態,是不是就能輕鬆完成目標了呢?

這邊要考量到原本設計該動畫的性質,在我們第四章節所製作的這幾種動畫特性,它的形式較接近於粒子系統中所具有的隨機性不可預測性,雖然我們有給定三角函數作為移動的公式,不過我們無法確保物件能夠移動到一個確切的位置,這也是我們賦予這些物件靈魂和自由的重要因素,因此,若要在這裡面添加新的prototype,反倒會使物件越來越肥大,隨時都攜帶著不必要的變數。

第二個想法希望的是,可以在不使動畫中斷的情況下,讓物件很自然的轉彎被送往目的地,卻是一種過度自信的展現,相似的例子有是有,在現實中,我們運動的時候,會在投籃時瞄準籃框、會在擊球時看好方向,透過給予該物體勢能,來讓它跑到指定的位置並且得分。但是我請大家想想歐,如果有一種新籃球,要求必須由隊友傳球,然後你在不接球的前提下,把他傳的球給送進籃框,這樣容易嗎?

這已經不是在打籃球了!這是在算數學,要你算入射角、反射角哪!同理,在程式面,當然也是能去思考,在已知出發點、終點、和當前速度後,要怎麼取得一個平滑的路徑,這卻是一個相當深入的主題,通常用在路徑演算法Pathfinding,又稱尋路問題,那是與自動控制、車輛有關的領域,雖然可以結合作討論,不過有點偏門,網頁主流的css動畫都講求簡單好寫,要是搞這麼複雜,未來要應用也難呀!(至於真的對車子有興趣想做那類型的遊戲的話......可以去Unity 3D豐富世界發展)

折衷的方案

答案只要一說,大家立刻就懂了,如果可以還是想讓大家思考看看,上面的第一種方法,有更好的實現方案嗎?

我們要先抽離對於葉子這個型態的想像,葉子之所以為葉子,是因為它會往下掉嗎?還是我們判斷它是一片葉子呢?對於使用者來說,其實也就是有個葉子的圖形在畫面上,只是因為看到圖片,就把它想像成一個物體,所以我們才很認真地操作,使葉子的動畫盡量自然,大小還要淡出淡入,誘導使用者判斷「這是一片葉子」,不過反過來說,卻也無比的隨性,只要圖片沒有改成花朵、羽毛什麼的,對使用者來說,還是同一片葉子。

「摁?你在工沙ㄒ...」別急著噴我!如果,今天是兩個看起來一模一樣的葉子出現在同一個位置,使用者分得出來嗎?叮咚!接下來我們要做的就是,複製一個看起來相同的葉子,來取代原本的葉子,如此一來,剛剛掉落的那片葉子就可以直接刪掉回收,也不用去動到原本我們設計的動畫物件了!

第二章學到的路徑動畫

因此,我們來設計另外一個動畫物件的建構式,目前沒想到合適名稱,那就叫做animeObject2吧!

// 路徑動畫物件
function animeObject2(img, size, originX, originY, targetX, targetY, frames = 120){
    this.img = img;
    this.sizeNow = size;
    this.pointX = originX;
    this.pointY = originY;
    this.originX = originX;
    this.originY = originY;
    this.targetX = targetX;
    this.targetY = targetY;
    this.period = frames;
    this.timer = frames;
}

概念和第二章一模一樣,可以回去複習

並且,在剛剛做好的碰撞檢測中,執行如下步驟:

  1. 製作一個新的葉子仿冒品
  2. 刪掉原本的葉子
  3. 將新的葉子放入原本的動畫清單中
if(distance2p < Math.pow((mouseWidth * 0.4 + thisWidth * 0.5), 2)){
    // 用另一個物件取代該物件
    let newObject = new animeObject2(this.img, this.sizeNow,
                                     this.pointX, this.pointY,
                                     WIDTH / 2, HEIGHT / 2, 120);
    let index = animeList.indexOf(this);
    delete animeList[index];
    animeList[index] = newObject;
}

收集點設在正中央(WIDTH/2, HEIGHT/2)

複製這片葉子的時候,只需要有它的圖案、大小、和當前位置即可,原本比較占空間、有著許多變數的那片葉子,就用delete語法刪除掉,接著讓新的物件取代它,就大功告成拉!

這邊細節上要注意的就是,原本的動畫框架是透過animeList來繪製一系列的動畫,並呼叫每個物件的NextFrame方法,如下所示:

function Redraw(){
    clear(context);
    AudioProcess();
    MouseAnime();
    animeList.forEach(obj => obj.NextFrame());
}

因此我們也要替今天這個物件建構式,設計一個NextFrame方法:

animeObject2.prototype.NextFrame = function(){
    if(this.timer > 0){
        // 1. 計算座標
        // 2. 渲染圖形
    }
    else{
        // 3. 把動畫物件刪掉
    }
}

這裡就相對簡單很多了,因為呢,這個骨架也是在第二章學過的,步驟2、3跟先前的動畫物件為相同邏輯,步驟1又是第二章的內容,幾乎沒變:

if(this.timer > 0){
    // 1. 計算座標
    let dX = this.targetX - this.originX;
    let dY = this.targetY - this.originY;
    let t = this.timer;
    let p = this.period;
    let linear = 1/p;
    let easeout = Math.pow(t/p, 2) - Math.pow((t-1)/p, 2);
    let easein = Math.pow(1 - (t-1)/p, 2) - Math.pow(1 - t/p, 2);
    let a = 1;
    let b = 1;
    let c = -3;
    this.pointX+= (a * linear + b * easein + c * easeout) / (a+b+c) * dX;
    this.pointY+= (a * linear + b * easein + c * easeout) / (a+b+c) * dY;
    this.timer--;
}

應該有著莫名的熟悉感吧!不過還是補充一下,當初不是有說abc參數可以設定負值,並且造成回彈效果嗎?如果開頭的demo大家有注意到的話,就是來自於我這邊設定c為-3,算是相當有趣的效果。

今天的文章就到這囉!喜歡的話請幫我按個讚唄

半小時挑戰失敗XD,別急,讓我把code慢慢貼上來...

Yeah整理完了,結果花了兩小時撰寫文章、20分鐘校稿www


上一篇
Chapter4 - Canvas背景動畫(III)風中的花朵 今天再加碼讓動畫更加自然的方法
下一篇
Chapter4 附錄 貝茲曲線
系列文
從零開始打造網頁遊戲-造輪子你也辦的到!31

尚未有邦友留言

立即登入留言