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

2018-06-08 13:57 更新

你一定注意到博文的標(biāo)題變了成了“TypeScript 版 ...”。在上一篇 JavaScript 版俄羅斯方塊——轉(zhuǎn)換為 TypeScript 中,它就變成了 TypeScript 實(shí)現(xiàn)。而在之前的 JavaScript 版俄羅斯方塊——重構(gòu) 中,只重構(gòu)了數(shù)據(jù)結(jié)構(gòu)部分,控制(業(yè)務(wù)邏輯)部分因?yàn)檫^(guò)于復(fù)雜,只是進(jìn)行了表面的重構(gòu)。所以現(xiàn)在來(lái)對(duì)控制部分進(jìn)行更深入的重構(gòu)。

傳送門

邏輯結(jié)構(gòu)分析

重構(gòu)不是盲目的,一定還是要先進(jìn)行一些分析。

Puzzle 職責(zé)很明確,負(fù)責(zé)繪制,除此之外,剩下的就是數(shù)據(jù)、狀態(tài)和對(duì)它們的控制。

從上圖可以看出來(lái),用于繪制的數(shù)據(jù)主要就是 blockmatrix 了。對(duì)于 block,需要控制它的位置變動(dòng)和旋轉(zhuǎn),而 block 下降到底之后,會(huì)通過(guò) 固化 變成 matrix 的部分?jǐn)?shù)據(jù),而由于 固化 造成 matrix 數(shù)據(jù)變動(dòng)之后,可能會(huì)產(chǎn)生若干整行有效數(shù)據(jù),這時(shí)候需要觸發(fā) 刪除行 操作。所有 blockmatrix 的變動(dòng),都應(yīng)該引起 Puzzle 的重繪。處理這部分控制過(guò)程的對(duì)象,且稱之為 BlockController。

游戲過(guò)程中方塊會(huì)定時(shí)下落,這是由 Timer 控制的。Timer 每達(dá)到一個(gè) interval 所指示的時(shí)間,就會(huì)向 BlockController 發(fā)送消息,通知它執(zhí)行一次 moveDown 操作。

block固化 操作開始,直到 刪除行 操作完成這一段時(shí)間,不應(yīng)處理 Timer 的消息??紤]到這一過(guò)程結(jié)束時(shí)最好不需要等到下一時(shí)鐘周期,所以在這段時(shí)間最好停止 Timer,所以這里應(yīng)該通知暫停。

說(shuō)到暫停,在之前就分析過(guò),除了 BlockController 要求的暫停外,還有可能是用戶手工請(qǐng)求暫暫停。只有當(dāng)兩種暫停狀態(tài)都取消的時(shí)候,才應(yīng)該繼續(xù)下落方塊。所以這里需要一個(gè) StateManager 來(lái)管理狀態(tài),除了暫停外,順便把游戲的 over 狀態(tài)一并管理了。所以 StateManager 需要接受 BlockControllerCommandPanel 的消息,并根據(jù)狀態(tài)計(jì)算結(jié)果來(lái)通知 Timer 是暫停還是繼續(xù)。

另一方面,由于 BlockController刪除行 操作,這個(gè)操作的發(fā)生意味著要給用戶加分,所以需要通知 InfoPanel 加分。而 InfoPanel 加分到一定程度會(huì)引起加速,它需要自己內(nèi)部判斷并處理這個(gè)過(guò)程。不過(guò)加速就意味著時(shí)鐘周期的變動(dòng),所以需要通知 Timer。

仍然存在的問(wèn)題

按照?qǐng)D示及上述過(guò)程,其實(shí)在之前的版本已經(jīng)基本實(shí)現(xiàn),相互之間的通知實(shí)現(xiàn)得并不十分清晰,部分是通過(guò)事件來(lái)實(shí)現(xiàn)的,也有部分是通過(guò)直接的方法調(diào)用來(lái)實(shí)現(xiàn)的。顯然,深入重構(gòu)就是要把這個(gè)結(jié)構(gòu)搞清楚。

1. 處理復(fù)雜的通知結(jié)構(gòu)

