炸弹人游戏开发系列(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):控制炸弹人移动,引入状态模式

相关文章
相关标签/搜索