炸彈人遊戲開發系列(4):炸彈人顯示與移動

前言javascript

在上文中,我已經介紹瞭如何測試、如何重構測試,而且經過實驗掌握了地圖顯示的技術。本文會將地圖顯示的技術用到炸彈人顯示中,而且讓咱們的炸彈人動起來。css

注:爲了提高博文質量和把重點放在記錄開發和迭代的思想實踐,本文及後續博文將再也不記錄測試過程。html

本文目的

實現炸彈人的顯示和移動java

本文主要內容

回顧上文更新後的領域模型

對領域模型進行思考

ShowMap類是負責顯示地圖,包含了遊戲邏輯。而Game類職責是負責遊戲邏輯,所以ShowMap和Game在職責上是有重複的。何況顯示地圖這部分邏輯並非很複雜,能夠不須要專門的類來負責這部分邏輯,而是直接放到Game中。canvas

如今來回頭看看ShowMap類的顯示地圖實現:設計模式

drawMap: function () {
    var i = 0,
        j = 0,
        map = bomberConfig.map,
        bitmap = null,
        mapData = mapDataOperate.getMapData(),
        x = 0,
        y = 0,
        img = null;

    this._createLayer();

    for (i = 0; i < map.ROW; i++) {
        //注意!
        //y爲縱向height,x爲橫向width
        y = i * map.HEIGHT;

        for (j = 0; j < map.COL; j++) {
            x = j * map.WIDTH;
            img = this._getMapImg(i, j, mapData);
            bitmap = bitmapFactory.createBitmap({ img: img, width: map.WIDTH, height: map.HEIGHT, x: x, y: y });
            this.layer.appendChild(bitmap);
        }
    }
    this.layer.draw();
}

ShowMap將顯示地圖的具體實現委託給了Layer,本身負責操做Layer,這個職責也能夠移到Game中。且考慮到ShowMap類是用做實驗(見上文的開發策略)的,如今「顯示地圖」的功能已經實現,ShowMap沒有存在的必要了。數組

所以,我去掉ShowMap類,將其移到Game中。app

重構後的領域模型

重構後Game類代碼

(function () {
    var Game = YYC.Class({
        Init: function(){
        },
        Private: {
            _pattern: null,
            _ground: null,

            _createLayer: function () {
                this.layer = new Layer(this.createCanvas());
            },
            _getMapImg: function (i, j, mapData) {
                var img = null;

                switch (mapData[i][j]) {
                    case 1:
                        img = main.imgLoader.get("ground");
                        break;
                    case 2:
                        img = main.imgLoader.get("wall");
                        break;
                    default:
                        break
                }

                return img;
            }
        },
        Public: {
            layer: null,

            onload: function () {
                $("#progressBar").css("display", "none");
                this.drawMap();
            },
            createCanvas: function (id) {
                var canvas = document.createElement("canvas");

                canvas.width = 600;
                canvas.height = 400;
                canvas.id = id;

                document.body.appendChild(canvas);

                return canvas;
            },
            drawMap: function () {
                var i = 0,
                    j = 0,
                    map = bomberConfig.map,
                    bitmap = null,
                    mapData = mapDataOperate.getMapData(),
                    x = 0,
                    y = 0,
                    img = null;

                this._createLayer();

                for (i = 0; i < map.ROW; i++) {
                    //注意!
                    //y爲縱向height,x爲橫向width
                    y = i * map.HEIGHT;

                    for (j = 0; j < map.COL; j++) {
                        x = j * map.WIDTH;
                        img = this._getMapImg(i, j, mapData);
                        bitmap = bitmapFactory.createBitmap({img: img, width: map.WIDTH, height: map.HEIGHT, x: x, y: y});

                        this.layer.appendChild(bitmap);
                    }
                }
                this.layer.draw();
            }
        }
    });

    window.Game = Game;
}());
View Code

開發策略ide

「顯示炸彈人」沒有難度,由於在上文中我已經掌握了使用canvas顯示圖片的方法。本文的難點在於讓炸彈人移動起來。函數

我採用與上文類似的開發策略,先在Game這個遊戲邏輯類中進行實驗,實現炸彈人移動的功能,而後再進行重構。

實驗

如今Game中的onload方法已經有了其它的職責(隱藏進度條、調用showMap顯示地圖),若是在該方法裏實現「炸彈人顯示及移動」的話,該實現會受到其它職責的影響,且很差編寫測試。所以增長drawPlayer方法,在該方法中實現「炸彈人顯示及移動」。

Game中實現人物顯示

首先,要顯示炸彈人。Game中須要建立畫布並得到上下文,而後是清空畫布區域,使用drawImage來繪製圖片。

加入玩家精靈圖片

這裏炸彈人圖片使用的是一個包含炸彈人移動的全部動做的精靈圖片。所謂精靈圖片就是包含多張小圖片的一張大圖片,使用它能夠減小http請求,提高性能。

炸彈人精靈圖片以下:

相關代碼

drawPlayer: function () {
    var sx = 0, sy = 0, sw = 64, sh = 64;
    var dx = 0, dy = 0, dw = 34, dh = 34;

    var canvas = document.createElement("canvas");
canvas.width = 500; canvas.height = 500; document.body.appendChild(canvas); this.context = canvas.getContext("2d");
this.context.clearRect(0, 0, 500, 500); this.context.drawImage(main.imgLoader.get("player"), sx, sy, sw, sh, dx, dy, dw, dh); }

Game中實現人物移動

將精靈圖片的不一樣動做圖片,在畫布上同一位置交替顯示,就造成了人物原地移動的動畫。在畫布的不一樣的位置顯示動做圖片,就造成了人物在畫布上來回移動的動畫。

開發策略

首先實現炸彈人在畫布上原地移動,顯示移動動畫;而後實現炸彈人在畫布上左右移動;而後將背景地圖與炸彈人同時顯示出來。

讓人物原地移動

須要一個循環,在循環體中清除畫布,並繪製更新了座標的炸彈人。

Game

drawPlayer: function () {
    var sx = 0, sy = 0, sw = 64, sh = 64, dx = 0, dy = 0, dw = 34, dh = 34,
        canvas = document.createElement("canvas"),
        sleep = 500,
        self = this,
        loop = null;

    canvas.width = 500;
    canvas.height = 500;
    document.body.appendChild(canvas);
    this.context = canvas.getContext("2d");

    loop = window.setInterval(function () {
        self.context.clearRect(0, 0, 600, 400);
        self.context.drawImage(main.imgLoader.get("player"), sx, sy, sw, sh, dx, dy, dw, dh);
        dx += 1;
    }, sleep);
}

重構Game

明確「主循環」的概念

回想我在第2篇博文中提到的「遊戲主循環」的概念:

每個遊戲都是由得到用戶輸入,更新遊戲狀態,處理AI,播放音樂和音效,還有畫面顯示這些行爲組成。遊戲主循環就是用來處理這個行爲序列,在javascript中能夠用setInterval方法來輪詢。

在drawPlayer中用到的循環,就是屬於遊戲主循環的概念。

提出start方法

所以,我loop變量重命名爲mainLoop,並將主循環提出來,放到一個新的方法start中。而後在start的循環中調用drawPlayer。

提出建立canvas的職責

每次調用drawPlayer都會建立canvas,可是建立canvas不屬於drawPlayer的職責(drawPlayer應該只負責繪製炸彈人)。所以我將建立canvas的職責提取出來造成prepare方法,而後在start的主循環外面調用prepare方法,這樣就能夠只建立一次canvas了。

提出遊戲的幀數FPS

回想我在第2篇博文中提到的「遊戲的幀數」的概念:

每秒所運行的幀數。如遊戲主循環每33.3(1000/30)ms輪詢一次,則遊戲的幀數FPS爲30.

FPS決定遊戲畫面更新的頻率,決定主循環的快慢。

這裏主循環中的間隔時間sleep與FPS有一個換算公式:

間隔時間 = 向下取整(1000 / FPS)

又由於FPS須要常常變動(如在測試遊戲時須要變動遊戲幀數來測試遊戲性能),所以在Config類中配置FPS。

相關代碼

Game

     
onload: function () {
    $("#progressBar").css("display", "none");
    this.start();
},
prepare: function () {
    var canvas = this.createCanvas();

    this._getContext(canvas);
},
createCanvas: function () {
    var canvas = document.createElement("canvas");

    canvas.width = 600;
    canvas.height = 400;

    document.body.appendChild(canvas);

    return canvas;
},
start: function () {
    var FPS = bomberConfig.FPS,
        self = this,
        mainLoop = null;

    this.sleep = Math.floor(1000 / FPS);

    this.prepare();
    mainLoop = window.setInterval(function () {
        self.drawPlayer();
    }, this.sleep);
},

注意:

目前將start、prepare、createCanvas設爲公有成員,這樣能夠方便測試。

後面會只將Game與main類交互的函數設爲公有成員,Game其他的公有成員都設爲私有成員。這樣在修改Game的私有成員時,就不會影響到調用Game的類了。

重構Main

重構前Main相關代碼

var _getImg = function () {
    var urls = [];
    var temp = [];
    var i = 0;

    temp = [
        { id: "ground", url: "ground.png" },
        { id: "wall", url: "wall.png" }
        { id: "player", url: "player.png"}
    ];

    for (i = 0, len = 2; i < len; i++) {
        urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + "image/map/" + temp[i].url });
    }
    urls.push({ id: temp[2].id, url: bomberConfig.url_pre.SHOWMAP + "image/player/" + temp[2].url });

    return urls;
};
return {
    init: function () {
        var game = new Game();
        this.imgLoader = new YYC.Control.PreLoadImg(_getImg(), ...

重構imgLoader

在init中,imgLoader爲Main的屬性。考慮到imgLoader常常會被其餘類使用(用來得到圖片對象),而其餘類不想與Main類關聯。

所以,將imgLoader設爲全局屬性:

init: function () {
...
    window.imgLoader = ...
},

分離temp出map和player

temp包含了兩種類型的圖片路徑信息:地圖圖片路徑和玩家圖片路徑。

所以,將其分離爲map和player:

        var map = [{ id: "ground", url: getImages("ground") },
            { id: "wall", url: getImages("wall") }
        ];
        var player = [{ id: "player", url: getImages("player") }];

提出_addImg

在_getImg中提出「加入圖片」職責,造成_addImg方法:

var _getImg = function () {
    var urls = [];
    var i = 0, len = 0;

    var map = [{ id: "ground", url: "ground.png" },
        { id: "wall", url: "wall.png" }
    ];
    var player = [{ id: "player", url: "player.png" }];

    _addImg(urls, map, player);

    return urls;
};
var _addImg = function (urls, map, player) {
    var args = Array.prototype.slice.call(arguments, 1),
        i = 0,
        j = 0,
        len = 0;

    for (i = 0, len = map.length; i < len; i++) {
        urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + "image/map/" + temp[i].url });
    }
    for (i = 0, len = player.length; i < len; i++) {
        urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + "image/player/" + temp[i].url });
    }
};

提出圖片路徑數據

考慮到圖片路徑可能會常常變化,所以將其提出來造成ImgPathData,並提供數據訪問類GetPath。在實現中將ImgPathData、GetPath寫在同一個文件中。

刪除Config的url_pre

將路徑前綴url_pre直接放到GetPath中,刪除Config的url_pre,對應修改Main。

領域模型

相關代碼

GetPath和ImgPathData

(function () {
    var getPath = (function () {
        var urlPre = "../Content/Image/";

        var imgPathData = {
            ground: "Map/ground.png",
            wall: "Map/wall.png",
            player: "Player/player.png"
        };

    return function (id) {
        return urlPre + imgPathData[id];
    };
}());

window.getPath = getPath;
}());

Main