各控制器之間需要要相互通知,并根據(jù)得到的通知來(lái)進(jìn)行處理。如果有一個(gè)統(tǒng)一的消息(通知)處理中心,結(jié)構(gòu)會(huì)不會(huì)看起來(lái)更簡(jiǎn)單一些呢?

BlockController 其實(shí)上已經(jīng)處理了大部分之前 Tetris 所做的工作。所以不妨把 Tetris 更名為 BlockController,再新建個(gè) Tetris 來(lái)專門處理各種通知。通知統(tǒng)一通過(guò)事件來(lái)實(shí)現(xiàn),不過(guò)如果涉及到一些較長(zhǎng)的過(guò)程(比如刪除動(dòng)畫),可以考慮通過(guò) Promise 來(lái)實(shí)現(xiàn)。

2. BlockController 過(guò)于復(fù)雜

BlockController 要管理 blockmatrix 兩個(gè)數(shù)據(jù),還要處理 block 的移動(dòng)和變形,以及處理 block 的固化,以及 matrix 的刪除行操作等,甚至還負(fù)責(zé)了刪除行動(dòng)畫的實(shí)現(xiàn)。

所以為了簡(jiǎn)化代碼結(jié)構(gòu),BlockController 應(yīng)該專注于 block 的管理,其它的操作,應(yīng)該由別的類來(lái)完成,比如 MatrixController、EraseAnimator 等。

深入重構(gòu) - 事件中心

為了將 BlockController 從“繁忙的事務(wù)”中解救出來(lái),首先是解耦。解耦比較流行的思想是 IoC(Inversion of Control,控制反轉(zhuǎn)) 或者 DI(Dependency Injection,依賴注入)。不過(guò)這里用的是另一種思想,消息驅(qū)動(dòng),或者事件驅(qū)動(dòng)。一般情況下消息驅(qū)動(dòng)用于異步處理,而事件驅(qū)動(dòng)用于同步處理。這個(gè)程序中基本上都是同步過(guò)程,所以采用事件即可。

改寫 Eventable,返回 this 的方法

雖然之前的 JavaScript 版就已經(jīng)用到了事件,不過(guò)處理的過(guò)程有限。經(jīng)常上圖的分析,對(duì)需要處理的事件進(jìn)行了擴(kuò)展。另外由于之前是直接使用的 jQuery 的事件,用起來(lái)有點(diǎn)繁瑣,處理函數(shù)的第一個(gè)參數(shù)一定是是 event 對(duì)象,而 event 對(duì)象其實(shí)是很少用的。所以先實(shí)現(xiàn)一個(gè)自己的 Eventable。

自己實(shí)現(xiàn)的 Eventable

事件支持看起來(lái)好像多復(fù)雜一樣,但實(shí)際上非常簡(jiǎn)單。

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

  • on 注冊(cè)事件處理函數(shù),就是將事件處理函數(shù)添加到事件處理函數(shù)列表

  • off 注銷事件處理函數(shù),即從事件處理函數(shù)列表中刪除處理函數(shù)

  • trigger 觸發(fā)事件(通常是內(nèi)部調(diào)用),依次調(diào)用對(duì)應(yīng)的事件處理函數(shù)

事件都有名稱,對(duì)應(yīng)著一個(gè)事件處理函數(shù)列表。為了便于查找事件,這應(yīng)該定義為一個(gè)映射表,其鍵是事件名稱,值為處理函數(shù)列表。TypeScript 可以用接口來(lái)描述這個(gè)結(jié)構(gòu)

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

Eventable 對(duì)象中會(huì)維護(hù)一上述的映射表對(duì)象

private _events: IEventMap;

on(type: string, handler: Function) 注冊(cè)一個(gè)事件名為 type 的處理函數(shù)。所以,是從 _events 里找到(或添加)指定名稱的列表,并在列表里添加 handler

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

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

