.0 開始之前
之前曾經用Html5/JavaScript/CSS實現過2048,用Cocos2d-html5/Chipmunk寫過一個Dumb Soccer的對戰游戲,但沒有使用過原生的Canvas寫過任何東西,為了加深對Canvas的學習,就心血來潮花了將近一天的時間利用原生Canvas實現了一個簡化版的flappy bird,下面就總結一下開發的過程。
在正式開前,對於沒有使用本地服務器的開發者來說,建議下載一個firefox來進行測試或使用IE 10,因為firefox和IE 10對本地文件的訪問限制較低,在本地無服務器環境調試的時候,在firefox瀏覽器中不容易碰到因為跨域而無法獲取文件的問題。如果能夠在本地搭建一個HTTP服務器那就更好了,基本不會碰到類似的錯誤。
.1 構造世界
.1.1 html頁面
首先需要在index.html中加載所需要的腳本,設置canva標簽,具體代碼如下:
<!doctype html> <html> <head> <style> .game_frame { margin: 20px auto; width: 240px; height: 400px; } </style> </head> <body> <div class='game_frame'> <canvas class='game_box' id='game_box' name='game_box' width='240px' height='400px'></canvas> </div> <script src="game.js" type="text/javascript"></script> </body> </html>
利用將canvas嵌入到一個div標簽中,利用div標簽來控制canvas的位置,目前是將canvas居中。
需要注意的是,必須要在canvas標簽內部設置樣式,否則Javascript中所繪制的圖像的比例很發生嚴重失真(圖像被拉伸,變形)。目前,暫時不考慮自適應屏幕大小的問題,首先把游戲實現了。
另外,腳本的加載最后放在body末尾,以免腳本獲取元素的時候html頁面並未加載完成。其實,更好的方法是利用window.onload設置頁面加載完成后的動作,保證Javascript腳本不會在元素未加載完成的時候去讀取元素。
.1.2 game.js腳本
為了避免污染全局變量,在game.js中定義一個World對象,World負責游戲中的所有元素操控(創建、銷毀、控制)、動畫幀的循環、碰撞檢測等工作,是整個游戲運作的引擎。下面是World中的所有方法和屬性:
var World = { // 保存Canvas theCanvas : null, // 游戲是否暫停 pause: false, // 初始化並運行游戲 init : function(){}, // 重置游戲 reset: function(){}, // 動畫循環 animationLoop: function(){}, // 繪制背景 BGOffset: 0, // scroll offset backgroundUpdate : function() {}, // 更新元素 elementsUpdate: function(){}, // 碰撞檢測 collisionDectect: function(){}, hitBox: function ( source, target ) {}, pixelHitTest: function( source, target ) {}, // 邊界檢測 boundDectect: function(){}, // 創建煙囪 pipesCreate: function(){}, // 清除煙囪 pipesClear: function(){}, // 小鳥出界檢測 isBirdOutOfBound: function(callback){}, };
通過這些方法,World就可以運行游戲,實現對游戲中“小鳥”和“煙囪”的控制。
.1.3 游戲的初始化:World.init
游戲通過World.init來初始化游戲並運行。利用Html5 canvas實現的游戲或動畫的原理都是一樣的,即以特定的時間間隔不斷地更新canvas畫布上的圖像以實現動畫。因此,游戲初始化時候必須要做的就是下面幾件事情:
- 獲取DOM中的canvas元素;
- 通過canvas元素獲取context;
- 進入動畫循環(animationLoop);
在我們的game.js寫下這樣的代碼:
1 World.init = function(){ 2 var theCanvas = this.theCanvas = document.getElementById('game_box'); 3 this.ctx = theCanvas.getContext('2d'); 4 this.width = theCanvas.width; 5 this.height = theCanvas.height; 6 this.bird = null; 7 this.items = []; 8 this.animationLoop(); 9 },
除了保存canvas元素及context以外,還將canvas畫布的長寬也保存下,並創建了bird對象和 items數組以保存游戲中的元素。然后就進入了animationLoop這個動畫循環。
.1.4 動畫循環
動畫循環以特定的時間間隔運行,負責更新游戲中每個元素屬性,並將所有元素在canvas中繪制出來。一般游戲會以60fps或30fps的幀率運行,顯然60fps的幀率需要更大的運算量,而畫面也更為流暢,現在就暫時使用60fps幀率,那么每幀圖像的時間間隔為1000ms/60 = 16.7ms。另外,要注意的是元素的繪制順序,一定要首先將背景繪制出來,然后再繪制其他游戲元素,否則這些元素就會被背景圖所覆蓋。由於幀間隔比較短,因此animationLoop中所允許的函數應當盡可能快。下面看看代碼:
1 animationLoop: function(){ 2 3 // scroll the background 4 this.backgroundUpdate(); 5 6 // detect elements which is out of boundary 7 this.boundDectect(); 8 9 // detect the collision between bird and pipes 10 this.collisionDectect(); 11 12 // update the elements 13 this.elementsUpdate(); 14 15 // next frame 16 if(!this.pause){ 17 setTimeout(function(){ 18 World.animationLoop(); 19 }, 16.7) 20 } 21 }
animationLoop中的工作順序是:
- 繪制背景;
- 邊界檢測,對出界的元素進行處理;
- 碰撞檢測,執行相應的處理;
- 繪制游戲中的元素;
- 設置下一幀的定時;
在這里使用了setTimeout來設置下一幀的定時,而不是使用setInterval來實現一次性的定時操作,最重要的原因是為了保持幀率的穩定。如果使用setInterval來定時,那么可能會出現由於當前animationLoop處理時間較長(超過16.7ms),導致下一幀處理 定時已經到來了而處於等待狀態,等當前animationLoop處理完成后,立即執行下一幀的處理,這樣使得幀間隔被壓縮,出現明顯的幀率不穩的狀態。如果使用setTimeout,即使當前處理時間較長,幀處理完成到下一幀的間隔也肯定是固定,而幀間隔時間會大於16.7ms。這樣雖然幀率會降低,但可以降低跳幀這種幀率波動較大的事件出現。
.1.5 繪制背景
在為游戲世界添加元素以前,先為這個世界創造一個背景。為此,從網上下載了一個flappy元素包,里面有一張整合了所有圖片元素的atlas圖集,為了提高游戲的加載速度,決定使用這種圖集,盡管顯式圖片的時候可能稍微麻煩一點,游戲加載速度的提高效果很值得我們這么做。
首先,是atlas.png圖片的加載,一般有兩種方式在html頁面中使用標簽進行加載,這樣圖片就會在DOM的構建過程中加載完成,此時我們設置該標簽的長寬為0,令其不占據文本流的空間,同時也不會顯示出來:
index.html:
1 <img src="atlas.png" id='atlas' style='visibility:hidden' width="0" height="0">
game.js:
1 var image = document.getElementById('atlas');
另一種是在Javascript腳本中動態加載,加載時間更加靈活:
1 var image = new image(); 2 image.src = 'atlas.png'; 3 imgge.onload = function(){ 4 // wait for the loading 5 };
無論在哪種方式下都要等待圖片加載完成才能使用圖片,否則會出錯。動態加載會使得圖片運行更加腳本運行流程變得更復雜,由於在這個游戲中只需要加載一張圖片,因此采用第一種方法。
圖片加載完成以后,只要在backgroundUpadte函數中將其繪制出來,即可以順利完成背景的繪制:
1 backgroundUpdate : function() { 2 var ctx = this.ctx; 3 ctx.drawImage(image, 0, 0, 288, 512, 0, 0, 288, 512); 4 },
drawImgae的第2個到第5個參數分別表示,目標圖像在圖源中的x坐標、y坐標以及圖源中圖像的寬度和長度,最后四個參數是目標圖像在畫布中的x坐標、y坐標以及在畫布中的寬度和長度。
然而,一個靜態的背景圖太簡陋了,缺乏活力,所以我們要令背景圖卷動起來。實現原理非常簡單,只需要將背景圖繪制的x軸偏移量隨着時間改變而改變。但由於我們所擁有的背景圖太窄了,需要將其會繪制兩次拼接出一張較寬的圖片,實現的代碼圖下所示:
1 backgroundUpdate : function() { 2 var ctx = this.ctx; 3 this.BGOffset--; 4 if(this.BGOffset <= 0) { 5 this.BGOffset = 288; 6 } 7 ctx.drawImage(image, 0, 0, 288, 512, this.BGOffset, 0, 288, 512); 8 ctx.drawImage(image, 0, 0, 288, 512, this.BGOffset - 288, 0, 288, 512); 9 },
這時,圖片還沒有開始動,因為我們的世界還沒有初始化!!!為了讓世界在頁面加載完成后初始化,在index.html的body末尾中嵌入腳本。
1 <script> 2 window.onload = function(){ 3 console.log('start'); 4 World.init(); 5 } 6 </script>
此時,用瀏覽器打開頁面,就可以看到我們所創造的新世界了。
.2 在世界中添加元素
世界創造完成以后,就可以往世界里面添加元素了。游戲世界里面每個元素都可能不一樣,有不同的大小、形狀、屬性、圖片,但這些元素也有一些共性,如它們都需要有記錄大小、位置、移動速度的屬性,還需要有在元素中渲染該圖片的方法。這里有些屬性和方法是特有的,如大小屬性,渲染方法,但同時這些元素也有共有的屬性,如設置位置、速度的方法等。為此,我們將創建一個名Item函數對象,利用這個函數對象的prototype來保存一些公有的方法和屬性,再創建Bird類和Pipe類來創建構造“bird”和“pipe”對象。
.2.1 基本元素:Item類
世界中每個元素都需要有的基本屬性是:大小、位置、速度、重力(重力加速度),而這些屬性每個具體的元素對象都可能不相同,因此它們不設置在prototype上,只在對象本身上創建。而prototype上有的是設置這些屬性的方法,還有一個叫generateRenderMap的方法。這個方法是用來生成用於像素碰撞檢測的數據的,暫時先不寫。
1 /* 2 * Item Class 3 * Basic tiem class which is the basic elements in the game world 4 *@param draw, the context draw function 5 *@param ctx, context of the canvas 6 *@param x, posisiton x 7 *@param y, posisiton y 8 *@param w, width 9 *@param h, height 10 *@param g, gravity of this item 11 */ 12 var Item = function(draw, ctx, x, y, w, h, g){ 13 this.ctx = ctx; 14 this.gravity = g || 0; 15 this.pos = { x: x || 0, 16 y: y || 0 17 }; 18 this.speed = { x: 0, // moving speed of the item 19 y: 0 20 } 21 this.width = w; 22 this.height = h; 23 this.draw = typeof draw == 'function' ? draw : function(){}; 24 return this; 25 }; 26 27 Item.prototype = { 28 // set up the 'draw' function 29 setDraw : function(callback) { 30 this.draw = typeof draw == 'function' ? draw : function(){}; 31 }, 32 33 // set up the position 34 setPos : function(x, y) { 35 // Handle: setPos({x: x, y: y}); 36 if(typeof x == 'object') { 37 this.pos.x = typeof x.x == 'number' ? x.x : this.pos.x; 38 this.pos.y = typeof x.y == 'number' ? x.y : this.pos.y; 39 // Handle: setPos(x, y); 40 } else { 41 this.pos.x = typeof x == 'number' ? x : this.pos.x; 42 this.pos.y = typeof y == 'number' ? y : this.pos.y; 43 } 44 }, 45 46 // set up the speed 47 setSpeed : function(x, y) { 48 this.speed.x = typeof x == 'number' ? x : this.speed.x; 49 this.speed.y = typeof y == 'number' ? y : this.speed.y; 50 }, 51 52 // set the size 53 setSize : function(w, h) { 54 this.width = typeof width == 'number' ? width : this.width; 55 this.height = typeof height == 'number' ? height : this.height; 56 }, 57 58 // update function which ran by the animation loop 59 update : function() { 60 this.setSpeed(null, this.speed.y + this.gravity); 61 this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y); 62 this.draw(this.ctx); 63 }, 64 65 // generate the pixel map for 'pixel collision dectection' 66 generateRenderMap : function( image, resolution ) {} 67 }
內部屬性的初始化有Item函數實現,里面有設置簡單的默認初始化。更加完善的初始化方法是首先檢測輸入參數的類型,然后再進行初始化。
gravity影響的是垂直方向的速度,即speed.y。而speed在每一次元素更新(動畫循環)的時候,影響pos屬性,從而改變元素的位置。
update這個公有方法通過setSpeed、setPos改變元素的速度和位置,並調用draw方法來將元素繪制在canvas畫布上。
元素初始化的時候,必須從World中獲得draw方法,否則元素的圖像是不會繪制到canvas畫布上的。而繪制所需要的context也是從World中獲取的,在初始化的時候獲取,並保存到內部變量中。
添加Item對象
通過Item來創建一個對象,就可以向世界中添加一個元素,在World.init中添加代碼:
1 World.init = function(){ 2 ... 3 4 var item = new Item(function(ctx){ 5 ctx.fillStyle = "#111111"; 6 ctx.beginPath(); 7 ctx.arc(this.pos.x, this.pos.y, this.width/2, 0, Math.PI*2, true); 8 ctx.closePath() 9 ctx.fill() 10 }, this.ctx, 50, 50, 10, 10, 0.2); 11 this.items.push(item); // 將元素放入到管理列表中 12 13 ... 14 }
通過上述代碼,就可以往World中添加一個圓點。但此時世界中仍然不會顯示圓點,那是因為World.elementsUpdate還沒有實現。該方法需要遍歷世界中的所有元素,調用元素的update方法,通過元素的update方法調用draw方法從而實現元素在畫布上的繪制。
1 World.elementsUpdate = function(){ 2 // update the pipes 3 var i; 4 for(i in this.items) { 5 this.items[i].update(); 6 } 7 }
刷新頁面之后,就會看到一個小圓點在做自由落體運動。
.2.2 繼承:extend函數
在創建其他類型的元素前,先來看看要如何實現類的繼承。
為何要使用繼承?
在游戲中的元素都存在共性,它們都有記錄大小、位置、速度的屬性,也都需要有設置大小、位置、速度的屬性,還必須要有一個提供給World.elementsUpdate方法調用的更新元素屬性、在畫布上繪制元素圖像的接口。通過類的繼承,在創建不同類型的時候就可以將來自早已定義好的基類——Item類——的屬性或方法繼承下來,簡化了類的創建,同時也節省了實例占用的空間。
如何實現類的繼承?
要實現類的繼承,最主要的是應用了constructor和prototype。在子類構造器函數中,通過調用Parent.constructor.call(this)就可使用基類構造器為子類構造內部屬性和方法;通過Child.prototype = Parent.prototype就可以繼承基類的prototype,這樣子類的實例對象就可以直接調用基類prototype上的代碼。JavaScript里實現類繼承的方法非常多,不同的方法能夠產生不同的效果,更多詳細的說明請翻閱相關的參考書,如《JavaScript面向對象編程指南》,《JavaScript設計模式》等。
extend函數
在這里,我們采用一個簡單的extend函數來實現繼承。
1 /* 2 * for deriving a new Class 3 * Child will copy the whole prototype the Parent has 4 */ 5 function extend(Child, Parent) { 6 var F = function(){}; 7 F.prototype = Parent.prototype; 8 Child.prototype = new F(); 9 Child.prototype.constructor = Child; 10 Child.uber = Parent.prototype; 11 }
這個函數干了下面一些事情:
- 創建一個空函數對象F作為中間變量;
- 中間變量獲取Parent的prototype;
- 子類從中間變量中繼承原型並更新原型中的構造器,此時子類的原型和基類的原型雖然包含相同的屬性和方法,但是已經兩個獨立的原型了,不會相互影響;
- 最后創建一個內部uber變量來引用Parent原型;
該方法參考自《JavaScript面向對象編程指南》。使用這個方法,會復制基類原型鏈並繼承之,並不會繼承基類的內部屬性和方法(this.xxxx)。這樣做的原因是,盡管子類和基類可能會有共同的元素,但是初始化構造時要執行的參數不一樣,有些元素可能擁有更多內部屬性,有些內部屬性可能已經被一些子類元素拋棄了,但原型鏈上的公有方法則是子類想繼承的。
利用內部uber屬性引用基類原型鏈的原因在於,子類有可能需要重載原型鏈上的公有方法,這樣就會把原有繼承而來的方法覆蓋掉,但有時又需要調用基類原有的方法,因此就利用內部屬性uber保留對基類原型鏈的引用。
.2.3 主角:Bird類
這個世界的主角是Bird,盡管它是主角,但它也是這個世界的元素之一,與Item類一樣擁有記錄大小、位置、速度的內部屬性,它將會繼承來自Item類原型鏈上設置內部屬性的方法,當然也有一個更重要的與眾不同的fly方法。
但首先,要獲取atlas中小鳥的圖源參數。為了方便起見,創建一個對象將其記錄下來。
1 var atlas = {}; 2 atlas.bird =[ 3 { sx: 0, sy: 970, sw: 48, sh: 48 }, 4 { sx: 56, sy: 970, sw: 48,sh: 48 }, 5 { sx: 112, sy: 970, sw: 48, sh: 48 }, 6 ]
atlas.bird中記錄了atlas圖左下角三只黃色小鳥的信息,分別是表示三種狀態。目前暫時用atlas.bird[1]展示小鳥的滑翔狀態。
1 /* 2 * Bird Class 3 * 4 * a sub-class of Item, which can generate a 'bird' in the world 5 *@param ctx, context of the canvas 6 *@param x, posisiton x 7 *@param y, posisiton y 8 *@param g, gravity of this item 9 */ 10 var Bird = function(ctx, x, y, g) { 11 this.ctx = ctx; 12 this.gravity = g || 0; 13 this.pos = { x: x || 0, 14 y: y || 0 15 }; 16 this.depos = { x: x || 0, // default position for reset 17 y: y || 0 18 }; 19 this.speed = { x: 0, 20 y: 0 21 } 22 this.width = atlas.bird[0].sw || 0; 23 this.height = atlas.bird[0].sh || 0; 24 25 this.pixelMap = null; // pixel map for 'pixel collistion detection' 26 this.type = 1; // image type, 0: falling down, 1: sliding, 2: raising up 27 this.rdeg = 0; // rotate angle, changed along with speed.y 28 29 this.draw = function drawPoint() { 30 var ctx = this.ctx; 31 ctx.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width, this.height, 32 this.pos.x, this.pos.y, this.width, this.height); // draw the image 33 }; 34 return this; 35 } 36 37 // derive fromt the Item class 38 extend(Bird, Item); 39 40 // fly action 41 Bird.prototype.fly = function(){ 42 this.setSpeed(0, -5); 43 }; 44 45 // reset the position and speed 46 Bird.prototype.reset = function(){ 47 this.setPos(this.depos); 48 this.setSpeed(0, 0); 49 }; 50 51 // update the bird state and image 52 Bird.prototype.update = function() { 53 this.setSpeed(null, this.speed.y + this.gravity); 54 this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y); // update position 55 this.draw(); 56 }
Bird的構造器基本上Item一樣,特別的在於它的寬度和長度由圖像的大小決定,而它在內部定制了draw方法,用於將小鳥的圖像繪制到畫布上。draw方法中調用了跟繪制背景時一樣的drawImage方法,只不過圖源信息從atlas.bird中獲取,暫時默認小鳥以滑翔狀態顯示。
Bird多了兩個方法,分別是reset和fly。reset用於重置小鳥的位置和速度;而fly則是給小鳥設置一個向上的速度(speed.y),讓其向上飛一下。
此外Bird還“重載”了update方法。現在看來,這個方法跟Item中的沒有什么區別,但由於它是世界的主角,后來會為它添置更多的動畫等,所以預先在這里“重載”了。
要注意的是,extend函數需要在定義Bird的prototype方法之前,否則新定義的方法會被Item類的prototype覆蓋掉。
在世界中添加小鳥
現在,就可以往世界里添加小鳥了,在World.init中添加如下代碼:
1 World.init = function(){ 2 ... 3 this.bird = new Bird(this.ctx, this.width/10, this.height/2, 0.15); 4 ... 5 }
此時,類封裝的好處就顯示出來了,由於Bird類已經將小鳥的構造過程封裝好,創建小鳥實例的時候只需要傳入context並設置位置及重力參數,創建過程變得極為簡便。
除此以外,還需要在World.elementsUpdate中添加代碼,讓動畫循環把小鳥圖像繪制在畫布上:
1 World.elementsUpdate = function(){ 2 // update the pipes 3 var i; 4 for(i in this.items) { 5 this.items[i].update(); 6 } 7 8 // update the bird 9 this.bird.update(); 10 },
刷新頁面,就可以在游戲世界中看到一只只會自由落體的小鳥了。
控制小鳥
一只只會自由落體的小鳥顯然是不好玩的,為此要在世界中添加控制小鳥的方法。簡單地,我們讓鍵盤按下任何鍵都會使小鳥往上飛,需要在World.init中添加代碼:
1 World.init = function(){ 2 ... 3 (function(that){ 4 document.onkeydown = function(e) { 5 that.bird.fly(); 6 }; 7 })(this); 8 ... 9 }
通過document.onkeydown設置按鍵按下時的回調函數,進而調用bird.fly使其往上飛。在這里使用了閉包來傳遞World的this對象,因為執行回調的時候上下文會改變,需要使用閉包來獲取定義回調函數時的上下文中的對象。除此之外,如果需要指定某個按鍵來控制小鳥的動作,則可以通過回調函數的參數e來得到被按下按鍵對象的keycode。然而,不同內核的瀏覽的keycode保存的位置不一樣,如webkit中是e.event.keycode,而Netscape中則是e.keycode。要解決這個兼容性問題,可以先利用navigator.appName來判斷瀏覽器類型來采用不同方式獲取keycode,或者直接在e對象中搜索keycode。
重新刷新頁面,按下鍵盤上任意一個按鍵,就可以讓小鳥往上飛了。
.2.4 反派:Pipe類
有了主角,就要有反派,世界才會充滿樂趣。而在這里,我們的反派就是那些長長短短的煙囪們。嘗到Bird創建的甜頭后,我們用同樣的方法來構造一個Pipe類。
首先還是得有圖源的參數,采用與Bird類似的方式來保存,在這里只選用圖集中綠色的兩根煙囪。
1 atlas.pipes = [ 2 { sx: 112, sy: 646, sw: 52, sh: 320 }, // face down 3 { sx: 168, sy: 646, sw: 52, sh: 320 } // face up 4 ]
Pipe類代碼:
1 /* 2 * Pipe Class 3 * 4 * a sub-class of Item, which can generate a 'bird' in the world 5 *@param ctx, context of the canvas 6 *@param x, posisiton x 7 *@param y, posisiton y 8 *@param w, width 9 *@param h, height 10 *@param spx, moving speed from left to right 11 *@param type, choose to face down(0) or face up(1) 12 */ 13 var Pipe = function(ctx, x, y, w, h, spx, type) { 14 this.ctx = ctx; 15 this.type = type || 0; 16 this.gravity = 0; // the pipe is not moving down 17 this.width = w; 18 this.height = h; 19 this.pos = { x: x || 0, 20 y: y || 0 21 }; 22 this.speed = { x: spx || 0, 23 y: 0 24 } 25 26 this.pixelMap = null; // pixel map for 'pixel collistion detection' 27 28 this.draw = function drawPoint(ctx) { 29 var pipes = atlas.pipes; 30 if(this.type == 0) { // a pipe which faces down, that means it should be on the top 31 ctx.drawImage(image, pipes[0].sx, pipes[0].sy + pipes[0].sh - this.height, 52, this.height, this.pos.x, 0, 52, this.height); 32 } else { // a pipe which faces up, that means it should be on the bottom 33 ctx.drawImage(image, pipes[1].sx, pipes[1].sy, 52, this.height, this.pos.x, this.pos.y, 52, this.height); 34 } 35 36 return this; 37 } 38 39 // derived from the Item class 40 extend(Pipe, Item);
Pipe類的定義同樣不復雜,由於Pipe的長度會隨機變化,而且有面朝上和面朝下兩種形態,因此構造器保留長寬參數並設置有類型參數。在這里假定Pipe不能上下移動,因此speed.y設置為0,同時只能初始化Pipe在x軸上的移動速度。
Pipe的draw方法也使用與Bird類似的方式,區別在於要根據煙囪類型來選擇繪制方式和參數。
在世界中隨機地添加煙囪
為了給世界增加趣味性,需要隨機地在世界中創建煙囪,為此在World.pipesCreate寫下代碼:
1 pipesCreate: function(){ 2 var type = Math.floor(Math.random() * 3); 3 var that = this; 4 // type = 0; 5 switch(type) { 6 7 // one pipe on the top 8 case 0: { 9 var height = 125 + Math.floor(Math.random() * 100); 10 that.items.push( new Pipe(that.ctx, 300, 0, 52, height, -1, 0)); // face down 11 break; 12 } 13 // one pipe on the bottom 14 case 1: { 15 var height = 125 + Math.floor(Math.random() * 100); 16 that.items.push(new Pipe(that.ctx, 300, that.height - height, 30, height, -1, 1)); // face up 17 break; 18 } 19 // one on the top and one on the bottom 20 case 2: { 21 var height = 125 + Math.floor(Math.random() * 100); 22 that.items.push( new Pipe(that.ctx, 300, that.height - height, 30, height, -1, 1) ); // face up 23 that.items.push( new Pipe(that.ctx, 300, 0, 30, that.height - height - 100, -1, 0) ); // face down 24 break; 25 } 26 } 27 }
pipesCreate中使用Math.random隨機設置煙囪類型(僅一只煙囪在上面;僅一只煙囪在下面;上下都有煙囪;),並隨機設置煙囪的長度。要注意的是,Math.random只會產生0~1的隨機小數,乘上所需隨機范圍的最大值后就可以獲取這個范圍中0~max的隨機小數,如果要獲取整數則需要使用Math.floor或Math.round來去除小數部分。更多的使用技巧請參考相關的JavaScript書籍。
每創建一個Pipe實例,都需要將其存入World.items中,由World.elementsUpdate來對所有的煙囪進行統一更新和繪制。
完成隨機創建方法以后,在World.init中添加定時器來調用創造煙囪的方法:
1 World.init = function(){ 2 (function(that){ 3 setInterval(function(){ 4 that.pipesCreate(); 5 }, 2000) 6 })(this); 7 }
同樣的,需要使用閉包了傳遞World本身,否則定時函數無法獲取this.pipesCreate方法。由於在pipesCreate中創建的Pipe都設置的固定的初始位置,Pipe以固定的速度向左移動,因此Pipe實例之間的距離就通過定時器的時間間隔來控制。當然,時間間隔越短,煙囪間距離就越窄,那么游戲的難度就加大了。
處理出界的元素
現在World.pipesCreate會不斷創建煙囪對象並保存到World.items中,即使出界了也沒有做任何處理,那么不再出現的煙囪對象會一直累積下來,一點點地消耗內存。因此,需要對出界的煙囪來進行處理。
而對於Bird實例而言,Bird掉落到世界下部時,如果沒有任何操作,那么小鳥就會永遠地掉落下去,很難再飛上來了。因此必須對小鳥的出界進行檢測和處理。
要記住,canvas畫布中,原點在左上角,X軸方向從左向右,而Y軸方向從上向下。
檢測及處理出界元素的代碼:
// boundary dectect World.boundDectect = function(){ // the bird is out of bounds if(this.isBirdOutOfBound()){ this.bird.reset(); this.items = []; } else { this.pipesClear(); } }, // pipe clearance // clear the pipes which are out of bound World.pipesClear = function(){ var it = this.items; var i = it.length - 1; for(; i >= 0; --i) { if(it[i].pos.x + it[i].width < 0) { it = it.splice(i, 1); } } }; // bird dectection World.isBirdOutOfBound = function(callback){ if(this.bird.pos.y - this.bird.height - 5 > this.height) { // the bird reach the bottom of the world return true; } return false; };
當檢測到煙囪的位置越過了畫面的左界的時候,就將該煙囪實例清除。這里使用了Array.splice方法,要注意的是,移除Array的時候會改變Array的長度和被移除元素后面元素的位置,因此在這里使用從后往前的遍歷方式。
當小鳥位置超過畫面下界時,利用World.items = []清除所有煙囪,並重置小鳥的位置。
在刷新一下頁面,試着任由小鳥自由落體至畫面底部,就會看到小鳥會被重置。
.3 碰撞檢測
到目前為止,游戲的基本元素都已經添加完畢了,但你會發現一個問題:無敵的小鳥像超級英雄一樣穿越所有煙囪,反派僅僅起到裝飾的作用。這是因為,我們還沒有添加碰撞檢測功能。
.3.1 邊框碰撞檢測
盡管每個元素的形狀都不一定是方方正正的,但是我們在創建元素的時候都為這個元素設置了長度和寬度,利用這個隱藏的邊框,就可以實現邊框檢測。檢測方法非常簡單,只要檢測兩個框是否有重復部分即可,實現手段就是堅持兩個框的邊界距離是否相互交錯。類似的算法在leetcode上面有算法題,都可以用來借鑒。
注意的是pos表示的是邊框左上角的坐標。
1 World.hitBox = function ( source, target ) { 2 return !( 3 ( ( source.pos.y + source.height ) < ( target.pos.y ) ) || 4 ( source.pos.y > ( target.pos.y + target.height ) ) || 5 ( ( source.pos.x + source.width ) < target.pos.x ) || 6 ( source.pos.x > ( target.pos.x + target.width ) ) 7 ); 8 }
邊框檢測極其簡單且快速,但是其效果是,小鳥還沒有碰到煙囪就就會判定為碰撞已發生。那是因為小鳥的圖像不僅沒有填滿這個邊框,還擁有不規則的形狀。因此邊框檢測只能用做初步的碰撞檢測。
.3.2 像素碰撞檢測
根據精細的檢測方式是對兩個元素的像素進行檢測,判斷是否有重疊的部分。
但是,像素碰撞檢測需要遍歷元素的像素,運算速度比較慢,如果元素較多,那么幀間隔時間內來不及完成檢測任務。為了減少碰撞檢測的耗時,可以先利用邊框檢測判斷那些元素之間有可能發生碰撞,對可能發生碰撞的元素使用像素碰撞檢測。
1 // dectect the collision 2 Wordl.collisionDectect = function(){ 3 for(var i in this.items) { 4 var pipe = this.items[i]; 5 if(this.hitBox(this.bird, pipe) && this.pixelHitTest(this.bird, pipe)) { 6 this.reset(); 7 break; 8 } 9 } 10 };
游戲里,只需要檢測小鳥和煙囪之間的碰撞,因此只需要拿小鳥和煙囪逐個做檢測,先進行邊框碰撞檢測,然后進行像素碰撞檢測,以提高運算效率。檢測到碰撞以后,調用World.reset來重置游戲或進行其他操作。
1 World.reset = function(){ 2 this.bird.reset(); 3 this.items = []; 4 }
下面來看看像素碰撞檢測。盡管減少了像素碰撞檢測的調用次數,但每次像素碰撞檢測的運算量仍然非常大。將如兩個圖像各包含200個像素,那么逐個像素進行比較就需要40000次運算,顯然效率低下。
仔細想想,圖像發生碰撞時只有邊緣發生碰撞就可以。那么只要記錄圖像的邊緣數據,然后檢查兩幅圖像邊緣是否重合判別碰撞,降低需要運算的像素點數量從而降低運算量。然而邊緣檢測及邊緣重合的算法並不簡單,當中會出現許多問題。
在這里,我們打算將邊框碰撞檢測應用到像素碰撞檢測當中。首先,需要將原圖像進行稀疏編碼,即將原圖像的分辨率降低,這樣就相當於將一個1pixel的像素點編程一個由更多像素點組成的方形小框。
然后,把這些小框的數據保存到每個元素的pixelMap中,這樣一來,在進行碰撞檢測的時候,就可以看元素圖像看作是多個邊框組合而成的圖像,我們要做的只需要檢測組成兩個元素的小框之間有沒有發生碰撞。
像素檢測算法的實現
World.pixelHitTest = function( source, target ) { // Loop through all the pixels in the source image for( var s = 0; s < source.pixelMap.data.length; s++ ) { var sourcePixel = source.pixelMap.data[s]; // Add positioning offset var sourceArea = { pos : { x: sourcePixel.x + source.pos.x, y: sourcePixel.y + source.pos.y, }, width: target.pixelMap.resolution, height: target.pixelMap.resolution }; // Loop through all the pixels in the target image for( var t = 0; t < target.pixelMap.data.length; t++ ) { var targetPixel = target.pixelMap.data[t]; // Add positioning offset var targetArea = { pos:{ x: targetPixel.x + target.pos.x, y: targetPixel.y + target.pos.y, }, width: target.pixelMap.resolution, height: target.pixelMap.resolution }; /* Use the earlier aforementioned hitbox function */ if( this.hitBox( sourceArea, targetArea ) ) { return true; } } } },
resolution是指像素點放大的比例,如果為4,則是將1 pixel 放大為4X4 pixel 大小的邊框。該算法是從原始的pixelMap中讀取每個小框,並構造一對Area對象(方形邊框)傳遞給World.hitBox方法進行邊框碰撞檢測。
pixelMap的構造
而pixelMap 的構造則需要用到context.getImageData方法。
本地環境下,getImageData在IE 10或firefox瀏覽器下能夠順利運行,如果是在Chrome下則會產生跨域問題。除非使用HTTP服務器來提供web服務,否則需要更改chrome的啟動參數--allow-file-access-from-files才能夠使用getImageData來獲取本地圖片文件的數據。
getImageData是從canvas畫布的指定位置獲取指定大小的圖像數據,因此如果存在背景的話,背景的圖像數據也會被截取。因此需要創建一個臨時的canvas DOM對象,在上面繪制目標圖像,然后再從臨時畫布上截取圖像信息。
Bird類的pixelMap:(在Bird類的draw方法中添加代碼)
1 var Bird = function(){ 2 ... 3 this.draw = function(){ 4 ... 5 // the access the image data using a temporaty canvas 6 if(this.pixelMap == null) { 7 var tempCanvas = document.createElement('canvas'); // create a temporary canvas 8 var tempContext = tempCanvas.getContext('2d'); 9 tempContext.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width, this.height, 10 0, 0, this.width, this.height); // put the image on the temporary canvas 11 var imgdata = tempContext.getImageData(0, 0, this.width, this.height); // fetch the image from the temporary canvas 12 this.pixelMap = this.generateRenderMap(imgdata, 4); // using the resolution the reduce the calculation 13 } 14 ... 15 } 16 ... 17 }
Pipe類的pixelMap:(類似地在draw方法中添加代碼)
1 var Pipe = function(){ 2 ... 3 this.draw = function(){ 4 ... 5 if(this.pixelMap == null) { // just create the pixel map from a temporary canvas 6 var tempCanvas = document.createElement('canvas'); 7 var tempContext = tempCanvas.getContext('2d'); 8 if(this.type == 0) { 9 tempContext.drawImage(image, 112, 966 - this.height, 52, this.height, 0, 0, 52, this.height); 10 } else { // face up 11 tempContext.drawImage(image, 168, 646, 52, this.height, 0, 0, 52, this.height); 12 } 13 var imgdata = tempContext.getImageData(0, 0, 52, this.height); 14 this.pixelMap = this.generateRenderMap(imgdata, 4); 15 } 16 ... 17 } 18 ... 19 }
無論是Bird類還是Pipe類,都使用從Item類中繼承而來的generateRenderMap方法
// generate the pixel map for 'pixel collision dectection' //@param image, contains the image size and data //@param reolution, how many pixels to skip to gernerate the 'pixelMap' Item.generateRenderMap = function( image, resolution ) { var pixelMap = []; // scan the image data for( var y = 0; y < image.height; y=y+resolution ) { for( var x = 0; x < image.width; x=x+resolution ) { // Fetch cluster of pixels at current position // Check the alpha value is above zero on the cluster if( image.data[4 * (48 * y + x) + 3] != 0 ) { pixelMap.push( { x:x, y:y } ); } } } return { data: pixelMap, resolution: resolution }; }
resolution決定小框的大小,也決定了每行每列跳過的像素點數量。當檢測到一個像素點的alpha通道值不為0,就將其保存到pixelMap中即可。
此時刷新一下頁面,你會發現小鳥再也不是無敵的了。
.4 增添動畫效果
基本的Flappy Bird基本完成了,然而小鳥只能以滑翔的姿態運動,沒有有扇動翅膀的動作,顯得沒有生氣。為此,我們可以給Bird類添加動畫效果,讓小鳥向上飛的時候會扇動翅膀,同時頭部朝上;向下墜落的時候則頭部朝下,以俯沖的姿態運動。
這時候,之前提到了“重載”Bird.update方法的意義就來了。
1 // update the bird state and image 2 Bird.prototype.update = function() { 3 this.setSpeed(null, this.speed.y + this.gravity); 4 5 if(this.speed.y < -2) { // raising up 6 if(this.rdeg > -10) { 7 this.rdeg--; // bird's face pointing up 8 } 9 this.type = 2; 10 } else if(this.speed.y > 2) { // fall down 11 if(this.rdeg < 10) { 12 this.rdeg++; // bird's face pointing down 13 } 14 this.type = 0; 15 } else { 16 this.type = 1; 17 } 18 this.setPos(this.pos.x + this.speed.x, this.pos.y + this.speed.y); // update position 19 this.draw(); 20 }
當小鳥速度speed.y小於-2(飛起來初速度是-4)時,就減少其旋轉角度rdeg讓其臉逐漸朝上並更改圖片顯示狀態為2(向下拍翅膀);
當小鳥速度speed.y大於2(下落時速度>0)時,就增加其旋轉角度rdeg讓其臉逐漸朝下並更改圖片顯示狀態為0(翅膀上拉,成俯沖姿態);
速度在-2和2之間時,就維持滑翔狀態。
旋轉小鳥
增加更新小鳥屬性的方法后,還需要更新Bird.draw,否則旋轉的效果是不會顯示出來的。
1 var Bird = function(){ 2 ... 3 this.draw = function(){ 4 ... 5 ctx.save(); // save the current ctx 6 ctx.translate(this.pos.x, this.pos.y); // move the context origin 7 ctx.rotate(this.rdeg*Math.PI/180); // rotate the image according to the rdeg 8 ctx.drawImage(image, atlas.bird[this.type].sx, atlas.bird[this.type].sy, this.width, this.height, 9 0, 0, this.width, this.height); // draw the image 10 ctx.restore(); // restore the ctx after rotation 11 ... 12 }; 13 ... 14 };
使用context.rotate旋轉圖像前,需要先保存原來的context狀態,將畫布的原點移動到當前圖像的坐標,接着根據Bird.rdeg旋轉圖像,然后繪制圖像。使用drawImage繪制圖形時,需要將目標坐標改為(0,0),因為此時畫布原點坐標以及移動到了Bird.pos上的。繪制完成后恢復context的狀態。這樣,就能夠實現小鳥的身體傾斜了。
刷新一下頁面,小鳥的動畫特效就完成了。
至於碰撞特效之類的動畫特效,就由大家自己自由發揮了,在這里,僅僅將最簡單的Flappy Bird 游戲功能實現。
.5 總結
游戲耗時一天完成,期間在碰撞檢測部分花費了大量的時間查閱資料和測試,找到合適的方法后又在getImageData折騰了好久解決本地調試的跨域問題和截取不含背景的圖像數據的問題。但總算是完成了這個簡化版的游戲。
目前,簡化版本沒有任何菜單、按鍵、顯示文本等,日后會考慮繼續把這部分功能完善。此外,有部分代碼寫的並不夠精簡,結構也不夠清晰,編程技術有待磨練。
跟之前使用Cocos2d-html的開發經歷對比,不使用任何框架的開發難度提高了不少,尤其是在動畫循環、元素繪制、碰撞檢測這些部分花了不是功夫。不過,之前看過的JavaScript編程相關的書籍幫了我不少忙。有關的讀書筆記都存在自己的Evernote里面,哪天再找機會整理整理把它們發到博客上來。
總之,這次的開發經歷讓我學到了不少知識,對canvas的了解也更深了,希望未來有一天能夠開發一個自己的游戲,而不是去重復實現別人的游戲。
參考
碰撞檢測算法參考:http://benjaminhorn.io/code/pixel-accurate-collision-detection-with-javascript-and-canvas/