var _getImg = function () {
    var urls = [];
    var i = 0, len = 0;
    var map = [
        { id: "ground", url: getPath("ground") },
        { id: "wall", url: getPath("wall") }
    ];
    var player = [
        { id: "player", url: getPath("player") }
    ];

    _addImg(urls, map, player);
    return urls;
};
var _addImg = function (urls, imgs) {
    var args = Array.prototype.slice.call(arguments, 1), i = 0, j = 0, len1 = 0, len2 = 0;
    for (i = 0, len1 = args.length; i < len1; i++) {
        for (j = 0, len2 = args[i].length; j < len2; j++) {
            urls.push({ id: args[i][j].id, url: args[i][j].url });
        }
    }
}; 

實現動畫

提出疑問 

從第2篇博文的幀動畫概念中,咱們知道動畫是經過繪製一組幀圖片來實現的。具體實現時有幾個須要考慮的問題:

  • 一組幀應該以怎樣的順序來繪製?
  • 如何控制每一幀繪製的時間?
  • 在畫布的什麼位置繪製幀?
  • 如何控制繪製的幀的內容、圖片大小?

提出幀動畫控制和幀數據的概念

結合以上的問題和本文參考資料,我引入幀動畫控制類Animation和幀數據類FrameData的概念。

FrameData負責保存每一幀的數據,包括幀的圖片對象、在精靈圖片中的位置等。

Animation負責讀取、配置、更新幀數據,控制幀數據的播放。

實現Animation、FrameData

在實現Animation類時,有一個問題須要思考清楚:

Animation是否應該包含繪製幀的職責呢?

咱們從職責上來分析,Animation類的職責是負責幀播放的管理,而繪製幀是屬於表現的職責,顯然與該類的職責正交。

所以Animation不該該包含該職責。

回答疑問

如今來試着回答以前提出的疑問。

Animation來負責幀顯示的順序,以及每一幀顯示的時間。

幀的內容和圖片大小等數據保存在FrameData類中。

繪製幀的類負責決定在畫布中繪製的幀的位置,以及如何讀取Frame的數據來繪製幀。

增長GetFrames

固然能夠增長數據操做類GetFrames。實現時也將GetFrames與FrameData寫到同一個文件中。

領域模型

相關代碼

Animation

(function () {
    var Animation = YYC.Class({
        Init: function (config) {
            this._frames = YYC.Tool.array.clone(config.frames);
            //config.img爲HtmlImg對象 
            this._img = config.img;

            this._init();
        },
        Private: {
            // Animation 包含的Frame, 類型:數組
            _frames: null,
            // 包含的Frame數目
            _frameCount: -1,
            _img: null,
            _currentFrame: null,
            _currentFrameIndex: -1,
            _currentFramePlayed: -1,

            _init: function () {
                this._frameCount = this._frames.length;
                this.setCurrentFrame(0);
            }
        },
        Public: {
            setCurrentFrame: function (index) {
                this._currentFrameIndex = index;
                this._currentFrame = this._frames[index];
                this._currentFramePlayed = 0;
            },
            // 更新Animation狀態. deltaTime表示時間的變化量.
            update: function (deltaTime) {
                //判斷當前Frame是否已經播放完成, 
                if (this._currentFramePlayed >= this._currentFrame.duration) {
                    //播放下一幀

                    if (this._currentFrameIndex >= this._frameCount - 1) {
                        //當前是最後一幀,則播放第0幀
                        this._currentFrameIndex = 0;
                    } else {
                        //播放下一幀
                        this._currentFrameIndex++;
                    }
                    //設置當前幀信息
                    this.setCurrentFrame(this._currentFrameIndex);

                } else {
                    //增長當前幀的已播放時間.
                    this._currentFramePlayed += deltaTime;
                }
            },
            getCurrentFrame: function () {
                return this._currentFrame;
            },
            getImg: function () {
                return this._img;
            }
        }
    });

    window.Animation = Animation;
}());
View Code

GetFrames、FrameData

(function () {
    var getPlayerFrames = (function () {
        var width = bomberConfig.player.WIDTH,
            height = bomberConfig.player.HEIGHT,
            //一幀在精靈圖片中x方向的長度
            x = bomberConfig.player.WIDTH,
            //一幀在精靈圖片中y方向的長度
            y = bomberConfig.player.HEIGHT;

        //幀數據
        //img:圖片對象
        //x和y:幀在精靈圖片中的位置
        //width和height:在畫布中顯示的圖片大小
        //duration:幀顯示的時間
        var frames = function () {
            return {
                //向右站立
                stand_right: {
                    img: window.imgLoader.get("player"),
                    frames: [
                        { x: 0, y: 2 * y, width: width, height: height, imgWidth: imgWidth, imgHeight: imgHeight, duration: 100 }
                    ]
                },
                //向右走
                walk_right: {
                    img: window.imgLoader.get("player"),
                    frames: [
                        { x: 0, y: 2 * y, width: width, height: height, duration: 100 },
                        { x: x, y: 2 * y, width: width, height: height, duration: 100 },
                        { x: 2 * x, y: 2 * y, width: width, height: height, duration: 100 },
                        { x: 3 * x, y: 2 * y, width: width, height: height, duration: 100 }
                    ]
                },
                //向左走
                walk_left: {
                    img: window.imgLoader.get("player"),
                    frames: [
                        { x: 0, y: y, width: width, height: height, duration: 100 },
                        { x: x, y: y, width: width, height: height, duration: 100 },
                        { x: 2 * x, y: y, width: width, height: height, duration: 100 },
                        { x: 3 * x, y: y, width: width, height: height, duration: 100 }
                    ]
                }
            }
        }

        return function (animName) {
            return frames()[animName];
        };
    }());

    window.getPlayerFrames = getPlayerFrames;
}());
View Code

Game:

在start中建立animation,傳入幀數據

