異名一文帶你讀懂Chrome小恐龍跑酷!
在chrome瀏覽器的斷網(wǎng)頁面,按空格鍵或者向上鍵會出現(xiàn)一個小恐龍跑酷小游戲,這個2D小游戲在設計上精致小巧,在代碼上也只有三千多行,思路清晰嚴謹,很有學習價值

在非斷網(wǎng)情況下,可以通過chrome://dino 進行訪問,源代碼在source面板中無法顯示,可以前往這里下載。在這篇文章中異名會梳理2D游戲的制作思路,主要包括游戲的mainloop主循環(huán)和實例的update更新、幀圖的動態(tài)繪制和切換、幀率的控制、游戲對象的運動控制、碰撞檢測的實現(xiàn)等
游戲循環(huán)
循環(huán)是游戲的心跳,是一個定時回調(diào),每隔一段時間去更新游戲的邏輯,比如處理用戶的交互,更新游戲的狀態(tài),繪制動畫等等
mainloop()?{
??this.clearCanvas()??//?清除畫布
??//??處理邏輯....
??
??window.requestAnimationFrame(this.mainloop.bind(this));
}
在rAF沒出現(xiàn)之前,大家使用setTimeout和setInterval來觸發(fā)視覺的變化,但是這兩個api在時間的精準控制上有缺陷。因為「定時器屬于異步任務,它必須等到同步任務執(zhí)行完畢之后,以及異步隊列里面的任務清空之后才輪到自己執(zhí)行,它的實際執(zhí)行時機一般都比設定的時間晚」,這就說明了它不能精準地按照一定的時間間隔去執(zhí)行。還有一點就是「定時器的調(diào)用間隔和屏幕繪制頻率不一致」,顯示器的頻率一般都默認是60Hz(1s繪制60次),每次繪制的時間差是16.7ms(1000/60≈16.7),因為定時器的調(diào)用間隔和屏幕頻率不一致,所以下面這種情況就一定會出現(xiàn)

紅色叉叉那里就丟幀了,下面通過一個更清晰的例子來說明:

這也是為什么以前大家把setInterval的間隔設置為1000/60的原因,但是這本質(zhì)上是硬件的差異,只要換個硬件,定時器的執(zhí)行步調(diào)和屏幕的刷新步調(diào)不一致就一定會產(chǎn)生丟幀。這也就是rAF的最大優(yōu)勢,它是「由系統(tǒng)來決定回調(diào)函數(shù)的執(zhí)行時機,系統(tǒng)每次繪制之前會主動調(diào)用 rAF 中的回調(diào)函數(shù)」,它能夠確保回調(diào)函數(shù)是按照系統(tǒng)的繪制頻率來調(diào)用,無論是60Hz還是50Hz,只要畫面刷新就會調(diào)用回調(diào)函數(shù),它就解決了步調(diào)統(tǒng)一以及回調(diào)頻率可靠這兩個問題。但是因為是系統(tǒng)主動調(diào)用,所以需要我們自己去做時間管理,raf的回調(diào)第一個參數(shù)是一個時間戳,但是在實踐上一般我們自己計時
??mainloop()?{
????const?now?=?performance.now()
????const?deltaTime?=?now?-?(this.time?||?now)
????this.time?=?now
????this.clearCanvas()??//?清除畫布
????
????//?處理邏輯...
????
????window.requestAnimationFrame(this.mainloop.bind(this))
??}
在源碼中,這里還做了一個嚴謹?shù)脑O計,它在非游戲中的時候會暫停mainloop循環(huán)并且清除rAF,再次游戲的時候會再次觸發(fā)mainloop,所以這里還做了一個加鎖
scheduleNextUpdate:?function?()?{
??if?(!this.updatePending)?{
????this.updatePending?=?true
????this.raqId?=?requestAnimationFrame(this.update.bind(this))
??}
}
畫面繪制
游戲基于canvas來繪制,游戲的圖片資源只有一張base64格式的精靈圖,如下

游戲的對象都在這張精靈圖中,我們先從精靈圖中把地面繪制出來。這里面涉及到的知識點是canvas的創(chuàng)建、畫面清除,以及drawImage的應用。通過drawImage我們可以裁剪精靈圖中某一部分的圖像,并繪制到畫布中,drawImage一共有9個參數(shù)context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height) 分別是精靈圖、裁剪區(qū)域的坐標,裁剪的區(qū)域大小,在畫布上放置圖像的位置坐標,在畫布上放置圖像的大小。簡單拆分一下任務:
下載圖片資源 創(chuàng)建畫布 從精靈圖中裁剪地面部分并繪制
核心代碼如下
//?下載資源
loadImage()?{
?return?new?Promise((resolve,?reject)?=>?{
??const?img?=?new?Image()
????img.src?=?"精靈圖的base64"
????img.onload?=?()?=>?{
??????window.imageSprite?=?img
??????resolve(img)
????}
????img.onerror?=?()?=>?{
??????reject()
????}
??})
}
//?繪制畫布
initCanvas()?{
??const?canvas?=?document.createElement('canvas')
??canvas.width?=?CANVAS_WIDTH
??canvas.height?=?CANVAS_HEIGHT
??document.body.appendChild(canvas)
??this.canvas?=?canvas
??this.ctx?=?canvas.getContext('2d')
}
//?二次繪制的時候清除畫布
this.ctx.clearRect(0,?0,?CANVAS_WIDTH,?CANVAS_WIDTH,?CANVAS_HEIGHT)
//?繪制地面
this.ctx.drawImage(window.imageSprite,
??2,?54,?600,?12,
??this.xPos,?this.yPos,?600,?12
)
同樣利用context.drawImage可以把精靈圖里面的其他對象也繪制畫布上,組合出游戲里面的對象

