炸弹人游戏开发系列(4):炸弹人显示与移动
前言
在上文中,我已经介绍了如何测试、如何重构测试,并且通过实验掌握了地图显示的技术。本文会将地图显示的技术用到炸弹人显示中,并且让我们的炸弹人动起来。
注:为了提升博文质量和把重点放在记录开发和迭代的思想实践,本文及后续博文将不再记录测试过程。
本文目的
实现炸弹人的显示和移动
本文主要内容
回顾上文更新后的领域模型
对领域模型进行思考
ShowMap类是负责显示地图,包含了游戏逻辑。而Game类职责是负责游戏逻辑,因此ShowMap和Game在职责上是有重复的。况且显示地图这部分逻辑并不是很复杂,可以不需要专门的类来负责这部分逻辑,而是直接放到Game中。
现在来回头看看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中。
重构后的领域模型
重构后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; }());
开发策略
“显示炸弹人”没有难度,因为在上文中我已经掌握了使用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; }());
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; }());
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; }());
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其实设计得有问题,因为:
1、数据类SpriteData依赖了数据操作类GetFrameData(因为SpriteData中调用getFrames方法获得帧数据)。
数据操作类应该依赖数据类,而数据类不应该依赖数据操作类。
2、数据类与其它类耦合。
因为数据类应该是独立的纯数据,保持简单,只有数据信息,这样才具有高度的可维护性、可读性和可移植性。而此处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"); } } }
重构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; }());
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; }());
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; }());
增加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; }());
本文最终领域模型
高层划分
重构层
经过本文的开发后,实际的概念层次结构为:
其中,入口对应用户交互层,主逻辑、层管理、层、精灵对应业务逻辑层,数据操作对应数据操作层,数据对应数据层。
受此启发,可以将业务逻辑层细化为主逻辑、层管理、层、精灵四个层。
另外,领域模型中的工厂类属于业务逻辑层,它与其它四个层中的层管理和层有关联,且不属于其它四个层。因此,在业务逻辑层中提出负责通用操作的辅助逻辑层,将工厂类放到该层中。
重构后的层
层、领域模型
提出包
包和组件的设计原则
内聚
-
重用发布等价原则(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游戏《驴子跳》