提煉游戲引擎系列:第一次迭代
前言
上文我們完成了引擎的初步設計,本文進行引擎提煉的第一次迭代,從炸彈人游戲中提煉引擎類,搭建引擎的整體框架。
名詞解釋
- 用戶
“用戶”是個相對的概念,指使用的一方法,默認指游戲開發者。
當相對于某個引擎類時,“用戶”就是指引擎類的使用方;當相對于整個引擎時,“用戶”就是指引擎的使用方。
- 用戶邏輯
引擎使用方的邏輯,默認指與具體游戲相關的業務邏輯。
本文目的
1、參考引擎初步領域模型,從炸彈人參考模型中提煉出對應的通用類,搭建引擎框架。
2、將炸彈人游戲改造為基于引擎實現。
本文主要內容
- 修改namespace方法,提出Tool
- 提出ImgLoader
- 提出Main
- 提出Director
- 提出Scene和Hash
- 提出Layer和Collection
- 提出Sprite、Config和collision
- 提出Factory
- 提出Animation
- 提出AI
- 提出EventManager和Event
- 提出DataOperator
- 提出Data
- 本文最終領域模型
- 高層劃分
- 總結
- 本文源碼下載
- 參考資料
炸彈人的參考模型

引擎初步領域模型

開發策略
按照引擎初步領域模型從左往右的順序,確定要提煉的引擎類,從炸彈人參考模型對應的炸彈人類中提煉出通用的引擎類。
本文迭代步驟

迭代步驟說明
- 確定要提煉的引擎類
按照“引擎初步領域模型”從左往右的順序依次確定要提煉的引擎類。
每次迭代提煉一個引擎類,如果有強關聯的引擎類,也一并提出。
- 提煉引擎類
從炸彈人參考模型中確定對應的炸彈人類,從中提煉出可復用的、通用的引擎類。
- 重構提煉的引擎類
如果提煉的引擎類有壞味道,或者包含了用戶邏輯,就需要進行重構。
- 炸彈人改造為基于引擎實現
對應修改炸彈人類,使用提煉的引擎類。
這樣能夠站在用戶角度發現引擎的改進點,得到及時反饋,從而馬上重構。
- 通過游戲的運行測試
通過運行測試,修復由于修改炸彈人代碼帶來的bug。
- 重構引擎
如果有必要的話,對引擎進行相應的重構。
下面的幾個原因會導致重構引擎:
1、提煉出新的引擎類后,與之關聯的引擎類需要對應修改。
2、得到了新的反饋,需要改進引擎。
3、違反了引擎設計原則。
4、處理前面遺留的問題
- 通過游戲的運行測試
通過運行測試,修復炸彈人和引擎的bug。
- 編寫并通過引擎的單元測試
因為引擎是從炸彈人代碼中提煉出來的,所以引擎的單元測試代碼可以參考或者直接復用炸彈人的單元測試代碼。
炸彈人代碼只進行運行測試,不進行單元測試。因為本系列的重點是提煉引擎,不是二次開發炸彈人,這樣做可以節省精力,專注于引擎的提煉。
- 完成本次迭代
進入新一輪迭代,確定下一個要提煉的引擎類。
思考
1、為什么先提煉引擎,后進行引擎的單元測試?
在炸彈人開發中,我采用TDD的方式,即先寫測試,再進行開發,然而這里不應該采用這種方式,這是因為:
(1)我已經有了一定的游戲開發經驗了,可以先進行一大步的開發,然后再寫對應的測試來覆蓋開發。
(2)在提煉引擎類前我只知道引擎類的大概的職責,不能確定引擎類的詳細設計。在提煉的過程中,引擎類會不停的變化,如果先寫了引擎類的單元測試代碼,則需要不停地修改,浪費很多時間。
2、為什么要先進行游戲的運行測試,再進行引擎的單元測試?
因為:
(1)炸彈人游戲并不復雜,如果運行測試失敗,也能比較容易地定位錯誤
(2)先進行游戲的運行測試,不用修改單元測試代碼就能直接修復發現的引擎bug,這樣之后進行的引擎單元測試就能比較順利的通過,節省時間。
不討論測試
因為測試并不是本系列的主題,所以本系列不會討論專門測試的過程,“本文源碼下載”中也沒有單元測試代碼!
您可以在最新的引擎版本中找到引擎完整的單元測試代碼: YEngine2D
引擎使用方式的初步研究
在開篇介紹中,給出了引擎的三種使用方式:直接使用引擎類提供的API、繼承重寫、實例重寫,現在來研究下后兩種使用方式。
繼承重寫應用了模板模式,由引擎類搭建框架,將變化點以鉤子方法、虛方法和抽象成員的形式提供給用戶子類實現。
實例重寫也是應用了模板模式的思想,引擎類也提供鉤子方法供用戶類重寫,不過用戶類并不是繼承復用引擎類,而是委托復用引擎類。
繼承重寫與實例重寫的區別,實際上就是繼承與委托的區別。
繼承重寫和實例重寫的比較
共同點
(1)都是單向關聯,即用戶類依賴引擎類,引擎類不依賴用戶類。
(2)用戶都可以插入自己的邏輯到引擎中。
不同點
(1)繼承重寫通過繼承的方式實現引擎類的使用,實例重寫通過委托的方式實現引擎類的使用
(2)繼承重寫不僅提供了鉤子方法,還提供了虛方法、抽象成員供用戶重寫,實例重寫則只提供了鉤子方法。
實例重寫的優勢主要在于用戶類與引擎類的關聯性較弱,用戶類只與引擎類實例的鉤子方法耦合,不會與整個引擎類耦合。
繼承重寫的優勢主要在于父類和子類代碼共享,提高代碼的重用性。
什么時候用繼承重寫
當用戶類與引擎類同屬于一個概念,引擎類是精心設計用于被繼承的類時,應該用繼承重寫。
什么時候用實例重寫
當用戶類需要插入自己的邏輯到引擎類中而又不想與引擎類緊密耦合時,應該用實例重寫。
本文選用的方式
因為引擎Main和Director是從炸彈人Main、Game中提出來的,不是設計為可被繼承的類,所以引擎Main、Director采用實例重寫的方式,
(它們的使用方式會在第二次迭代中修改)
引擎Layer和Sprite是從炸彈人Layer、Sprite中提出來的,都是抽象基類,本身就是設計為被繼承的類,所以引擎Layer和Sprite采用繼承重寫的方式。
其它引擎類不能被重寫,而是提供API,供引擎類或用戶類調用。
(第二次迭代會將引擎Scene改為繼承重寫的方式)
修改namespace方法,提出Tool
修改namespace方法
引擎使用命名空間來組織,引擎的頂級命名空間為YE。
在炸彈人開發中,我使用工具庫YTool的namespace方法來定義命名空間。
分析YTool的namespace方法:
var YToolConfig = {
topNamespace: "YYC", //指定了頂級命名空間為YYC
toolNamespace: "Tool"
};
...
namespace: function (str) {
var parent = window[YToolConfig.topNamespace],
parts = str.split('.'),
i = 0,
len = 0;
if (str.length == 0) {
throw new Error("命名空間不能為空");
}
if (parts[0] === YToolConfig.topNamespace) {
parts = parts.slice(1);
}
for (i = 0, len = parts.length; i < len; i++) {
if (typeof parent[parts[i]] === "undefined") {
parent[parts[i]] = {};
}
parent = parent[parts[i]];
}
return parent;
},
該方法指定了頂級命名空間為YYC,不能修改,這顯然不符合引擎的“頂級命名空間為YE”的需求。
因此將其修改為不指定頂級命名空間,并設為全局方法:
(function(){
var extend = function (destination, source) {
var property = "";
for (property in source) {
destination[property] = source[property];
}
return destination;
};
(function () {
/**
* 創建命名空間。
示例:
namespace("YE.Collection");
*/
var global = {
namespace: function (str) {
var parent = window,
parts = str.split('.'),
i = 0,
len = 0;
if (str.length == 0) {
throw new Error("命名空間不能為空");
}
for (i = 0, len = parts.length; i < len; i++) {
if (typeof parent[parts[i]] === "undefined") {
parent[parts[i]] = {};
}
parent = parent[parts[i]]; //遞歸增加命名空間
}
return parent;
}
};
extend(window, global);
}());
}());
提出Tool類
不應該直接修改YTool的namespace方法,而應該將修改后的方法提取到引擎中,因為:
(1)導致引擎依賴工具庫YTool
YTool中的很多方法引擎都使用不到,如果將修改后的namespace方法放到YTool中,在使用引擎時就必須引入YTool。
這樣做會增加引擎的不穩定性,增加整個引擎文件的大小,違反引擎設計原則“盡量減少引擎依賴的外部文件”。
(2)導致大量關聯代碼修改
我的很多代碼都使用了YTool,如果修改了YTool的namespace方法,那么使用了YTool的namespace方法的相關代碼可能都需要進行修改。
所以,引擎增加Tool類,負責放置引擎內部使用的通用方法,將修改后的namespace方法放在Tool類中,從而將引擎的依賴YTool改為依賴自己的Tool。
同理,在后面的提煉引擎類時,將引擎類依賴的YTool的方法也全部轉移到Tool類中。
引擎Tool的命名空間為YE.Tool。
因為引擎Tool類僅供引擎內部使用,所以炸彈人仍然依賴YTool,而不依賴引擎Tool類。
領域模型

提出ImgLoader
按照從左到右的提煉順序,首先要提煉引擎初步領域模型中的LoaderResource。
提煉引擎類
領域類LoaderResource負責加載各種資源,對應炸彈人PreLoadImg類,該類本身就是一個獨立的圖片預加載組件(參考發布我的圖片預加載控件YPreLoadImg v1.0),可直接提煉到引擎中。
我將其重命名為ImgLoader,加入到命名空間YE中,代碼如下:
引擎ImgLoader
namespace("YE").ImgLoader = YYC.Class({
Init: function (images, onstep, onload) {
this._checkImages(images);
this.config = {
images: images || [],
onstep: onstep || function () {
},
onload: onload || function () {
}
};
this._imgs = {};
this.imgCount = this.config.images.length;
this.currentLoad = 0;
this.timerID = 0;
this.loadImg();
},
Private: {
_imgs: {},
_checkImages: function (images) {
var i = null;
for (var i in images) {
if (images.hasOwnProperty(i)) {
if (images[i].id === undefined || images[i].url === undefined) {
throw new Error("應該包含id和url屬性");
}
}
}
}
},
Public: {
imgCount: 0,
currentLoad: 0,
timerID: 0,
get: function (id) {
return this._imgs[id];
},
loadImg: function () {
var c = this.config,
img = null,
i,
self = this,
image = null;
for (i = 0; i < c.images.length; i++) {
img = c.images[i];
image = this._imgs[img.id] = new Image();
image.onload = function () {
this.onload = null;
YYC.Tool.func.bind(self, self.onload)();
};
image.src = img.url;
this.timerID = (function (i) {
return setTimeout(function () {
if (i == self.currentLoad) {
image.src = img.url;
}
}, 500);
})(i);
}
},
onload: function (i) {
clearTimeout(this.timerID);
this.currentLoad++;
this.config.onstep(this.currentLoad, this.imgCount);
if (this.currentLoad === this.imgCount) {
this.config.onload(this.currentLoad);
}
},
dispose: function () {
var i, _imgs = this._imgs;
for (i in _imgs) {
_imgs[i].onload = null;
_imgs[i] = null;
}
this.config = null;
}
}
});
炸彈人使用提煉的引擎類
對應修改炸彈人Main,改為使用引擎ImgLoader加載圖片:
炸彈人Main修改前
init: function () {
window.imgLoader = new YYC.Control.PreLoadImg(…);
},
炸彈人Main修改后
init: function () {
window.imgLoader = new YE.ImgLoader(...);
},
領域模型

提出Main
接著就是提煉依賴LoadResource的Main。
提煉引擎類
領域類Main負責啟動游戲,對應炸彈人Main。
先來看下相關代碼:
炸彈人Main
var main = (function () {
var _getImg = function () {
var urls = [];
var i = 0, len = 0;
var map = [
{ id: "ground", url: getImages("ground") },
{ id: "wall", url: getImages("wall") }
];
var player = [
{ id: "player", url: getImages("player") }
];
var enemy = [
{ id: "enemy", url: getImages("enemy") }
];
var bomb = [
{ id: "bomb", url: getImages("bomb") },
{ id: "explode", url: getImages("explode") },
{ id: "fire", url: getImages("fire") }
];
_addImg(urls, map, player, enemy, bomb);
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 });
}
}
};
var _hideBar = function () {
$("#progressBar").css("display", "none");
};
return {
init: function () {
//使用引擎ImgLoader加載圖片
window.imgLoader = new YE.ImgLoader(_getImg(), function (currentLoad, imgCount) {
$("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10)); //調用進度條插件
}, YYC.Tool.func.bind(this, this.onload));
},
onload: function () {
_hideBar();
var game = new Game();
game.init();
game.start();
},
};
window.main = main;
}());
炸彈人Main負責以下的邏輯:
(1)定義要加載的圖片數據
(2)創建ImgLoader實例,加載圖片
(3)完成圖片加載后,啟動游戲
(4)提供入口方法,由頁面調用
可以將第4個邏輯提到引擎Main中,由引擎搭建一個框架,炸彈人Main負責填充具體的業務邏輯。
引擎Main:
(function () {
var _instance = null;
namespace("YE").Main = YYC.Class({
Init: function () {
},
Public: {
init: function () {
this. loadResource ();
},
//* 鉤子
loadResource: function () {
}
},
Static: {
getInstance: function () {
if (instance === null) {
_instance = new this();
}
return _instance;
}
}
});
}());
分析引擎Main
- 使用方式為實例重寫
提供了loadResource鉤子方法供用戶重寫。
- 單例
因為游戲只有一個入口類,因此引擎Main為單例類。
- 框架設計
頁面調用引擎Main的init方法進入游戲,init方法調用鉤子方法loadResource,該鉤子方法由炸彈人Main重寫,從而實現在引擎框架中插入用戶邏輯。
炸彈人使用提煉的引擎類
炸彈人Main通過重寫引擎Main的loadResource鉤子方法來插入用戶邏輯。
炸彈人Main
(function () {
var main = YE.Main.getInstance();
var _getImg = function () {
...
};
var _addImg = function (urls, imgs) {
...
};
var _hideBar = function () {
...
};
var _onload = function(){
…
};
//重寫引擎Main的loadResource鉤子
main.loadResource =function () {
window.imgLoader = new YE.ImgLoader(_getImg(), function (currentLoad, imgCount) {
$("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10));
}, YYC.Tool.func.bind(this,_onload));
}
}());
修改頁面,調用引擎Main的init方法進入游戲:
頁面修改前
<script type="text/javascript">
(function () {
//調用炸彈人Main的init方法
main.init();
})();
</script>
頁面修改后
<script type="text/javascript">
(function () {
YE.Main.getInstance().init();
})();
</script>
重構引擎
引擎Main應該負責加載圖片,對用戶隱藏引擎ImgLoader
炸彈人應該只負責定義要加載的圖片和在加載圖片過程中要插入的用戶邏輯,不需要知道如何加載圖片。這個工作應該交給引擎Main,由它封裝引擎ImgLoader,向用戶提供操作圖片的方法和在加載圖片的過程中插入用戶邏輯的鉤子方法。
1、重構引擎ImgLoader
由于引擎ImgLoader設計僵化,需要先進行重構。
現在來看下引擎ImgLoader的構造函數
Init: function (images, onstep, onload) {
this._checkImages(images);
this.config = {
images: images || [],
onstep: onstep || function () {
},
onload: onload || function () {
}
};
this._imgs = {};
this.imgCount = this.config.images.length;
this.currentLoad = 0;
this.timerID = 0;
this.loadImg();
},
“設置加載圖片的回調函數”和“加載圖片”的邏輯和ImgLoader構造函數綁定在了一起,創建ImgLoader實例時會執行這兩項任務。
需要將其從構造函數中分離出來,由用戶自己決定何時執行這兩個任務。
因此進行下面的重構:
(1)將回調函數onstep重命名為onloading,將onload、onloading從構造函數中提出,作為鉤子方法。
(2)將圖片數據images的設置和檢查提取到新增的load方法中。
(3)提出done方法,負責調用_loadImg方法加載圖片。
引擎ImgLoader修改后
namespace("YE").ImgLoader = YYC.Class({
Init: function () {
},
Private: {
_images: [],
_imgs: {},
//修改了原來的_checkImages方法,現在傳入的圖片數據可以為單個數據,也可為數組形式的多個數據
_checkImages: function (images) {
var i = 0,
len = 0;
if (YYC.Tool.judge.isArray(images)) {
for (len = images.length; i < len; i++) {
if (images[i].id === undefined || images[i].url === undefined) {
throw new Error("應該包含id和url屬性");
}
}
}
else {
if (images.id === undefined || images.url === undefined) {
throw new Error("應該包含id和url屬性");
}
}
},
//將onload改為私有方法
_onload: function (i) {
…
//調用鉤子
this.onloading(this.currentLoad, this.imgCount);
if (this.currentLoad === this.imgCount) {
this.onload(this.imgCount);
}
},
//改為私有方法
_loadImg: function () {
…
}
}
},
Public: {
…
done: function () {
this._loadImg();
},
//負責檢查和保存圖片數據
load: function (images) {
this._checkImages(images);
if (YYC.Tool.judge.isArray(images)) {
this._images = this._images.concat(images);
}
else {
this._images.push(images);
}
this.imgCount = this._images.length;
},
…
//*鉤子
onloading: function (currentLoad, imgCount) {
},
onload: function (imgCount) {
}
}
});
2、重構引擎Main
現在回到引擎Main的重構,通過下面的重構來實現封裝引擎ImgLoader,向用戶提供鉤子方法和操作圖片的方法:
(1)構造函數中創建ImgLoader實例
(2)init方法中調用ImgLoader的done方法加載圖片
(3)提供getImg和load方法來操作圖片數據
(4)增加onload、onloading鉤子,將其與ImgLoader的onload、onloading鉤子綁定到一起。
綁定鉤子的目的是為了讓炸彈人Main只需要知道引擎Main的鉤子,從而達到引擎Main封裝引擎ImgLoader的目的。
這個方案并不是很好,在第二次迭代中會修改。
引擎Main修改后
(function () {
var _instance = null;
namespace("YE").Main = YYC.Class({
Init: function () {
this._imgLoader = new YE.ImgLoader();
},
Private: {
_imgLoader: null,
_prepare: function () {
this.loadResource();
this._imgLoader.onloading = this.onloading;
this._imgLoader.onload = this.onload;
}
},
Public: {
init: function () {
this._prepare();
this._imgLoader.done();
},
getImg: function (id) {
return this._imgLoader.get(id);
},
load: function (images) {
this._imgLoader.load(images);
},
//* 鉤子
loadResource: function () {
},
onload: function () {
},
onloading: function (currentLoad, imgCount) {
}
},
…
});
}());
3、修改炸彈人Main
炸彈人Main在重寫的引擎Main的loadResource方法中重寫引擎Main的onload、onloading鉤子方法,這相當于重寫了imgLoader的onload、onloading鉤子方法,從而在加載圖片的過程中插入用戶邏輯。
炸彈人Main
(function () {
…
main.loadResource = function () {
this.load(_getImg());
};
main.onloading = function (currentLoad, imgCount) {
$("#progressBar_img_show").progressBar(parseInt(currentLoad * 100 / imgCount, 10));
};
main.onload = function () {
_hideBar();
var game = new Game();
game.init();
game.start();
};
}());
將依賴的YTool方法移到Tool
修改后的引擎ImgLoader需要調用YTool的isArray方法,將其移到引擎Tool中。
引擎Tool
namespace("YE.Tool").judge = {
isArray: function (val) {
return Object.prototype.toString.call(val) === "[object Array]";
}
};
對應修改ImgLoader,將YYC.Tool調用改為YE.Tool
...
if (YE.Tool.judge.isArray(images)) {
…
}
領域模型

提出Director
繼續往右提煉Director。
提煉引擎類
領域類Director負責游戲的統一調度,對應炸彈人的Game類
炸彈人Game
(function () {
//初始化游戲全局狀態
window.gameState = window.bomberConfig.game.state.NORMAL;
var Game = YYC.Class({
Init: function () {
window.subject = new YYC.Pattern.Subject();
},
Private: {
_createLayerManager: function () {
this.layerManager = new LayerManager();
this.layerManager.addLayer("mapLayer", layerFactory.createMap());
this.layerManager.addLayer("enemyLayer", layerFactory.createEnemy(this.sleep));
this.layerManager.addLayer("playerLayer", layerFactory.createPlayer(this.sleep));
this.layerManager.addLayer("bombLayer", layerFactory.createBomb());
this.layerManager.addLayer("fireLayer", layerFactory.createFire());
},
_addElements: function () {
var mapLayerElements = this._createMapLayerElement(),
playerLayerElements = this._createPlayerLayerElement(),
enemyLayerElements = this._createEnemyLayerElement();
this.layerManager.addSprites("mapLayer", mapLayerElements);
this.layerManager.addSprites("playerLayer", playerLayerElements);
this.layerManager.addSprites("enemyLayer", enemyLayerElements);
},
_createMapLayerElement: function () {
var i = 0,
j = 0,
x = 0,
y = 0,
row = bomberConfig.map.ROW,
col = bomberConfig.map.COL,
element = [],
mapData = mapDataOperate.getMapData(),
img = null;
for (i = 0; i < row; i++) {
y = i * bomberConfig.HEIGHT;
for (j = 0; j < col; j++) {
x = j * bomberConfig.WIDTH;
img = this._getMapImg(i, j, mapData);
element.push(spriteFactory.createMapElement({ x: x, y: y }, bitmapFactory.createBitmap({ img: img, width: bomberConfig.WIDTH, height: bomberConfig.HEIGHT })));
}
}
return element;
},
_getMapImg: function (i, j, mapData) {
var img = null;
switch (mapData[i][j]) {
case 1:
img = YE.Main.getInstance().getImg("ground");
break;
case 2:
img = YE.Main.getInstance().getImg("wall");
break;
default:
break
}
return img;
},
_createPlayerLayerElement: function () {
var element = [],
player = spriteFactory.createPlayer();
player.init();
element.push(player);
return element;
},
_createEnemyLayerElement: function () {
var element = [],
enemy = spriteFactory.createEnemy(),
enemy2 = spriteFactory.createEnemy2();
enemy.init();
enemy2.init();
element.push(enemy);
element.push(enemy2);
return element;
},
_initLayer: function () {
this.layerManager.initLayer();
},
_initEvent: function () {
//監聽整個document的keydown,keyup事件
keyEventManager.addKeyDown();
keyEventManager.addKeyUp();
},
_judgeGameState: function () {
switch (window.gameState) {
case window.bomberConfig.game.state.NORMAL:
break;
case window.bomberConfig.game.state.OVER:
this.gameOver();
return "over";
break;
case window.bomberConfig.game.state.WIN:
this.gameWin();
return "over";
break;
default:
throw new Error("未知的游戲狀態");
}
return false;
}
},
Public: {
sleep: 0,
layerManager: null,
mainLoop: null,
init: function () {
this.sleep = Math.floor(1000 / bomberConfig.FPS);
this._createLayerManager();
this._addElements();
this._initLayer();
this._initEvent();
window.subject.subscribe(this.layerManager.getLayer("mapLayer"), this.layerManager.getLayer("mapLayer").changeSpriteImg);
},
start: function () {
var self = this;
this.mainLoop = window.setInterval(function () {
self.run();
}, this.sleep);
},
run: function () {
if (this._judgeGameState() === "over") {
return;
}
this.layerManager.run();
this.layerManager.change();
},
gameOver: function () {
YYC.Tool.asyn.clearAllTimer(this.mainLoop);
alert("Game Over!");
},
gameWin: function () {
YYC.Tool.asyn.clearAllTimer(this.mainLoop);
alert("You Win!");
}
}
});
window.Game = Game;
}());
炸彈人Game負責游戲的統一調度,包括以下的邏輯:
(1)初始化場景
(2)調度layerManager
(3)控制主循環
(4)計算幀率fps
(5)管理游戲狀態
其中控制主循環、調度layerManager、計算fps的邏輯可以提取到引擎Director中:
引擎Director
(function () {
var _instance = null;
var GameStatus = {
NORMAL: 0,
STOP: 1
};
var STARTING_FPS = 60;
namespace("YE").Director = YYC.Class({
Private: {
_startTime: 0,
_lastTime: 0,
_fps: 0,
_layerManager: null,
//內部游戲狀態
_gameState: null,
_getTimeNow: function () {
return +new Date();
},
_run: function (time) {
var self = this;
this._loopBody(time);
if (this._gameState === GameStatus.STOP) {
return;
}
window.requestNextAnimationFrame(function (time) {
self._run(time);
});
},
_loopBody: function (time) {
this._tick(time);
this.onStartLoop();
this._layerManager.run();
this._layerManager.change();
this.onEndLoop();
},
_tick: function (time) {
this._updateFps(time);
this.gameTime = this._getTimeNow() - this._startTime;
this._lastTime = time;
},
_updateFps: function (time) {
if (this._lastTime === 0) {
this._fps =STARTING_FPS;
}
else {
this._fps = 1000 / (time - this._lastTime);
}
}
},
Public: {
gameTime: null,
start: function () {
var self = this;
this._startTime = this._getTimeNow();
window.requestNextAnimationFrame(function (time) {
self._run(time);
});
},
setLayerManager: function (layerManager) {
this._layerManager = layerManager;
},
getFps: function () {
return this._fps;
},
stop: function () {
this._gameState = GameStatus.STOP;
},
//*鉤子
init: function () {
},
onStartLoop: function () {
},
onEndLoop: function () {
}
},
Static: {
getInstance: function () {
if (_instance === null) {
_instance = new this();
}
return _instance;
}
}
});
}());
分析引擎Director
使用方式為實例重寫
引擎Director提供了init、onStartLoop、onEndLoop鉤子方法供用戶重寫。
引擎會在加載完圖片后調用鉤子方法init,用戶可以通過重寫該鉤子,插入初始化游戲的用戶邏輯。
onStartLoop、onEndLoop鉤子分別在每次主循環開始和結束時調用,插入用戶邏輯:
引擎Director
_loopBody: function (time) {
this._tick(time);
this.onStartLoop();
…
this.onEndLoop();
},
單例
因為全局只有一個Director,因此為單例。
主循環
使用requestAnimationFrame實現主循環
炸彈人Game中使用setInterval方法,而引擎Director使用requestAnimationFrame方法實現主循環。這是因為可以通過setTimeout和setInterval方法在腳本中實現動畫,但是這樣效果可能不夠流暢,且會占用額外的資源。
參考《HTML5 Canvas核心技術:圖形、動畫與游戲開發》中的論述:
它們有如下的特征:
1、即使向其傳遞毫秒為單位的參數,它們也不能達到ms的準確性。這是因為javascript是單線程的,可能會發生阻塞。
2、沒有對調用動畫的循環機制進行優化。
3、沒有考慮到繪制動畫的最佳時機,只是一味地以某個大致的事件間隔來調用循環。
其實,使用setInterval或setTimeout來實現主循環,根本錯誤就在于它們抽象等級不符合要求。我們想讓瀏覽器執行的是一套可以控制各種細節的api,實現如“最優幀速率”、“選擇繪制下一幀的最佳時機”等功能。但是如果使用它們的話,這些具體的細節就必須由開發者自己來完成。
requestAnimationFrame不需要使用者指定循環間隔時間,瀏覽器會基于當前頁面是否可見、CPU的負荷情況等來自行決定最佳的幀速率,從而更合理地使用CPU。
需要注意的時,不同的瀏覽器對于requestAnimationFrame、cancelNextRequestAnimationFrame的實現不一樣,因此需要定義通用的方法,放到引擎Tool類中。
引擎Tool
/**
* 來自《HTML5 Canvas核心技術:圖形、動畫與游戲開發》
*/
window.requestNextAnimationFrame = (function () {
var originalWebkitRequestAnimationFrame = undefined,
wrapper = undefined,
callback = undefined,
geckoVersion = 0,
userAgent = navigator.userAgent,
index = 0,
self = this;
// Workaround for Chrome 10 bug where Chrome
// does not pass the time to the animation function
if (window.webkitRequestAnimationFrame) {
// Define the wrapper
wrapper = function (time) {
if (time === undefined) {
time = +new Date();
}
self.callback(time);
};
// Make the switch
originalWebkitRequestAnimationFrame = window.webkitRequestAnimationFrame;
window.webkitRequestAnimationFrame = function (callback, element) {
self.callback = callback;
// Browser calls the wrapper and wrapper calls the callback
originalWebkitRequestAnimationFrame(wrapper, element);
}
}
// Workaround for Gecko 2.0, which has a bug in
// mozRequestAnimationFrame() that restricts animations
// to 30-40 fps.
if (window.mozRequestAnimationFrame) {
// Check the Gecko version. Gecko is used by browsers
// other than Firefox. Gecko 2.0 corresponds to
// Firefox 4.0.
index = userAgent.indexOf('rv:');
if (userAgent.indexOf('Gecko') != -1) {
geckoVersion = userAgent.substr(index + 3, 3);
if (geckoVersion === '2.0') {
// Forces the return statement to fall through
// to the setTimeout() function.
window.mozRequestAnimationFrame = undefined;
}
}
}
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function (callback, element) {
var start,
finish;
window.setTimeout(function () {
start = +new Date();
callback(start);
finish = +new Date();
self.timeout = 1000 / 60 - (finish - start);
}, self.timeout);
};
}());
window.cancelNextRequestAnimationFrame = window.cancelRequestAnimationFrame
|| window.webkitCancelAnimationFrame
|| window.webkitCancelRequestAnimationFrame
|| window.mozCancelRequestAnimationFrame
|| window.oCancelRequestAnimationFrame
|| window.msCancelRequestAnimationFrame
|| clearTimeout;
控制主循環
主循環的邏輯封裝在_run方法中。
start方法負責啟動主循環。
退出主循環的機制
為了能夠退出主循環,增加內部游戲狀態_gameState。用戶可調用引擎Director的stop方法來設置內部游戲狀態為STOP,然后Director會在主循環中的_run方法中判斷內部游戲狀態,如果為STOP狀態,則退出主循環。
引擎Director
_run: function (time) {
var self = this;
this._loopBody(time);
if (this._gameState === GameStatus.STOP) {
//退出主循環
return;
}
window.requestNextAnimationFrame(function (time) {
self._run(time);
});
},
…
stop: function () {
this._gameState = GameStatus.STOP;
},
這里有同學可能會問為什么stop方法不直接調用cancelNextRequestAnimationFrame方法來結束主循環?
參考代碼如下所示:
引擎Director
_run: function (time) {
var self = this;
this._loopBody(time);
//刪除游戲狀態的判斷
this._loopId = window.requestNextAnimationFrame(function (time) {
self._run(time);
});
},
…
stop: function () {
//直接在stop方法中結束主循環
window.cancelNextRequestAnimationFrame(this._loopId);
}
這是因為:
如果用戶是在引擎的鉤子中調用stop方法,由于引擎的鉤子方法都是在主循環中調用的(_loopBody方法中調用),所以不能結束主循環!
//該方法包含了主循環邏輯,所有的鉤子方法都是在該方法中調用
_loopBody: function (time) {
this._tick(time);
this._scene.onStartLoop();
this._scene.run();
this._scene.onEndLoop();
},
只有當用戶在引擎主循環外部調用stop方法時,才可以結束主循環。
詳見《深入理解requestAnimationFrame》中的“為什么在callback內部執行cancelAnimationFrame不能取消動畫”
調度layerManager
目前LayerManager為炸彈人類,用戶通過調用引擎Director的setLayerManager方法將其注入到引擎Director中。
領域模型