在drawPlayer中控制幀的顯示,顯示向下走的動畫。

            start: function () {
                var FPS = bomberConfig.FPS,
                    self = this,
                    mainLoop = null,
                    frames = window.getPlayerFrames("stand_right");

                this.animation = new Animation(frames);
                this.sleep = Math.floor(1000 / FPS);
                this.prepare();

                mainLoop = window.setInterval(function () {
                    self.drawPlayer();
                }, this.sleep);
            },
            drawPlayer: function () {
                var dx = 0, dy = 0, dw = bomberConfig.WIDTH, dh = bomberConfig.HEIGHT;
                var deltaTime = this.sleep;
                var currentFrame = null;

                this.animation.update(deltaTime);
                currentFrame = this.animation.getCurrentFrame();
                this.context.clearRect(0, 0, 600, 400);
                this.context.drawImage(this.animation.getImg(), currentFrame.x, currentFrame.y, currentFrame.width, currentFrame.height, 0, 0, dw, dh);
            }

重構

提出init

 回頭看下start方法,發現它作了兩件事:

  • 初始化
  • 主循環

所以,我把初始化的職責提出來,造成init方法,從而使start只負責遊戲主循環。

去掉onload

在onload方法中,負責隱藏進度條的職責顯然不屬於遊戲的邏輯,所以應該提出去,放到Main類中。

onload方法跟Main中的圖片預加載密切相關,應該把onload也移到Main中。

增長run方法

回顧第2篇博文中的「Action接口」概念:

Actor 是一個接口,他的做用是統一類的行爲。。。。。。因此咱們讓他們都實現Actor接口,只要調用接口定義的函數,他們就會作出各自的動做。

反思start中的遊戲主循環。循環中直接調用drawPlayer。這樣與繪製炸彈人的職責耦合過重,一旦drawPlayer發生了改變,則start也可能要相應變化。因此我提出一個抽象的actor方法run,主循環中只調用run,不用管run的實現。run方法負責每次循環的具體操做。

這裏運用了間接原則,增長了一箇中間方法run,來使得主循環與具體細節隔離開來,從而隔離變化。

重構後Game的相關代碼

            init: function () {
                var frames = window.getPlayerFrames("stand_right");

                this.prepare();
                this.animation = new Animation(frames);
            },
            start: function () {
                var FPS = bomberConfig.FPS,
                    self = this,
                    mainLoop = null;

                this.sleep = Math.floor(1000 / FPS);

                mainLoop = window.setInterval(function () {
                    self.run();
                }, this.sleep);
            },
            run: function () {
                this.drawPlayer();
            }

重構後Main的相關代碼

        init: function () {
            var self = this;

            window.imgLoader = new YYC.Control.PreLoadImg(_getImg(), function (currentLoad, imgCount) {
                $("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10));     //調用進度條插件
            }, YYC.Tool.func.bind(self, self.onload));
        },
        onload: function () {
            _hideBar();

            var game = new Game();
            game.init();
            game.start();
        }

提出精靈類

回顧第2篇博文的「精靈」概念:

遊戲中具備獨立外觀和屬性的個體。

「炸彈人」應該屬於精靈的概念,所以提出PlayerSprite類,把與炸彈人相關的屬性和方法都從Game類中移到PlayerSprite類。

精靈類的職責

那麼,具體是哪些職責應該移到PlayerSprite中呢?

  • 幀的控制
  • 炸彈人的繪製
  • 炸彈人在畫布中的座標dx和dy等

畫布的建立依然由Game負責。

根據以前的分析,幀的控制由Animation負責,所以在PlayerSprite中也把這部分職責委託給Animation。

提出精靈數據、精靈數據操做

把炸彈人精靈類的初始配置數據提出來造成SpriteData類,並增長數據操做GetSpriteData類,將數據操做與精靈數據數據一塊兒寫到同一個文件中。

提出精靈工廠

增長一個SpriteFactory,工廠類負責建立精靈實例。

重構後相關的領域模型

相關代碼

PlayerSprite

(function () {
    var PlayerSprite = YYC.Class({
        Init: function (data) {
            this.x = data.x;
            this.y = data.y;

            this.defaultAnimId = data.defaultAnimId;
            this.anims = data.anims;
        },
        Private: {
            _resetCurrentFrame: function (index) {
                this.currentAnim.setCurrentFrame(index);
            }
        },
        Public: {
            //精靈的座標
            x: 0,
            y: 0,
            anims: null,
        //當前的Animation.
            currentAnim: null,

            //設置當前Animation, 參數爲Animation的id
            setAnim: function (animId) {
                this.currentAnim = this.anims[animId];
                
                this._resetCurrentFrame(0);
            },
            // 更新精靈當前狀態.
            update: function (deltaTime) {
                if (this.currentAnim) {
                    this.currentAnim.update(deltaTime);
                }
            },
            draw: function (context) {
                if (this.currentAnim) {
                    var frame = this.currentAnim.getCurrentFrame();

                    context.clearRect(0, 0, 600, 400);
                    context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, this.x, this.y, frame.imgWidth, frame.imgHeight);
                }
            }
        }
    });

    window.PlayerSprite = PlayerSprite;
}());
View Code

Game

init: function () {this.prepare();
    this.playerSprite = spriteFactory.createPlayer();
    this.playerSprite.setAnim("stand_right");
},
drawPlayer: function () {
    this.playerSprite.update(this.sleep);
    this.playerSprite.draw(this.context);
}

GetSpriteData和SpriteData

(function () {
    var getSpriteData = (function () {
        var data = function(){
            return {
                //炸彈人精靈類
                player: {
                    x: 0,
                    y: 0,

                    anims: {
                        "stand_right": new Animation(getPlayerFrames("stand_right")),
                        "walk_right": new Animation(getPlayerFrames("walk_right")),
                        "walk_left": new Animation(getPlayerFrames("walk_left"))
                    }
                }
            }
        };

        return function (spriteName) {
            return data()[spriteName];
        };
    }());

    window.getSpriteData = getSpriteData;
}());

這裏SpriteData其實設計得有問題,由於:

一、數據類SpriteData依賴了數據操做類GetFrameData(由於SpriteData中調用getFrames方法得到幀數據)。

數據操做類應該依賴數據類,而數據類不該該依賴數據操做類。

 

