之前閑時開發過一個簡單的網頁版貪食蛇游戲程序,現在把程序的實現思路寫下來,供有興趣同學參考閱讀。
代碼的實現比較簡單,整個程序由三個類,一組常量和一些游戲邏輯以外的初始化和控制代碼組成,總共400多行JavaScript。
游戲中的三個類分別是「組成蛇身體的節點」「蛇」「貪食蛇游戲」的抽象,常量用來表示游戲中的各種狀態。
先從常量講起
var TRANSVERSE = 30; var VERTICAL = 40; var LEFT = 1; var RIGHT = 2; var TOP = 3; var BOTTOM = 4; var GAME_START = 1; var GAME_STOP = 2; var GAME_OVER = 3
首先,可以把游戲的邏輯想象成一個不斷變換的數據結構,把游戲的界面想象成由一組像素格子組成的長方形,界面渲染程序定時讀取游戲數據結構,將數據結構中不同的值表示成不同的顏色並畫在游戲界面上。
因此,常量TRANSVERSE和VERTICAL分別代表游戲數據結構的最大邊界,也就是游戲界面橫向和縱向的像素點個數。
常量LEFT、RIGHT、TOP、BOTTOM分別代表貪食蛇上下左右的走向
常量GAME_START、GAME_STOP、GAME_OVER代表游戲的三個狀態,游戲進行中、游戲暫停中、游戲結束
游戲中的三個類是游戲的邏輯實現,相對復雜
貪食蛇蛇身由一系列相互引用的節點組成,是一個鏈表結構,如下圖
每一個節點是SnakeNode類的一個實例
//組成蛇的節點,一個鏈表結構 var SnakeNode = function(point) { var prevDirection, currDirection, next, pos = point; //獲得下一個 this.getNext = function() { return next; } //設置下一個 this.setNext = function(el) { next = el; } //設置方向 this.setDirection = function(value) { currDirection = value; } //獲得方向 this.getDirection = function() { return currDirection; } //計算結點下一個位置 this.computePosition = function() { pos = SnakeNode.getNextPoint( pos, currDirection ); if( next ) { next.computePosition(); } if( prevDirection != currDirection ) { prevDirection = currDirection; if( next ){ next.setDirection(currDirection); } } } //獲得位置 this.getPosition = function(){ return pos; } } //通過方向計算相對與當前位置的下一個位置 SnakeNode.getNextPoint = function (point, direction) { var newPoint = {}; switch(direction) { case LEFT: newPoint.x = point.x - 1; newPoint.y = point.y ; break; case RIGHT: newPoint.x = point.x + 1; newPoint.y = point.y; break; case TOP: newPoint.x = point.x; newPoint.y = point.y - 1; break; case BOTTOM: newPoint.x = point.x; newPoint.y = point.y + 1; break; } return newPoint; }
蛇身節點有四個屬性
prevDirection 上一次移動時的蛇身走向
currDirection 當前蛇身走向
next 節點的下一個節點
pos 節點的位置
六個方法
getNext 獲得節點的下一個節點
setNext 設置節點的下一個節點
setDirection 設置節點的方向
getDirection 獲得節點的方向
computePosition 計算節點移動后的目標位置
getPosition 獲得節點的位置
SnakeNode.getNextPoint 這個方法是一個靜態方法, 不屬於節點實例, 它的功能是根據方向計算出某一個坐標的下一個坐標, 比如說10和10是某個節點當前的坐標, 那么它向左移動一個單位后坐標就是9和10;向右移動一個單位后坐標就是11和10,同理向上和向下坐標分別是10,9和10,11。
computePosition需要特點說明一下,它在計算出自身移動后的目標位置以后,還會調用它引用的下一個節點的 computePosition方法,然后下一個節點再次執行相同的操作,一直到蛇身的最后一個節點為止,這就是鏈表的特性。同時如果方向發了變化,這個方法還會把當前節點的方向同步給它引用的下一個節點,就是靠這一點, 蛇身每一個節點的走向才能一致。
通過這一系列屬性和方法就能表示出蛇身的節點特性了。
類Snake是整條蛇的抽象表示,代碼如下
//蛇 var Snake = function( head ) { var snake = head; var isGameover = false; var self = this; //為蛇增加一個節點 this.addNode = function() { var lastNode = getLastNode(); var point = lastNode.getPosition(); var reverse; switch(lastNode.getDirection()) { case LEFT: reverse = RIGHT; break; case RIGHT: reverse = LEFT; break; case TOP: reverse = BOTTOM; break; case BOTTOM: reverse = TOP; break; } var newPoint = SnakeNode.getNextPoint(point, reverse); var node = new SnakeNode(newPoint); node.setDirection(lastNode.getDirection()); lastNode.setNext(node); } //獲所所有蛇節點的位置 this.getAllNodePos = function() { var posList = new Array; var node = snake; do{ posList.push(node.getPosition()); node = node.getNext(); }while(node); return posList; } //獲得蛇長度 this.getLength = function() { var count = 0; var node = snake; while(node) { count ++; node = node.getNext(); } return count; } //游戲是否結束 this.isGameover = function() { return isGameover; } //移動 this.move = function() { if (!isGameover) { snake.computePosition(); } checkGameover(); } //根據方向導航 this.setDirection = function (direction) { if( !isGameover ) snake.setDirection(direction); } //獲得蛇頭位置 this.getHeadPos = function() { return snake.getPosition(); } //獲得蛇頭方向 this.getHeadDirection = function() { return snake.getDirection(); } var checkGameover = function() { var l = snake.getPosition(); var cl = self.getAllNodePos(); if(l.x < 0 || l.x >= TRANSVERSE || l.y < 0 || l.y >= VERTICAL ) { isGameover = true; return; } for(var i = 0 ; i < cl.length ; i ++) { if(l != cl[i] && cl[i].x == l.x && cl[i].y == l.y) { isGameover = true; return; } } } var getLastNode = function() { var node = snake.getNext(); while( node ){ var nextNode = node.getNext(); if(!nextNode) return node; node = nextNode; } return snake; } }
這個類有三個屬性
snake是蛇的腦袋節點,因為是一個鏈表,所以通過蛇的腦袋就可以訪問到蛇的尾巴,因此,蛇的腦袋就可以表示一條蛇了。
isGameover游戲是否結束
self是實例自身的引用,跟游戲邏輯的表示沒有任何關系。
八個公有方法
addNode 給蛇身增加一個結點,當蛇吃到食物時會調用這個方法,這個方法會把新的節點追加到最后一個節點(蛇尾)的后面。其中局部變量reverse是用來計算新節點的位置用的,假如當前節點的方向是向右的,那么下一個節點肯定在當前節點的左邊,以此類推, reverse變量就是當前節點相反方向的值,細節請結合代碼理解。
getAllNodePos 獲得蛇身所有節點的位置。
getLength 獲得蛇身長度(蛇身節點個數)
isGameover 游戲是否結束
move 移動蛇身,調用一次整個蛇身便移動一下,這里的移動僅僅是數據結構變化,具體效果需要將數據結構結果渲染至頁面。
setDirection 設置蛇的游動方向
getHeadPos 獲得蛇身的第一個節點(蛇頭)的位置
getHeadDirection 獲得蛇(蛇頭)游動的方向
二個私有方法
checkGameover 檢查游戲是否結束,分別檢測游戲的第一個節點是否落在 TRANSVERSE和VERTICAL常量定義的范圍之外(撞牆)和是否落在蛇身節點的位置之上(咬到自己)。
getLastNode 獲得蛇身的最后一個結果
通過SnakeNode和Snake這兩個類,便抽象出了貪食蛇的結構和特性,但是現在這條蛇只是一個邏輯結構,是不會動的, 更不能玩。接下來我們便讓這條蛇游動起來, 還可以控制它的方向, 讓它去覓食並越長越長越游越快。
//貪食蛇游戲 var SnakeGame = function() { var snake ; var moveTimer, randomTimer; var currDirection; var foods = []; var status = GAME_STOP; var context; var self = this; this.onEatOne = function(){}; var getRandom = function(notin) { var avaiable = []; for(var y = 0 ; y < VERTICAL ; y ++) { for(var x = 0 ; x < TRANSVERSE; x++ ) { var j = 0; var avaiableFlag = true; while( j < notin.length ){ var el = notin[j]; if( el.x == x && el.y == y ) { notin.splice(j,1); avaiableFlag = false; break; } j++; } if(avaiableFlag) avaiable.push({ x: x , y: y }); } } var rand = Math.floor(Math.random() * avaiable.length); return avaiable[rand]; } //導航 var navigate = function(direction) { var sd = snake.getHeadDirection(); var d ; if((sd == LEFT || sd == RIGHT) && (direction == TOP || direction == BOTTOM)) d = direction; if((sd == TOP || sd == BOTTOM) && (direction == LEFT || direction == RIGHT)) d = direction; if(d) currDirection = d; } var move = function() { moveTimer = window.setTimeout( move, computeMoveInterval() ); if(currDirection) snake.setDirection( currDirection ); snake.move(); var lc = snake.getHeadPos(); for(var i = 0 ; i < foods.length ; i ++) { if(lc.x == foods[i].x && lc.y == foods[i].y) { snake.addNode(); self.onEatOne(); foods.splice( i, 1 ); break; } } if(snake.isGameover()){ gameover(); return; } draw(); } var createFood = function() { var notin = snake.getAllNodePos().concat(foods); var rand = getRandom(notin); foods.push(rand); } var arrayToMap = function(array) { var map = {}; for(var i = 0 , point ; point = array[i++];) map[[point.x , point.y]] = null; return map; } //獲得當前游戲數據結構 var getMap = function() { var board = new Array; for (var y = 0 ; y < VERTICAL; y++) { for (var x = 0 ; x < TRANSVERSE ; x++) { board.push({ x: x, y: y }); } } var cl = snake.getAllNodePos(); var food = arrayToMap(foods); cl = arrayToMap(cl); board = arrayToMap(board); for(var key in cl) board[key] = 'snake'; for(var key in food) board[key] = 'food'; return board; } //獲得分數 this.getScore = function() { return snake.getLength() - 1; } //獲得級別 this.getLevel = function() { var score = self.getScore(); var level = 0; if(score <= 5) level = 1; else if(score <= 12) level = 2; else if(score <= 22) level = 3; else if(score <= 35) level = 4; else if(score <= 50) level = 5; else if(score <= 75) level = 6; else if(score <= 90) level = 7; else if(score <= 100) level = 8; else level = 9; return level; } var computeMoveInterval = function() { var speed = { '1':200, '2':160, '3':120, '4':100, '5':80, '6':60, '7':40, '8':20, '9':10 } var level = self.getLevel(); return speed[level]; } var gameover = function () { status = GAME_OVER; window.clearTimeout(moveTimer); window.clearInterval(foodTimer); unBindEvent(); alert('游戲結束'); } //獲得游戲狀態 this.gameState = function () { return status; } //游戲開始 this.start = function() { status = GAME_START; moveTimer = window.setTimeout(move , computeMoveInterval()); foodTimer = window.setInterval(createFood, 5000); bindEvent(); } //暫停游戲 this.stop = function() { status = GAME_STOP; window.clearTimeout(moveTimer); window.clearInterval(foodTimer); unBindEvent(); } this.initialize = function( canvasId ) { var head = new SnakeNode({ x: Math.ceil(TRANSVERSE / 2), y: Math.ceil(VERTICAL / 2) }); head.setDirection([LEFT, RIGHT , TOP , BOTTOM][Math.floor(Math.random() * 4)]) snake = new Snake(head); var canvas = document.getElementById(canvasId); context = canvas.getContext('2d'); } //畫界面 var draw = function () { context.fillStyle = '#fff'; context.fillRect(0, 0, 300, 400); var map = getMap(); for (var key in map) { var pointType = map[key]; var x = key.split(',')[0]; var y = key.split(',')[1]; if (pointType == 'snake') { context.fillStyle = '#000'; } else if (pointType == 'food') { context.fillStyle = '#f00'; } else { continue; } context.fillRect( x * 10, y * 10, 10, 10 ); } } //綁定事件 var bindEvent = function () { document.body.onkeydown = function (e) { e = e || window.event; var keyCode = e.keyCode; switch (keyCode) { case 37: navigate(LEFT); break; case 38: navigate(TOP); break; case 39: navigate(RIGHT); break; case 40: navigate(BOTTOM); break; } } } //取消綁定 var unBindEvent = function () { document.body.onkeydown = null; } }
SnakeGame類算不上某一種結構抽象, 它僅僅是一組功能的封裝, 其中包括人機交互事件、將數據結構轉換成界面和一系列組成游戲的功能。此類比較復雜,就不以講解之前兩個類的方法講解了。我們從類的實例化為入口開始講解,然后再逐步擴展至類中的其它方法和屬性。
var game = new SnakeGame();
實例化對象,調用構造函數后,類的幾個屬性被聲明或初始化。
var snake ; var moveTimer, randomTimer; var currDirection; var foods = []; var status = GAME_STOP; var context; var self = this; this.onEatOne = function(){};
snake 也就是Snake類的實例
moveTimer 使蛇身運動的setTimeout函數的返回值, clearTimeout此值后,表示游戲暫停
randomTimer 隨機產生食物的setInterval函數的返回值,clearInterval后停止生成食物,表示游戲暫停
foods 食物,因為會有多個食物產生,因為初始化為數組來存放食物
status 游戲狀態,初始化狀態為暫停中
context 游戲界面的canvas對象
self 沒有表示實例自身, 跟游戲不相關
onEatOne 並不是屬性, 而是游戲的一個事件, 當蛇吃到食物時, 此函數(事件)會被調用以用來通知監聽者
game.initialize("snake");
初始化游戲,initialize方法的參數是游戲界面的canvas的元素ID,這個方法的細節如下
this.initialize = function( canvasId ) { var head = new SnakeNode({ x: Math.ceil(TRANSVERSE / 2), y: Math.ceil(VERTICAL / 2) }); head.setDirection([LEFT, RIGHT , TOP , BOTTOM][Math.floor(Math.random() * 4)]) snake = new Snake(head); var canvas = document.getElementById(canvasId); context = canvas.getContext('2d'); }
執行的操作分別是
-
實例化蛇的第一個節點,事實上剛開始也只有一個節點,位置設置在界面的中間。
-
隨機生成一個方向並設置
-
實例化Snake類,以head(第一個節點)作為構造函數參數
-
引用canvas,獲取canvas的context對象
至此,游戲已經初始化完成,然而,此刻的游戲是靜止的,我們還需要調用start方法讓游戲開始
this.start = function() { status = GAME_START; moveTimer = window.setTimeout(move , computeMoveInterval()); foodTimer = window.setInterval(createFood, 5000); bindEvent(); }
此方法執行的操作分別是
-
將游戲的狀態設置成 GAME_START常量的值(表示游戲開始)
-
讓蛇身持續移動
-
每5秒生成一個食物
-
綁定交互事件,也就是我們用鍵盤的方向鍵上下左右控制蛇游動的方向的事件
先看被setTimeout調用的move方法
var move = function() { moveTimer = window.setTimeout( move, computeMoveInterval() ); if(currDirection) snake.setDirection( currDirection ); snake.move(); var lc = snake.getHeadPos(); for(var i = 0 ; i < foods.length ; i ++) { if(lc.x == foods[i].x && lc.y == foods[i].y) { snake.addNode(); self.onEatOne(); foods.splice( i, 1 ); break; } } if(snake.isGameover()){ gameover(); return; } draw(); }
-
方法里面還有一次setTimeout調用,起的到作用和setInterval相同
-
設置蛇游動的方向
-
調用蛇的move方法移動
-
獲得蛇頭的位置,檢查它是否與物品的位置重疊,假如重疊那么表示蛇吃到了食物,因為會調用蛇的addNode方法為蛇增加一個結點,並且觸發onEatOne事件用來通知外部的事件監聽,再將初吃掉的食物從食物列表中拿掉
-
判斷游戲是否結束,假如沒結束那么就執行draw方法將數據結果渲染至游戲界面
再來看 computeMoveInterval 方法,這個方法是setTimeout的第二個參數,在這里表達的意思就是定時執行move方法的時間間隔。
var computeMoveInterval = function() { var speed = { '1':200, '2':160, '3':120, '4':100, '5':80, '6':60, '7':40, '8':20, '9':10 } var level = self.getLevel(); return speed[level]; }
隨着游戲的進行,游戲的級別會增加,隨着級別增加, 這個值越小, 也就是說move方法被執行的頻率就越高,因此蛇游動的速度會越快, 游戲難度也就越大。
createFood每5秒被調用一次生成一個食物
var createFood = function() { var notin = snake.getAllNodePos().concat(foods); var rand = getRandom(notin); foods.push(rand); }
蛇身體所占的位置和已有食物的位置被排除掉,顯然食物不能生成在已被占用的位置上。
最后,我們來講一下draw方法,它的作用是將游戲的數據結構轉換為可視化界面
var draw = function () { context.fillStyle = '#fff'; context.fillRect(0, 0, 300, 400); var map = getMap(); for (var key in map) { var pointType = map[key]; var x = key.split(',')[0]; var y = key.split(',')[1]; if (pointType == 'snake') { context.fillStyle = '#000'; } else if (pointType == 'food') { context.fillStyle = '#f00'; } else { continue; } context.fillRect( x * 10, y * 10, 10, 10 ); } }
將游戲結構轉換成draw方法可用的數據結構還需要調用兩個方法,分別是getMap和arrayToMap
var arrayToMap = function(array) { var map = {}; for(var i = 0 , point ; point = array[i++];) map[[point.x , point.y]] = null; return map; } var getMap = function() { var board = new Array; for (var y = 0 ; y < VERTICAL; y++) { for (var x = 0 ; x < TRANSVERSE ; x++) { board.push({ x: x, y: y }); } } var cl = snake.getAllNodePos(); var food = arrayToMap(foods); cl = arrayToMap(cl); board = arrayToMap(board); for(var key in cl) board[key] = 'snake'; for(var key in food) board[key] = 'food'; return board; }
arrayToMap的作用其實是將一個一維數組轉換為二維數組(並不是真正的二維數組,但是為了方便表達就借用二維數組這種結構),只是JavaScript的二維數組表示的有點奇葩,是一個map,所以這個函數的名稱就被命名為arrayToMap
getMap函數的邏輯如下
-
建一個二維數組,元素個數等於TRANSVERSE * VERTICAL
-
獲取蛇身所占的位置列表,轉換成二維數組
-
獲得食物所占的位置列表,轉換成二維數組
-
通過null、snake、food三種值區分空、蛇身節點、食物
最終的數組結構從可視的角度來表示大概是這個樣子
[null,null,null,null,null,
null,null,null,food,null,
null,null,null,null,null,
null,null,food,null,null,
null,null,snake,snake,null,
null,null,snake,null,null]
這個結構會隨着move方法的調用而不斷變化, draw方法就不斷的將數據結構渲染至canvas上,整條蛇因此也就動了起來。
最后我們來看bindEvent方法
var bindEvent = function () { document.body.onkeydown = function (e) { e = e || window.event; var keyCode = e.keyCode; switch (keyCode) { case 37: navigate(LEFT); break; case 38: navigate(TOP); break; case 39: navigate(RIGHT); break; case 40: navigate(BOTTOM); break; } } }
這個方法很簡單,就是用來監聽方向鍵的事件,然后控制蛇的方向以達到操作游戲的效果。
至此,整個游戲的邏輯也就開發完成了。麻雀雖小,但五臟俱全,這個游戲玩法雖然很少,但確實是一個正兒八經的貪食蛇游戲。附上可運行的源代碼的鏈接地址
http://pan.baidu.com/s/1o7VIcWy
就一個html文件
游戲是我多年前寫的,代碼略顯青澀,函數和變量的命名也是詞不達意,但大致意思能表達清楚,大家就將就着看吧。