你一定注意到博文的標題變了成了“TypeScript 版 ...”。在上一篇 JavaScript 版俄羅斯方塊——轉換為 TypeScript 中,它就變成了 TypeScript 實現(xiàn)。而在之前的 JavaScript 版俄羅斯方塊——重構 中,只重構了數(shù)據(jù)結構部分,控制(業(yè)務邏輯)部分因為過于復雜,只是進行了表面的重構。所以現(xiàn)在來對控制部分進行更深入的重構。
傳送門
重構不是盲目的,一定還是要先進行一些分析。
Puzzle
職責很明確,負責繪制,除此之外,剩下的就是數(shù)據(jù)、狀態(tài)和對它們的控制。
從上圖可以看出來,用于繪制的數(shù)據(jù)主要就是 block
和 matrix
了。對于 block
,需要控制它的位置變動和旋轉,而 block
下降到底之后,會通過 固化
變成 matrix
的部分數(shù)據(jù),而由于 固化
造成 matrix
數(shù)據(jù)變動之后,可能會產(chǎn)生若干整行有效數(shù)據(jù),這時候需要觸發(fā) 刪除行
操作。所有 block
和 matrix
的變動,都應該引起 Puzzle
的重繪。處理這部分控制過程的對象,且稱之為 BlockController
。
游戲過程中方塊會定時下落,這是由 Timer
控制的。Timer
每達到一個 interval
所指示的時間,就會向 BlockController
發(fā)送消息,通知它執(zhí)行一次 moveDown
操作。
block
從 固化
操作開始,直到 刪除行
操作完成這一段時間,不應處理 Timer
的消息??紤]到這一過程結束時最好不需要等到下一時鐘周期,所以在這段時間最好停止 Timer
,所以這里應該通知暫停。
說到暫停,在之前就分析過,除了 BlockController
要求的暫停外,還有可能是用戶手工請求暫暫停。只有當兩種暫停狀態(tài)都取消的時候,才應該繼續(xù)下落方塊。所以這里需要一個 StateManager
來管理狀態(tài),除了暫停外,順便把游戲的 over
狀態(tài)一并管理了。所以 StateManager
需要接受 BlockController
和 CommandPanel
的消息,并根據(jù)狀態(tài)計算結果來通知 Timer
是暫停還是繼續(xù)。
另一方面,由于 BlockController
有 刪除行
操作,這個操作的發(fā)生意味著要給用戶加分,所以需要通知 InfoPanel
加分。而 InfoPanel
加分到一定程度會引起加速,它需要自己內(nèi)部判斷并處理這個過程。不過加速就意味著時鐘周期的變動,所以需要通知 Timer
。
按照圖示及上述過程,其實在之前的版本已經(jīng)基本實現(xiàn),相互之間的通知實現(xiàn)得并不十分清晰,部分是通過事件來實現(xiàn)的,也有部分是通過直接的方法調(diào)用來實現(xiàn)的。顯然,深入重構就是要把這個結構搞清楚。
各控制器之間需要要相互通知,并根據(jù)得到的通知來進行處理。如果有一個統(tǒng)一的消息(通知)處理中心,結構會不會看起來更簡單一些呢?
BlockController
其實上已經(jīng)處理了大部分之前 Tetris
所做的工作。所以不妨把 Tetris
更名為 BlockController
,再新建個 Tetris
來專門處理各種通知。通知統(tǒng)一通過事件來實現(xiàn),不過如果涉及到一些較長的過程(比如刪除動畫),可以考慮通過 Promise 來實現(xiàn)。
BlockController
要管理 block
和 matrix
兩個數(shù)據(jù),還要處理 block
的移動和變形,以及處理 block
的固化,以及 matrix
的刪除行操作等,甚至還負責了刪除行動畫的實現(xiàn)。
所以為了簡化代碼結構,BlockController
應該專注于 block
的管理,其它的操作,應該由別的類來完成,比如 MatrixController
、EraseAnimator
等。
為了將 BlockController
從“繁忙的事務”中解救出來,首先是解耦。解耦比較流行的思想是 IoC(Inversion of Control,控制反轉) 或者 DI(Dependency Injection,依賴注入)。不過這里用的是另一種思想,消息驅動,或者事件驅動。一般情況下消息驅動用于異步處理,而事件驅動用于同步處理。這個程序中基本上都是同步過程,所以采用事件即可。
雖然之前的 JavaScript 版就已經(jīng)用到了事件,不過處理的過程有限。經(jīng)常上圖的分析,對需要處理的事件進行了擴展。另外由于之前是直接使用的 jQuery 的事件,用起來有點繁瑣,處理函數(shù)的第一個參數(shù)一定是是 event 對象,而 event 對象其實是很少用的。所以先實現(xiàn)一個自己的 Eventable
。
事件支持看起來好像多復雜一樣,但實際上非常簡單。
首先,事件處理的外部接口就三個:
on
注冊事件處理函數(shù),就是將事件處理函數(shù)添加到事件處理函數(shù)列表
off
注銷事件處理函數(shù),即從事件處理函數(shù)列表中刪除處理函數(shù)
trigger
觸發(fā)事件(通常是內(nèi)部調(diào)用),依次調(diào)用對應的事件處理函數(shù)
事件都有名稱,對應著一個事件處理函數(shù)列表。為了便于查找事件,這應該定義為一個映射表,其鍵是事件名稱,值為處理函數(shù)列表。TypeScript 可以用接口來描述這個結構
interface IEventMap {
[type: string]: Array<(data?: any) => any>;
}
Eventable
對象中會維護一上述的映射表對象
private _events: IEventMap;
on(type: string, handler: Function)
注冊一個事件名為 type
的處理函數(shù)。所以,是從 _events
里找到(或添加)指定名稱的列表,并在列表里添加 handler
(this._events[type] || (this._events[type] = [])).push(handler);
如果不希望 type
區(qū)分大小寫,可以首先對 type
進行 toLowerCase()
處理。
在上面已經(jīng)把 _events
的結構說清楚了,off()
的處理就容易理解了。如果 off()
沒有參數(shù),直接把 _events
清空或者重新賦值一個新的 {}
即可;如果 off(type: string)
這種形式的調(diào)用,則從 delete _events[type]
就能達到目的;只有在給了 handler
的時候麻煩一點,需要先取出列表,再從列表中找到 handler
,把它去除掉。
trigger()
的處理過程就更容易了,按 type
找到列表,遍歷,依次調(diào)用即可。
之前一直很糾結一個問題:如果要把 Eventable
做成像 jQuery 一樣的鏈式調(diào)用,那就必須 return this
,但是如果把方法定義為 Eventable
類型,子類實現(xiàn)的時候就只能鏈調(diào) Eventable
的方法,而不是子類的方法(因為返回固定的 Eventable
類型。后來終于從 StackOverflow 上查到答案就在文檔中:Advanced Types : Polymorphic this types。
原來可以將方法定義為 this
類型。是的,這里的 this
表示一種類型而不是一個對象,表示返回的是自己。返回類型會根據(jù)調(diào)用方法的類來決定,即使子類調(diào)用的是父類中返回 this
的方法,也可以識別為返回類型是子類類型。
class Father {
test(): this { return this; }
}
class Son extends Father {
doMore(): this { return this; }
}
// 這會識別出 test() 返回 Son 類型而不是 Father 類型
// 所以可以直接調(diào)用 doMore()
new Son().test().doMore();
IoC 和 DI 實現(xiàn),像 Java 的 Spring,.NET 的 Unity,通常都會有一個集中配置的地方,有可能是 XML,也有可能是 @Configure 注釋的 Config 類(Spring 4)等……
這里也采用這種思想,寫一個類來集中配置事件。之前已經(jīng)將 Tetris
的事情交給了 BlockController
去處理,這里用 Tetris
來處理這個事情正好。
class Tetris {
constructor() {
// 生成各部件的實例
}
private setup() {
this.setupEvents();
this.setupKeyEvents();
}
private setupEvents() {
// 將各部件的實例之間用事件關聯(lián)起來
}
private setupKeyEvents() {
// 處理鍵盤事件
// 從 BlockController 中拆分出來的鍵盤事件處理部分
}
run() {
// 開始 BlockController 的工作
// 并啟動 Timer
}
}
刪除行這部分邏輯相對獨立,可以從 BlockController
中剝離出來,取名 Eraser
。那么 Eraseer
需要處理的事情包括
檢查是否有可刪除的行 - check()
檢查之后可以獲得可刪除行的總數(shù) rowCount
如果有可刪除行以進行刪除操作 erase()
其中 erase()
中需要通過 setInterval()
來控制刪除動畫,這是一個異步過程。所以需要回調(diào),或者 Promise …… 不過既然是為了做技術嘗試,不妨用新一點的技術,async/await 怎么樣?
Eraser 的邏輯部分是直接照搬原來的實現(xiàn),所以這里主要討論 async/await 實現(xiàn)。
TypeScript 的編譯目標參數(shù) target
設置為 es2015
或者 es6
的時候,允許使用 async/await 語法,它編譯出來的 JavaScript 是使用 es6 的 Promise
來實現(xiàn)的。而我們需要的是 es5 語法的實現(xiàn),所以又得靠 Babel 了。Babel 的 presets es2017
、stage-3
等都支持將 async/await 和 Promise 轉換成 es5 語法。
不過這次使用 Babel 不是從 JavaScript 源文件編譯成目標文件。而是利用 gulp 的流管道功能,將 TypeScript 的編譯結果直接送給 Babel,再由 Babel 轉換之后輸出。
這里需要安裝 3 個包
npm install --save-dev gulp-babel babel-preset-es2015 babel-preset-stage-3
同時需要修改 gulpfile.js 中的 typescript
任務
gulp.task("typescript", callback => {
const ts = require("gulp-typescript");
const tsProj = ts.createProject("tsconfig.json", {
outFile: "./tetris.js"
});
const babel = require("gulp-babel");
const result = tsProj.src()
.pipe(sourcemaps.init())
.pipe(tsProj());
return result.js
.pipe(babel({
presets: ["es2015", "stage-3"]
}))
.pipe(sourcemaps.write("../js", {
sourceRoot: "../src/scripts"
}))
.pipe(gulp.dest("../js"));
});
請注意到 typescript
任務中 ts.createProject()
中覆蓋了配置中的 outFile
選項,將結果輸出為 npm 項目所在目錄的文件。這是一個 gulp 處理過程中虛擬的文件,并不會真的存儲于硬盤上,但 Babel 會以為它得到的是這個路徑的文件,會根據(jù)這個路徑去 node_modules
中尋找依賴庫。
編譯沒問題了,但運行會有問題,因為缺少 babel-polyfill,也就是 Babel 的 Promise 實現(xiàn)部分。先通過 npm 添加包
npm install --save-dev babel-polyfill
這個包下面的 dist/polyfill.min.js
需要在 index.html
中加載。所以在 gulpfile.js 中像處理 jquery.min.js
那樣,在 libs
任務中加一個源即可。之后運行 gulp build
會將 polyfill.min.js
拷貝到 /js
目錄中。
關于 async/await 語法,我曾在 閑談異步調(diào)用“扁平”化 一文中討論過。雖然那篇博文中只討論了 C# 而不是 JavaScript 的 async/await,但是最后那部分使用了 co 庫的 JavaScript 代碼對理解 async/await 很有幫助。
在 co 的語法中,通過 yield
來模擬了 await
,而 yeild
后面接的是一個 Promise 對象。await
后面跟著的民是一個 Promise 對象,而它“等待”的,就是這個 Promise 的 resolve,并將 resolve 的的值傳遞出去。
相應的,async
則是將一個返回 Promise 的函數(shù)是可以等待的。
由于 await
必須出現(xiàn)在 async
函數(shù)中,所以最終調(diào)用 async erase()
的部分用 async IIFE 實現(xiàn):
(async () => {
// do something before
this._matrix = await eraser.erase();
// do something after
// do more things
})();
上面的代碼 IIFE 中 await
后面的部分相當于被封裝成了一個 lambda,作為 eraser.erase().then()
的第一個回調(diào),即
// 等效代碼
(() => {
// do something before
eraser.erase().then(r => {
this._matrix = r;
// do something after
// do more things
});
})();
這個程序結構比較簡單,并不能很好的體現(xiàn) async/await 的好處,不過它對于簡化瀑布式回調(diào)和 Promise 的 then 鏈確實非常有效。
以前對于 Matrix
這個類是加了刪、刪了加,一直沒能很好的定位?,F(xiàn)在由于程序結構已經(jīng)發(fā)生了較大的變化,Matrix
的功能也能更清晰的定義出來了。
創(chuàng)建矩陣行及矩陣 - createRow()
、createMatrix()
提供 width
和 height
將 Block
的各個點固化下來 - addBlockPoints()
設置/取消某個坐標的 BlockPoint
對象 - set()
判斷并獲取滿行 - getFullRows()
刪除行,數(shù)據(jù)層面的操作 - removeRows()
提取有效(有小方塊的)BlockPoint
列表 - fasten()
判斷某個/某些點是否為空(可以放置新小方塊) - isPutable()
JavaScript/TypeScript 版俄羅斯方塊是以技術研究為目的而寫,到此已經(jīng)可以告一段落了。由于它不是以游戲體驗為目的寫的一個游戲程序,所以在體驗上還有很多需要改進的地方,就留給有興趣的朋友們研究了。
傳送門
更多建議: