【我理解的游戲】
在我的理解里,游戲就是可以交互的動畫。所以游戲的原理跟動畫是差不多的。
相信動畫的原理大家都知道,就是通過一系列的變化來讓靜態的圖片達到動的效果。
不過游戲與動畫不同的是,游戲是可以交互的。也就是說,用戶對游戲有一定的控制權。游戲也會根據用戶的操作來反饋給用戶不同的動畫,當然也會記錄用戶在整個游戲中的表現。一般會用分數顯示的反饋給用戶,他在整個游戲中的表現。
大多數的canvas游戲,是通過不斷的擦除canvas然后重繪被擦除的部分。並改變被擦除前那一部分的所有元素的位置或者顏色來達到動畫的效果。當然也有部分游戲是根據用戶的某個操作來激活某個動作。比如五子棋,就是通過用戶在棋盤上的點擊來添加一個新的棋子來構成游戲。
當然既然是canvas游戲,對canvas的一些API就一定要熟悉啦,不過不熟悉也沒關系拉。 在游戲中用到的API我都會逐一介紹它的用法和用處。下面就正式開始進入游戲開發的過程吧。
【游戲結構】
既然是做游戲,大家可以先想一想。游戲都有哪些基本元素。簡單分析一下應該有如下幾點是每個游戲都共有 。
開始,暫停,結束。 這三點應該是大多游戲都有的,然后如果要整個游戲運轉,肯定會對游戲里面的畫面進行更新。 如此一來,就又多了一個更新。這四點應該就是游戲的基礎配置了,如此一來我們就可以根據以上四點來搭建一個游戲的基本結構了。 為了以后的游戲也可以同樣的使用,我把它寫成了一個父函數,可以供游戲的具體函數來繼承。 我稱它為gamebase
。下面是gamebase函數的所有代碼。
/** * @author cat */ function GameBase(){ this.event = { //存儲游戲事件 death : function(){}, updates : [] }; this.FPS = 1000 / 60; //游戲刷新速度 this.play = false; //是否開始 } GameBase.prototype = { constructor : GameBase, //游戲更新的單步驟 step : function(){ throw new Error('此方法必須被覆蓋!'); }, //是否在運行 isPlay : function(){ return this.play; }, //添加事件 bind : function(listen, callback, time){ if(listen == 'death'){ this.event['death'] = callback; }else if(listen == 'update'){ this.event['updates'] ? true : this.event['updates'] = []; time || (callback.time == time, callback.timer = 0); this.event['updates'].push(callback); } return this; }, //刪除事件 unbind : function(listen, fn){ if(listen == 'death'){ this.event['death'] = function(){}; }else if(listen == 'update'){ var i = 0, len = this.event.updates.length; for(; i < len && fn == this.event.updates[i]; i++){ this.event.updates.splice(i, 1); } } return this; }, //游戲開始函數 start : function(){ var self = this; self.startTime = new Date().getTime(); if(!self.isPlay()){ //避免重復開始 self.timer = self.timer = setInterval(function(){ self.step(); },self.FPS); self.play = true; } }, //游戲停止函數 stop : function(){ this.play = false; clearInterval(this.timer); }, //游戲結束函數 death : function(){ this.stop(); this.event['death'].call(this); }, //游戲更新函數 update : function(){ var updates = this.event['updates'], i = 0, len = updates.length, now = new Date().getTime(), update, time; for(; update = updates[i], i < len; i++){ update.time ? (now - (update.timer || 0) > update.time) && (update.timer == now, update.call(this)) : update.call(this); } } } //兩個工具方法 var lynx = { extend : function(parent, child){ var fn = function(){}; fn.prototype = parent.prototype; child.prototype = new fn(); child.prototype.constructor = child; return child; }, mix : function(target, source){ var hasOwn = Object.prototype.hasOwnProperty; if( !target || !source ) return; var override = typeof arguments[arguments.length - 1] === "boolean" ? arguments[arguments.length - 1] : true, prop; for (prop in source) { if (hasOwn.call(source, prop) && (override || !(prop in target))) { target[prop] = source[prop]; } } return target; } }
有了上面的這個方法,我們就能夠不用在所有的游戲里寫開始,暫停,結束,循環了。還有一個好處是把所有的更新操作都以事件的形式放入到一個更新的數組里,然后在一個setInterval里面批量執行,避免了使用多個setInterval。 如果要控制不同的刷新速率,可以通過第三個參數來指定FPS。 當然在update方法是需要在step里面手動調用的,之所以沒有在start里面直接調用update方法,是為了更高的可控性。 有了這樣一個基礎框架,我們就能夠開始真正的游戲制作了。(PS: 本博后續游戲系列都會基於這個函數來制作)
【貪吃蛇游戲元素分析】
想想你平時玩貪吃蛇,它有哪些元素。首先,需要有一個背景。蛇就在這個背景中活動。其次是有蛇和蛇的身體。基本就這三個元素。從復雜性上來說,貪吃蛇不是一個特別復雜的游戲。因為在整個游戲過程中,始終只有蛇的身體在運動而已。 而且游戲結束的邏輯也不復雜,蛇頭碰到牆壁,或者撞到自己的身體。還有就是在碰到食物的時候將自己本身增長一節。
首先,我們先從最小的元素想起。最小的元素就是食物。 它有着自己的坐標。 有着自己的顏色。 其次它也是組成蛇身體的一部分。所以我們用一個對象來秒數它。大致是這樣的。
{ x : 0, y : 0, color : '#ff0000' }
然后我們用一個數組把這些對象串連起來。就形成了一條完整的蛇了。我選擇了這樣的結構。
[[x, y],[x,y],[x,y]]
因為如果不做特殊處理的話(比如蛇是一個動畫,這就要處理轉角地方的蛇身了),蛇身的顏色應該是一樣的,所以就用存顏色了。 當然如此以來,多使用一個對象,是有點浪費的。 畢竟對象比數組多了key。
然后用一個canvas來做背景。 如此一來這條蛇就有地方可以活動了。 當然最好canvas的長寬是蛇一節身體的倍數。 這樣就不用處理蛇走到邊緣的時候,以及檢測碰壁的時候會方便一些。
首先我們先來創造一條蛇,在游戲中這條蛇每個節點占10 * 10像素的位置。 被顯現出來的位置是9 * 9。這樣每個節點之間就有一條空隙了。不會太難看。
[ [61,31], [51,31], [41,31], [31,31], [21,31], [11,31], [1,31]];
這樣一來,我們就擁有一條蛇了,雖然現在還看不到它。 下面我們來看看這條蛇是怎么動起來的。想一下,它在動的時候有什么規律。 是不是永遠都是頭部像正在前進的方向移動一步。然后身體順序都前進一步。 如果上面的這條蛇像x方向移動一步。我們看看會是什么樣。
[ [71,31], [61,31], [51,31], [41,31], [31,31], [21,31], [11,31]];
按照規則移動后就變成了上面這樣。 不難發現,其實只是在數組的最前面多加了一節,然后刪除了最后的一節而已。
這樣一來,我們便可以寫出整個蛇移動的函數了。因為蛇只有4個方向可以移動,所以在代碼里就很好實現了。只要找一個變量保存方向,然后根據方向來走就可以了,具體的實現代碼如下。
updateSnapperContext:function(){ var _this = this; Snappers = _this.snapperContext; switch(_this.aspect){ case 1: Snappers.unshift([Snappers[0][0] + 10,Snappers[0][1]]); Snappers.pop(); break; case 2: Snappers.unshift([Snappers[0][0] - 10,Snappers[0][1]]); Snappers.pop(); break; case 3: Snappers.unshift([Snappers[0][0],Snappers[0][1] - 10]); Snappers.pop(); break; case 4: Snappers.unshift([Snappers[0][0],Snappers[0][1] + 10]); Snappers.pop(); break; } _this.eatFood(Snappers); //是否碰到食物 }
最后的代碼是檢測蛇是否遇到食物。如果遇到就在尾巴上增加一節。之所以增加在尾巴上。是因為整條蛇如果處於即將碰壁的情況下吃到食物。在前方在加一節的話,就導致游戲直接結束了。
蛇搞定了之后,就需要搞定食物了。食物就更簡單了,創建一個對象。 用隨機數來生成它的xy,隨后檢測這個點是否與蛇的身體重合。不重合,就返回。如果重合就再次調用自身。直到這個點不再蛇的身上為止。具體實現如下。
getBread:function(){ var _this = this; var x = 10 * Math.floor((Math.random() * (_this.canvasWidth-10) / 10)) + 1 ; var y = 10 * Math.floor((Math.random() * (_this.canvasHeight-10) / 10)) + 1; var bread, color = '#ff0000'; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ if(_this.snapperContext[i][0] == x && _this.snapperContext[i][1] == y){ _this.bread = {}; bread = _this.getBread(); return bread; } } bread = {"x":x,"y":y,"color":color}; return bread; }
之所以除以10 + 1 是因為蛇的x占位空間為10,而顯示空間為9. 所以要如此處理。 這樣一來食物也解決了。 剩下的問題就只有,吃到食物和死亡了。 吃到食物比較簡單。判斷蛇頭和食物的x,y是否一致就好了。死亡可以分為兩步來檢測,一是是否碰撞到牆壁。二是是否碰撞到身體。 也是比較簡單的,就不詳細講解了。 有興趣的可以看代碼。
還有最后一個問題就是把整個游戲畫出來,呈現給用戶看到。 這需要用到fillRect函數,這個函數是專門用來繪制正方形的。 它有四個參數x,y,width,height 分別是x坐標,y坐標,以及長度和寬度。還有一個函數是clearRect。 這個函數用來清除cavvas的context中的像素。 跟fillRect一樣。也是四個參數。分別是x坐標,y坐標,以及長度和寬度。
最后在整理一下整個游戲的流程吧。
1.創建一條蛇和食物,並給定一個初始方向。
2.在setInterval中讓蛇的身體移動起來。
3.檢測是否碰到食物,如果碰到食物就處理吃食物。然后生成一個新的食物。
4.檢測是否死亡。如果死亡就結束整個游戲。如果沒死亡就畫出整條蛇。 當然在畫之前要清除上一幀的畫面。如此循環 3,4 兩個步驟。一直到死亡為止。
下面是整個游戲的源碼。

//gamebase.js /** * @author cat */ function GameBase(){ this.event = { //存儲游戲事件 death : function(){}, updates : [] }; this.FPS = 1000 / 60; //游戲刷新速度 this.play = false; //是否開始 } GameBase.prototype = { constructor : GameBase, //游戲更新的單步驟 step : function(){ throw new Error('此方法必須被覆蓋!'); }, //是否在運行 isPlay : function(){ return this.play; }, //添加事件 bind : function(listen, callback, time){ if(listen == 'death'){ this.event['death'] = callback; }else if(listen == 'update'){ this.event['updates'] ? true : this.event['updates'] = []; time || (callback.time == time, callback.timer = 0); this.event['updates'].push(callback); } return this; }, //刪除事件 unbind : function(listen, fn){ if(listen == 'death'){ this.event['death'] = function(){}; }else if(listen == 'update'){ var i = 0, len = this.event.updates.length; for(; i < len && fn == this.event.updates[i]; i++){ this.event.updates.splice(i, 1); } } return this; }, //游戲開始函數 start : function(){ var self = this; self.startTime = new Date().getTime(); if(!self.isPlay()){ //避免重復開始 self.timer = self.timer = setInterval(function(){ self.step(); },self.FPS); self.play = true; } }, //游戲停止函數 stop : function(){ this.play = false; clearInterval(this.timer); }, //游戲結束函數 death : function(){ this.stop(); this.event['death'].call(this); }, //游戲更新函數 update : function(){ var updates = this.event['updates'], i = 0, len = updates.length, now = new Date().getTime(), update, time; for(; update = updates[i], i < len; i++){ update.time ? (now - (update.timer || 0) > update.time) && (update.timer == now, update.call(this)) : update.call(this); } } } //兩個工具方法 var lynx = { extend : function(parent, child){ var fn = function(){}; fn.prototype = parent.prototype; child.prototype = new fn(); child.prototype.constructor = child; return child; }, mix : function(target, source){ var hasOwn = Object.prototype.hasOwnProperty; if( !target || !source ) return; var override = typeof arguments[arguments.length - 1] === "boolean" ? arguments[arguments.length - 1] : true, prop; for (prop in source) { if (hasOwn.call(source, prop) && (override || !(prop in target))) { target[prop] = source[prop]; } } return target; } } //snapper.js /** * 貪吃蛇游戲 * @author cat */ //繼承方法 var Snapper = lynx.extend(GameBase, function(canvas, width, height){ GameBase.apply(this); this.canvas = canvas; this.canvasWidth = canvas.width; this.canvasHeight = canvas.height; this.context = canvas.getContext("2d"); this.aspect = 1; // 1 - right, 2 - left, 3 - up, 4 - down; this.bread = {}; this.snapperContext = new Array(); this.FPS = 1000 / 10; this.init(); }); //增加函數 lynx.mix(Snapper.prototype, { init:function(){ var _this = this; _this.snapperContext = _this.getSnapperContext(); _this.bread = _this.getBread(); _this.bind('death', function(){ alert('你輸了'); }).bind('update', _this.updateSnapperContext).bind('update', _this.updateBread); _this.draw(); }, restart : function(){ this.unbind('update',this.updateSnapperContext).unbind('update',this.updateBread).unbind('death'); this.init(); this.start(); }, step : function(){ var _this = this; _this.update(); if(!_this.checkDeath()){ _this.death(); return _this; } _this.draw(); }, draw : function(){ var _this = this; _this.context.clearRect(0,0,this.canvasWidth,this.canvasHeight); for(var i = 0,cnt = _this.snapperContext.length; i < cnt; i++){ var tmpSnapper = _this.snapperContext[i]; _this.context.fillStyle = "#000000"; _this.context.fillRect(tmpSnapper[0],tmpSnapper[1],9,9); } _this.context.fillStyle = this.bread.color; _this.context.fillRect(this.bread.x,this.bread.y,9,9); }, getBread:function(){ var _this = this; var x = 10 * Math.floor((Math.random() * (_this.canvasWidth-10) / 10)) + 1 ; var y = 10 * Math.floor((Math.random() * (_this.canvasHeight-10) / 10)) + 1; var bread, color = '#ff0000'; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ if(_this.snapperContext[i][0] == x && _this.snapperContext[i][1] == y){ _this.bread = {}; bread = _this.getBread(); return bread; } } bread = {"x":x,"y":y,"color":color}; return bread; }, updateBread : function(){ var _this = this; if(!_this.bread){ _this.bread = _this.getBread(); } }, getSnapperContext:function(){ return [ [61,31], [51,31], [41,31], [31,31], [21,31], [11,31], [1,31] ]; }, updateSnapperContext:function(){ var _this = this; Snappers = _this.snapperContext; switch(_this.aspect){ case 1: Snappers.unshift([Snappers[0][0] + 10,Snappers[0][1]]); Snappers.pop(); break; case 2: Snappers.unshift([Snappers[0][0] - 10,Snappers[0][1]]); Snappers.pop(); break; case 3: Snappers.unshift([Snappers[0][0],Snappers[0][1] - 10]); Snappers.pop(); break; case 4: Snappers.unshift([Snappers[0][0],Snappers[0][1] + 10]); Snappers.pop(); break; } _this.eatFood(Snappers); //是否碰到食物 }, eatFood:function(Snappers){ var _this = this; if(Snappers[0][0] == _this.bread.x && Snappers[0][1] == _this.bread.y){ Snappers.push(Snappers[Snappers.length - 1]); _this.bread = null; } }, checkDeath:function(){ var _this = this; return _this.snapperContext[0][0] > 0 && _this.snapperContext[0][0] < _this.canvasWidth && _this.snapperContext[0][1] > 0 && _this.snapperContext[0][1] < _this.canvasHeight && _this.collide(); }, collide:function(){ var _this = this; for(var i = 1,cnt = _this.snapperContext.length; i < cnt; i++){ var tmpSnapper = _this.snapperContext[i]; if(_this.snapperContext[0][0] == tmpSnapper[0] && _this.snapperContext[0][1] == tmpSnapper[1]){ return false; } } return true; } }); //index.html <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html,charset=utf-8" /> <title>貪吃蛇</title> <script type="text/javascript" src="scripts/gamebase.js"></script> <script type="text/javascript" src="scripts/snapper.js"></script> <script type="text/javascript"> window.onload = function(){ var canvas = document.getElementById("canvas"); canvas.width = 300; canvas.height = 300; canvas.style.border = 'solid 1px #cccccc'; var snapper = new Snapper(canvas); if(window.addEventListener){ window.addEventListener("keydown",function(e){ var e = e || event || windwo.event; if(e.keyCode == 37){ if(snapper.aspect != 1){ snapper.aspect = 2; } }else if(e.keyCode == 38){ if(snapper.aspect != 4){ snapper.aspect = 3; } }else if(e.keyCode == 39){ if(snapper.aspect != 2){ snapper.aspect = 1; } }else if(e.keyCode == 40){ if(snapper.aspect != 3){ snapper.aspect = 4; } } },false); document.getElementById('start').addEventListener('click', function(e){ snapper.start(); }, false); document.getElementById('stop').addEventListener('click', function(e){ snapper.stop(); }, false); document.getElementById('restart').addEventListener('click', function(e){ snapper.restart(); }, false); }else if(window.attachEvent){ window.attachEvent("onkeydown",function(e){ var e = e || event || windwo.event; if(e.keyCode == 37){ if(snapper.aspect != 1){ snapper.aspect = 2; } }else if(e.keyCode == 38){ if(snapper.aspect != 4){ snapper.aspect = 3; } }else if(e.keyCode == 39){ if(snapper.aspect != 2){ snapper.aspect = 1; } }else if(e.keyCode == 40){ if(snapper.aspect != 3){ snapper.aspect = 4; } } }); document.getElementById('start').attachEvent('onclick', function(e){ snapper.start(); }); document.getElementById('stop').attachEvent('onclick', function(e){ snapper.stop(); }); document.getElementById('restart').attachEvent('onclick', function(e){ snapper.restart(); }); } } </script> </head> <body> <canvas id="canvas"> </canvas> <input type="button" id="start" value="開始" /> <input type="button" id="stop" value="暫停" /> <input type="button" id="restart" value="重新開始" /> </body> </html>
由於我一直不知道要怎么樣在博客園里放一點開就是一個運行的頁面的代碼。所以沒辦法把演示頁面放在這里。如果有那位大俠知道的話,麻煩指點一下。
【結束】
雖然整篇文章沒有對所有代碼的函數都分析到,但是整個流程以及關鍵點都已經說的比較清楚了。如果看完還有什么疑問的話,歡迎提出。 最后,留一個問題給大家,就是怎么樣來判斷用戶贏了。也就是蛇布滿了整個畫布。(ps: 題目很簡單哦,實現的辦法也很多。)