這篇日志將以偏技術(shù)的角度介紹我最近在做的業(yè)余項(xiàng)目 Code Game,其中我會(huì)解釋 Code Game 對(duì)某些技術(shù)是如何進(jìn)行取舍的,包括為什么不使用 CoffeeScript 以及選擇 Myth 代替 Less/Sass 的原因。
Code Game 是什么
Code Game 是我花費(fèi)一個(gè)月的業(yè)余時(shí)間完成的 AI 腳本對(duì)戰(zhàn)平臺(tái),網(wǎng)址是 http://,玩家可以通過(guò)編寫 JavaScript 腳本來(lái)控制游戲中的角色并與平臺(tái)上的其他玩家進(jìn)行競(jìng)賽。
Code Game 的靈感來(lái)源于大學(xué)時(shí)我在北航 MSTC(Microsoft Technology Club) 參與的 BigTank 項(xiàng)目。BigTank 是一個(gè)使用 C# 開發(fā)的 3D 坦克對(duì)戰(zhàn)游戲,與傳統(tǒng)坦克對(duì)戰(zhàn)游戲由玩家直接操控坦克不同,BigTank 的玩家需要通過(guò)編寫 Lua 腳本來(lái)分析游戲局勢(shì)并控制自己的坦克行動(dòng),本質(zhì)上是一個(gè)關(guān)于 AI 算法的 Online Judge 平臺(tái)?;?BigTank,MSTC 舉辦了編程挑戰(zhàn)賽,在北航獲得了很好的反響??上抻陂_發(fā)周期和當(dāng)時(shí)的技術(shù)水平,BigTank 本身存在很多不足,包括游戲規(guī)則欠考慮、腳本解析器存在不少 Bug 等,種種這些都或多或少地影響了比賽的精彩程度。一個(gè)月前,我終于決定重新開發(fā)一套類似 BigTank 的平臺(tái)來(lái)彌補(bǔ)遺憾。
功能
Code Game 的頁(yè)面構(gòu)成很簡(jiǎn)單,挨個(gè)介紹也并不會(huì)花費(fèi)很多篇幅。用戶使用 GitHub 賬號(hào)登錄網(wǎng)站會(huì)進(jìn)入個(gè)人主頁(yè)頁(yè)面,在這個(gè)頁(yè)面中玩家可以看到自己的資料和 AI 的排名:

點(diǎn)擊“編寫我的 AI”按鈕就來(lái)到了腳本編寫頁(yè)面,該頁(yè)面由支持代碼高亮和自動(dòng)補(bǔ)全的在線編輯器、可以實(shí)時(shí)看到腳本的運(yùn)行效果的預(yù)覽畫面以及用來(lái)查看調(diào)試信息的控制臺(tái)組成:

Code Game 每天會(huì)根據(jù) AI 的勝率更新一次排行榜,通過(guò)排行榜可以看到每個(gè)玩家的排名,并且點(diǎn)擊昵稱可以進(jìn)入其個(gè)人主頁(yè):

玩家可以在他人的個(gè)人主頁(yè)向?qū)Ψ桨l(fā)起挑戰(zhàn),發(fā)起挑戰(zhàn)后玩家會(huì)進(jìn)入挑戰(zhàn)頁(yè)面,并看到兩個(gè)腳本的對(duì)戰(zhàn)情況,游戲結(jié)束后頁(yè)面會(huì)告知玩家勝者及勝利原因:

