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

你必定注意到博文的標題變了成了「TypeScript 版 ...」。在上一篇 JavaScript 版俄羅斯方塊——轉換爲 TypeScript 中,它就變成了 TypeScript 實現。而在以前的 JavaScript 版俄羅斯方塊——重構 中,只重構了數據結構部分,控制(業務邏輯)部分由於過於複雜,只是進行了表面的重構。因此如今來對控制部分進行更深刻的重構。javascript

傳送門

邏輯結構分析

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

圖片描述

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

從上圖能夠看出來,用於繪製的數據主要就是 blockmatrix 了。對於 block,須要控制它的位置變更和旋轉,而 block 降低到底以後,會經過 固化 變成 matrix 的部分數據,而因爲 固化 形成 matrix 數據變更以後,可能會產生若干整行有效數據,這時候須要觸發 刪除行 操做。全部 blockmatrix 的變更,都應該引發 Puzzle 的重繪。處理這部分控制過程的對象,且稱之爲 BlockControllernode

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

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

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

另外一方面,因爲 BlockController刪除行 操做,這個操做的發生意味着要給用戶加分,因此須要通知 InfoPanel 加分。而 InfoPanel 加分到必定程度會引發加速,它須要本身內部判斷並處理這個過程。不過加速就意味着時鐘週期的變更,因此須要通知 Timertypescript

仍然存在的問題

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

1. 處理複雜的通知結構

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

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

2. BlockController 過於複雜

BlockController 要管理 blockmatrix 兩個數據,還要處理 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 能夠用接口來描述這個結構

interface IEventMap {
    [type: string]: Array<(data?: any) => any>;
}

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

private _events: IEventMap;

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

(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 的方法,也能夠識別爲返回類型是子類類型。

class Father {
    test(): this { return this; }
}

class Son extends Father {
    doMore(): this { return this; }
}

// 這會識別出 test() 返回 Son 類型而不是 Father 類型
// 因此能夠直接調用 doMore()
new Son().test().doMore();

集中處理事件

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

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

class Tetris {
    constructor() {
        // 生成各部件的實例
    }
    private setup() {
        this.setupEvents();
        this.setupKeyEvents();
    }
    private setupEvents() {
        // 將各部件的實例之間用事件關聯起來
    }
    private setupKeyEvents() {
        // 處理鍵盤事件
        // 從 BlockController 中拆分出來的鍵盤事件處理部分
    }
    run() {
        // 開始 BlockController 的工做
        // 並啓動 Timer
    }
}

用 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 的 presets es2017stage-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 會覺得它獲得的是這個路徑的文件,會根據這個路徑去 node_modules 中尋找依賴庫。

編譯沒問題了,但運行會有問題,由於缺乏 babel-polyfill,也就是 Babel 的 Promise 實現部分。先經過 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 語法

關於 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 實現:

(async () => {
    // do something before
    this._matrix = await eraser.erase();
    // do something after
    // do more things
})();

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

// 等效代碼
(() => {
    // do something before
    eraser.erase().then(r => {
        this._matrix = r;
        // do something after
        // do more things
    });
})();

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

封裝矩陣操做 - Matrix

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

  • 建立矩陣行及矩陣 - createRow()createMatrix()
  • 提供 widthheight
  • Block 的各個點固化下來 - addBlockPoints()
  • 設置/取消某個座標的 BlockPoint 對象 - set()
  • 判斷並獲取滿行 - getFullRows()
  • 刪除行,數據層面的操做 - removeRows()
  • 提取有效(有小方塊的)BlockPoint 列表 - fasten()
  • 判斷某個/某些點是否爲空(能夠放置新小方塊) - isPutable()

小結

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

傳送門

相關文章
相關標籤/搜索