JavaScript貪食蛇游戲制作詳解


之前閑時開發過一個簡單的網頁版貪食蛇游戲程序,現在把程序的實現思路寫下來,供有興趣同學參考閱讀。

代碼的實現比較簡單,整個程序由三個類,一組常量和一些游戲邏輯以外的初始化和控制代碼組成,總共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

首先,可以把游戲的邏輯想象成一個不斷變換的數據結構,把游戲的界面想象成由一組像素格子組成的長方形,界面渲染程序定時讀取游戲數據結構,將數據結構中不同的值表示成不同的顏色並畫在游戲界面上。

因此,常量TRANSVERSEVERTICAL分別代表游戲數據結構的最大邊界,也就是游戲界面橫向和縱向的像素點個數。

常量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 檢查游戲是否結束,分別檢測游戲的第一個節點是否落在 TRANSVERSEVERTICAL常量定義的范圍之外(撞牆)和是否落在蛇身節點的位置之上(咬到自己)。

getLastNode 獲得蛇身的最后一個結果

通過SnakeNodeSnake這兩個類,便抽象出了貪食蛇的結構和特性,但是現在這條蛇只是一個邏輯結構,是不會動的, 更不能玩。接下來我們便讓這條蛇游動起來, 還可以控制它的方向, 讓它去覓食並越長越長越游越快。

//貪食蛇游戲
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');
    }

執行的操作分別是

  1. 實例化蛇的第一個節點,事實上剛開始也只有一個節點,位置設置在界面的中間。

  2. 隨機生成一個方向並設置

  3. 實例化Snake類,以head(第一個節點)作為構造函數參數

  4. 引用canvas,獲取canvascontext對象

至此,游戲已經初始化完成,然而,此刻的游戲是靜止的,我們還需要調用start方法讓游戲開始

    this.start = function() {
        status = GAME_START;
        moveTimer = window.setTimeout(move , computeMoveInterval());
        foodTimer = window.setInterval(createFood, 5000);
        bindEvent();
    }

此方法執行的操作分別是

  1. 將游戲的狀態設置成 GAME_START常量的值(表示游戲開始)

  2. 讓蛇身持續移動

  3. 每5秒生成一個食物

  4. 綁定交互事件,也就是我們用鍵盤的方向鍵上下左右控制蛇游動的方向的事件

先看被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();
    }
  1. 方法里面還有一次setTimeout調用,起的到作用和setInterval相同

  2. 設置蛇游動的方向

  3. 調用蛇的move方法移動

  4. 獲得蛇頭的位置,檢查它是否與物品的位置重疊,假如重疊那么表示蛇吃到了食物,因為會調用蛇的addNode方法為蛇增加一個結點,並且觸發onEatOne事件用來通知外部的事件監聽,再將初吃掉的食物從食物列表中拿掉

  5. 判斷游戲是否結束,假如沒結束那么就執行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方法可用的數據結構還需要調用兩個方法,分別是getMaparrayToMap

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函數的邏輯如下

  1. 建一個二維數組,元素個數等於TRANSVERSE * VERTICAL

  2. 獲取蛇身所占的位置列表,轉換成二維數組

  3. 獲得食物所占的位置列表,轉換成二維數組

  4. 通過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文件

游戲是我多年前寫的,代碼略顯青澀,函數和變量的命名也是詞不達意,但大致意思能表達清楚,大家就將就着看吧。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM