在chrome瀏覽器的斷網頁面,按空格鍵或者向上鍵會出現一個小恐龍跑酷小遊戲,這個2D小遊戲在設計上精緻小巧,在代碼上也只有三千多行,思路清晰嚴謹,頗有學習價值前端

在非斷網狀況下,能夠經過chrome://dino
進行訪問,源代碼在source
面板中沒法顯示,能夠前往這裏下載。在這篇文章中異名會梳理2D遊戲的製做思路,主要包括遊戲的mainloop主循環和實例的update更新、幀圖的動態繪製和切換、幀率的控制、遊戲對象的運動控制、碰撞檢測的實現等web
遊戲循環
循環是遊戲的心跳,是一個定時回調,每隔一段時間去更新遊戲的邏輯,好比處理用戶的交互,更新遊戲的狀態,繪製動畫等等chrome
mainloop() {
this.clearCanvas() // 清除畫布
// 處理邏輯....
window.requestAnimationFrame(this.mainloop.bind(this));
}
在rAF
沒出現以前,你們使用setTimeout和setInterval來觸發視覺的變化,可是這兩個api在時間的精準控制上有缺陷。由於「定時器屬於異步任務,它必須等到同步任務執行完畢以後,以及異步隊列裏面的任務清空以後才輪到本身執行,它的實際執行時機通常都比設定的時間晚」,這就說明了它不能精準地按照必定的時間間隔去執行。還有一點就是「定時器的調用間隔和屏幕繪製頻率不一致」,顯示器的頻率通常都默認是60Hz(1s繪製60次),每次繪製的時間差是16.7ms(1000/60≈16.7),由於定時器的調用間隔和屏幕頻率不一致,因此下面這種狀況就必定會出現canvas

紅色叉叉那裏就丟幀了,下面經過一個更清晰的例子來講明:api
這也是爲何之前你們把setInterval的間隔設置爲1000/60的緣由,可是這本質上是硬件的差別,只要換個硬件,定時器的執行步調和屏幕的刷新步調不一致就必定會產生丟幀。這也就是rAF
的最大優點,它是「由系統來決定回調函數的執行時機,系統每次繪製以前會主動調用 rAF 中的回調函數」,它可以確保回調函數是按照系統的繪製頻率來調用,不管是60Hz仍是50Hz,只要畫面刷新就會調用回調函數,它就解決了步調統一以及回調頻率可靠這兩個問題。可是由於是系統主動調用,因此須要咱們本身去作時間管理,raf的回調第一個參數是一個時間戳,可是在實踐上通常咱們本身計時瀏覽器
mainloop() {
const now = performance.now()
const deltaTime = now - (this.time || now)
this.time = now
this.clearCanvas() // 清除畫布
// 處理邏輯...
window.requestAnimationFrame(this.mainloop.bind(this))
}
在源碼中,這裏還作了一個嚴謹的設計,它在非遊戲中的時候會暫停mainloop循環而且清除rAF
,再次遊戲的時候會再次觸發mainloop,因此這裏還作了一個加鎖性能優化
scheduleNextUpdate: function () {
if (!this.updatePending) {
this.updatePending = true
this.raqId = requestAnimationFrame(this.update.bind(this))
}
}
畫面繪製
遊戲基於canvas來繪製,遊戲的圖片資源只有一張base64格式的精靈圖,以下微信

遊戲的對象都在這張精靈圖中,咱們先從精靈圖中把地面繪製出來。這裏面涉及到的知識點是canvas的建立、畫面清除,以及drawImage的應用。經過drawImage咱們能夠裁剪精靈圖中某一部分的圖像,並繪製到畫布中,drawImage一共有9個參數context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
分別是精靈圖、裁剪區域的座標,裁剪的區域大小,在畫布上放置圖像的位置座標,在畫布上放置圖像的大小。簡單拆分一下任務:app
-
下載圖片資源 -
建立畫布 -
從精靈圖中裁剪地面部分並繪製
核心代碼以下框架
// 下載資源
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在每次主循環中都會執行,在這個小恐龍遊戲中每一個實例的update都被直接地調用,若是須要更好地解耦和維護可使用訂閱發佈等模式
mainloop() {
// ...
ground.update()
trex.update()
}
ground.update = function() {
// ...
context.drawImage() // 更新繪製
}
動畫就涉及到更新頻率,若是像上面那樣每次循環的時候都去繪製,mainloop一秒會執行60次,可是繪製的內容更新並無這麼頻繁,因此咱們須要作時間管理。「遊戲中的幀頻能夠分爲兩種,一個是序列幀的幀頻,一個是遊戲的全局幀頻」。好比恐龍就是由指定的序列幀動畫展現的,它一共有5種狀態,其幀動畫參數定義以下
Trex.animFrames = {
WAITING: { // 等待狀態下的序列幀
frames: [44, 0], // 每一幀的起點位置
msPerFrame: 1000 / 3 // 繪製的頻率
},
RUNNING: { // 奔跑狀態下的序列幀
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
}
};
拿奔跑狀態來講,它是由兩張圖片按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) {
// 根據全局的幀頻計算速度
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)
}
給小恐龍加上序列幀動畫以及給跑道加上位移以後效果以下:

值得注意的是,在小恐龍遊戲中沒有對主循環作幀頻控制,每一次循環的時候都會執行清除畫布和畫面重繪操做,若是遇到須要可控幀頻的場景主循環就可能會產生過分繪製或者丟幀的狀況了
用戶交互和運動狀態
小恐龍遊戲中的用戶交互主要是跳和下蹲,監聽用戶按鍵事件,根據鍵碼去切換小恐龍的狀態和處理位置信息。這裏有兩個小邏輯,在蹲的時候由於幀圖的大小有變化須要作寬高的切換;在跳的時候由於遊戲是變速運動,因此也根據遊戲的當前速度作了一個關聯咱們把仙人掌加上以後,遊戲的核心交互流程就已經實現出來了:
碰撞檢測
小恐龍裏面使用的是矩形檢測,每一個碰撞體都是一個矩形,遊戲循環的時候判斷每一個矩形是否重疊就知道是否碰撞了。

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

碰撞盒子以及恐龍的碰撞盒子定義:矩形重合判斷
在mainloop中進行碰撞檢測:
結尾
上面就已經把小恐龍的核心功能過了一遍,剩下的一些小功能堆疊和細節的完善,就再也不展開。異名以往都是經過遊戲引擎或者互動框架來開發遊戲,這仍是第一次生擼,引擎封裝帶來的開發體驗和本身從零開發是不同的,這也是前段時間異名的小困惑,高度封裝就表明底層的隱藏,開發一段時間以後很快就會遇到概念上的困惑,甚至你的理解和真實的狀況徹底相反,雖然他們的表現一致,此次跟着代碼敲完一次以後,異名對2D遊戲的製做思路也有了更清晰的理解。
本文分享自微信公衆號 - 異名(async-code)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。