TypeScript(JavaScript) 版俄羅斯方塊——深入重構

在上一篇 JavaScript 版俄羅斯方塊——轉換爲 TypeScripthttps://segmentfault.com/a/1190000007074816 中,程序就變成了 TypeScript 實現。而在之前的 JavaScript 版俄羅斯方塊——重構https://segmentfault.com/a/1190000007063852)中,只重構了數據結構部分,控制(業務邏輯)部分因爲過於複雜,只是進行了表面的重構。所以現在來對控制部分進行更深入的重構。

受微信權限限制,文內鏈接不能打開,請移步原文(https://segmentfault.com/a/1190000007167312)通過鏈接閱讀相關博文。

也可通過文末的“閱讀原文”連接進入原文閱讀

邏輯結構分析

重構不是盲目的,一定還是要先進行一些分析。


Puzzle 職責很明確,負責繪製,除此之外,剩下的就是數據、狀態和對它們的控制。

從上圖可以看出來,用於繪製的數據主要就是 block 和 matrix 了。對於block,需要控制它的位置變動和旋轉,而 block 下降到底之後,會通過 固化變成 matrix 的部分數據,而由於 固化 造成 matrix 數據變動之後,可能會產生若干整行有效數據,這時候需要觸發 刪除行 操作。所有 block 和 matrix的變動,都應該引起 Puzzle 的重繪。處理這部分控制過程的對象,且稱之爲BlockController

遊戲過程中方塊會定時下落,這是由 Timer 控制的。Timer 每達到一個interval 所指示的時間,就會向 BlockController 發送消息,通知它執行一次 moveDown 操作。

block 從 固化 操作開始,直到 刪除行 操作完成這一段時間,不應處理 Timer的消息。考慮到這一過程結束時最好不需要等到下一時鐘週期,所以在這段時間最好停止 Timer,所以這裏應該通知暫停。

說到暫停,在之前就分析過,除了 BlockController 要求的暫停外,還有可能是用戶手工請求暫暫停。只有當兩種暫停狀態都取消的時候,才應該繼續下落方塊。所以這裏需要一個 StateManager 來管理狀態,除了暫停外,順便把遊戲的over 狀態一併管理了。所以 StateManager 需要接受 BlockController 和CommandPanel 的消息,並根據狀態計算結果來通知 Timer 是暫停還是繼續。

另一方面,由於 BlockController 有 刪除行 操作,這個操作的發生意味着要給用戶加分,所以需要通知 InfoPanel 加分。而 InfoPanel 加分到一定程度會引起加速,它需要自己內部判斷並處理這個過程。不過加速就意味着時鐘週期的變動,所以需要通知 Timer

仍然存在的問題

按照圖示及上述過程,其實在之前的版本已經基本實現,相互之間的通知實現得並不十分清晰,部分是通過事件來實現的,也有部分是通過直接的方法調用來實現的。顯然,深入重構就是要把這個結構搞清楚。

\1. 處理複雜的通知結構

各控制器之間需要要相互通知,並根據得到的通知來進行處理。如果有一個統一的消息(通知)處理中心,結構會不會看起來更簡單一些呢?

BlockController 其實上已經處理了大部分之前 Tetris 所做的工作。所以不妨把 Tetris 更名爲 BlockController,再新建個 Tetris 來專門處理各種通知。通知統一通過事件來實現,不過如果涉及到一些較長的過程(比如刪除動畫),可以考慮通過 Promise 來實現。

\2. BlockController 過於複雜

BlockController 要管理 block 和 matrix 兩個數據,還要處理 block 的移動和變形,以及處理 block 的固化,以及 matrix 的刪除行操作等,甚至還負責了刪除行動畫的實現。

所以爲了簡化代碼結構,BlockController 應該專注於 block 的管理,其它的操作,應該由別的類來完成,比如 MatrixControllerEraseAnimator 等。

深入重構 - 事件中心

爲了將 BlockController 從“繁忙的事務”中解救出來,首先是解耦。解耦比較流行的思想是 IoC(Inversion of Control,控制反轉) 或者 DI(Dependency Injection,依賴注入)。不過這裏用的是另一種思想,消息驅動,或者事件驅動。一般情況下消息驅動用於異步處理,而事件驅動用於同步處理。這個程序中基本上都是同步過程,所以採用事件即可。

改寫 Eventable,返回 this 的方法

雖然之前的 JavaScript 版就已經用到了事件,不過處理的過程有限。經常上圖的分析,對需要處理的事件進行了擴展。另外由於之前是直接使用的 jQuery 的事件,用起來有點繁瑣,處理函數的第一個參數一定是是 event 對象,而 event 對象其實是很少用的。所以先實現一個自己的 Eventable

自己實現的 Eventable

事件支持看起來好像多複雜一樣,但實際上非常簡單。

首先,事件處理的外部接口就三個:

  • on 註冊事件處理函數,就是將事件處理函數添加到事件處理函數列表

  • off 註銷事件處理函數,即從事件處理函數列表中刪除處理函數

  • trigger 觸發事件(通常是內部調用),依次調用對應的事件處理函數

事件都有名稱,對應着一個事件處理函數列表。爲了便於查找事件,這應該定義爲一個映射表,其鍵是事件名稱,值爲處理函數列表。TypeScript 可以用接口來描述這個結構

  
    
  
  
  
  1. interface IEventMap {

  2.    [type: string]: Array<(data?: any) => any>;

  3. }

Eventable 對象中會維護一上述的映射表對象

  
    
  
  
  
  1. private _events: IEventMap;

on(type: string, handler: Function) 註冊一個事件名爲 type 的處理函數。所以,是從 _events 裏找到(或添加)指定名稱的列表,並在列表裏添加handler

  
    
  
  
  
  1. (this._events[type] || (this._events[type] = [])).push(handler);

如果不希望 type 區分大小寫,可以首先對 type 進行 toLowerCase() 處理。

在上面已經把 _events 的結構說清楚了,off() 的處理就容易理解了。如果off() 沒有參數,直接把 _events 清空或者重新賦值一個新的 {} 即可;如果off(type: string) 這種形式的調用,則從 delete _events[type] 就能達到目的;只有在給了 handler 的時候麻煩一點,需要先取出列表,再從列表中找到 handler,把它去除掉。

trigger() 的處理過程就更容易了,按 type 找到列表,遍歷,依次調用即可。

TypeScript 的方法類型 - this

之前一直很糾結一個問題:如果要把 Eventable 做成像 jQuery 一樣的鏈式調用,那就必須 return this,但是如果把方法定義爲 Eventable 類型,子類實現的時候就只能鏈調 Eventable 的方法,而不是子類的方法(因爲返回固定的Eventable 類型。後來終於從 StackOverflow 上查到答案就在文檔中:Advanced Types : Polymorphic this types。

原來可以將方法定義爲 this 類型。是的,這裏的 this 表示一種類型而不是一個對象,表示返回的是自己。返回類型會根據調用方法的類來決定,即使子類調用的是父類中返回 this 的方法,也可以識別爲返回類型是子類類型。

  
    
  
  
  
  1. class Father {

  2.    test(): this { return this; }

  3. }

  4. class Son extends Father {

  5.    doMore(): this { return this; }

  6. }

  7. // 這會識別出 test() 返回 Son 類型而不是 Father 類型

  8. // 所以可以直接調用 doMore()

  9. new Son().test().doMore();

集中處理事件

IoC 和 DI 實現,像 Java 的 Spring,.NET 的 Unity,通常都會有一個集中配置的地方,有可能是 XML,也有可能是 @Configure 註釋的 Config 類(Spring 4)等……

這裏也採用這種思想,寫一個類來集中配置事件。之前已經將 Tetris 的事情交給了 BlockController 去處理,這裏用 Tetris 來處理這個事情正好。

  
    
  
  
  
  1. class Tetris {

  2.    constructor() {

  3.        // 生成各部件的實例

  4.    }

  5.    private setup() {

  6.        this.setupEvents();

  7.        this.setupKeyEvents();

  8.    }

  9.    private setupEvents() {

  10.        // 將各部件的實例之間用事件關聯起來

  11.    }

  12.    private setupKeyEvents() {

  13.        // 處理鍵盤事件

  14.        // 從 BlockController 中拆分出來的鍵盤事件處理部分

  15.    }

  16.    run() {

  17.        // 開始 BlockController 的工作

  18.        // 並啓動 Timer

  19.    }

  20. }

用 async/await 異步處理動畫 - Eraser

刪除行這部分邏輯相對獨立,可以從 BlockController 中剝離出來,取名Eraser。那麼 Eraseer 需要處理的事情包括

  • 檢查是否有可刪除的行 - check()

  • 檢查之後可以獲得可刪除行的總數 rowCount

  • 如果有可刪除行以進行刪除操作 erase()

其中 erase() 中需要通過 setInterval() 來控制刪除動畫,這是一個異步過程。所以需要回調,或者 Promise …… 不過既然是爲了做技術嘗試,不妨用新一點的技術,async/await 怎麼樣?

Eraser 的邏輯部分是直接照搬原來的實現,所以這裏主要討論 async/await 實現。

改造構建及配置以支持 async/await

TypeScript 的編譯目標參數 target 設置爲 es2015 或者 es6 的時候,允許使用 async/await 語法,它編譯出來的 JavaScript 是使用 es6 的 Promise 來實現的。而我們需要的是 es5 語法的實現,所以又得靠 Babel 了。Babel 的 presetses2017stage-3 等都支持將 async/await 和 Promise 轉換成 es5 語法。

不過這次使用 Babel 不是從 JavaScript 源文件編譯成目標文件。而是利用 gulp 的流管道功能,將 TypeScript 的編譯結果直接送給 Babel,再由 Babel 轉換之後輸出。

這裏需要安裝 3 個包

  
    
  
  
  
  1. npm install --save-dev gulp-babel babel-preset-es2015 babel-preset-stage-3

同時需要修改 gulpfile.js 中的 typescript 任務

  
    
  
  
  
  1. gulp.task("typescript", callback => {

  2.    const ts = require("gulp-typescript");

  3.    const tsProj = ts.createProject("tsconfig.json", {

  4.        outFile: "./tetris.js"

  5.    });

  6.    const babel = require("gulp-babel");

  7.    const result = tsProj.src()

  8.        .pipe(sourcemaps.init())

  9.        .pipe(tsProj());

  10.    return result.js

  11.        .pipe(babel({

  12.            presets: ["es2015", "stage-3"]

  13.        }))

  14.        .pipe(sourcemaps.write("../js", {

  15.            sourceRoot: "../src/scripts"

  16.        }))

  17.        .pipe(gulp.dest("../js"));

  18. });

請注意到 typescript 任務中 ts.createProject() 中覆蓋了配置中的 outFile選項,將結果輸出爲 npm 項目所在目錄的文件。這是一個 gulp 處理過程中虛擬的文件,並不會真的存儲於硬盤上,但 Babel 會以爲它得到的是這個路徑的文件,會根據這個路徑去 node_modules 中尋找依賴庫。

編譯沒問題了,但運行會有問題,因爲缺少 babel-polyfill,也就是 Babel 的 Promise 實現部分。先通過 npm 添加包

  
    
  
  
  
  1. 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 語法

關於 async/await 語法,我曾在 閒談異步調用“扁平”化 一文中討論過。雖然那篇博文中只討論了 C# 而不是 JavaScript 的 async/await,但是最後那部分使用了 co 庫的 JavaScript 代碼對理解 async/await 很有幫助。

在 co 的語法中,通過 yield 來模擬了 await,而 yeild 後面接的是一個 Promise 對象。await 後面跟着的民是一個 Promise 對象,而它“等待”的,就是這個 Promise 的 resolve,並將 resolve 的的值傳遞出去。

相應的,async 則是將一個返回 Promise 的函數是可以等待的。

由於 await 必須出現在 async 函數中,所以最終調用 async erase() 的部分用 async IIFE 實現:

  
    
  
  
  
  1. (async () => {

  2.    // do something before

  3.    this._matrix = await eraser.erase();

  4.    // do something after

  5.    // do more things

  6. })();

上面的代碼 IIFE 中 await 後面的部分相當於被封裝成了一個 lambda,作爲eraser.erase().then() 的第一個回調,即

  
    
  
  
  
  1. // 等效代碼

  2. (() => {

  3.    // do something before

  4.    eraser.erase().then(r => {

  5.        this._matrix = r;

  6.        // do something after

  7.        // do more things

  8.    });

  9. })();

這個程序結構比較簡單,並不能很好的體現 async/await 的好處,不過它對於簡化瀑布式回調和 Promise 的 then 鏈確實非常有效。

封裝矩陣操作 - Matrix

以前對於 Matrix 這個類是加了刪、刪了加,一直沒能很好的定位。現在由於程序結構已經發生了較大的變化,Matrix 的功能也能更清晰的定義出來了。

  • 創建矩陣行及矩陣 - createRow()createMatrix()

  • 提供 width 和 height

  • 將 Block 的各個點固化下來 - addBlockPoints()

  • 設置/取消某個座標的 BlockPoint 對象 - set()

  • 判斷並獲取滿行 - getFullRows()

  • 刪除行,數據層面的操作 - removeRows()

  • 提取有效(有小方塊的)BlockPoint 列表 - fasten()

  • 判斷某個/某些點是否爲空(可以放置新小方塊) - isPutable()

小結

JavaScript/TypeScript 版俄羅斯方塊是以技術研究爲目的而寫,到此已經可以告一段落了。由於它不是以遊戲體驗爲目的寫的一個遊戲程序,所以在體驗上還有很多需要改進的地方,就留給有興趣的朋友們研究了。

傳送門 
- 本文源碼(https://git.oschina.net/jamesfancy/tetris)

- 演示地址(http://jamesfancy.oschina.io/tetris)
- 如何構建 - 參考首篇博文(https://segmentfault.com/a/1190000006919702)


本文分享自微信公衆號 - 邊城客棧(fancyidea-full)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章