動畫和幀頻控制
游戲中的每個實例都有update的方法, update在每次主循環(huán)中都會執(zhí)行,在這個小恐龍游戲中每個實例的update都被直接地調(diào)用,如果需要更好地解耦和維護可以使用訂閱發(fā)布等模式
mainloop()?{
??//?...
???ground.update()
???trex.update()
}
ground.update?=?function()?{
?//?...
??context.drawImage()?//?更新繪制
}
動畫就涉及到更新頻率,如果像上面那樣每次循環(huán)的時候都去繪制,mainloop一秒會執(zhí)行60次,但是繪制的內(nèi)容更新并沒有這么頻繁,所以我們需要做時間管理。「游戲中的幀頻可以分為兩種,一個是序列幀的幀頻,一個是游戲的全局幀頻」。比如恐龍就是由指定的序列幀動畫展示的,它一共有5種狀態(tài),其幀動畫參數(shù)定義如下
Trex.animFrames?=?{
??WAITING:?{????????????????????//?等待狀態(tài)下的序列幀
????frames:?[44,?0],????????????//?每一幀的起點位置
????msPerFrame:?1000?/?3????????//?繪制的頻率
??},
??RUNNING:?{????????????????????//?奔跑狀態(tài)下的序列幀
????frames:?[88,?132],??????????//?每一幀的地點位置
????msPerFrame:?1000?/?12???????//?繪制的頻率
??},
??CRASHED:?{
????frames:?[220],
????msPerFrame:?1000?/?60
??},
??JUMPING:?{
????frames:?[0],
????msPerFrame:?1000?/?60
??},
??DUCKING:?{
????frames:?[264,?323],
????msPerFrame:?1000?/?8
??}
};
拿奔跑狀態(tài)來說,它是由兩張圖片按12Hz的頻率來更新的,每一幀的耗時是1000/12,我們在update的時候做一個計時:
class?Trex?{
??constructor(ctx)?{
????this.ctx?=?ctx
????this.currentAnimFrames?=?Trex.animFrames['RUNNING'].frames
????this.msPerFrame?=?Trex.animFrames['RUNNING'].msPerFrame
????this.currentFrame?=?0
????this.timer?=?0
??}
??
??update(dt)?{
????this.timer?+=?dt
????
????//?更新當前幀序號
????if?(this.timer?>=?this.msPerFrame)?{
??????this.currentFrame?=?this.currentFrame?==?this.currentAnimFrames.length?-?1???0?:?this.currentFrame?+?1;
??????this.timer?=?0;
????}
????
????//?繪制當前幀圖?
????const?sx?=?this.currentAnimFrames[this.msPerFrame]
????this.ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
??}
}
另外一種動畫就是非序列幀動畫,比如地面的運動,因為沒有指定的幀頻所以它的運動頻率就是全局的幀頻
const?FPS?=?60????//?設定全局的幀頻為60
ground.update(dt)?{
??//?根據(jù)全局的幀頻計算速度
??const?increment?=?Math.floor(speed?*?(FPS?/?1000)?*?dt);
??this.xPos?-=?increment
??
??//?繪制當前幀圖?
??const?x?=?this.xPos
??this.ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
}
給小恐龍加上序列幀動畫以及給跑道加上位移之后效果如下:

值得注意的是,在小恐龍游戲中沒有對主循環(huán)做幀頻控制,每一次循環(huán)的時候都會執(zhí)行清除畫布和畫面重繪操作,如果遇到需要可控幀頻的場景主循環(huán)就可能會產(chǎn)生過度繪制或者丟幀的情況了
用戶交互和運動狀態(tài)
小恐龍游戲中的用戶交互主要是跳和下蹲,監(jiān)聽用戶按鍵事件,根據(jù)鍵碼去切換小恐龍的狀態(tài)和處理位置信息。這里有兩個小邏輯,在蹲的時候因為幀圖的大小有變化需要做寬高的切換;在跳的時候因為游戲是變速運動,所以也根據(jù)游戲的當前速度做了一個關聯(lián)
我們把仙人掌加上之后,游戲的核心交互流程就已經(jīng)實現(xiàn)出來了:
碰撞檢測
小恐龍里面使用的是矩形檢測,每個碰撞體都是一個矩形,游戲循環(huán)的時候判斷每個矩形是否重疊就知道是否碰撞了。

因為物體是不規(guī)則的形狀,所以像左上圖那樣只有兩個矩形是做不到精準地描述物體的邊界的。「在游戲中,為了簡化每一幀中的計算計算量,只有當這兩個外矩形相碰的時候,才會去遍歷每個對象下的細分矩形」,比如右上圖小恐龍和仙人掌都分別用了四個矩形來描述它們的邊界,當外矩形重疊的時候,內(nèi)部矩形才開始遍歷判斷重疊,下面這個過程圖很好地把這個過程演示了出來:

碰撞盒子以及恐龍的碰撞盒子定義:
矩形重合判斷
在mainloop中進行碰撞檢測:
結尾
上面就已經(jīng)把小恐龍的核心功能過了一遍,剩下的一些小功能堆疊和細節(jié)的完善,就不再展開。異名以往都是通過游戲引擎或者互動框架來開發(fā)游戲,這還是第一次生擼,引擎封裝帶來的開發(fā)體驗和自己從零開發(fā)是不一樣的,這也是前段時間異名的小困惑,高度封裝就代表底層的隱藏,開發(fā)一段時間之后很快就會遇到概念上的困惑,甚至你的理解和真實的情況完全相反,雖然他們的表現(xiàn)一致,這次跟著代碼敲完一次之后,異名對2D游戲的制作思路也有了更清晰的理解。

