炸彈人游戲開發系列(3):顯示地圖
前言
上文我們進行了初步的高層設計,現在我們將實現圖片預加載和顯示地圖的功能需求。我采用TDD開發,大家可以看到在實現的過程中我們會修改設計,修改設計后又會修改對應的實現代碼,這是一個不斷迭代的過程。在有測試套件的保護下,我們可以放心地重構。
本文目的
掌握地圖繪制的技術。
本文主要內容
回顧上文與顯示地圖相關的領域模型
開發策略
使用我的控件YPreLoadImg來實現圖片預加載,結合progressBar插件,能夠顯示出加載進度條。
只建一個ShowMap類,用這個類來進行實驗,找到顯示地圖的方法,然后在重構的時候再分離出Main類和MapData數據類。
原因
- 因為我對canvas很不熟悉,需要先集中精力熟悉canvas的使用,而不是把精力放在架構設計上。
- 因為我是采用TDD開發,因此可以安全地進行重構??梢栽趯崿F“顯示地圖”功能后,再在重構時提煉出Main和MapData類。
開發策略也是迭代修正的
開發策略只是就當前的知識指定的大概計劃,會隨著開發的進行而進行細化和修正。如現在的開發策略并沒有考慮到在重構時會增加Game類。
預加載圖片
預加載的目的
將圖片下載到本地內存中。
為什么要預加載
- 必須等到圖片完全加載后才能使用canvas對圖片進行操作。如果試圖在圖片未完全加載之前就將其呈現到canvas上,那么canvas將不會顯示任何圖片。
- 如果不使用預加載,則在使用圖片之前需要先下載圖片到本地。即使有瀏覽器優化,第一次使用圖片時也需要先下載圖片,這樣在第一次使用圖片時,會有卡的感覺。
因此,在游戲開始之前,先要進行游戲初始化,預加載游戲所需的圖片。
如何進行預加載
基本示例:
var img = new Image(); //創建一個圖片對象 img.src = "test.png"; //下載該路徑的圖片 img.onload = function () { //圖片下載完畢時異步調用callback函數。 callback(img); };
預加載網上教程
顯示進度條
YPreLoadImg控件結合進度條插件progressbar,可以顯示出進度條。
新增ShowMap類并實現預加載圖片
新增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();
});
顯示效果

加載圖片中

加載完成
重構
識別出config類,放到輔助操作層
為什么要增加Config類?
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顯示圖片)。
用drawImage顯示空地圖片
因為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篇博文中,我們會碰到這個問題,到時候我再詳細說明。
運行效果

用createPattern顯示空地圖片區域
在游戲開發中,可能需要繪制一片相同圖片的區域,該區域由一張圖片在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); },
運行效果

重構
現在需要停一下,對已有的代碼進行梳理。
創建Bitmap類,并進行對應重構
我發現,在ShowMap的onload方法中,多次使用到了圖片對象:showMap.imgLoader.get("ground"),這個對象是預加載圖片后的Image對象。
考慮到在游戲中需要對圖片進行操作,那為什么不能提出“圖片類”的概念,將與圖片本身相關的內容都放到該類中呢?
因此,我提煉出Bitmap類,該類有以下職責:
- 包含圖片的信息(如圖片對象image、width、height等)
- 包含圖片的基本操作(如剪切、縮放等)
進一步思考
- 繪制圖片的draw方法應不應該放到Bitmap類中呢?
考慮到Bitmap類是圖片的包裝類,包含與圖片本身密切相關的屬性和方法。而繪制圖片方法的職責是讀取圖片的屬性,使用canvas的api進行操作。Bitmap類不需要知道自己是如何被調用的,因此繪制圖片的職責應該放到調用Bitmap的類,即放到ShowMap類中。
- 為什么不創建圖片的精靈類?
因為圖片不是獨立的個體,它屬于數據的概念,是精靈的一個屬性,在概念上它并不是精靈。
- Bitmap應該具體有哪些成員?
屬性:
應該包含預加載后的圖片對象、寬度和高度、圖片的坐標。
方法:
目前來看,不需要對圖片進行操作,因此不需要如剪切等方法。
領域模型

測試代碼
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
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); });
創建BitmapFactory類
因為在ShowMap類中需要創建Bitmap實例,因此需要增加Bitmap的工廠BitmapFactory類。
理由如下:
- Bitmap構造函數有3個參數,比較復雜,需要將創建實例這個過程封裝起來。因此需要工廠類來負責和管理創建過程。
領域模型

測試代碼
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
現在,讓我們再來看看showMap類,發現該類做了三件事:
- 游戲初始化:圖片預加載
- 游戲邏輯
- 顯示地圖
增加Main類
根據單一職責原則,一個類只應該有一個職責,只有一個原因引起變化。再結合之前給出的領域模型,Main類是游戲入口,負責控制游戲的主循環,調用相關的操作。因此,將showMap中負責游戲初始化的init方法移到Main中。
增加Game類
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
改成類的形式
將ShowMap改成類的形式。
提出Layer
結合領域模型分析和第2篇博文中分層渲染的概念,我增加Layer類,將圖片類Bitmap裝入Layer,然后使用Layer來統一繪制圖片。
Layer包含canvas屬性,canvas由Game創建并通過Layer的構造函數將其注入到Layer中。
Layer有一個draw方法,負責調用canvas的API來繪制圖片。
重構Layer
提出Collection
考慮到Layer是集合類,因此可以將集合這個概念提出,形成新的Collection類,把集合的相關操作和集合的容器_childs放到Collection中,讓Layer繼承Collection。從而Layer就具有了集合類的功能。
進一步思考
為什么這里選擇繼承的方式復用,而不是選擇組合的方式復用呢?
- 通過繼承來復用比起組合來說更方便和優雅,可以減少代碼量。
- 從概念上來說,Collection和Layer都是屬于集合類,應該屬于一個類族。Collection是從Layer中提煉出來的,它是集合類的共性,因此Collection作為父類,Layer作為子類。
領域模型

繼續實現顯示地圖
重構到這里就告一段落,現在繼續實現“顯示地圖”。
增加MapData
根據領域模型,增加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; }());
增加MapDataOperate
ShowMap不應該直接操作MapData,因為:
- MapData在后續的迭代中可能會變化,因此需要封裝這個變化,使得MapData變化時不會影響到ShowMap
- 根據分層的結果,應該由數據操作層的類來操作MapData
因此增加數據操作層的MapDataOperate,它負責獲得MapData。
領域模型

相關代碼
MapDataOperate
(function () { var mapDataOperate = { getMapData: function () { return YYC.Tool.array.clone(mapData); } }; window.mapDataOperate = mapDataOperate; }());
顯示地圖的實現
實現drawMap
在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
將MapData的ground與wall設為枚舉值,增加可讀性。
將枚舉值放到Config類中。
相關代碼
Config
map: {
...
type: {
GROUND: 1,
WALL: 2
}
},
MapData
var ground = bomberConfig.map.type.GROUND, wall = bomberConfig.map.type.WALL;
重構drawMap
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的職責:
- 包含圖片的信息(如圖片對象image、width、height等)
- 包含圖片的基本操作(如剪切、縮放等)
從中得出Bitmap應該放到數據操作層。
層、領域模型

演示
本文參考資料
浙公網安備 33010602011771號