引擎Director在主循環中調用layerManager實例的run和change方法,執行炸彈人LayerManager的主循環邏輯。
- 為什么要由用戶注入LayerManager實例,而不是直接在引擎Director中創建LayerManager實例?
(1)根據引擎設計原則“引擎不應該依賴用戶,用戶應該依賴引擎”,LayerManager為用戶類,引擎不應該依賴用戶。
(2)這樣會降低引擎Director的通用性
引擎Director應該操作抽象角色,而不應該直接操作具體的“層管理”類,這樣會導致具體的“層管理”類變化時,引擎Director也會受到影響。
因此,此處采用“由用戶注入”的設計更加合理。
- 為什么由引擎Director調用炸彈人LayerManager的change方法?
LayerManager的change方法負責調用每個層的change方法,設置畫布的狀態(主循環中會判斷畫布狀態,決定是否更新畫布):
炸彈人LayerManager
change: function () {
this.__iterator("change");
}
change方法的調用有兩個選擇:
(1)由用戶調用
用戶可在重寫引擎Director提供的鉤子方法中(如onEndLoop),調用炸彈人LayerManager的change方法
(2)由引擎調用
引擎Director主循環在調用layerManager的run方法后調用layerManager的change方法。
因為:
(1)設置畫布狀態的邏輯屬于通用邏輯
(2)引擎對什么時候設置畫布狀態有最多的知識
所以應該由引擎Director調用。
計算幀率fps
引擎Director的_updateFps方法負責根據上一次主循環執行時間計算fps:
//time為當前主循環的開始時間(從1970年1月1日到當前所經過的毫秒數)
//lastTime為上一次主循環的開始時間
_updateFps: function (time) {
if (this._lastTime === 0) {
this._fps = STARTING_FPS;
}
else {
this._fps = 1000 / (time - this._lastTime);
}
}
其中引擎Director的STARTING_FPS定義了初始的fps,“time-this._lastTime”計算的是上次主循環的執行時間。
如果為第一次主循環,lastTime為0,fps為初始值;
否則,fps為上次主循環執行時間的倒數。
炸彈人使用提煉的引擎類
修改炸彈人Game
炸彈人Game改為只負責初始化場景和管理游戲狀態,其它邏輯委托引擎實現。
炸彈人Game
(function () {
//獲得引擎Director實例,從而可實例重寫。
var director = YE.Director.getInstance();
var Game = YYC.Class({
Init: function () {
},
Private: {
...
_gameOver: function () {
director.stop(); //結束主循環
alert("Game Over!");
},
_gameWin: function () {
director.stop(); //結束主循環
alert("You Win!");
}
},
Public: {
…
init: function () {
//初始化游戲全局狀態
window.gameState = window.bomberConfig.game.state.NORMAL;
window.subject = new YYC.Pattern.Subject();
//調用引擎Director的getFps方法獲得fps
this.sleep = 1000 / director.getFps();
…
},
judgeGameState: function () {
…
}
}
});
var game = new Game();
//重寫引擎Director的init鉤子
director.init = function () {
game.init();
//設置場景
this.setLayerManager(game.layerManager);
};
//重寫引擎Director的onStartLoop鉤子
director.onStartLoop = function () {
game.judgeGameState();
};
}());
重構炸彈人Game
- 移動邏輯
將Game中屬于“初始化場景”職責的“初始化游戲全局狀態”和“創建Subject實例”邏輯提到Game的 init方法中。
- 將gameOver、gameWin設為私有方法,judgeGameState設為公有方法
因為只有Game調用這兩個方法,因此將其設為私有方法。
而judgeGameState方法被director的鉤子方法調用,因此將其設為公有方法。
炸彈人Game實例重寫引擎Director
- 重寫引擎Director的init鉤子
在init鉤子中,炸彈人插入了Game的初始化場景的邏輯,注入了Game創建的layerManager實例。
- 刪除start和run方法
這部分職責已經移到引擎Director中了,所以Game刪除start和run方法,由引擎負責控制主循環。
- 重寫了Director的onStartLoop鉤子,實現了炸彈人游戲的結束機制
修改了Game的gameOver、gameWin方法,改為調用director.stop方法來結束主循環。
將Game的run方法的“關于全局游戲狀態判斷”的邏輯移到Director的onStartLoop鉤子中,引擎會在每次主循環開始時判斷一次全局游戲狀態,決定是否調用Game的gameOver或gameWin方法結束游戲。
修改炸彈人Main
為了能通過游戲的運行測試,先修改炸彈人Main重寫引擎Main的onload鉤子,改為調用引擎Director的init和start方法來執行游戲初始化并啟動主循環。
炸彈人Main修改前
main.onload = function () {
…
var game = new Game();
game.init();
game.start();
};
炸彈人Main修改后
main.onload = function () {
…
var director = YE.Director.getInstance();
director.init();
director.start();
};
重構引擎
- 將炸彈人Main的“執行游戲初始化并啟動主循環”的邏輯移到引擎Main中
因為:
(1)“執行游戲初始化”的邏輯具體是調用Director的鉤子方法init,而鉤子方法應該由引擎調用。
(2)“執行游戲初始化”和“啟動主循環”的邏輯應該由入口類負責,也就是說可以由引擎Main或炸彈人Main負責。因為該邏輯與引擎更相關,并且考慮到引擎設計原則“盡量減少用戶負擔”,所以應該由引擎Main負責。
所以應該由引擎Main負責該邏輯。
因此修改引擎ImgLoader,增加onload_game鉤子;然后在引擎Main中重寫ImgLoader的onload_game鉤子,實現“執行游戲初始化并啟動主循環”的邏輯;最后修改炸彈人Main重寫引擎Main的onload鉤子,不再調用引擎Director的init和start方法。
為什么引擎ImgLoader要增加onload_game鉤子?
因為現在引擎ImgLoader的鉤子是供炸彈人Main重寫的,引擎Main無法重寫引擎ImgLoader的鉤子來執行“執行游戲初始化并啟動主循環”邏輯,所以引擎ImgLoader增加內部鉤子onload_game,供引擎Main重寫,而炸彈人Main則負責在重寫的引擎ImgLoader的onload鉤子中實現“加載圖片完成到執行游戲初始化并啟動主循環”之間的用戶邏輯。
相關代碼
引擎ImgLoader
_onload: function (i) {
...
if (this.currentLoad === this.imgCount) {
//圖片加載完成后調用onload和onload_game鉤子
this.onload(this.imgCount);
this.onload_game();
}
},
...
//*內部鉤子
onload_game: function () {
},
...
}
引擎Main
_prepare: function () {
this.loadResource();
this._imgLoader.onloading = this.onloading;
this._imgLoader.onload = this.onload;
this._imgLoader.onload_game = function () {
var director = YE.Director.getInstance();
director.init();
director.start();
}
}
炸彈人Main
main.onload = function () {
//隱藏資源加載進度條
_hideBar();
};
待重構點
引擎ImgLoader的onload鉤子和onload_game鉤子重復了,兩者都是在加載圖片完成后調用。
提出onload_game鉤子只是一個臨時的解決方案,在第二次迭代中會刪除它。
領域模型

