前言
上文我们进行了初步的高层设计,现在我们将实现图片预加载和显示地图的功能需求。我采用TDD开发,大家可以看到在实现的过程中我们会修改设计,修改设计后又会修改对应的实现代码,这是一个不断迭代的过程。在有测试套件的保护下,我们可以放心地重构。
掌握地图绘制的技术。
开发策略
使用我的控件YPreLoadImg来实现图片预加载,结合progressBar插件,能够显示出加载进度条。
只建一个ShowMap类,用这个类来进行实验,找到显示地图的方法,然后在重构的时候再分离出Main类和MapData数据类。
开发策略只是就当前的知识指定的大概计划,会随着开发的进行而进行细化和修正。如现在的开发策略并没有考虑到在重构时会增加Game类。
预加载图片
将图片下载到本地内存中。
因此,在游戏开始之前,先要进行游戏初始化,预加载游戏所需的图片。
基本示例:
var img = new Image(); //创建一个图片对象 img.src = "test.png"; //下载该路径的图片 img.onload = function () { //图片下载完毕时异步调用callback函数。 callback(img); };
YPreLoadImg控件结合进度条插件progressbar,可以显示出进度条。
新增ShowMap类,使用TDD开发并实现预加载图片,需要先写测试用例。
这里先简单介绍下测试驱动开发的步骤:
下面是关于预加载的测试代码(这里只是展示最后结果,实际开发中并不是一口气就先把测试代码写完然后就直接写实现代码了,而是每次先写验证一个行为的测试代码,然后再写相应的实现代码,然后再写验证下一个行为的测试代码,这样迭代开发):
describe("showMap.js", function () { describe("init", function () { beforeEach(function () { //不执行onload spyOn(showMap, "onload").andCallFake(function () { }); }); afterEach(function () { }); it("传入的参数为数组,数组元素包含id和url属性", function () { var urls = []; var temp = []; var i = 0, len = 0; spyOn(window.YYC.Control, "PreLoadImg"); temp = [ { id: "ground", url: "ground.png" }, { id: "wall", url: "wall.png" } ]; for (i = 0, len = temp.length; i < len; i++) { urls.push({ id: temp[i].id, url: "../../../../Content/Bomber/Image/Map/" + temp[i].url }); } showMap.init(); expect(YYC.Control.PreLoadImg).toHaveBeenCalled(); expect(YYC.Control.PreLoadImg.calls[0].args[0]).toBeArray(); expect(YYC.Control.PreLoadImg.calls[0].args[0][0].id).toBeDefined(); expect(YYC.Control.PreLoadImg.calls[0].args[0][0].url).toBeDefined(); }); }); describe("onload", function () { var dom = null; function insertDom() { dom = $("<div id='progressBar'></div>"); $("body").append(dom); }; function removeDom() { dom.remove(); }; beforeEach(function () { insertDom(); }); afterEach(function () { removeDom(); }); it("加载完毕后,隐藏加载进度条", function () { expect($("#progressBar").css("display")).toEqual("block"); showMap.onload(); expect($("#progressBar").css("display")).toEqual("none"); }); }); });
var showMap = (function () { var _getImg = function () { var urls = []; var temp = []; var i = 0, len = 0; temp = [{ id: "ground", url: "ground.png" }, { id: "wall", url: "wall.png" }]; for (i = 0, len = temp.length; i < len; i++) { urls.push({ id: temp[i].id, url: "../../../../Content/Bomber/Image/Map/" + temp[i].url }); } return urls; }; return { init: function () { this.imgLoader = new YYC.Control.PreLoadImg(_getImg(), function (currentLoad, imgCount) { $("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10)); //调用进度条插件 }, YYC.Tool.func.bind(this, this.onload)); }, onload: function(){ $("#progressBar").css("display", "none"); alert("complete!"); } }; window.showMap = showMap; }());
YYC.Tool.func.bind是我的一个工具类方法,作用是将onload中的this指向showMap。
预加载完成后,调用onload,隐藏进度条,弹出对话框,提示“complete”。
在页面上调用init方法
$(function () {
showMap.init();
});
加载图片中
加载完成
ShowMap中预加载图片的url是相对于当前页面的,所以在不同的页面预加载图片时(如在测试页面和实际页面),url的前缀可能不一样。
因此我希望url的前缀是可以配置的,这样当在不同的页面调用ShowMap时,只需要改动配置文件就好了。
所以我增加Config全局配置类,在Config中配置url的前缀。该类属于辅助操作层。
Config
var bomberConfig = { url_pre: { //showMap.js SHOWMAP: "../../../../Content/Bomber/" } };
ShowMap
var _getImg = function () { ... for (i = 0, len = temp.length; i < len; i++) { urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + temp[i].url }); } return urls; };
显示地图
现在我就要开始着手显示游戏地图了。考虑到我没有canvas的使用经验,因此我先进行试验,熟悉canvas中与显示地图相关的api;然后对代码和领域模型进行重构,提炼出新的显示地图的模型;最后再具体实现“显示地图”的功能。
因为要使用canvas显示图片,先要对该图片进行预加载。因此先在showMap.onload中进行试验。
注意:对onload的测试为异步测试(要等待图片加载完成后才能使用canvas显示图片)。
因为canvas中使用drawImage来绘制图片,因此需要先掌握该API的用法。
it("绘制一张空地图片", function () { spyOn(showMap, "getContext").andCallFake(function (canvas) { showMap.context = canvas.getContext("2d"); spyOn(showMap.context, "drawImage"); }); showMap.init(); //延迟100ms测试 testTool.asynRun(function () { expect(showMap.context.drawImage).toHaveBeenCalledWith(showMap.imgLoader.get("ground"), 0, 0); }, 100); });
var showMap = (function () { var _createCanvas = function () { // 创建canvas,并初始化 (我们也可以直接以标签形式写在页面中,然后通过id等方式取得canvas) var canvas = document.createElement("canvas"); //设置宽度、高度 canvas.width = 600; canvas.height = 400; document.body.appendChild(canvas); return canvas; }; var _getImg = function () { var urls = []; var temp = []; var i = 0, len = 0; temp = [{ id: "ground", url: "ground.png" }, { id: "wall", url: "wall.png" }]; for (i = 0, len = temp.length; i < len; i++) { urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + "image/map/" + temp[i].url }); } return urls; }; return { context: null, imgLoader: null, init: function () { var self = this; var canvas = _createCanvas(); //为了方便测试,将“取得2d绘图上下文”封装到方法中 this.getContext(canvas); this.imgLoader = new YYC.Control.PreLoadImg(_getImg(), function (currentLoad, imgCount) { $("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10)); //调用进度条插件 }, YYC.Tool.func.bind(this, this.onload)); }, onload: function(){ $("#progressBar").css("display", "none"); this.context.drawImage(this.imgLoader.get("ground"), 0, 0); }, getContext: function (canvas) { this.context = canvas.getContext("2d"); } }; window.showMap = showMap; }());
这里canvas是动态创建的,但是这样创建canvas会有drawImage中的dx、dy和clearRect中的x、y按比例缩放的问题。在第5篇博文中,我们会碰到这个问题,到时候我再详细说明。
在游戏开发中,可能需要绘制一片相同图片的区域,该区域由一张图片在x、y方向重复绘制而成,需要用到createPattern、fillStyle、fillRect。因此先进行实验。
describe("画出地图", function () { function spyOnContext(func) { spyOn(showMap, "getContext").andCallFake(function (canvas) { showMap.context = canvas.getContext("2d"); func(); }); }; it("获得pattern", function () { spyOnContext(function () { spyOn(showMap.context, "createPattern"); }); showMap.init(); //延迟100ms测试 testTool.asynRun(function () { expect(showMap.context.createPattern).toHaveBeenCalledWith(showMap.imgLoader.get("ground"), "repeat"); expect(showMap._pattern).not.toBeNull(); }, 100); }); it("缓存pattern", function () { spyOnContext(function () { spyOn(showMap.context, "createPattern"); }); showMap.init(); expect(showMap.context.createPattern.calls.length).toEqual(1); //*延迟100ms后,执行“showMap.init();”。 //*然后再延迟100ms后,如果showMap.context.createPattern没有被调用,就验证pattern被缓存了“ testTool.asynRun(function () { showMap.init(); }, 100); testTool.asynRun(function () { expect(showMap.context.createPattern.calls.length).toEqual(1); }, 100); }); it("画出200*200的空地图片区域,ie、ff显示正常", function () { spyOnContext(function () { spyOn(showMap.context, "fillRect"); }); showMap.init(); testTool.asynRun(function () { expect(showMap.context.fillStyle).toEqual(showMap._pattern); expect(showMap.context.fillRect).toHaveBeenCalledWith(0, 0, 200, 200); }, 100); }); });
onload: function(){ $("#progressBar").css("display", "none"); if (!this._pattern) { this._pattern = this.context.createPattern(showMap.imgLoader.get("ground"), "repeat"); } this.context.fillStyle = this._pattern; this.context.fillRect(0, 0, 200, 200); },
重构
现在需要停一下,对已有的代码进行梳理。
我发现,在ShowMap的onload方法中,多次使用到了图片对象:showMap.imgLoader.get("ground"),这个对象是预加载图片后的Image对象。
考虑到在游戏中需要对图片进行操作,那为什么不能提出“图片类”的概念,将与图片本身相关的内容都放到该类中呢?
因此,我提炼出Bitmap类,该类有以下职责:
考虑到Bitmap类是图片的包装类,包含与图片本身密切相关的属性和方法。而绘制图片方法的职责是读取图片的属性,使用canvas的api进行操作。Bitmap类不需要知道自己是如何被调用的,因此绘制图片的职责应该放到调用Bitmap的类,即放到ShowMap类中。
因为图片不是独立的个体,它属于数据的概念,是精灵的一个属性,在概念上它并不是精灵。
属性:
应该包含预加载后的图片对象、宽度和高度、图片的坐标。
方法:
目前来看,不需要对图片进行操作,因此不需要如剪切等方法。
describe("Bitmap.js", function () { var bitmap = null; beforeEach(function () { }); afterEach(function () { }); describe("构造函数Init", function () { var dom = null; function insertDom() { dom = $("<img id='test_img'>"); $("body").append(dom); }; function removeDom() { dom.remove(); }; beforeEach(function () { insertDom(); }); afterEach(function () { removeDom(); }); it("获得预加载后的图片对象、宽度、高度、图片的坐标", function () { bitmap = new Bitmap($("#test_img")[0], 2, 3, 4, 5); expect(bitmap.img).not.toBeNull(); expect(bitmap.width).toEqual(2); expect(bitmap.height).toEqual(3); expect(bitmap.x).toEqual(4); expect(bitmap.y).toEqual(5); }); }); });
(function () { var Bitmap = YYC.Class({ Init: function (img, width, height, x, y) { var judge = YYC.Tool.judge; this.img = img; this.width = width; this.height = height; this.x = x; this.y = y; }, Private: { }, Public: { img: null, width: 0, height: 0, x: 0, y: 0 } }); window.Bitmap = Bitmap; }());
Bitmap构造函数的参数太多了,因此使用一个对象直接量来包装参数:
(function () { var Bitmap = YYC.Class({ Init: function (data) { this.img = data.img; this.width = data.width; this.height = data.height; this.x = data.x; this.y = data.y; }, Private: { }, Public: { img: null, width: 0, height: 0, x: 0, y: 0 } }); window.Bitmap = Bitmap; }());
运行测试,测试失败。
修改测试代码,使测试通过:
it("获得预加载后的图片对象、宽度、高度、图片的坐标", function () { bitmap = new Bitmap({ img: $("#test_img")[0], width: 2, height: 3, x: 4, y: 5 }); expect(bitmap.img).not.toBeNull(); expect(bitmap.width).toEqual(2); expect(bitmap.height).toEqual(3); expect(bitmap.x).toEqual(4); expect(bitmap.y).toEqual(5); });
因为在ShowMap类中需要创建Bitmap实例,因此需要增加Bitmap的工厂BitmapFactory类。
理由如下:
describe("bitmapFactory.js", function () { describe("createBitmap", function () { var dom = null; function insertDom() { dom = $("<img id='test_img'>"); $("body").append(dom); }; function removeDom() { dom.remove(); }; beforeEach(function () { insertDom(); }); afterEach(function () { removeDom(); }); it("方法存在", function () { expect(bitmapFactory.createBitmap).toBeDefined(); }); it("如果参数为1个(HTMLImg对象),则bitmap的width、height为HTMLImg的width、height", function () { var bitmap = null, width = 0, height = 0; bitmap = bitmapFactory.createBitmap($("#test_img")[0]), width = $("#test_img").width(), height = $("#test_img").height(); expect(bitmap.width).toEqual(width); expect(bitmap.height).toEqual(height); }); it("如果参数为3个(HTMLImg对象、width、height),则bitmap的width、height为传入的width、height", function () { var bitmap = null; bitmap = bitmapFactory.createBitmap($("#test_img")[0], 100, 200), expect(bitmap.width).toEqual(100); expect(bitmap.height).toEqual(200); }); }); });
(function () { var bitmapFactory = { createBitmap: function (img, width, height) { if (arguments.length == 1) { return new Bitmap(img, img.width, img.height); } else if (arguments.length == 3) { return new Bitmap(img, width, height); } } }; window.bitmapFactory = bitmapFactory; }());
showMap.onload对应改变
onload: function(){ $("#progressBar").css("display", "none"); if (!this._pattern) { this._pattern = this.context.createPattern(bitmapFactory.createBitmap(showMap.imgLoader.get("ground")).img, "repeat"); } this.context.fillStyle = this._pattern; this.context.fillRect(0, 0, 200, 200); },
现在让我们来回头看下drawImage和createPattern的测试,在测试中都需要异步测试。
每增加一个测试用例就需要延迟测试,这样增加了很多重复代码,为什么不能把延迟测试分离出去,从而在测试中把精力放到我们的主要任务-即如何测试行为上呢?
因此,我把init方法放到测试页面SpecRunner上调用,然后在测试页面上对整个测试进行延迟,这样就能保证整个测试都是在图片预加载成功后进行的了。
测试页面相关代码:
<body> <script type="text/javascript"> (function () { //图片预加载 main.init() //清除“main.init()”创建的多余html元素 function clear() { $("body").children().not("script").remove(); }; var jasmineEnv = jasmine.getEnv(); jasmineEnv.updateInterval = 1000; var htmlReporter = new jasmine.HtmlReporter(); jasmineEnv.addReporter(htmlReporter); jasmineEnv.specFilter = function (spec) { return htmlReporter.specFilter(spec); }; var currentWindowOnload = window.onload; //延迟300ms执行测试(等待预加载完后执行) setTimeout(function () { clear(); if (currentWindowOnload) { currentWindowOnload(); } execJasmine(); }, 1000); function execJasmine() { jasmineEnv.execute(); } })(); </script> </body>
现在测试用例中就不再需要“testTool.asynRun”进行异步测试了。
现在,让我们再来看看showMap类,发现该类做了三件事:
根据单一职责原则,一个类只应该有一个职责,只有一个原因引起变化。再结合之前给出的领域模型,Main类是游戏入口,负责控制游戏的主循环,调用相关的操作。因此,将showMap中负责游戏初始化的init方法移到Main中。
showMap中还包含了游戏逻辑,如在onload中隐藏进度条,然后显示地图。根据以往游戏开发经验,知道游戏逻辑会越来越复杂,因此可以将游戏逻辑提取出来形成新类Game,这样ShowMap只负责调用canvas的API显示地图了。
Main
var main = (function () { var _getImg = function () { var urls = []; var temp = []; var i = 0, len = 0; temp = [{ id: "ground", url: "ground.png" }, { id: "wall", url: "wall.png" }]; for (i = 0, len = temp.length; i < len; i++) { urls.push({ id: temp[i].id, url: bomberConfig.url_pre.SHOWMAP + "image/map/" + temp[i].url }); } return urls; }; return { imgLoader: null, init: function () { var game = new Game(); this.imgLoader = new YYC.Control.PreLoadImg(_getImg(), function (currentLoad, imgCount) { $("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10)); //调用进度条插件 }, YYC.Tool.func.bind(game, game.onload)); } }; window.main = main; }());
Game
(function () { var Game = YYC.Frame.MyClass({ Init: function () { this.showMap = new ShowMap(); }, Private: { }, Public: { showMap: null, onload: function () { $("#progressBar").css("display", "none"); this.showMap.drawMap(); } } }); window.Game = Game; }());
将ShowMap改成类的形式。
结合领域模型分析和第2篇博文中分层渲染的概念,我增加Layer类,将图片类Bitmap装入Layer,然后使用Layer来统一绘制图片。
Layer包含canvas属性,canvas由Game创建并通过Layer的构造函数将其注入到Layer中。
Layer有一个draw方法,负责调用canvas的API来绘制图片。
考虑到Layer是集合类,因此可以将集合这个概念提出,形成新的Collection类,把集合的相关操作和集合的容器_childs放到Collection中,让Layer继承Collection。从而Layer就具有了集合类的功能。
为什么这里选择继承的方式复用,而不是选择组合的方式复用呢?
继续实现显示地图
重构到这里就告一段落,现在继续实现“显示地图”。
根据领域模型,增加MapData类,它是一个二维数组,用来保存地图数据。
MapData
(function () { var ground = 1, wall = 2; var mapData = [ [ground, wall, ground, ground], [ground, wall, ground, ground], [ground, wall, ground, ground], [ground, wall, ground, ground] ]; window.mapData = mapData; }());
ShowMap不应该直接操作MapData,因为:
因此增加数据操作层的MapDataOperate,它负责获得MapData。
MapDataOperate
(function () { var mapDataOperate = { getMapData: function () { return YYC.Tool.array.clone(mapData); } }; window.mapDataOperate = mapDataOperate; }());
在ShowMap中,通过调用MapDataOperate的getMapData方法,就可以获得地图数据,然后再根据数据向Layer中加入对应的Bitmap类即可。
ShowMap
_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; } ... drawMap: function () { var i = 0, j = 0, width = 34, height = 34, row = 4, col = 4, bitmap = null, mapData = mapDataOperate.getMapData(), x = 0, y = 0, img = null; this._createLayer(); for (i = 0; i < row; i++) { //注意! //y为纵向height,x为横向width y = i * height; for (j = 0; j < col; j++) { x = j * width; img = this._getMapImg(i, j, mapData); bitmap = bitmapFactory.createBitmap({ img: img, width: width, height: height, x: x, y: y }); this.layer.appendChild(bitmap); } } this.layer.draw(); }
将MapData的ground与wall设为枚举值,增加可读性。
将枚举值放到Config类中。
Config
map: { ... type: { GROUND: 1, WALL: 2 } },
MapData
var ground = bomberConfig.map.type.GROUND, wall = bomberConfig.map.type.WALL;
config增加bomberConfig.map配置
目前地图大小是在drawMap写死了:
大小为4*4,单元格宽度和高度为34px。
考虑到地图大小可能在后期的开发中不断变化,因此将其放到Config中进行统一配置。
Config
map: { //方格宽度 WIDTH: 34, //方格高度 HEIGHT: 34, ROW: 4, COL: 4, type: { GROUND: 1, WALL: 2 } },
ShowMap
_getMapImg: function (i, j, mapData) { var img = null, type = bomberConfig.map.type; switch (mapData[i][j]) { case type.GROUND: img = window.imgLoader.get("ground"); break; case type.WALL: img = window.imgLoader.get("wall"); break; default: break } return img; } ... 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(); }
本文最终领域模型
高层划分
本文增加了MapDataOperate类,对应增加数据操作层。该层负责对数据进行操作。
我们来看下Bitmap的职责:
从中得出Bitmap应该放到数据操作层。
演示
本文参考资料