在上面已經(jīng)把 _events 的結(jié)構(gòu)說(shuō)清楚了,off() 的處理就容易理解了。如果 off() 沒(méi)有參數(shù),直接把 _events 清空或者重新賦值一個(gè)新的 {} 即可;如果 off(type: string) 這種形式的調(diào)用,則從 delete _events[type] 就能達(dá)到目的;只有在給了 handler 的時(shí)候麻煩一點(diǎn),需要先取出列表,再?gòu)牧斜碇姓业?handler,把它去除掉。

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

TypeScript 的方法類型 - this

之前一直很糾結(jié)一個(gè)問(wèn)題:如果要把 Eventable 做成像 jQuery 一樣的鏈?zhǔn)秸{(diào)用,那就必須 return this,但是如果把方法定義為 Eventable 類型,子類實(shí)現(xiàn)的時(shí)候就只能鏈調(diào) Eventable 的方法,而不是子類的方法(因?yàn)榉祷毓潭ǖ?Eventable 類型。后來(lái)終于從 StackOverflow 上查到答案就在文檔中:Advanced Types : Polymorphic this types。

原來(lái)可以將方法定義為 this 類型。是的,這里的 this 表示一種類型而不是一個(gè)對(duì)象,表示返回的是自己。返回類型會(huì)根據(jù)調(diào)用方法的類來(lái)決定,即使子類調(diào)用的是父類中返回 this 的方法,也可以識(shí)別為返回類型是子類類型。

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

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

// 這會(huì)識(shí)別出 test() 返回 Son 類型而不是 Father 類型
// 所以可以直接調(diào)用 doMore()
new Son().test().doMore();

集中處理事件

IoC 和 DI 實(shí)現(xiàn),像 Java 的 Spring,.NET 的 Unity,通常都會(huì)有一個(gè)集中配置的地方,有可能是 XML,也有可能是 @Configure 注釋的 Config 類(Spring 4)等……

這里也采用這種思想,寫一個(gè)類來(lái)集中配置事件。之前已經(jīng)將 Tetris 的事情交給了 BlockController 去處理,這里用 Tetris 來(lái)處理這個(gè)事情正好。

class Tetris {
    constructor() {
        // 生成各部件的實(shí)例
    }
    private setup() {
        this.setupEvents();
        this.setupKeyEvents();
    }
    private setupEvents() {
        // 將各部件的實(shí)例之間用事件關(guān)聯(lián)起來(lái)
    }
    private setupKeyEvents() {
        // 處理鍵盤事件
        // 從 BlockController 中拆分出來(lái)的鍵盤事件處理部分
    }
    run() {
        // 開始 BlockController 的工作
        // 并啟動(dòng) Timer
    }
}

用 async/await 異步處理動(dòng)畫 - Eraser

刪除行這部分邏輯相對(duì)獨(dú)立,可以從 BlockController 中剝離出來(lái),取名 Eraser。那么 Eraseer 需要處理的事情包括

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

  • 檢查之后可以獲得可刪除行的總數(shù) rowCount

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

其中 erase() 中需要通過(guò) setInterval() 來(lái)控制刪除動(dòng)畫,這是一個(gè)異步過(guò)程。所以需要回調(diào),或者 Promise …… 不過(guò)既然是為了做技術(shù)嘗試,不妨用新一點(diǎn)的技術(shù),async/await 怎么樣?

Eraser 的邏輯部分是直接照搬原來(lái)的實(shí)現(xiàn),所以這里主要討論 async/await 實(shí)現(xiàn)。

改造構(gòu)建及配置以支持 async/await

TypeScript 的編譯目標(biāo)參數(shù) target 設(shè)置為 es2015 或者 es6 的時(shí)候,允許使用 async/await 語(yǔ)法,它編譯出來(lái)的 JavaScript 是使用 es6 的 Promise 來(lái)實(shí)現(xiàn)的。而我們需要的是 es5 語(yǔ)法的實(shí)現(xiàn),所以又得靠 Babel 了。Babel 的 presets es2017stage-3 等都支持將 async/await 和 Promise 轉(zhuǎn)換成 es5 語(yǔ)法。

不過(guò)這次使用 Babel 不是從 JavaScript 源文件編譯成目標(biāo)文件。而是利用 gulp 的流管道功能,將 TypeScript 的編譯結(jié)果直接送給 Babel,再由 Babel 轉(zhuǎn)換之后輸出。

這里需要安裝 3 個(gè)包

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

同時(shí)需要修改 gulpfile.js 中的 typescript 任務(wù)

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"));
});