提出Scene和Hash
現在應該提出Scene領域類,使引擎Director依賴引擎Scene,而不是依賴炸彈人LayerManager。
由于Scene繼承于Hash,因此將Hash也一起提出。
提煉引擎類
領域類Scene負責管理場景,對應炸彈人LayerManager;領域類Hash為哈希結構的集合類,對應炸彈人Hash。
炸彈人LayerManager是一個容器類,負責層的管理,屬于通用類,可直接提取到引擎中,重命名為Scene。
炸彈人Hash是一個獨立的抽象類,可直接提取到引擎中
引擎Hash
(function () {
namespace("YE").Hash = YYC.AClass({
Private: {
//容器
_childs: {}
},
Public: {
getChilds: function () {
return this._childs;
},
getValue: function (key) {
return this._childs[key];
},
add: function (key, value) {
this._childs[key] = value;
return this;
}
}
});
}());
引擎Scene
(function () {
namespace("YE").Scene = YYC.Class(YE.Hash, {
Private: {
__iterator: function (handler, args) {
var args = Array.prototype.slice.call(arguments, 1),
i = null,
layers = this.getChilds();
for (i in layers) {
if (layers.hasOwnProperty(i)) {
layers[i][handler].apply(layers[i], args);
}
}
},
__getLayers: function () {
return this.getChilds();
}
},
Public: {
addLayer: function (name, layer) {
this.add(name, layer);
return this;
},
getLayer: function (name) {
return this.getValue(name);
},
addSprites: function (name, elements) {
this.getLayer(name).appendChilds(elements);
},
initLayer: function () {
this.__iterator("setCanvas");
this.__iterator("init", this.__getLayers());
},
run: function () {
this.__iterator("run");
},
change: function () {
this.__iterator("change");
}
}
});
}());
炸彈人使用提煉的引擎類
重構炸彈人Game,改為依賴引擎Scene
因為炸彈人LayerManager重構為引擎Scene了,因此炸彈人Game也要對應修改為依賴引擎Scene。
領域模型

將Game的layerMangaer屬性重命名為scene,并重命名_createLayerManager方法為_createScene,改為創建引擎Scene實例。
炸彈人Game
_createScene: function () {
this.scene = new YE.Scene();
this.scene.addLayer("mapLayer", layerFactory.createMap());
this.scene.addLayer("enemyLayer", layerFactory.createEnemy(this.sleep));
this.scene.addLayer("playerLayer", layerFactory.createPlayer(this.sleep));
this.scene.addLayer("bombLayer", layerFactory.createBomb());
this.scene.addLayer("fireLayer", layerFactory.createFire());
},
_addElements: function () {
…
this.scene.addSprites("mapLayer", mapLayerElements);
this.scene.addSprites("playerLayer", playerLayerElements);
this.scene.addSprites("enemyLayer", enemyLayerElements);
},
…
_initLayer: function () {
this.scene.initLayer();
},
…
init: function () {
…
this._createScene();
…
}
重構引擎
因為引擎Director依賴引擎Scene了,所以應該將_layerManager屬性重命名為scene,將setLayerManager方法重命名為setScene。
引擎Director
_scene: null,
…
_loopBody: function (time) {
…
this._scene.run();
this._scene.change();
…
},
…
setScene: function (scene) {
this._scene = scene;
},
對應修改Game,改為調用setScene方法:
炸彈人Game
director.init = function () {
…
//設置場景
this.setScene(game.scene);
};
領域模型