二、數據類與其它類耦合。

由於數據類應該是獨立的純數據,保持簡單,只有數據信息,這樣才具備高度的可維護性、可讀性和可移植性。而此處SpriteData卻與GetFrameData、Animation強耦合。

 

考慮到目前複雜度還不高,還在可接受的範圍,所以暫時不重構設計。

 

SpriteFactory

(function () {
    var spriteFactory = {
        createPlayer: function () {
            return new PlayerSprite(getSpriteData("player"));
        }
    }

    window.spriteFactory = spriteFactory;
}());

實現左右移動

掌握了炸彈人動畫的技術後,我就開始嘗試將移動與動畫結合,實現炸彈人在畫布上左右移動的動畫。

考慮到PlayerSprite負責炸彈人的繪製,所以應該在PlayerSprite中實現炸彈人的左右移動。

PlayerSprite

Init: function (data) {
            this.x = data.x;
            this.y = data.y;

            this.speedX = data.speedX;
            this.speedY = data.speedY;

            //x/y座標的最大值和最小值, 可用來限定移動範圍.
            this.minX = data.minX;
            this.maxX = data.maxX;
            this.minY = data.minY;
            this.maxY = data.maxY;

            this.defaultAnimId = data.defaultAnimId;
            this.anims = data.anims;

            //設置當前Animation
            this.setAnim(this.defaultAnimId);
        },
        Public: {
            //精靈的座標
            x: 0,
            y: 0,

            speedX: 0,
            speedY: 0,

            //精靈的座標區間
            minX: 0,
            maxX: 9999,
            minY: 0,
            maxY: 9999,

            ...

            // 更新精靈當前狀態.
            update: function (deltaTime) {
                //每次循環,改變一下繪製的座標
                this.x = this.x + this.speedX * deltaTime;
                //限定移動範圍
                this.x = Math.max(this.minX, Math.min(this.x, this.maxX));

                if (this.currentAnim) {
                    this.currentAnim.update(deltaTime);
                }
            },
            draw: function (context) {
                if (this.currentAnim) {
                    var frame = this.currentAnim.getCurrentFrame();

                    //要加上圖片的寬度/高度
                    context.clearRect(0, 0, this.maxX + frame.imgWidth, this.maxY + frame.imgHeight);
                    context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, this.x, this.y, frame.imgWidth, frame.imgHeight);
                }

          //若是作到最右側,則折向左走,若是走到最左側,則向右走.
                //經過改變speedX的正負,來改變移動的方向.
                if (this.x >= this.maxX) {
                    this.speedX = -this.speedX;
                    this.setAnim("walk_left");
                } else if (this.x <= this.minX) {
                    this.speedX = -this.speedX;
                    this.setAnim("walk_right");
                }
            }      
}   
View Code

重構PlayerSprite

分離職責

如今draw方法既負責炸彈人繪製,又負責炸彈人移動方向的判斷,顯然違反了單一原則。所以,我將炸彈人移動方向的判斷提出來成爲一個新方法。

方法的名字

該方法應該叫什麼名字呢?

這是一個值得認真思考的問題,方法的命名應該體現它的職責。

它的職責是判斷方向與更新動畫,那它的名字彷佛就應該叫judgeDirAndSetAnim嗎?

等等!如今它有兩個職責:判斷方向、更新動畫,那麼是否是應該分紅兩個方法:judgeDir、setAnim呢?

再仔細想一想,這兩個職責又是緊密關聯的,所以不該該將其分開。

讓咱們換個角度,從更高的層面來分析。從調用PlayerSprite的Game類來看,這個職責應該屬於一個更大的職責:

處理本次循環的邏輯,更新到下一次循環的初始狀態。

所以,我將名字暫定爲handleNext,之後在PlayerSprite中屬於本循環邏輯的內容均可以放到handleNext。

可能有人會以爲handleNext名字好像也比較彆扭。不要緊,在後期的迭代中咱們能根據實際狀況和反饋再來修改,別忘了咱們有測試做爲保障!

重構後的PlayerSprite的相關代碼

draw: function (context) {
    if (this.currentAnim) {
        var frame = this.currentAnim.getCurrentFrame();

        //要加上圖片的寬度/高度
        context.clearRect(0, 0, this.maxX + frame.imgWidth, this.maxY + frame.imgHeight);
        context.drawImage(this.currentAnim.getImg(), frame.x, frame.y, frame.width, frame.height, this.x, this.y, frame.imgWidth, frame.imgHeight);
    }
},
handleNext: function () {
    //若是走到最右側,則向左走;若是走到最左側,則向右走.
    //經過改變speedX的正負,來改變移動的方向.
    if (this.x >= this.maxX) {
        this.speedX = -this.speedX;
        this.setAnim("walk_left");
    } else if (this.x <= this.minX) {
        this.speedX = -this.speedX;
        this.setAnim("walk_right");
    }


}

繪製地圖和炸彈人

 如今,須要同時在頁面上繪製地圖和炸彈人,有如下兩種方案能夠考慮:

  • 同一個畫布中繪製地圖和炸彈人
  • 使用兩個畫布,位於頁面上同一區域,分別顯示地圖和炸彈人。繪製地圖的畫布位於繪製炸彈人畫布的下面。

對於第一種方案,由於炸彈人和地圖在同一個畫布中,所以繪製炸彈人時勢必會影響到繪製地圖。

對於第二種方案,繪製地圖和繪製炸彈人是分開的,互不影響。這樣就能夠在遊戲初始化時繪製一次地圖,遊戲主循環中只繪製炸彈人,不繪製地圖。只有在地圖發生改變時才須要繪製地圖。這樣能夠提升遊戲性能。

所以,採用第二種方案,在頁面上定義地圖畫布和玩家畫布,地圖畫布繪製地圖,玩家畫布繪製炸彈人。經過設置畫布Canvas的z-index,使繪製地圖的畫布位於繪製玩家畫布的下面。

重構

增長PlayerLayer

根據第2篇博文中分層渲染的概念以及第3篇博文中提出Layer的經驗,我認爲如今是時候提出PlayerLayer類了。

PlayerLayer負責統一管理它的集合內元素PlayerSprite。

PlayerLayer有draw和clear方法,負責繪製炸彈人和清除畫布。

PlayerLayer與玩家畫布對應。

重構PlayerLayer

    增長render方法

結合第2篇博文的actor接口和Game類中重構出run方法的經驗,PlayerLayer應該增長一個render方法,它負責遊戲主循環中PlayerLayer層的邏輯。這樣在Game的主循環中,就只須要知道render方法就好了,而不用操心在循環中PlayerLayer層有哪些邏輯操做。

    Layer中建立canvas

再來看看「在Game中建立canvas,而後把canvas注入到Layer中」的行爲。

我注意到canvas與層密切相關,因此應該由層來負責canvas的建立。

    Collection.js採用迭代器模式

因爲PlayerLayer層中的draw方法須要調用層內每一個元素的draw方法,這就讓我想到了迭代器模式。所以,使用迭代器模式對Collection類重構。

Collection重構後:

(function () {
    //*使用迭代器模式

    var IIterator = YYC.Interface("hasNext", "next", "resetCursor");


    var Collection = YYC.AClass({Interface: IIterator}, {
        Private: {
            //當前遊標
            _cursor: 0,
            //容器
            _childs: []
        },
        Public: {
            getChilds: function () {
                return YYC.Tool.array.clone(this._childs);
            },
            appendChild: function (child) {
                this._childs.push(child);

                return this;
            },
            hasNext: function () {
                if (this._cursor === this._childs.length) {
                    return false;
                }
                else {
                    return true;
                }
            },
            next: function () {
                var result = null;

                if (this.hasNext()) {
                    result = this._childs[this._cursor];
                    this._cursor += 1;
                }
                else {
                    result = null;
                }

                return result;
            },
            resetCursor: function () {
                this._cursor = 0;
            }
        },
        Abstract: {
        }
    });

    window.Collection = Collection;
}());

PlayeLayer中使用迭代器調用每一個元素的draw方法:

            draw: function (context) {
                var nextElement = null;

                while (this.hasNext()) {
                    nextElement = this.next();
                    nextElement.draw.apply(nextElement, [context]);  //要指向nextElement
                }

                this.resetCursor();
            },

有必要用迭代器模式嗎?

   設計過分?

有同窗可能要問:這裏PlayerLayer的元素明明就只有一個(即炸彈人精靈類PlayerSprite),爲何要遍歷集合呢?直接把PlayerSprite做爲PlayerLayer的一個屬性,使PlayerLayer保持對PlayerSprite的引用,不是也能更簡單地使PlayerLayer操做PlayerSprite了嗎?

確實,目前來看是不必遍歷集合的。並且根據敏捷思想,只要實現現有需求就行了,保持簡單。可是,開發炸彈人遊戲並非爲了商用,而是爲了學習知識。

我對迭代器模式不是很熟悉,而且考慮到之後在建立EnemyLayer時,會包括多個敵人精靈,那時也會須要遍歷集合。

所以,此處我用了迭代器模式,在PlayerLayer中遍歷集合。

迭代器模式請詳見Javascript設計模式之我見:迭代器模式

將原Layer重命名爲MapLayer

再來看看以前第3篇博文中建立的Layer類。這個類負責地圖圖片的渲染,應該將其重命名爲MapLayer地圖層。

提出父類Layer

如今有了PlayerLayer和MapLayer類後,須要將其通用操做提出來造成父類Layer類,而後由Layer類來繼承Collection類。這樣PlayerLayer和MapLayer類也就具備集合類的功能了。

重構Layer

  增長change 狀態

 在上面的實現中,在遊戲主循環中每次循環都會繪製一遍地圖和炸彈人。考慮到地圖是沒有變化的,不必重複的繪製相同的地圖;並且若是炸彈人在畫布上站到不動時,也是沒有必要重複繪製炸彈人。

因此爲了提高畫布的性能,當只有畫布內容發生變化時(如改變地圖、炸彈人移動),才繪製畫布。

所以,在Layer中增長state屬性,該屬性有兩個枚舉值:change、normal,用來標記畫布改變和沒有改變的狀態。

在繪製畫布時先判斷Layer的state狀態,若是爲change,則繪製;不然則不繪製。

  在哪裏判斷?

應該在繪製畫布的地方判斷狀態。那麼應該是在Game的遊戲主循環中判斷,仍是在Layer的render中判斷呢?

仍是從職責上分析。

Layer的職責:負責層內元素的統一管理。

Game的職責:負責遊戲邏輯。

顯然判斷狀態的職責應該屬於Layer的職責,且與Layer的render方法最相關。因此應該在Layer的render中判斷。

  何時改變state狀態爲change,何時爲normal?

應該在畫布內容發生改變時,畫布須要重繪的時候改變state爲change,而後在重繪完後,再回復狀態爲normal。

領域模型

相關代碼

Layer

(function () {
    var Layer = YYC.AClass(Collection, {
        Init: function () {
        },
        Private: {
            __state: bomberConfig.layer.state.NORMAL,

            __getContext: function () {
                this.P__context = this.P__canvas.getContext("2d");
            }
        },
        Protected: {
            //*子類使用的變量(可讀、寫)
            
            P__canvas: null,
            P__context: null,

            P__isChange: function(){
                return this.__state === bomberConfig.layer.state.CHANGE;
            },
            P__isNormal: function () {
                return this.__state === bomberConfig.layer.state.NORMAL;
            },
            P__setStateNormal: function () {
                this.__state = bomberConfig.layer.state.NORMAL;
            },
            P__setStateChange: function () {
                this.__state = bomberConfig.layer.state.CHANGE;
            },

            Abstract: {
                P__createCanvas: function () { }
            }
        },
        Public: {
            //更改狀態
            change: function () {
                this.__state = bomberConfig.layer.state.CHANGE;
            },
            setCanvas: function (canvas) {
                if (canvas) {
                    if (!YYC.Tool.canvas.isCanvas(canvas)) {
                        throw new Error("參數必須爲canvas元素");
                    }
                    this.P__canvas = canvas;
                }
                else {
                    //子類實現
                    this.P__createCanvas();
                }
            },
            clear: function () {
                this.P__context.clearRect(0, 0, bomberConfig.canvas.WIDTH, bomberConfig.canvas.HEIGHT);
            },

            Virtual: {
                init: function () {
                    this.__getContext();
                }
            }
        },
        Abstract: {
            //統一繪製
            draw: function () { },
            //渲染到畫布上
            render: function () { }
        }
    });

    window.Layer = Layer;
}());
View Code

MapLayer

(function () {
    var MapLayer = YYC.Class(Layer, {
        Init: function () {
        },
        Protected: {
            //實現父類的抽象保護方法
            P__createCanvas: function () {
                var canvas = $("<canvas/>", {
                    //id: id,
                    width: bomberConfig.canvas.WIDTH.toString(),
                    height: bomberConfig.canvas.HEIGHT.toString(),
                    css: {
                        "position": "absolute",
                        "top": bomberConfig.canvas.TOP,
                        "left": bomberConfig.canvas.LEFT,
                        "border": "1px solid blue",
                        "z-index": 0
                    }
                });
                $("body").append(canvas);

                this.P__canvas = canvas[0];
            }
        },
        Public: {
            draw: function () {
                var i = 0,
                    len = 0,
                    imgs = null;

                imgs = this.getChilds();

                for (i = 0, len = imgs.length; i < len; i++) {
                    this.P__context.drawImage(imgs[i].img, imgs[i].x, imgs[i].y, imgs[i].width, imgs[i].height);
                }
            },
            render: function () {
                if (this.P__isChange()) {
                    this.clear();
                    this.draw();
                    this.P__setStateNormal();
                }
            }
        }
    });

    window.MapLayer = MapLayer;
}());
View Code

 

PlayerLayer

(function () {
    var PlayerLayer = YYC.Class(Layer, {
        Init: function (deltaTime) {
            this.___deltaTime = deltaTime;
        },
        Private: {
            ___deltaTime: 0,

            ___iterator: function (handler) {
                var args = Array.prototype.slice.call(arguments, 1),
                    nextElement = null;

                while (this.hasNext()) {
                    nextElement = this.next();
                    nextElement[handler].apply(nextElement, args);  //要指向nextElement
                }
                this.resetCursor();
            },
            ___update: function (deltaTime) {
                this.___iterator("update", deltaTime);
            },
            ___handleNext: function () {
                this.___iterator("handleNext");
            }
        },
        Protected: {
            //實現父類的抽象保護方法
            P__createCanvas: function () {
                var canvas = $("<canvas/>", {
                    //id: id,
                    width: bomberConfig.canvas.WIDTH.toString(),
                    height: bomberConfig.canvas.HEIGHT.toString(),
                    css: {
                        "position": "absolute",
                        "top": bomberConfig.canvas.TOP,
                        "left": bomberConfig.canvas.LEFT,
                        "border": "1px solid red",
                        "z-index": 1
                    }
                });
                $("body").append(canvas);

                this.P__canvas = canvas[0];
            }
        },
        Public: {
            draw: function (context) {
                this.___iterator("draw", context);
            },
            render: function () {
                if (this.P__isChange()) {
                    this.clear();
                    this.___update(this.___deltaTime);
                    this.draw(this.P__context);
                    this.___handleNext();
                    this.P__setStateNormal();
                }
            }
        }
    });

    window.PlayerLayer = PlayerLayer;
}());
View Code

增長LayerFactory

增長LayerFactory工廠,負責建立PlayerLayer和MapLayer類的實例。

LayerFactory

(function () {
    var layerFactory = {
        createMap: function () {
            return new MapLayer();
        },
        createPlayer: function (deltaTime) {
            return new PlayerLayer(deltaTime);
        }
    }

    window.layerFactory = layerFactory;
}());

分離出了LayerManager類

回顧Game類,它作的事情太多了。

精靈類、Bitmap都是屬於層的集合元素,所以由層來負責建立他們。

可是根據以前的分析,層的職責是負責統一管理層內元素,不該該給它增長建立元素的職責。

並且,如今Game中負責建立和管理兩個層,這兩個層在Game中的行爲類似。

基於以上分析和參照了網上資料,我提出層管理類的概念。

  層管理類的職責

負責層的邏輯

  與層的區別

調用層面不同。層是處理精靈的邏輯,它的元素爲精靈。層管理是處理層的邏輯,它的元素爲層。一個層對應一個層管理類,再把每個層管理類中的通用行爲提取出來,造成層管理類的父類。

所以,我提出了PlayerLayerManager、MapLayerManager、LayerManager類。

  領域模型

  相關代碼

LayerManager

var LayerManager = YYC.AClass({
        Init: function (layer) {
            this.layer = layer;
        },
        Private: {
        },
        Public: {
            layer: null,

            addElement: function (element) {
                var i = 0,
                    len = 0;

                for (i = 0, len = element.length; i < len; i++) {
                    this.layer.appendChild(element[i]);
                }
            },
            initLayer: function () {
                this.layer.setCanvas();
                this.layer.init();
                this.layer.change();
}, render: function () { this.layer.render(); } }, Abstract: { createElement: function () { } } });

PlayerLayerManager

var PlayerLayerManager = YYC.Class(LayerManager, {
        Init: function (layer) {
            this.base(layer);
        },
        Private: {
        },
        Public: {
            createElement: function () {
                var element = [],
                     player = spriteFactory.createPlayer();

                player.setAnim("walk_right");
                element.push(player);

                return element;
            }
        }
    });

MapLayerManager