請(qǐng)注意到 typescript 任務(wù)中 ts.createProject() 中覆蓋了配置中的 outFile 選項(xiàng),將結(jié)果輸出為 npm 項(xiàng)目所在目錄的文件。這是一個(gè) gulp 處理過(guò)程中虛擬的文件,并不會(huì)真的存儲(chǔ)于硬盤上,但 Babel 會(huì)以為它得到的是這個(gè)路徑的文件,會(huì)根據(jù)這個(gè)路徑去 node_modules 中尋找依賴庫(kù)。

編譯沒(méi)問(wèn)題了,但運(yùn)行會(huì)有問(wèn)題,因?yàn)槿鄙?babel-polyfill,也就是 Babel 的 Promise 實(shí)現(xiàn)部分。先通過(guò) npm 添加包

npm install --save-dev babel-polyfill

這個(gè)包下面的 dist/polyfill.min.js 需要在 index.html 中加載。所以在 gulpfile.js 中像處理 jquery.min.js 那樣,在 libs 任務(wù)中加一個(gè)源即可。之后運(yùn)行 gulp build 會(huì)將 polyfill.min.js 拷貝到 /js 目錄中。

async/await 語(yǔ)法

關(guān)于 async/await 語(yǔ)法,我曾在 閑談異步調(diào)用“扁平”化 一文中討論過(guò)。雖然那篇博文中只討論了 C# 而不是 JavaScript 的 async/await,但是最后那部分使用了 co 庫(kù)的 JavaScript 代碼對(duì)理解 async/await 很有幫助。

在 co 的語(yǔ)法中,通過(guò) yield 來(lái)模擬了 await,而 yeild 后面接的是一個(gè) Promise 對(duì)象。await 后面跟著的民是一個(gè) Promise 對(duì)象,而它“等待”的,就是這個(gè) Promise 的 resolve,并將 resolve 的的值傳遞出去。

相應(yīng)的,async 則是將一個(gè)返回 Promise 的函數(shù)是可以等待的。

由于 await 必須出現(xiàn)在 async 函數(shù)中,所以最終調(diào)用 async erase() 的部分用 async IIFE 實(shí)現(xiàn):

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

上面的代碼 IIFE 中 await 后面的部分相當(dāng)于被封裝成了一個(gè) lambda,作為 eraser.erase().then() 的第一個(gè)回調(diào),即

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

這個(gè)程序結(jié)構(gòu)比較簡(jiǎn)單,并不能很好的體現(xiàn) async/await 的好處,不過(guò)它對(duì)于簡(jiǎn)化瀑布式回調(diào)和 Promise 的 then 鏈確實(shí)非常有效。

封裝矩陣操作 - Matrix

以前對(duì)于 Matrix 這個(gè)類是加了刪、刪了加,一直沒(méi)能很好的定位。現(xiàn)在由于程序結(jié)構(gòu)已經(jīng)發(fā)生了較大的變化,Matrix 的功能也能更清晰的定義出來(lái)了。

  • 創(chuàng)建矩陣行及矩陣 - createRow()、createMatrix()

  • 提供 widthheight

  • Block 的各個(gè)點(diǎn)固化下來(lái) - addBlockPoints()

  • 設(shè)置/取消某個(gè)坐標(biāo)的 BlockPoint 對(duì)象 - set()

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

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

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

  • 判斷某個(gè)/某些點(diǎn)是否為空(可以放置新小方塊) - isPutable()

小結(jié)

JavaScript/TypeScript 版俄羅斯方塊是以技術(shù)研究為目的而寫,到此已經(jīng)可以告一段落了。由于它不是以游戲體驗(yàn)為目的寫的一個(gè)游戲程序,所以在體驗(yàn)上還有很多需要改進(jìn)的地方,就留給有興趣的朋友們研究了。

傳送門


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)