提出Layer和Collection
現在應該提出Layer領域類,使引擎Scene依賴引擎Layer。
由于Layer繼承于Collection類,因此將Collection也一起提出。
提煉引擎類
領域類Layer負責層內精靈的統一管理,對應炸彈人的Layer。
領域類Collection為線性結構的集合類,對應炸彈人Collection.
炸彈人Layer是一個抽象類,負責精靈的管理,具有通用性,直接提取到引擎中。
炸彈人Collection是一個獨立的類,可直接提取到引擎中
引擎Layer
(function () {
namespace("YE").Layer = YYC.AClass(Collection, {
Init: function () {
},
Private: {
__state: bomberConfig.layer.state.CHANGE,
__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_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();
},
P_render: function () {
if (this.P_isChange()) {
this.clear();
this.draw();
this.setStateNormal();
}
}
},
Public: {
remove: function (sprite) {
this.base(function (e, obj) {
if (e.x === obj.x && e.y === obj.y) {
return true;
}
return false;
}, sprite);
},
setStateNormal: function () {
this.__state = bomberConfig.layer.state.NORMAL;
},
setStateChange: function () {
this.__state = bomberConfig.layer.state.CHANGE;
},
Virtual: {
init: function () {
this.__getContext();
},
clear: function (sprite) {
if (arguments.length === 0) {
this.P_iterator("clear", this.P_context);
}
else if (arguments.length === 1) {
sprite.clear(this.P_context);
}
}
}
},
Abstract: {
setCanvas: function () {
},
change: function () {
},
draw: function () {
},
//游戲主循環調用的方法
run: function () {
}
}
});
}());
引擎Collecton
(function () {
//*使用迭代器模式
var IIterator = YYC.Interface("hasNext", "next", "resetCursor");
namespace("YE").Collection = YYC.AClass({Interface: IIterator}, {
Private: {
//當前游標
_cursor: 0,
//容器
_childs: []
},
Public: {
getChilds: function () {
return YYC.Tool.array.clone(this._childs);
},
getChildAt: function (index) {
return this._childs[index];
},
appendChild: function (child) {
this._childs.push(child);
return this;
},
appendChilds: function (childs) {
var i = 0,
len = 0;
for (i = 0, len = childs.length; i < len; i++) {
this.addChild(childs[i]);
}
},
removeAll: function () {
this._childs = [];
},
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;
},
Virtual: {
remove: function (func, child) {
this._childs.remove(func, child);
}
}
}
});
}());
分析
將引擎Collection依賴YTool的clone方法提到引擎Tool中。
引擎Tool
namespace("YE.Tool").array = {
/*返回一個新的數組,元素與array相同(地址不同)*/
clone: function (array) {
var new_array = new Array(array.length);
for (var i = 0, _length = array.length; i < _length; i++) {
new_array[i] = array[i];
}
return new_array;
}
};
對應修改引擎Collection
getChilds: function () {
return YE.Tool.array.clone(this._childs);
},
重構提煉的引擎類
重構Collection
引擎Collection重命名appendChild、appendChilds為addChild、addChilds:
引擎Collection
addChild: function (child) {
…
},
addChilds: function (childs) {
…
},
重構Layer
現在引擎Layer依賴炸彈人Config定義的枚舉值State:
引擎Layer
Private: {
__state: bomberConfig.layer.state.CHANGE,
…
Protected: {
…
P_isChange: function () {
return this.__state === bomberConfig.layer.state.CHANGE;
},
P_isNormal: function () {
return this.__state === bomberConfig.layer.state.NORMAL;
},
…
Public: {
…
setStateNormal: function () {
this.__state = bomberConfig.layer.state.NORMAL;
},
setStateChange: function () {
this.__state = bomberConfig.layer.state.CHANGE;
},
因為引擎Layer不應該依賴用戶類,因此應該將枚舉值State移到引擎類中。又因為State為畫布狀態,與引擎Layer相關,因此將其提出來直接放到引擎Layer中,解除引擎Layer對炸彈人Config的依賴。
引擎Layer
//定義State枚舉值
var State = {
NORMAL: 0,
CHANGE: 1
};
namespace("YE").Layer = YYC.AClass(YE.Collection, {
Init: function () {
},
Private: {
__state: State.CHANGE,
…
Protected: {
…
P_isChange: function () {
return this.__state === State.CHANGE;
},
P_isNormal: function () {
return this.__state === State.NORMAL;
},
…
Public: {
…
setStateNormal: function () {
this.__state = State.NORMAL;
},
setStateChange: function () {
this.__state = State.CHANGE;
},
炸彈人使用提煉的引擎類
炸彈人層類改為繼承引擎Layer
由于引擎Layer的使用方式為繼承重寫,所以修改炸彈人BombLayer、CharacterLayer、FireLayer、MapLayer、PlayerLayer,繼承引擎Layer:
var BombLayer = YYC.Class(YE.Layer, {
…
var CharacterLayer = YYC.Class(YE.Layer, {
…
var FireLayer = YYC.Class(YE.Layer, {
…
var MapLayer = YYC.Class(YE.Layer, {
…
var PlayerLayer = YYC.Class(YE.Layer, {
領域模型

提出Sprite、Config和collision
現在應該提出Sprite類,使引擎Layer依賴引擎Sprite。
提煉引擎類
領域類Sprite為精靈類,對應炸彈人的Sprite。
炸彈人Sprite作為抽象類,提煉了炸彈人精靈類的共性,具有通用性,因此將其直接提取到引擎中。
引擎Sprite
(function () {
namespace("YE").Sprite = YYC.AClass({
Init: function (data, bitmap) {
this.bitmap = bitmap;
if (data) {
//初始坐標
this.x = data.x;
this.y = data.y;
this.defaultAnimId = data.defaultAnimId;
this.anims = data.anims;
}
},
Private: {
//更新幀動畫
_updateFrame: function (deltaTime) {
if (this.currentAnim) {
this.currentAnim.update(deltaTime);
}
}
},
Public: {
//bitmap實例
bitmap: null,
//精靈的坐標
x: 0,
y: 0,
//精靈動畫集合
anims: null,
//默認的動畫id
defaultAnimId: null,
//當前的Animation.
currentAnim: null,
//設置當前動畫
setAnim: function (animId) {
this.currentAnim = this.anims[animId];
},
//重置當前幀
resetCurrentFrame: function (index) {
this.currentAnim && this.currentAnim.setCurrentFrame(index);
},
//取得精靈的碰撞區域,
getCollideRect: function () {
var obj = {
x: this.x,
y: this.y,
width: this.bitmap.width,
height: this.bitmap.height
};
return YE.collision.getCollideRect(obj);
},
Virtual: {
init: function () {
//初始化時顯示默認動畫
this.setAnim(this.defaultAnimId);
},
// 更新精靈當前狀態.
update: function (deltaTime) {
this._updateFrame(deltaTime);
},
//獲得坐標對應的方格坐標(向下取值)
getCellPosition: function (x, y) {
return {
x: Math.floor(x / YE.Config.WIDTH),
y: Math.floor(y / YE.Config.HEIGHT)
}
},
draw: function (context) {
context.drawImage(this.bitmap.img, this.x, this.y, this.bitmap.width, this.bitmap.height);
},
clear: function (context) {
//直接清空畫布區域
context.clearRect(0, 0, YE.Config.canvas.WIDTH, YE.Config.canvas.HEIGHT);
}
}
}
});
}());
重構提煉的引擎類
提出Config
現在引擎Sprite引用了炸彈人Config類定義的“方格大小”和“畫布大小”:
引擎Sprite
getCellPosition: function (x, y) {
return {
x: Math.floor(x / bomberConfig.Config.WIDTH),
y: Math.floor(y / bomberConfig.Config.HEIGHT)
}
},
…
clear: function (context) {
context.clearRect(0, 0, bomberConfig.Config.canvas.WIDTH, bomberConfig.Config.canvas.HEIGHT);
}
有下面幾個問題:
1、引擎Sprite依賴了炸彈人Config,違背了引擎設計原則“不應該依賴用戶”。
2、“方格大小”和“畫布大小”與精靈無關,因此不應該像引擎Layer的枚舉值State一樣放在Sprite中
因此,引擎提出Config配置類,將“方格大小”和“畫布大小”放在其中,使引擎Sprite依賴引擎Config。
引擎Config
namespace("YE").Config = {
//方格寬度
WIDTH: 30,
//方格高度
HEIGHT: 30,
//畫布
canvas: {
//畫布寬度
WIDTH: 600,
//畫布高度
HEIGHT: 600
}
對應修改引擎Sprite,依賴引擎Config
引擎Sprite
getCellPosition: function (x, y) {
return {
x: Math.floor(x / YE.Config.WIDTH),
y: Math.floor(y / YE.Config.HEIGHT)
}
},
…
clear: function (context) {
context.clearRect(0, 0, YE.Config.canvas.WIDTH, YE.Config.canvas.HEIGHT);
}
待重構點
引擎Config應該放置與引擎相關的、與用戶邏輯無關的配置屬性,而“方格大小”和“畫布大小”與具體的游戲邏輯相關,屬于用戶邏輯,不應該放在引擎Config中。
另外,引擎Sprite訪問了“方格大小”和“畫布大小”,混入了用戶邏輯。因此引擎Sprite還需要進一步提煉和抽象。
這個重構放到第二次迭代中進行。
炸彈人和引擎都有Config配置類,兩者有什么區別?
炸彈人Config放置與用戶邏輯相關的配置屬性,引擎Config放置與引擎相關的配置屬性,炸彈人類應該只訪問炸彈人的Config類,而引擎類應該只訪問引擎Config類。
提出collision
引擎Sprite使用了炸彈人collision的getCollideRect方法來獲得碰撞區域數據:
引擎Sprite
getCollideRect: function () {
…
return YYC.Tool.collision.getCollideRect(obj);
},
考慮到炸彈人collision是一個碰撞算法類,具有通用性,因此將其提取到引擎中。
引擎collision
namespace("YE").collision = (function () {
return {
//獲得精靈的碰撞區域,
getCollideRect: function (obj) {
return {
x1: obj.x,
y1: obj.y,
x2: obj.x + obj.width,
y2: obj.y + obj.height
}
},
//矩形和矩形間的碰撞
col_Between_Rects: function (obj1, obj2) {
var rect1 = this.getCollideRect(obj1);
var rect2 = this.getCollideRect(obj2);
if (rect1 && rect2 && !(rect1.x1 >= rect2.x2 || rect1.y1 >= rect2.y2 || rect1.x2 <= rect2.x1 || rect1.y2 <= rect2.y1)) {
return true;
}
return false;
}
};
}());
對應修改引擎Sprite,依賴引擎collision
getCollideRect: function () {
…
return YE.collision.getCollideRect(obj);
},
炸彈人使用提煉的引擎類
炸彈人精靈類改為繼承引擎Sprite
由于引擎Sprite的使用方式為繼承重寫,所以修改炸彈人的具體精靈類BombSprite、FireSprite、MapElementSprite、MoveSprite,繼承引擎Sprite類
var BombSprite= YYC.Class(YE.Sprite, {
…
var FireSprite = YYC.Class(YE.Sprite, {
…
var MapElementSprite = YYC.Class(YE.Sprite, {
…
var MoveSprite = YYC.Class(YE.Sprite, {
…
炸彈人改為依賴引擎collision
因為炸彈人collision提取到引擎中了,因此炸彈人改為依賴引擎的collision。
炸彈人BombSprite
collideFireWithCharacter: function (sprite) {
…
if (YE.collision.col_Between_Rects(fire, obj2)) {
return true;
}
炸彈人EnemySprite
collideWithPlayer: function (sprite2) {
…
if (YE.collision.col_Between_Rects(obj1, obj2)) {
throw new Error();
}
領域模型

提出Factory
現在提煉Factory類。
思考
有兩個問題需要思考:
1、哪些引擎類需要工廠。
2、用哪種方式實現工廠。
對于第1個問題,目前我認為抽象類不需要工廠(第二次迭代中抽象類Scene、Layer、Sprite也會加上工廠方法create,使得用戶可直接使用這些引擎類),其它非單例的類都統一用工廠創建實例。
對于第2個問題,有以下兩個選擇:
1、與炸彈人代碼一樣,提出工廠類LayerFactory、SpriteFactory,分別負責創建引擎Layer、Sprite的實例
2、直接在類中提出create靜態方法,負責創建自身的實例
考慮到工廠只需要負責創建實例,沒有復雜的邏輯,因此采用第二個選擇,引擎所有的非單例類都提出create靜態方法。
修改引擎類
目前只有引擎ImgLoader需要增加create方法
引擎ImgLoader
Static: {
create: function(){
return new this();
}
}
對應修改引擎Main,使用引擎ImgLoader的create方法創建它的實例
getInstance: function () {
if (_instance === null) {
_instance = new this();
_instance.imgLoader = YE.ImgLoader.create();
}
return _instance;
},
提出Animation
提煉Animation類,使引擎Sprite依賴引擎Animation。
提煉引擎類
領域類Animation負責控制幀動畫的播放,對應炸彈人Animation類。
該類負責幀動畫的控制,具有通用性,因此將其提取到引擎中
引擎Animation
(function () {
namespace("YE").Animation = YYC.Class({
Init: function (config) {
this._frames = YE.Tool.array.clone(config);
this._init();
},
Private: {
//幀數據
_frames: null,
_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;
},
/**
* 更新當前幀
* @param deltaTime 主循環的持續時間
*/
update: function (deltaTime) {
//如果沒有duration屬性(表示動畫只有一幀),則返回(因為不需要更新當前幀)
if (this._currentFrame.duration === undefined) {
return;
}
//判斷當前幀是否播放完成
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;
}
},
Static: {
create: function(config){
return new this(config);
}
}
});
}());
炸彈人使用提煉的引擎類
炸彈人改為創建引擎Animation實例
修改炸彈人SpriteData,改為創建引擎Animation實例
炸彈人SpriteData
anims: {
"stand_right": YE.Animation.create(getFrames("player", "stand_right")),
…
重構引擎
引擎Animation改為依賴引擎Tool的clone方法
引擎Animation
Init: function (config) {
this._frames = YE.Tool.array.clone(config);
…
},
領域模型

提出AI
現在提煉AI類。
提煉引擎類
領域類AI負責實現人工智能算法,對應炸彈人使用的碰撞算法和尋路算法。碰撞算法已經提煉到引擎中了(提煉為引擎collision),尋路算法對應炸彈人FindPath類,它實現了A*尋路算法,屬于通用的算法,應該將其提取到引擎中。
然而“FindPath”這個名字范圍太大了,應該重命名為實際采用的尋路算法的名字,因此將其重命名為AStar。
引擎AStar
(function () {
…
function aCompute(mapData, begin, end) {
…
//8方向尋路
if (bomberConfig.algorithm.DIRECTION == 8) {
…
//4方向尋路
if (bomberConfig.algorithm.DIRECTION == 4) {
…
}
…
namespace("YE").AStar = {
aCompute: function (terrainData, begin, end) {
…
return aCompute(terrainData, begin, end);
}
};
}());
重構提煉的引擎類
用戶能夠設置尋路方向數為4或者為8
現在引擎AStar直接讀取炸彈人Config中配置的尋路方向數algorithm.Director,導致引擎AStar依賴用戶類,違反了引擎設計原則。
因此,引擎AStar增加setDirection方法,由用戶調用該方法來設置尋路方向數,并刪除炸彈人Config的algorithm屬性。
引擎AStar
…
DIRECTION = 4; //默認為4方向尋路
…
if (DIRECTION == 8) {
…
if (DIRECTION == 4) {
…
namespace("YE").AStar = {
…
/**
* 設置尋路方向
* @param direction 4或者8
*/
setDirection: function (direction) {
DIRECTION = direction;
}
}
炸彈人使用提煉的引擎類
修改炸彈人EnemySprite,在構造函數中設置尋路的方向數為4,并改為調用引擎AStar的aCompute方法來尋路。
炸彈人EnemySprite
Init: function (data, bitmap) {
…
YE.AStar.setDirection(4);
…
},
Private: {
___findPath: function () {
return YE.AStar.aCompute(window.terrainData, this.___computeCurrentCoordinate(), this.___computePlayerCoordinate()).path
},
領域模型

提出EventManager和Event
現在提煉EventManager類。
提煉引擎類
領域類EventManager負責事件的監聽和移除,與炸彈人KeyCodeMap、KeyState以及KeyEventManager對應。
炸彈人KeyCodeMap、KeyState以及KeyEventManager都在KeyEventManager.js文件中,先來看下這個文件:
KeyEventManager.js
(function () {
//枚舉值
var keyCodeMap = {
LEFT: 65, // A鍵
RIGHT: 68, // D鍵
DOWN: 83, // S鍵
UP: 87, // W鍵
SPACE: 32 //空格鍵
};
//按鍵狀態
var keyState = {
};
keyState[keyCodeMap.LEFT] = false;
keyState[keyCodeMap.RIGHT] = false;
keyState[keyCodeMap.UP] = false;
keyState[keyCodeMap.DOWN] = false;
keyState[keyCodeMap.SPACE] = false;
//鍵盤事件管理類
var KeyEventManager = YYC.Class({
Private: {
_keyDown: function () {
},
_keyUp: function () {
}
},
Public: {
addKeyDown: function () {
this._keyDown = YYC.Tool.event.bindEvent(this, function (e) {
keyState[e.keyCode] = true;
e.preventDefault();
});
YYC.Tool.event.addEvent(document, "keydown", this._keyDown);
},
removeKeyDown: function () {
YYC.Tool.event.removeEvent(document, "keydown", this._keyDown);
},
addKeyUp: function () {
this._keyUp = YYC.Tool.event.bindEvent(this, function (e) {
keyState[e.keyCode] = false;
});
YYC.Tool.event.addEvent(document, "keyup", this._keyUp);
},
removeKeyUp: function () {
YYC.Tool.event.removeEvent(document, "keyup", this._keyUp);
}
}
});
window.keyCodeMap = keyCodeMap;
window.keyState = keyState;
window.keyEventManager = new KeyEventManager();
}());
提出KeyCodeMap
KeyCodeMap是鍵盤按鍵的枚舉值,因為所有瀏覽器中的鍵盤按鍵值都一樣,因此具有通用性,可以將其提取到引擎中。
不提出KeyState
炸彈人KeyState是存儲當前按鍵狀態的容器類,與用戶邏輯相關,因此不提取到引擎中。
從KeyEventManager中提出EventManager
炸彈人KeyEventManager負責鍵盤事件的監聽和移除,可以從中提出一個通用的、負責所有事件的監聽和移除的引擎類EventManager。
另外,將事件類型(如"keydown"、"keyup")提取為枚舉值EventType,從而對用戶隔離具體的事件類型的變化。
提出事件枚舉類Event
引擎增加Event類,放置KeyCodeMap和EventType枚舉值。
引擎EventManager
(function () {
var _keyListeners = {};
namespace("YE").EventManager = {
_getEventType: function (event) {
var eventType = "",
e = YE.Event;
switch (event) {
case e.KEY_DOWN:
eventType = "keydown";
break;
case e.KEY_UP:
eventType = "keyup";
break;
case e.KEY_PRESS:
eventType = "keypress";
break;
default:
throw new Error("事件類型錯誤");
}
return eventType;
},
addListener: function (event, handler) {
var eventType = "";
eventType = this._getEventType(event);
YYC.Tool.event.addEvent(window, eventType, handler);
this._registerEvent(eventType, handler);
},
_registerEvent: function (eventType, handler) {
if (_keyListeners[eventType] === undefined) {
_keyListeners[eventType] = [handler];
}
else {
_keyListeners[eventType].push(handler);
}
},
removeListener: function (event) {
var eventType = "";
eventType = this._getEventType(event);
if (_keyListeners[eventType]) {
_keyListeners[eventType].forEach(function (e, i) {
YYC.Tool.event.removeEvent(window, eventType, e);
})
}
}
};
}());
引擎Event
namespace("YE").Event = {
//事件枚舉值
KEY_DOWN: 0,
KEY_UP: 1,
KEY_PRESS: 2,
//按鍵枚舉值
KeyCodeMap: {
LEFT: 65, // A鍵
RIGHT: 68, // D鍵
DOWN: 83, // S鍵
UP: 87, // W鍵
SPACE: 32 //空格鍵
}
};
待重構點
目前引擎只支持鍵盤事件,以后可以通過“增加Event事件枚舉值,并對應修改EventManager的_getEventType方法”的方式來增加更多的事件支持。
重構提煉的引擎類
將依賴的YTool方法移到Tool
引擎類依賴了YTool事件操作方法addEvent和removeEvent,考慮到YTool的event中的事件操作方法都具有通用性,因此將其提取到引擎Tool類中
又因為YTool的event對象依賴YTool的judge對象的方法,所以將judge對象的相關的方法提取到引擎Tool中。
引擎Tool
namespace("YE.Tool").judge = {
…
/**
* 判斷是否為jQuery對象
*/
isjQuery: function (ob) {
…
},
/**
* 檢查宿主對象是否可調用
*
* 任何對象,如果其語義在ECMAScript規范中被定義過,那么它被稱為原生對象;
環境所提供的,而在ECMAScript規范中沒有被描述的對象,我們稱之為宿主對象。
該方法用于特性檢測,判斷對象是否可用。用法如下:
MyEngine addEvent():
if (Tool.judge.isHostMethod(dom, "addEventListener")) { //判斷dom是否具有addEventListener方法
dom.addEventListener(sEventType, fnHandler, false);
}
*/
isHostMethod: (function () {
…
}())
};
namespace("YE.Tool").event = (function () {
return {
bindEvent: function (object, fun) {
…
},
/* oTarget既可以是單個dom元素,也可以是jquery集合。
如:
Tool.event.addEvent(document.getElementById("test_div"), "mousedown", _Handle);
Tool.event.addEvent($("div"), "mousedown", _Handle);
*/
addEvent: function (oTarget, sEventType, fnHandler) {
…
},
removeEvent: function (oTarget, sEventType, fnHandler) {
…
},
wrapEvent: function (oEvent) {
…
},
getEvent: function () {
…
}
}
}());
提煉通用的KeyCodeMap
現在引擎KeyCodeMap的枚舉變量與用戶邏輯有關,定死了上下左右移動對應的按鍵keyCode值(如左對應A鍵,右對應D鍵):
引擎Event
KeyCodeMap: {
LEFT: 65, // A鍵
RIGHT: 68, // D鍵
DOWN: 83, // S鍵
UP: 87, // W鍵
SPACE: 32 //空格鍵
}
然而對于不同的游戲,它的上下左右對應的按鍵可能不同。
因此KeyCodeMap應該只定義按鍵對應的keyCode值,由用戶來決定上下左右移動對應的按鍵。
引擎Event
KeyCodeMap: {
A: 65,
D: 68,
S: 83,
W: 87,
SPACE: 32
}
炸彈人使用提煉的引擎類
修改炸彈人Game的初始化事件邏輯
修改前
炸彈人實現了監聽事件的邏輯:
炸彈人Game
_initEvent: function () {
//監聽整個document的keydown,keyup事件
keyEventManager.addKeyDown();
keyEventManager.addKeyUp();
},
炸彈人KeyEventManager
addKeyDown: function () {
this._keyDown = YYC.Tool.event.bindEvent(this, function (e) {
keyState[e.keyCode] = true;
e.preventDefault();
});
YYC.Tool.event.addEvent(document, "keydown", this._keyDown);
},
addKeyUp: function () {
this._keyUp = YYC.Tool.event.bindEvent(this, function (e) {
keyState[e.keyCode] = false;
});
YYC.Tool.event.addEvent(document, "keyup", this._keyUp);
},
修改后
炸彈人調用引擎EventManager API和傳入鍵盤事件的枚舉值來監聽鍵盤事件:
炸彈人Game
_initEvent: function () {
//調用引擎EventManager的addListener綁定事件,傳入引擎Event定義的事件類型枚舉值,并定義事件處理方法
YE.EventManager.addListener(YE.Event.KEY_DOWN, function (e) {
window.keyState[e.keyCode] = true;
e.preventDefault();
});
YE.EventManager.addListener(YE.Event.KEY_UP, function (e) {
window.keyState[e.keyCode] = false;
});
}
刪除炸彈人KeyEventManager.js文件中的KeyCodeMap和KeyEventManager,并將該文件重命名為KeyState
因為炸彈人KeyEventManager.js中的KeyCodeMap和KeyEventManager已經移到引擎中了,所以刪除它們,只保留keyState,并重命名文件為KeyState.js。
炸彈人KeyState
(function () {
//按鍵狀態
var keyState = {
};
keyState[keyCodeMap.LEFT] = false;
keyState[keyCodeMap.RIGHT] = false;
keyState[keyCodeMap.UP] = false;
keyState[keyCodeMap.DOWN] = false;
keyState[keyCodeMap.SPACE] = false;
window.keyState = keyState;
}());
炸彈人改為使用引擎Event的KeyCodeMap
如對應修改炸彈人KeyState和PlayerLayer
炸彈人KeyState
keyState[YE.Event.KeyCodeMap.A] = false;
keyState[YE.Event.KeyCodeMap.D] = false;
keyState[YE.Event.KeyCodeMap.W] = false;
keyState[YE.Event.KeyCodeMap.S] = false;
keyState[YE.Event.KeyCodeMap.SPACE] = false;
炸彈人PlayerLayer
___keyDown: function () {
if (keyState[YE.Event.KeyCodeMap.A] === true || keyState[YE.Event.KeyCodeMap.D] === true
|| keyState[YE.Event.KeyCodeMap.W] === true || keyState[YE.Event.KeyCodeMap.S] === true) {
return true;
}
else {
return false;
}
},
領域模型

提出DataOperator
現在提煉DataOperator類。
提煉引擎類
領域類DataOperator負責對數據進行讀、寫操作,對應炸彈人數據操作層的類,具體為MapDataOperate、GetPath、TerrainDataOperate、GetSpriteData、GetFrames。
這些數據操作類都與具體的業務邏輯相關,沒有可提煉的。
提出Data
現在提煉Data類。
提煉引擎類
領域類Data負責保存游戲數據,對應炸彈人的數據層的類,具體為MapData、Bitmap、ImgPathData、TerrainData、SpriteData、FrameData。
其中Bitmap是圖片的包裝類,包含與圖片本身密切相關的屬性和方法,但不包含與游戲相關的具體圖片,因此具有通用性,可提取到引擎中。
引擎Bitmap
(function () {
namespace("YE").Bitmap = YYC.Class({
Init: function (data) {
this.img = data.img;
this.width = data.width;
this.height = data.height;
},
Private: {
},
Public: {
img: null,
width: 0,
height: 0
}
});
}());
炸彈人使用提煉的引擎類
修改炸彈人BitmapFactory,改為創建引擎的Bitmap實例
炸彈人BitmapFactory
(function () {
var bitmapFactory = {
createBitmap: function (data) {
…
return new YE.Bitmap(bitmapData);
}
}
window.bitmapFactory = bitmapFactory;
}());
領域模型

本文最終領域模型

此處炸彈人省略了與引擎類無關的類。
高層劃分
包圖

對應領域模型
- 核心包
放置引擎的核心類。- Main
- Director
- Scene
- Layer
- Sprite
- 算法包
放置通用的算法類。- AStar
- collision
- 動畫包
放置游戲動畫的相關類。- Animation
- 加載包
負責游戲資源的加載和管理。- ImgLoader
- 數據結構包
放置引擎的基礎結構類。- Bitmap
- 集合包
放置引擎集合類。- Collection
- Hash
- 通用工具包
放置引擎通用的方法類。- Tool
- 配置包
放置引擎配置類。- Config
- 事件管理包
負責事件的管理。- Event
- EventManager
引擎集合類也屬于數據結構,為什么不放在數據結構包中,而是放在單獨的集合包中?
因為引擎集合類的使用方式為繼承,而數據結構包中的引擎Bitmap的使用方式為委托,兩者使用方式不同,因此不能放到一個包中。
總結
本文將炸彈人通用的類提煉到了引擎中,搭建了引擎的整體框架。但是現在引擎還很粗糙,包含了很多炸彈人邏輯,不具備通用性。因此,在下文中,我會進行第二次迭代,對引擎進行進一步的抽象和提煉。
浙公網安備 33010602011771號