前言
在上文中,我已經介紹了如何測試、如何重構測試,並且通過實驗掌握了地圖顯示的技術。本文會將地圖顯示的技術用到炸彈人顯示中,並且讓我們的炸彈人動起來。
注:為了提升博文質量和把重點放在記錄開發和迭代的思想實踐,本文及后續博文將不再記錄測試過程。
本文目的
實現炸彈人的顯示和移動
本文主要內容
回顧上文更新后的領域模型
對領域模型進行思考
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游戲《驢子跳》