技術(shù)架構(gòu)
Code Game 的技術(shù)架構(gòu)以及對(duì)技術(shù)的取舍理念是優(yōu)先考慮開發(fā)效率,并在保證運(yùn)行效率可接受的前提下盡量降低技術(shù)棧復(fù)雜度。
1. 開發(fā)語(yǔ)言
Code Game 使用 Node.js 開發(fā),除了我對(duì)其較熟悉以外,選擇 Node.js 的另一個(gè)重要原因就是使用 Node.js 實(shí)現(xiàn) JavaScript 腳本沙盒相較其他語(yǔ)言要容易得多。
2. 前端技術(shù)棧
前端使用了 Gulp + Myth + Browserify + SketchTool,下面分別詳細(xì)介紹。
Gulp
平常的開發(fā)中,對(duì)于小型項(xiàng)目我一般會(huì)使用 Make 或 NPM 的 scripts 來(lái)進(jìn)行構(gòu)建任務(wù)。而 Code Game 由于需要構(gòu)建的內(nèi)容較多,所以選擇了 Gulp。相較于更為流行的 Grunt,Gulp 的 Stream 理念使得完成同樣的構(gòu)建任務(wù)時(shí)編寫的代碼更少。在 Code Game 中,Gulp 中的任務(wù)分為兩類:一類任務(wù)用來(lái)實(shí)現(xiàn)檢測(cè)源文件的改動(dòng)并自動(dòng)編譯,比如將基于 Myth 編寫的 CSS 實(shí)時(shí)編譯為可以被瀏覽器解析的 CSS;另一類任務(wù)則用來(lái)執(zhí)行生產(chǎn)環(huán)境的構(gòu)建,比如合并 CSS、壓縮 JavaScript 等。
Myth
Myth 在各種 CSS 預(yù)處理語(yǔ)言中絕對(duì)算不上流行,在 GitHub 上其共被 3000 余人 star,雖然不算少,但相比 Less 這樣動(dòng)輒一萬(wàn)多 star 的項(xiàng)目說(shuō)是冷門也毫不過(guò)分。Myth 的優(yōu)勢(shì)和它的口號(hào)一樣:“CSS the way it was imagined.” Myth 可以讓你提前使用 CSS 的高級(jí)特性而無(wú)需考慮瀏覽器兼容問題。舉例來(lái)說(shuō),當(dāng)用到
transform屬性時(shí)通常還需要額外加上瀏覽器前綴-webkit來(lái)兼容 Safari 和 舊版的 Chrome,如果要兼容 IE 9,則更是要加上-ms。而使用 Myth 則不用操心這個(gè)問題:只需要寫一個(gè)transform,Myth 會(huì)在編譯過(guò)程中自動(dòng)加上需要的前綴。Myth 與 Less、Sass 這樣的預(yù)處理語(yǔ)言最大的區(qū)別就在于寫 Less 時(shí)你是在寫 Less,寫 Sass 時(shí)你是在寫 Sass,而當(dāng)你寫 Myth 時(shí),你就是在寫 CSS。這一點(diǎn)十分重要,因?yàn)榘褬?biāo)準(zhǔn)和草案都算上,CSS 語(yǔ)言本身已經(jīng)足夠完備了,它支持變量::root { --purple: #847AD1; --large: 10px; } a { color: var(--purple); } pre { padding: var(--large); }
也支持?jǐn)?shù)學(xué)計(jì)算:
pre { margin: calc(var(--large) * 2); }
甚至還支持顏色處理:
a { color: var(--purple); } a:hover { color: color(var(--purple) tint(20%)); }
可以說(shuō)需要用到的特性 CSS 本身就已經(jīng)具備了,那么何必再使用另一種語(yǔ)言呢?更何況 Less 和 Sass 這樣“強(qiáng)大”的預(yù)處理語(yǔ)言在帶來(lái)開發(fā)上方便的同時(shí)也引入了很多問題,而大部分問題都可以歸結(jié)到一點(diǎn),即“你根本就不是在寫 CSS”??聪旅娴?Less 代碼:
.container { width: 960px; overflow: hidden; .main { width: 61.8%; float: left; .post { background: #f00; .title { position: absolute; background: url("images/header-image.jpg"); } } } }
Less 支持的樣式嵌套很容易誘使開發(fā)者寫出上面這樣層層嵌套的代碼,編譯成 CSS 后,最長(zhǎng)的 Selector 是
.container .main .post .title,以純 CSS 的眼光來(lái)看,應(yīng)該減少嵌套層數(shù)來(lái)提高性能(比如改成.post .title 亦或是優(yōu)化類名來(lái)實(shí)現(xiàn)樣式模塊化(比如把) , .post .title改成.post-title 然后一旦用 Less 寫出來(lái),就很難以 CSS 的角度來(lái)審視本就要編譯成 CSS 的代碼。很多使用 Less 或 Sass 的公司的 Style Guide 都會(huì)明確禁止過(guò)度嵌套,然而與其以規(guī)范來(lái)要求開發(fā)者,不如就單純地使用 CSS,并享受 Myth 提供的便利來(lái)的方便自然。) 。 Browserify
Browserify 可以非常方便地實(shí)現(xiàn)前端 JavaScript 的模塊化。使用 Browserify,你可以在前端的 JavaScript 中使用和 Node.js 一樣的模塊加載方式,即
require('modules'),使得前后端 JavaScript 模塊級(jí)復(fù)用成為了可能。Code Game 游戲沙盒部分的所有模塊曾經(jīng)是前后端共用的,當(dāng)用戶在編輯器中預(yù)覽時(shí)沙盒運(yùn)行在前端,當(dāng)與其他玩家競(jìng)賽時(shí),沙盒則運(yùn)行在后端。同時(shí) Browserify 作為一個(gè)構(gòu)建工具,并不影響前端腳本的加載邏輯,換言之在使用 Browserify 的同時(shí)依然可以使用 RequireJS、SeaJS 這樣的 Module Loader 以及 Combo Handler 等技術(shù)。除了 Browserify 以外,Code Game 沒有使用其他的 JavaScript 預(yù)處理工具。也沒有使用CoffeeScript、LiveScript 這樣的語(yǔ)言替代 JavaScript,原因在于 JavaScript 在前端工程方面本身已經(jīng)足夠優(yōu)秀,而 CoffeeScript 和 LiveScript 這樣的語(yǔ)言在提供更“現(xiàn)代”的語(yǔ)法同時(shí),也會(huì)大大降低代碼的可控性。同 Less 和 CSS 的關(guān)系一樣,CoffeeScript 與 JavaScript 在語(yǔ)言層面的差異會(huì)導(dǎo)致二者間代碼邏輯的不調(diào)和。一個(gè)最明顯的例子是 CoffeeScript 提供的
class關(guān)鍵詞使得其可以像基于類的語(yǔ)言一樣實(shí)現(xiàn)和管理對(duì)象,然而 JavaScript 本身卻是是基于原型的語(yǔ)言,當(dāng)一個(gè)以基于類的思想編寫的代碼邏輯編譯成基于原型的語(yǔ)言時(shí),其間產(chǎn)生的落差已經(jīng)不是語(yǔ)言之間的優(yōu)劣可以衡量的了。更何況 CoffeeScript 語(yǔ)言本身就存在太多的問題,比如 CoffeeScript 的函數(shù)調(diào)用無(wú)需寫(),使得易讀性大為下降(比較console.log x + 1和console.log x +1 同時(shí)也引入了很多細(xì)節(jié)問題(比如無(wú)法實(shí)現(xiàn) JavaScript 里的具名匿名函數(shù)) , invoke(function func() {}) 另外 CoffeeScript 生成的 JavaScript 雖然有很多最佳實(shí)踐,但總體并不易讀,也很容易生成冗余代碼,比如:) , greet = (name) -> for time in ['morning', 'afternoon', 'nignt'] console.log "Good #{time}, #{name}!" greet 'Bob'
會(huì)生成:
var greet; greet = function(name) { var time, _i, _len, _ref, _results; _ref = ['morning', 'afternoon', 'nignt']; _results = []; for (_i = 0, _len = _ref.length; _i < _len; _i++) { time = _ref[_i]; _results.push(console.log("Good " + time + ", " + name + "!")); } return _results; }; greet('Bob');
雖然語(yǔ)句表達(dá)式化是好的,免去寫
return也是好的,但是 CoffeeScript 終究還是一門要編譯成 JavaScript 的語(yǔ)言,本來(lái)簡(jiǎn)單的代碼變得如此復(fù)雜,即使表面再光鮮又有什么意義呢。CoffeeScript 對(duì)于初級(jí) JavaScript 程序員來(lái)說(shuō),可以幫助他們避免很多 JavaScript 的陷阱,也能更順暢地寫出最佳實(shí)踐的代碼,但從整體而言,一個(gè)富有經(jīng)驗(yàn)的 JavaScript 開發(fā)者寫出的 JavaScript 更可能要比 CoffeeScript 的可控性來(lái)的高。SketchTool
之前做設(shè)計(jì)一直使用 Photoshop,直到見識(shí)到 Sketch 的威力后便很少碰 Photoshop 了。網(wǎng)上有很多文章討論兩者的設(shè)計(jì)優(yōu)劣,所以不再贅述。這里主要介紹對(duì)開發(fā)者來(lái)說(shuō) Sketch 的優(yōu)勢(shì)。
相較 Photoshop,Sketch 最大的優(yōu)勢(shì)就是可以實(shí)現(xiàn)切圖自動(dòng)化,對(duì)于每個(gè)圖層來(lái)都可以指定其導(dǎo)出格式以及文件名:

更重要的是,Sketch 官方提供了命令行工具 SketchTool,可以通過(guò)命令將 Sketch 源文件按規(guī)則導(dǎo)出成圖像文件,這意味著配合 Gulp 可以實(shí)現(xiàn)當(dāng)修改 Sketch 的設(shè)計(jì)后自動(dòng)切圖。同時(shí)由于 Sketch 的文件普遍很小,所以甚至可以將其放入版本庫(kù)中來(lái)維護(hù)其版本(自然這樣也就無(wú)需將切好的圖片放入版本庫(kù),因?yàn)檫@些圖片可以由 Gulp 構(gòu)建腳本生成
) 。
3. 沙盒的實(shí)現(xiàn)
在 Code Game 中最關(guān)鍵的一環(huán)就是沙盒的實(shí)現(xiàn)了。因?yàn)樯婕暗綄?duì)戰(zhàn),所以比賽時(shí)雙方選手的代碼自然不能運(yùn)行在前端以免讓玩家看到對(duì)手的代碼。所以 Code Game 采用如下流程來(lái)實(shí)現(xiàn)腳本對(duì)戰(zhàn):
- 玩家在編輯器調(diào)試代碼并保存
- 服務(wù)端將玩家的代碼保存到 MySQL 數(shù)據(jù)庫(kù)中
- 進(jìn)行比賽時(shí),服務(wù)端調(diào)集雙方的代碼,并在后端解析運(yùn)行
- 運(yùn)行結(jié)束后,將游戲“錄像”傳回前端
- 前端解析“錄像”,并以動(dòng)畫形式展現(xiàn)給用戶
下面分三個(gè)部分著重介紹 3 和 5 兩個(gè)過(guò)程。
在后端解析玩家代碼
這一過(guò)程是沙盒的意義所在。因?yàn)楹蠖耸褂?Node.js 開發(fā),而玩家的腳本本身是 JavaScript,所以解析腳本的過(guò)程本身就很簡(jiǎn)單,一個(gè) eval() 即可。然而 eval() 并不能限制用戶的腳本權(quán)限,從而使得用戶的腳本可以訪問 Node.js 的各種庫(kù)函數(shù),也會(huì)污染 Node.js 的全局變量,同時(shí)也無(wú)法對(duì)腳本的運(yùn)行時(shí)間進(jìn)行任何限制。
這個(gè)問題的解決方式是使用 Node.js 提供的 script.runInNewContext() 函數(shù)。runInNewContext()函數(shù)接受兩個(gè)參數(shù):一個(gè)是全局變量對(duì)象,這個(gè)對(duì)象包含腳本可以使用的全部全局變量,在腳本中聲明的全局變量也會(huì)保存在這個(gè)對(duì)象中;另一個(gè)是運(yùn)行選項(xiàng),可以在這個(gè)參數(shù)中指定腳本的超時(shí)時(shí)間,要注意的是這個(gè)參數(shù)是從 Node.js 0.11.x 開始支持的。
Code Game 定義了 Sandbox 類:
var Sandbox = module.exports = function(sandbox) {
this.Math = Math;
this.parseInt = parseInt;
for (var key in sandbox) {
if (sandbox.hasOwnProperty(key)) {
this[key] = sandbox[key];
}
}
};
執(zhí)行玩家腳本時(shí),會(huì)實(shí)例化一個(gè) Sandbox 實(shí)例作為全局變量對(duì)象,所以玩家能使用的全局變量只有Math 和 parseInt。同時(shí)玩家的腳本都會(huì)聲明一個(gè) onIdle() 函數(shù)(具體可以參見官網(wǎng)文檔Sandbox 實(shí)例 sandbox 中獲取到。接下來(lái)需要實(shí)現(xiàn)在坦克空閑時(shí)執(zhí)行onIdle() 函數(shù),如果直接調(diào)用 sandbox.onIdle() 來(lái)執(zhí)行的話就無(wú)法借助 runInNewContext()函數(shù)來(lái)實(shí)現(xiàn)超時(shí)檢測(cè)了,所以 Code Game 使用如下的方法來(lái)解決這個(gè)問題:
Player.prototype.onIdle = function(self, enemy, game) {
var code = 'onIdle(__self, __enemy, __game);';
if (!this.script) {
this.script = vm.createScript(code);
}
var start = Date.now();
try {
this.sandbox.__self = self;
this.sandbox.__enemy = enemy;
this.sandbox.__game = game;
this.sandbox.print = function() {
// ...
};
this.script.runInNewContext(this.sandbox, {
timeout: 1500
});
} catch (e) {
// ...
}
this.runTime += Date.now() - start;
};
首先為 __self、__enemy 和 __game 這三個(gè)不對(duì)用戶公開的全局變量賦相應(yīng)的值,之后使用同樣的 Sandbox 實(shí)例執(zhí)行代碼 onIdle(__self, __enemy, __game)。因?yàn)檎{(diào)用了runInNewContext(),所以可以定義超時(shí)規(guī)則。
錄像文件的格式
為了將代碼的執(zhí)行結(jié)果在前端展示給用戶,最重要的是把結(jié)果以一定規(guī)則記錄下來(lái),形成游戲錄像。錄像文件中記錄了每個(gè)對(duì)象(坦克、子彈等)在每個(gè)幀的位置和執(zhí)行的動(dòng)作,如:
[
[{
"objectId": "6e723",
"type": "tank",
"direction": "right",
"position": [6, 7],
"action": "go",
}, {
"objectId": "4ad3f",
"type": "tank",
"direction": "left",
"position": [10, 2],
"action": "turn",
"value": "left"
}],
[{
"objectId": "6e723",
"type": "tank",
"direction": "right",
"position": [6, 8],
"action": "go"
}],
]
首先錄像的最外層是個(gè)數(shù)組,數(shù)組的一個(gè)元素代表一幀發(fā)生的所有動(dòng)作。上面的示例錄像中,第一幀坦克 6e723 從坐標(biāo) (6, 7) 向當(dāng)前方向(右)前進(jìn)了一個(gè)單位,同時(shí)坦克 4ad3f 向左轉(zhuǎn)彎。第二幀坦克 6e723 從坐標(biāo) (6, 8) 繼續(xù)前進(jìn)了一步,同時(shí)坦克 4ad3f 沒有執(zhí)行任何操作。
理論上來(lái)說(shuō),錄像文件中只要記錄每個(gè)對(duì)象最初的狀態(tài)和中間每步的動(dòng)作即可使數(shù)據(jù)完整。然而可以注意到上面的錄像樣例中的每一幀都會(huì)把對(duì)象的所有信息記錄下來(lái),包括朝向和坐標(biāo)。這使得前端播放錄像時(shí)可以從任意一幀開始播放,而不需要從頭開始初始化對(duì)象狀態(tài),另外由于 Code Game 的錄像一般都很小(游戲限定 200 幀之內(nèi)必須結(jié)束
前端錄像展示
一般而言在前端實(shí)現(xiàn)動(dòng)畫有如下幾種方式:
- 通過(guò) JavaScript 操作 DOM
- 使用 CSS Animation
- Canvas 動(dòng)畫
在開發(fā) Code Game 項(xiàng)目時(shí),首先排除的是 Canvas。雖然 Canvas 的性能優(yōu)異且兼容性良好,但是就播放錄像這樣簡(jiǎn)單的需求而言使用 Canvas 開發(fā)相對(duì)繁瑣。其次排除的是直接操作 DOM,因?yàn)?JavaScript 實(shí)現(xiàn)的動(dòng)畫進(jìn)行微小的位移時(shí)會(huì)出現(xiàn)抖動(dòng),而 Code Game 開發(fā)時(shí)希望對(duì)戰(zhàn)頁(yè)面可以在移動(dòng)設(shè)備上播放,同時(shí)用戶可以自定義播放速度,這就使得小位移的動(dòng)畫非常容易出現(xiàn)。
所以最后采取的播放錄像的動(dòng)畫方案是 CSS Animation。錄像中的每個(gè)擁有 objectId 屬性的對(duì)象都會(huì)為其生成 DOM 節(jié)點(diǎn),節(jié)點(diǎn)的 ID 由 objectid 構(gòu)成,同時(shí)根據(jù) type 的不同為其賦予不同的背景圖。具體的動(dòng)畫實(shí)現(xiàn)以下面的動(dòng)作為例:
{
"objectId": "6e723",
"type": "tank",
"direction": "right",
"position": [6, 7],
"action": "go",
}
首先通過(guò) JavaScript 找到 6e723 DOM 節(jié)點(diǎn),然后根據(jù)當(dāng)前游戲的 FPS 和地圖大小 修改該節(jié)點(diǎn)的 Transition 時(shí)間,同時(shí)通過(guò) transform 的 translate 操作來(lái)移動(dòng)對(duì)象。
這樣的實(shí)現(xiàn)存在一個(gè)問題,假設(shè)一個(gè)坦克不轉(zhuǎn)向,一直前進(jìn)了 10 個(gè)單位,前端會(huì)修改 10 次節(jié)點(diǎn)的transition 和 transform 屬性,每次只移動(dòng)一個(gè)單位。而實(shí)際上對(duì)于這種情況可以優(yōu)化成只修改一次屬性,一次直接前進(jìn) 10 個(gè)單位,當(dāng)然 transition 的時(shí)間也要相應(yīng)乘以 10。因?yàn)樵谟螒蛑刑箍酥本€連續(xù)行進(jìn)的地方很多,所以這種優(yōu)化效果很明顯。為此 Code Game 在前端會(huì)在解析錄像前對(duì)錄像進(jìn)行優(yōu)化,合并直線前進(jìn)的操作。如上面的錄像實(shí)例會(huì)被優(yōu)化成:
[
[{
"objectId": "6e723",
"type": "tank",
"direction": "right",
"position": [6, 7],
"action": "go",
"frames": 2
}, {
"objectId": "4ad3f",
"type": "tank",
"direction": "left",
"position": [10, 2],
"action": "turn",
"value": "left"
}],
[],
]
坦克 6e723 在第一幀的動(dòng)作中增加了 frames: 2 這個(gè)屬性,表明一共前進(jìn)了兩幀,在之后播放動(dòng)畫時(shí)就可以大大降低修改 DOM 屬性的次數(shù)了。
結(jié)語(yǔ)
這篇文章從開發(fā) Code Game 項(xiàng)目的角度介紹了我對(duì) Less/Sass 和 CoffeeScript 這些流行技術(shù)的取舍,以及在 Node.js 中開發(fā)沙盒的一些經(jīng)驗(yàn)。然而技術(shù)本身其實(shí)并沒有明顯的優(yōu)劣可言,只有基于特定項(xiàng)目和特定的開發(fā)者討論時(shí),關(guān)于“取舍”的話題才有意義。如果大家關(guān)于本文有什么想法,歡迎留言討論。
另外 Code Game 已經(jīng)在 GitHub 上開源




