【老臉教你做遊戲】動畫類
本文不允許任何形式的轉載!
閱讀提示
本系列文章不適合以下人群閱讀,如果你無意點開此文,請對號入座,以免浪費你寶貴的時間。
- 想要學習利用遊戲引擎開發遊戲的朋友。本文不會涉及任何第三方遊戲引擎。
- 不具備面向物件程式設計經驗的朋友。本文的語言主要是Javascript(ECMA 2016),如果你不具備JS程式設計經驗倒也無妨,但沒有面向物件程式語言經驗就不好辦了。
- 想要直接下載例項程式碼的朋友。抱歉,我都用嘴說,基本上沒有示例程式碼。
上期作業
沒什麼特別的,只是用封裝一下drawImage而已:
import Figure from "./Figure.js"; export default class FigureImage extends Figure { constructor(p) { p = p || {}; super(p); this.img = p['image']; this.srcLeft = p['srcLeft'] || 0; this.srcTop = p['srcTop'] || 0; this.srcWidth = p['srcWidth']; this.srcHeight = p['srcHeight']; } drawSelf(ctx) { if (!this.img) return; // 如果沒有設定Image就不繪製 if (this.srcWidth == undefined || this.srcHeight == undefined) { // 如果沒有設定源圖片剪下大小和位置,就預設繪製整張圖片 ctx.drawImage(this.img, 0, 0, this.width, this.height); } else { ctx.drawImage(this.img, this.srcLeft, this.srcTop, this.srcWidth, this.srcHeight, 0, 0, this.width, this.height); } } }
什麼是動畫,JS裡怎麼實現動畫
本文中的程式碼承接上一篇文章,如果沒看過的請先閱讀
人眼有一種“視覺暫留”的特點,就是說我們看到的的景象會在大腦裡停溜很短一段時間,所謂動畫就是將一張張靜態的圖片逐一展示在我們眼前,利用人眼的這個特性,只要這些圖片替換得夠快,那我們會誤以為整個景象是連續流暢的,這就是動畫。
專家指出,如果一秒鐘內能夠展示60張圖片,人眼的感覺就是流暢的,而低於這個值,就會感覺“卡”。換成專業術語,我們稱這每一張圖片為“幀”,英文單詞是Frame,而每秒展示幀的數量翻譯成英文就是:Frame Per Second,取第一個大寫字母得到縮寫:FPS,對咯,就是我們說的FPS值,一旦FPS=60就說明這個動畫已經足夠流暢了,換算一下,每幀的間隔時間不能夠超過 1000/60 毫秒,即16毫秒。
那JS裡怎麼實現動畫效果呢。首先我們認為canvas就是一個熒幕,我們要不斷替換這個熒幕上的圖片且間隔時間不能超過16毫秒來達到動畫效果,換句話說就是不停的清空canvas然後再進行繪製:
import Graph from "./example/Graph"; let graph = new Graph(wx.createCanvas()); while(true){ pause16MS(); // 暫停16毫秒 doSomething(); // 做一些操作,比如更改座標啊,顏色什麼的 graph.refresh(); // 重新重新整理 }
上面這種程式碼寫法是不可取的!JS提供有兩個方法,專門用於定時迴圈:
- setInterval(repeatFunctionHandler,timeout)
- setTimeout(repeatFunctionHandler,timeout)
兩個方法差不多,我們就用setInterval來實現動畫:
import Graph from "./example/Graph"; let graph = new Graph(wx.createCanvas()); // 這是一個迴圈方法,每個16毫秒執行一次 function repeat(){ // 在重新整理繪製前做一些操作 graph.refresh(); // 重新重新整理 } setInterval(repeat,16); // 每個16毫秒執行一次repeat方法
第一篇文章已經告訴大家如何將繪製物件化,這裡我們繼續使用之前程式碼。我給一個case,實現一個動畫:讓一個矩形從(0,0)向右移動到canvas版邊,接觸到版邊後向左移動,接觸到canvas的左側版邊後又向右移動。
那我們就可以在repeat方法裡不停的更改矩形的座標,這樣就可以實現動畫效果了:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; let graph = new Graph(wx.createCanvas()); let rect = new Rectangle({ left: 0, top: 0, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let deltaX = 1; // 這是一個迴圈方法,每隔16毫秒執行一次 function repeat() { // 接觸到版邊就反向移動 if (rect.left < 0 || (rect.left + rect.width) > graph.width) { deltaX *= -1; } rect.left += deltaX; graph.refresh(); // 重新重新整理 } setInterval(repeat, 16); // 每隔16毫秒執行一次repeat方法
這是輸出結果:(gif圖片幀數不夠,看上去不流暢)

setInterval實現動畫
感覺還不錯,如果你真的用上面程式碼執行在客戶端,特別是移動裝置上,你會發現其實不是想象中那麼流暢,這裡有人會疑惑為什麼,已經保證16毫秒繪製一次了還會卡嗎。
setInterval方法是早期JS上做動畫會使用的方法,雖然間隔時間保證了FPS為60,但是這個方法和我們裝置的真實重新整理時間是不一致的,就是說介面在第t時間點開始重新整理,而我的程式碼卻沒在t時間點繪製。另外,如果我的介面被隱藏,setInterval還會繼續工作。
所以發展都後來,JS提供了一個叫做requestAnimationFrame的方法,這個方法的引數是一個方法控制代碼,其用意是說:註冊一個方法,而這個方法會在device重新整理到來的時候執行,但是,requestAnimationFrame每次執行完註冊的方法後就會將這個方法從註冊的方法列表中剔除,即下一次裝置重新整理到來的時候就不會再執行這個方法了。
既然可以註冊一個方法,那也可以取消這個註冊方法。requestAnimationFrame會返回一個ID,我們可以呼叫cancelAnimationFrame方法並傳入這個ID,告訴它撤銷這個ID對應的方法。
這裡有個問題。我在做Facebook Instant Game的時候,發現FB SDK包裝了requestAnimationFrame方法,且這個包裝過後的方法是不會返回ID的,所以也就無法呼叫cancelAnimationFrame,問過一些人這個問題,都說不知道不關心,因為他們一旦進入這個迴圈後就不會停下來,所以我也學著不再使用cancelAnimationFrame了。
如果我們用setInterval來模擬一下這個requestAnimationFrame和canelAnimationFrame,程式碼應該類似這樣的:
let handlerArray = []; // 記錄註冊方法的陣列 let handlerId = 0; // 自增長的id setInterval(onDeviceRefresh, 16); // 模擬沒16毫秒裝置重新整理一次 // 裝置重新整理的時候就將註冊方法都執行一邊並清空陣列 function onDeviceRefresh() { for (let i = 0; i < handlerArray.length; i++) { handlerArray[i].handler(); // 執行註冊方法 } handlerArray.length = 0;// 清空 handlerId = 0; // 還原id } function requestAnimationFrame(handler) { let id = handler++; // id 自增長 handlerArray.push({id: id, handler: handler}); return id; } function cancelAnimationFrame(id) { for (let i = 0; i < handlerArray.length; i++) { let handlerEntry = handlerArray[i]; if(handlerEntry.id == id){ handlerArray.splice(i,1); return; } } }
我們把剛才的程式碼改為用requesAnimationRequest來實現動畫:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; let graph = new Graph(wx.createCanvas()); // 新增一個矩形 let rect = new Rectangle({ left: 0, top: 0, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let deltaX = 1; // 這是x座標移動的增量大小 // 這是一個迴圈方法,每隔16毫秒執行一次 function repeat() { // 基礎到版邊就反向移動 if (rect.left < 0 || (rect.left + rect.width) > graph.width) { deltaX *= -1; } rect.left += deltaX; // 增加或者減少x的座標值 graph.refresh(); // 重新重新整理 requestAnimationFrame(repeat); // 一旦重新整理即將到來就執行repeat方法,且這是一個遞迴呼叫會不行執行repeat方法 } repeat();
看一下repeat方法:
在執行graph.refresh之後我們呼叫了requestAnimationFrame將repeat方法註冊了進去,那一旦重新整理到來,就會執行repeat,這樣就形成了 :
執行repeat -> 呼叫requestAnimationFrame將repeat註冊到重新整理到來時執行的方法列表中 -> 重新整理到來 -> 執行repeat
這麼一個遞迴過程。
物件化requestAnimationFrame
還是那句老話“萬物皆可物件”。
我們知道了如果使用reqeustAnimationFrame來實現定時迴圈,但每次這麼寫實在是麻煩,所以我決定用一個類來封裝它,而這個類會具有啟動、停止等方法,便於呼叫。
那我們這麼設計它,命名為AnimationFrame(這個命名有問題,但僅是個例子不要在意太多細節)。這個類對外應該具有start 開始執行,和stop 停止執行的方法。
並且我們應該要在它停止後允許執行回撥程式碼,所以我們還要設計一個屬性,叫做stopCallback,當我們stop後這個stopCallback會被執行:
export default class AnimationFrame { constructor(p) { p = p || {}; this.repeat = p['repeat']; // 需要執行的迴圈方法控制代碼 this.stopCallback = p['stopCallback']; // 需要執行的回撥方法控制代碼 this.running = false; // 屬性,檢視該物件是否在執行 this.requestStop = false; // 實際上是一個flag,迴圈執行方法中如果發現該屬性為true,就停止執行 this.repeatCount = 0; // 迴圈執行的次數。不要小看它,這個可以讓物件模擬出狀態 } _run(source) { if (source.requestStop) return; if (source.repeat) { requestAnimationFrame(function () { source._run(source); }); source.repeat(source.repeatCount++); } } start() { this.repeatCount = 0; this.running = true; this.requestStop = false; this._run(this); } stop() { this.requestStop = true; this.running = false; if (this.stopCallback) { this.stopCallback(); } } }
簡單易懂,我就不囉嗦了。注意,迴圈執行方法在執行的時候給出了一個引數repeatCount進去,這個在後面會有用。
那我們把一開始那個左右彈的矩形動畫改一改,讓這個矩形碰撞到最右側版邊的時候就停止:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; import AnimationFrame from "./example/AnimationFrame"; let graph = new Graph(wx.createCanvas()); // 新增一個矩形 let rect = new Rectangle({ left: 0, top: 0, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let deltaX = 1; // 這是x座標移動的增量大小 let animationFrame = new AnimationFrame(); animationFrame.repeat = function (refreshCount) { if((rect.left + rect.width) > graph.width){ animationFrame.stop(); // 超過右側版邊就停止 } if (rect.left < 0) { deltaX *= -1; } rect.left += deltaX; // 增加或者減少x的座標值 graph.refresh(); // 重新重新整理 }; animationFrame.start();
輸出結果如下:

Animation類
我們已經知道了如果利用requestAnimationFrame來實現一個動畫效果,而且還寫了一個AnimationFrame的類來封裝reqeustAnimationFrame方法。
可是注意看上面的程式碼,都是需要我們自己來設定圖形的改變,雖然也是動畫,但是還是比較繁瑣的。我們需要的是一個真正的Animation類,只需要告訴它我們要讓哪個圖形做動畫,動畫怎麼做,整個動畫多長時間,好比這樣:
let animation = new Animation(rect,400); animation.moveTo(x,y); animation.start();
只要animation一執行,rect物件就會在規定時間內移動到(x,y),而我們也就不需要自己編碼去改變rect物件的座標。
我們就以上面給出的虛擬碼介面,直接實現這個Animation(測試驅動程式設計?):
import AnimationFrame from "./AnimationFrame"; const PRE_FRAME_TIME_FLOAT = 1000/60; // 每幀間隔時間。不取整 export default class Animation { constructor(figure, totalTime, p) { p = p || {}; this.figure = figure; // 利用AnimationFrame來實現定時迴圈 this.animationFrame = new AnimationFrame(); // 計算一下totalTime如果間隔16毫秒重新整理一次的話,一共需要animationFrame重新整理多少次: // 這個重新整理次數要取整 this.totalRefreshCount = Math.floor(totalTime/PRE_FRAME_TIME_FLOAT); // 這兩個變數將會記錄figure所要移動到的最終位置座標 this.finalX = 0; this.finalY = 0; // 這是記錄figure起始的座標位置 this.startX = figure.left; this.startY = figure.top; } moveTo(x, y) { // 記錄figure要移動到的最終位置座標以及figure的起始位置 this.finalX = x; this.finalY = y; this.startX = this.figure.left; this.startY = this.figure.top; } start() { // 要在總的重新整理次數totalRefreshCount內移動到(x,y) ,那就需要計算每次重新整理需要移動的距離: let deltaX = this.finalX - this.startX; let deltaY = this.finalY - this.startY; let perRefreshDeltaX = deltaX/this.totalRefreshCount; let perRefreshDeltaY = deltaY/this.totalRefreshCount; let that = this; // 便於匿名方法內能訪問到this this.animationFrame.repeat = function(refreshCount){ // 如果AnimationFrame重新整理次數超過了動畫規定的最大次數 // 說明動畫已經結束了 if(refreshCount >= that.totalRefreshCount){ // 動畫結束,figure的最終座標直接設定: that.animationFrame.stop(); that.figure.left = that.finalX; that.figure.top = that.finalY; }else{ // 如果動畫在執行,每次重新整理座標均勻增加: that.figure.left += perRefreshDeltaX; that.figure.top += perRefreshDeltaY; } // 重新整理介面 that.figure.getGraph().refresh(); } } }
然後我們測試一下這個類:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; import AnimationFrame from "./example/AnimationFrame"; import Animation from "./example/Animation"; let graph = new Graph(wx.createCanvas()); // 新增一個矩形 let rect = new Rectangle({ left: 0, top: 0, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let animation = new Animation(rect,400); animation.moveTo(300,300); animation.start();
這是輸出結果(因為gif是迴圈播放的,實際上動畫到最後就停住了):

Animation的第一次迭代
上面給的Animation只能是移動座標而已,如果我現在想要旋轉怎麼辦?
如果根據上面的程式碼來看,我們還需要設定這幾個屬性:startRotate :Figure起始旋轉度數,finalRotate: Figure的最終旋轉度數。 然後還要在start方法中更改一下,讓figure的rotate屬性每幀都發生變化。如下:
start() { // 要在總的重新整理次數totalRefreshCount內移動到(x,y) ,那就需要計算每次重新整理需要移動的距離: let deltaX = this.finalX - this.startX; let deltaY = this.finalY - this.startY; let perRefreshDeltaX = deltaX/this.totalRefreshCount; let perRefreshDeltaY = deltaY/this.totalRefreshCount; let deltaRotate = this.finalRotate - this.startRotate; let perRotate = deltaRotate/this.totalRefreshCount; let that = this; // 便於匿名方法內能訪問到this // 設定AnimationFrame的迴圈方法 this.animationFrame.repeat = function(refreshCount){ // 如果AnimationFrame重新整理次數超過了動畫規定的最大次數 // 說明動畫已經結束了 if(refreshCount >= that.totalRefreshCount){ // 動畫結束 that.animationFrame.stop(); }else{ // 如果動畫在執行,每次重新整理座標均勻增加: that.figure.left += perRefreshDeltaX; that.figure.top += perRefreshDeltaY; that.figure.rotate += perRotate; } // 重新整理介面 that.figure.getGraph().refresh(); }; // 設定AnimationFrame的結束回撥方法 this.animationFrame.stopCallback = function(){ // 一旦動畫結束就直接設定figure的最終座標: that.figure.left = that.finalX; that.figure.top = that.finalY; that.figure.rotate = that.finalRotate; } // 開始啟動AnimationFrame: this.animationFrame.start(); }
傻比才會這麼寫!那下次我要拉伸Figure怎麼辦,也跟剛才那樣繼續加嗎,肯定不是的。
我們歸納一下,Animation到底在做什麼。
Animation一直在做的工作就是在不停更改Figure的屬性值,然後重新整理介面。
所以我們認為, Animation其實就是一個在給定時間內均勻或者不均勻地改變物件屬性值的類。
那麼就不需要可以去記錄什麼座標啊旋轉角度了這些特定的值,統一起來,就是“某個屬性值”,讓我們徹底改掉剛才的Animation類:
import AnimationFrame from "./AnimationFrame"; const PRE_FRAME_TIME_FLOAT = 1000 / 60; // 每幀間隔時間。不取整 export default class Animation { constructor(figure, totalTime, p) { p = p || {}; this.figure = figure; // 利用AnimationFrame來實現定時迴圈 this.animationFrame = new AnimationFrame(); // 計算一下totalTime如果間隔16毫秒重新整理一次的話,一共需要animationFrame重新整理多少次: // 這個重新整理次數要取整 this.totalRefreshCount = Math.floor(totalTime / PRE_FRAME_TIME_FLOAT); // 這是存放屬性初始值和結束值的列表,資料結構是:{ 屬性名 : { start:初始值, end:結束值}} this.propertyValueTable = {}; } /** * 記錄屬性值改變 */ propertyChange(propertyName, startValue, endValue) { // 如果沒記錄過屬性值就新建一個 if (!this.propertyValueTable[propertyName]) { this.propertyValueTable[propertyName] = {start: startValue, end: endValue}; } else { this.propertyValueTable[propertyName].start = startValue; this.propertyValueTable[propertyName].end = endValue; } } /** * 根據當前重新整理次數要設定物件當前屬性值 */ applyPropertiesChange(refreshCount) { for (let property in this.propertyValueTable) { this.figure[property] += this.calculateDeltaValue(property, refreshCount); } } /** * 直接設定結束值給Figure */ applyEndValue(){ for (let property in this.propertyValueTable) { this.figure[property] =this.propertyValueTable[property].end; } } /** * 根據重新整理次數來計算該屬性此時的增量 */ calculateDeltaValue(property, refreshCount) { let start = this.propertyValueTable[property].start; let end = this.propertyValueTable[property].end; // 因為我們是均勻變化的,所以直接算出平均值即可: return (end - start) / this.totalRefreshCount; } start() { let that = this; // 便於匿名方法內能訪問到this // 設定AnimationFrame的迴圈方法 this.animationFrame.repeat = function (refreshCount) { // 如果AnimationFrame重新整理次數超過了動畫規定的最大次數 // 說明動畫已經結束了 if (refreshCount >= that.totalRefreshCount) { // 動畫結束 that.animationFrame.stop(); } else { // 如果動畫在執行,計算每次屬性增量: that.applyPropertiesChange(refreshCount); } // 重新整理介面 that.figure.getGraph().refresh(); }; // 設定AnimationFrame的結束回撥方法 this.animationFrame.stopCallback = function () { that.applyEndValue(); // 清空我們的記錄的屬性值表: for(let p in that.propertyValueTable){ delete that.propertyValueTable[p]; } }; // 開始啟動AnimationFrame: this.animationFrame.start(); } }
幾個關鍵的方法我逐一講一下:
start方法就不多說了,執行AnimationFrame,每次重新整理的時候更改物件的屬性值並重新整理介面。
propertyChange :
這個方法是用來記錄Figure物件希望在Animation中更改的屬性以及這個屬性的初始值、結束值,我們把這些資料都存放到Animation的一個propertyValueTable中,這個Table的資料結構如下:
{ 屬性名 : { start :初始值, end :結束值 } }
如果沒有呼叫過該方法去記錄物件的屬性值,那Animation是不會更改它的屬性值的,當然也不知道怎麼去改。
同時注意,如果propertyChange方法多次呼叫設定同一個屬性的話,以最後一次為準,因為之前的全部都被最後一次呼叫覆蓋了。
這樣一來,那我們之前的moveTo(x,y)的方法其實就可以利用propertyChange來封裝:
moveTo(x, y) { this.propertyChange('left',this.figure.left,x); this.propertyChange('top',this.figure.top,y); }
applyPropertiesChange
遍歷我們記錄的屬性值列表,然後逐一將屬性值進行更改。
而屬性需要疊加的值是用另外一個方法calculateDeltaValue計算得出的。
calculateDeltaValue
計算出當前重新整理次數(即當前時間點),需要增加的值。
我們的這個方法好像並沒有用到refreshCount(重新整理次數),直接給出了一個平均變化值。下面就要講講這個refreshCount到底有什麼用。
Animation的第二次迭代
在第一次迭代的時候我們說,Animation是在均勻或者不均勻地改變著物件的屬性值,而我們第一次迭代的時候只是給的一個均勻變化值。
如果你做過CSS3的動畫,你就知道它的動畫可以設定一個時間曲線方程,animation-timing-function,這個屬性可以設定一組值:

那我們第一次迭代的程式碼中,動畫從頭到尾都是速度相同的,因為我們每次計算出來的delta值都是固定的。
怎麼能做到讓動畫慢慢變快呢
我們設屬性值為y,時間(也就是我們的refreshCount)為x,我們的開始值為m,結束值為n,那麼f(x) = y,y的範圍是(m,n)。這個就是表示我們屬性值變化的方程。
所以calculateDeltaValue方法其實是在計算y嗎?不是的,是在計算△y,即計算f(x + △x) - f(x)的值。
第一次迭代是均勻變化的,其實就是說f(x)是一個直線方程,f(x) = a x+b,那麼我們的△y就應該是:a x + a △x - a x = a*△x = a (我們每次重新整理間隔為1,即△x=1)
這個a就是直線方程的斜率,因為我們知道整個動畫的重新整理次數,以及y的起始值和結束值,帶入到方程 y = a*x+b中,可以求出 a = (n -m)/ (totalRefreshCount - 0) ,所以我們看到calculateDeltaValue的方法就是這個樣子:
calculateDeltaValue(property, refreshCount) { let start = this.propertyValueTable[property].start; let end = this.propertyValueTable[property].end; // 因為我們是均勻變化的,所以直接算出平均值即可: return (end - start) / this.totalRefreshCount; }
只要你還記得拋物線方程,那很快就能想到,要想讓動畫慢慢變快,就可以利用二次方程來解決這個問題,則我們剛才的f(x) = ax^2+b。
那麼△y = 2 a x + a(希望我沒算錯) 。將我們的起始值和結束值,以及totalRereshCount帶入到這個二次方程中來計算a的值:(n-m)/ totalRefreshCount^2,那麼我的calculateDeltaValue就是這樣的了:
calculateDeltaValue(property, refreshCount) { let start = this.propertyValueTable[property].start; let end = this.propertyValueTable[property].end; switch(this.type){ case Linear : return (end - start) / (this.totalRefreshCount); case Ease_In: let a = (end - start) / (this.totalRefreshCount*this.totalRefreshCount); return 2*refreshCount * a + a; } }
我在Animation類里加入了一個type屬性,預設是Linear(值是1),calculateDeltaValue就可以根據不同型別來計算當前時間的屬性值增量了。
測試Ease_In結果如下:

逐漸變快
而其他的例如Ease啊,Ease_out等,都可以通過更改calculateDeltaValue方法程式碼來實現,自行腦補。
Animation第三次迭代
首先我們把moveTo,rotateTo等方法先通過propertyChange實現了再說:
moveTo(x, y) { this.propertyChange('left', this.figure.left, x); this.propertyChange('top', this.figure.top, y); } rotateTo(angle){ this.propertyChange('rotate', this.figure.rotate, angle); }
給一個case:一個矩形在1秒內很像移動100個畫素點,並且旋轉一週。
那我們的程式碼可以這樣寫:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; import AnimationFrame from "./example/AnimationFrame"; import Animation from "./example/Animation"; let graph = new Graph(wx.createCanvas()); // 新增一個矩形 let rect = new Rectangle({ left: 0, top: 0, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let animation = new Animation(rect,1000); // 整個動畫時長為1秒 animation.moveTo(rect.left+300,rect.top); animation.rotateTo(360); animation.start();
輸出結果:

邊移動邊旋轉
有一種動畫類編碼習慣,即使呼叫完一次動畫動作後,接著呼叫第二次動作:
let animation = new Animation(rect,1000); // 整個動畫時長為1秒 animation.moveTo(rect.left+300,rect.top).rotateTo(360).start();
實現這個簡單,在moveTo和rotateTo方法的返回this指標就好了。
這種程式碼看上去好像更能讀懂:moveTo的同時進行rotateTo並且開始。
(可我老認為是:moveTo後再rotateTo,開始執行。)
先看看這個case:如果我現在想要讓這個figure在當前動畫結束後再次執行一次動畫:縱向移動到canvas的末尾,總時長為500毫秒。怎麼辦?
以目前的Animation程式碼來看是無法實現的,我們先寫一段虛擬碼:
import Graph from "./example/Graph"; import Rectangle from "./example/Rectangle"; import AnimationFrame from "./example/AnimationFrame"; import Animation from "./example/Animation"; let graph = new Graph(wx.createCanvas()); // 新增一個矩形 let rect = new Rectangle({ left: 0, top: 100, width: 100, height: 100, color: 'red' }); graph.addChild(rect); let animation = new Animation(rect,1000); // 整個動畫時長為1秒 animation.moveTo(rect.left+300,rect.top).rotateTo(360) .then(500).moveTo(rect.left+300,graph.height).start();
注意,多了一個then方法!
這個then方法是告訴Animation,在結束moveTo和roateTo後再執行一個新的動畫,動畫總時長是500,而動畫的動作是moveTo到某個位置。
實際上這個就是一個動畫鏈。
我們目前的Animation就是一個單獨獨立的動畫物件,如果要實現上面的程式碼,就必須在Animation中加入一個nextAnimation屬性以及preAnimation,意為:下一個動畫和上一個動畫(是的,這是一個雙向連結串列)。
每當我們動畫開始的時候,先檢視是否具有preAnimation,如果有上一個動畫,那就先執行它;如果沒有就執行自己;當動畫結束後,如果有nextAnimation,先將nextAnimation的preAnimation屬性設定為空(避免形成一個死迴圈),然後執行nextAnimation。這樣一來就形成了一個動畫鏈的執行。
根據上面描述我們來寫一下新的Aniamtion類:
import AnimationFrame from "./AnimationFrame"; const PRE_FRAME_TIME_FLOAT = 1000 / 60; // 每幀間隔時間。不取整 const Linear = 1; const Ease_In = 2; export default class Animation { constructor(figure, totalTime, type) { this.type = type || Linear; this.figure = figure; // 利用AnimationFrame來實現定時迴圈 this.animationFrame = new AnimationFrame(); // 計算一下totalTime如果間隔16毫秒重新整理一次的話,一共需要animationFrame重新整理多少次: // 這個重新整理次數要取整 this.totalRefreshCount = Math.floor(totalTime / PRE_FRAME_TIME_FLOAT); // 這是存放屬性初始值和結束值的列表,資料結構是:{ 屬性名 : { start:初始值, end:結束值}} this.propertyValueTable = {}; this.nextAnimation = undefined; //下一個動畫 this.preAnimation = undefined; // 上一個動畫 } then(totalTime) { // 呼叫then方法後新建一個Animation,並把它和自身關聯起來 this.nextAnimation = new Animation(this.figure, totalTime); this.nextAnimation.preAnimation = this; return this.nextAnimation; } /** * 記錄屬性值改變 */ propertyChange(propertyName, startValue, endValue) { if (!this.propertyValueTable[propertyName]) { this.propertyValueTable[propertyName] = {start: startValue, end: endValue}; } else { this.propertyValueTable[propertyName].start = startValue; this.propertyValueTable[propertyName].end = endValue; } } propertyChangeTo(propertyName,endValue){ if(this.preAnimation) { // 如果有前一個動畫,那就要看它是不是也要改變了這個property的值,如果是,則開始值應該是 // 上一個動畫的結束值; 否則還是記錄figure的屬性原始值 let preEndValue = this.preAnimation.propertyValueTable[propertyName].end; if(preEndValue != undefined){ this.propertyChange(propertyName,preEndValue,endValue); }else{ this.propertyChange(propertyName,this.figure[propertyName],endValue); } }else{ this.propertyChange(propertyName,this.figure[propertyName],endValue); } } /** * 根據當前重新整理次數要設定物件當前屬性值 */ applyPropertiesChange(refreshCount) { for (let property in this.propertyValueTable) { this.figure[property] += this.calculateDeltaValue(property, refreshCount); } } /** * 直接設定結束值給Figure */ applyEndValue() { for (let property in this.propertyValueTable) { this.figure[property] = this.propertyValueTable[property].end; } } /** * 根據重新整理次數來計算該屬性此時的增量 */ calculateDeltaValue(property, refreshCount) { let start = this.propertyValueTable[property].start; let end = this.propertyValueTable[property].end; switch (this.type) { case Linear : return (end - start) / (this.totalRefreshCount); case Ease_In: let a = (end - start) / (this.totalRefreshCount * this.totalRefreshCount); return 2 * refreshCount * a + a; } } start() { if (this.preAnimation) { // 如果有上一個動畫就先執行它: this.preAnimation.start(); return; } let that = this; // 便於匿名方法內能訪問到this // 設定AnimationFrame的迴圈方法 this.animationFrame.repeat = function (refreshCount) { // 如果AnimationFrame重新整理次數超過了動畫規定的最大次數 // 說明動畫已經結束了 if (refreshCount >= that.totalRefreshCount) { // 動畫結束 that.animationFrame.stop(); } else { // 如果動畫在執行,計算每次屬性增量: that.applyPropertiesChange(refreshCount); } // 重新整理介面 that.figure.getGraph().refresh(); }; // 設定AnimationFrame的結束回撥方法 this.animationFrame.stopCallback = function () { that.applyEndValue(); // 清空我們的記錄的屬性值表: for (let p in that.propertyValueTable) { delete that.propertyValueTable[p]; } if (that.nextAnimation) { that.nextAnimation.preAnimation = undefined; // 避免形成死迴圈 that.nextAnimation.start(); } }; // 開始啟動AnimationFrame: this.animationFrame.start(); } moveTo(x, y) { this.propertyChangeTo('left', x); this.propertyChangeTo('top', y); return this; } rotateTo(angle) { this.propertyChangeTo('rotate', angle); return this; } }
這裡我增加了一個then方法,新生成一個Animation物件,並且將這個新的Animation和當前的Animation物件關聯了起來。
增加了一個propertyChangeTo的方法,注意看我們的case程式碼:
let animation = new Animation(rect,1000); // 整個動畫時長為1秒 animation.moveTo(rect.left+300,rect.top).rotateTo(360) .then(500).moveTo(rect.left+300,graph.height).start();
我們在呼叫了then後獲得的是一個不同於當前Animation的物件,一旦呼叫moveTo等記錄屬性值修改的方法就會出現一個問題:如果上一個動畫更改過這個屬性值,那麼當前動畫物件就必須找到上一個動畫記錄的結束值,以該值作為屬性在新動畫中的起始值。
這個還是好理解吧。
所以我增加了propertyChangeTo方法,用於判斷是否需要找到前一個動畫的結束值作為該動畫的起始值。
另外就是moveTo和rotateTo不再直接呼叫propertyChange方法,而是改成呼叫propertyChangeTo方法了。
讓我們看看輸出結果:

順序執行兩個動畫
小結
動畫是做遊戲的基礎,我們已經知道了怎麼用JS實現動畫,這離我們能做出遊戲已經很近了。不過Animation類還缺很多東西,比如動畫停止回撥函式,動畫完成回撥函式,動畫暫停,迴圈執行動畫等等,這些就留給聰明的你自己來寫吧。
對於今天的文章你怎麼看呢?歡迎到其他UP的文章或視訊下方留言。
作業就不留了,下一期我們就要開始做一個簡單遊戲了。