var MapLayerManager = YYC.Class(LayerManager, {
        Init: function (layer) {
            this.base(layer);
        },
        Private: {
            __getMapImg: function (i, j, mapData) {
                var img = null;

                switch (mapData[i][j]) {
                    case 1:
                        img = window.imgLoader.get("ground");
                        break;
                    case 2:
                        img = window.imgLoader.get("wall");
                        break;
                    default:
                        break
                }

                return img;
            }
        },
        Public: {
            createElement: function () {
                var i = 0,
                   j = 0,
                   map = bomberConfig.map,
                   element = [],
                   mapData = mapDataOperate.getMapData(),
                   img = null;

                for (i = 0; i < map.ROW; i++) {
                    //注意!
                    //y爲縱向height,x爲橫向width
                    y = i * bomberConfig.HEIGHT;

                    for (j = 0; j < map.COL; j++) {
                        x = j * bomberConfig.WIDTH;
                        img = this.__getMapImg(i, j, mapData);
                        element.push(bitmapFactory.createBitmap({ img: img, width: bomberConfig.WIDTH, height: bomberConfig.HEIGHT, x: x, y: y }));
                    }
                }

                return element;
            }
        }
    });

Game

(function () {
    var Game = YYC.Class({
        Init: function () {
        },
        Private: {
            _layerManager: [],

            _createLayer: function () {
                this.mapLayer = layerFactory.createMap();
                this.playerLayer = layerFactory.createPlayer(this.sleep);
            },
            _createLayerManager: function () {
                this._layerManager.push(new MapLayerManager(this.mapLayer));
                this._layerManager.push(new PlayerLayerManager(this.playerLayer));
            },
            _initLayer: function () {
                var i = 0,
                    len = 0;

                for (i = 0, len = this._layerManager.length; i < len; i++) {
                    this._layerManager[i].addElement(this._layerManager[i].createElement());
                    this._layerManager[i].initLayer();
                }
            }
        },
        Public: {
            context: null,
            sleep: 0,
            x: 0,
            y: 0,

            mapLayer: null,
            playerLayer: null,

            init: function () {
                this.sleep = Math.floor(1000 / bomberConfig.FPS);

                this._createLayer();
                this._createLayerManager();
                this._initLayer();
            },
            start: function () {
                var self = this;

                var mainLoop = window.setInterval(function () {
                    self.run();
                }, this.sleep);
            },
            run: function () {
                var i = 0,
                            len = 0;

                for (i = 0, len = this._layerManager.length; i < len; i++) {
                    this._layerManager[i].render();
                }
            }
        }
    });

    window.Game = Game;
}());
View Code

本文最終領域模型

高層劃分

重構層

通過本文的開發後,實際的概念層次結構爲:

其中,入口對應用戶交互層,主邏輯、層管理、層、精靈對應業務邏輯層,數據操做對應數據操做層,數據對應數據層。

受此啓發,能夠將業務邏輯層細化爲主邏輯、層管理、層、精靈四個層。

另外,領域模型中的工廠類屬於業務邏輯層,它與其它四個層中的層管理和層有關聯,且不屬於其它四個層。所以,在業務邏輯層中提出負責通用操做的輔助邏輯層,將工廠類放到該層中。

重構後的層

層、領域模型

提出包

包和組件的設計原則

  內聚

  • 重用發佈等價原則(REP) 

重用的粒度就是發佈的粒度:一個包中的軟件要麼都是可重用的,要麼都是不可重用的。

  • 共同重用原則(CRP)

一個包中全部類應該是共同重用的。若是重用了包中的一個類,那麼就重用包中的全部類。

  • 共同封閉原則(CCP)

包中的全部類對於同一類性質的變化應該是共同封閉的。一個變化若對一個包產生影響,則將對包中的全部類產生影響,而對於其餘的包不形成任何影響。

  耦合 

  • 無環依賴原則(ADP) 

在包的依賴圖中,不容許存在環。

  • 穩定依賴原則(SDP)

朝着穩定的方向進行依賴。

  • 穩定抽象原則(SAP)

包的抽象程度應該和其穩定程度一致。

本文包劃分

對應領域模型

  • 輔助操做層
    • 控件包
      PreLoadImg
    • 配置包
      Config
  • 用戶交互層
    • 入口包
      Main
  • 業務邏輯層
    • 輔助邏輯
      • 工廠包
        BitmapFactory、LayerFactory、SpriteFactory
    • 遊戲主邏輯
      • 主邏輯包
        Game
    • 層管理
      • 層管理實現包
        PlayerLayerManager、MapLayerManager
      • 層管理抽象包
      • LayerManager
      • 層實現包
        PlayerLayer、MapLayer
      • 層抽象包
        Layer
      • 集合包
        Collection
    • 精靈
      • 精靈包
        PlayerSprite
      • 動畫包
        Animation、GetSpriteData、SpriteData、GetFrames、FrameData
  • 數據操做層
    • 地圖數據操做包
      MapDataOperate
    • 路徑數據操做包
      GetPath
    • 圖片數據操做包
      Bitmap
  • 數據層
    • 地圖包
      MapData
    • 圖片路徑包
      ImgPathData

Animation爲何與GetSpriteData、SpriteData、GetFrames、FrameData放在一塊兒?

雖然從封閉性上分析,GetSpriteData、SpriteData、GetFrames、FrameData對於精靈數據的變化會一塊兒變化,而Animation不會一塊兒變化,Animation應該對於動畫邏輯的變化而變化。所以,Animation與GetSpriteData、SpriteData、GetFrames、FrameData不知足共同封閉原則。

可是,由於Animation與其它四個類緊密相關,能夠一塊兒重用。

所以仍是將Animation和GetSpriteData、SpriteData、GetFrames、FrameData都一塊兒放到動畫包中。

本文參考資料

《敏捷軟件開發:原則、模式與實踐》 

HTML5研究小組第二期技術講座《手把手製做HTML5遊戲》

徹底分享,共同進步——我開發的第一款HTML5遊戲《驢子跳》

歡迎瀏覽上一篇博文:炸彈人遊戲開發系列(3):顯示地圖

歡迎瀏覽下一篇博文:炸彈人遊戲開發系列(5):控制炸彈人移動,引入狀態模式

相關文章
相關標籤/搜索