JavaScript 版俄羅斯方塊——重構

JavaScript 版俄羅斯方塊 中曾提到,由於臨時起意,因此項目結構和不少命名都比較混亂。另外,計分等功能也未實現。此次抽空實現計分和速度設置,並在此以前進行了簡單的重構。javascript

傳送門

重構項目結構

項目結構上主要是將原來的 app 改名爲 src,表示腳本和 less 源碼都在這裏。固然原來存放腳本源碼的 app/src 也相改名爲 src/scriptscss

[root>
  |-- index.html    : 入口
  |-- js/           : 構建生成的腳本
  |-- css/          : 構建生成的樣式表
  |-- lib/          : bower 引入的庫
  `-- src/          : 前端源文件
        |-- less    : 樣式表源文件
        `-- scripts : 腳本(es6)源文件

除此這外,基 scripts 中細分了模塊,在重構的過程當中建立了 modeltetris 兩個子目錄。html

結構分析

重構以前先進行了簡單的結構分析,主要是將幾個模塊劃分出來,放在 model 目錄下。重構和寫新功能的過程當中建立了 tetris 目錄,這裏放的是功能類和輔助類。然而最主要的功能仍是在 scrits/tetris.js 中。前端

下面是一開始分析模型時畫的圖:java

clipboard.png

重構

寫程序,重構老是很是須要但也很是容易出錯的部分。俄羅斯方塊的整個重構的過程從 源碼中 working 分支 的提交日誌中能夠看到。git

關於重構,最重要的一點是:改變代碼結構,但不改變邏輯。也就是說,每一步重構都要在保證原有業務邏輯的基礎上對代碼進行修改——雖然並非 100% 能達到,但要盡最大努力遵循這個原則,纔不會在重構的過程當中產生莫名其妙的 BUG。關於這一點,應該是在《重構 改善既有代碼的設計》一書中提到的。es6

雖然不肯定改代碼不改邏輯的原則是在 《重構 改善既有代碼的設計》 這本書中提到的,可是這本書仍是推薦你們去看一看。重構對於開發有着很重要的做用,不太重構過程當中涉及到不少設計模式,因此設計模式也是須要讀一讀的。typescript

私有成員

在重構的過程當中,我爲全部類都加入了私有成員定義。這樣作的目的是避免在使用它們的時候,不當心訪問了不應訪問的成員(通常指不當心改寫,但有時候不當心取值也可能形成錯誤)。segmentfault

關於私有成員這個話題,我曾在 ES5 中模擬 ES6 的 Symbol 實現私有成員 中討論過。在這裏我沒有用那篇博客中提到的方法,而是直接使用了 Symbol。Babel 對 Symbol() 作了兼容處理,若是是在支持 Symbol 的瀏覽器上,會直接使用 ES6 的 Symbol;不支持的,則用 Babel 實現的一個模擬的 Symbol 代替。設計模式

加入了私有化成員的代碼看起來有些奇怪,好比下面這個簡單的 Point 類的代碼。如下的實現主要是爲了(儘量)保證 Point 對象一但生成,其座標就不能隨意改動——也就是 Immutable。

const __ = {
    x: Symbol("x"),
    y: Symbol("y")
};

export default class Point {
    constructor(x, y) {
        this[__.x] = x;
        this[__.y] = y;
    }

    get x() {
        return this[__.x];
    }

    get y() {
        return this[__.y];
    }

    move(offsetX = 0, offsetY = 0) {
        return new Point(this.x + offsetX, this.y + offsetY);
    }
}

這段代碼還好,在寫了不少 const __ = { ... } 以後,我忽然以爲很是思念 TypeScript。在 TypeScript 中只須要簡單的 private _x; 就能夠申明私有成員。

TypeScript 中申明的私有成員僅限於靜態檢查,最終生成的 JavaScript 腳本中,這些成員均可以在外部訪問。不過不要緊,由於靜態檢查能夠更好的幫咱們規避錯誤。

Models

只有 scripts/model 下面實現的幾個類是比較純粹的模型,除了用於存儲數據的字段(Field)和存取數據的屬性(Property)以外,方法也都是用於存取數據的。

Point 和 BlockPoint,繼承

model/point.jsmodel/blockpoint.js 裏分別實現了用於描述點(小方塊)的兩個類,區別僅僅在於 BlockPoint 多一個顏色屬性。實際上 BlockPointPoint 的子類。在 ES6 裏實現繼承太容易了,下面是這兩個類的結構示意

class Point {
    constructor(x, y) {
        // ....
    }
}

class BlockPoint extends Point {
    constructor(x = 0, y = 0, c = "c0") {
        super(x, y);
        // ....
    }
}

繼氶的實現關鍵就兩點須要注意:

  1. 經過 extends 關鍵字實現繼承
  2. 若是子類中定義了構造函數 constructor,記得第一句話必定要調用父類的構造函數 super(...)。Javaer 應該很熟悉這個要求的。

Form

Form 在這裏不是「表單」的意思,而是「形狀、外形」的意思,表示一個方塊圖形(Shape)經過旋轉造成的最多4 種形態,每一個 Form 對象是其中一種。因此 Form 實際上是一組 Point 組成的。

上一個版本中沒有定義 Form 這個數據結構,是在生成 Shape 的時候生成的匿名對象。那段代碼看起來特別繞,雖然也能夠提取個函數出來,不過如今經過 Form 類的構造函數來生成,不只達到了一樣的目的,也把 widthheight 封裝起來了。

Shape 和 SHAPES

ShapeSHAPES 跟原來區別不大。SHAPES 的生成代碼經過定義 Form 類,簡化了很多。而 Shape 類在構建後,也因爲成員私有化的緣由,colorforms 不能被改變了,只能獲取。

Tetris 中的遊戲相關類

除了幾個比較純粹的模型類放在 model 中,主要入口 index.jstetris.js 放在腳本源碼根目錄下,其它的遊戲相關類都是放在 tetris 目錄下的。這只是用包(Java概念)或命名空間(C++/C#概念)的概念對源碼進行了一個基本的劃分。

Block 和 BlockFactory

Block 表示一個大方塊,是由四個小方塊組成的大方塊,它的原型(此原型非 JS 的 Prototype)就是 Shape。因此一個 Block 會有一個 Shape 原型的引用,同時保存着當前它的位置 position 和形態 formIndex,這兩個屬性在遊戲過程當中是能夠改變的,直接影響着 Block 最終繪製出來的位置和樣子。

整有遊戲中其實只有兩個 Block,一個在預覽區中,另外一個在遊戲區定時下落並被玩家操做。

Block 對象下落到底以後就再也不是 Block 了,它會被固化在遊戲區。爲何要這樣設計呢?由於 Block 表示的是一個完整的大方塊,而遊戲區下方的方塊一旦填滿一行就會被消除,大方塊將不再完整。這種狀況有兩個方案能夠描述:

  1. 仍然以大方塊對象放在那裏,可是標記已被消除的塊,這樣在繪製的時候就能夠不繪製已消除的塊。
  2. 大方塊下落完成以後就將其打散成一個個的 BlockPoint,經過矩陣管理。

很明顯,第二種方法經過二維數組實現,會更直觀,程序寫起來也會更簡單。因此我選用了第二種方法。

Block 除了描述大方塊的位置和形態以外,也會配合遊戲控制進行一些數據運算和變化,好比位置的變化:moveLeft()moveRight()moveDown() 等,以及形態的變化 rotate();還有幾個 fastenXxxx 方法,生成 BlockPoint[] 用於繪製或判斷下一個位置是否能夠放置。關於這一點,在 JavaScript 版俄羅斯方塊 中已經談過。

BlockFactory 功能未變,仍然是產生一個隨機方塊。

Puzzle 和 Matrix

以前對 Puzzle 和 Matrix 的定義有點混淆,這裏把它們區分開了。

Puzzle 用於繪製瀏覽區和預覽區,它除了描述一個指定長寬的繪製區域以外,還有存儲着兩個重要的對象,block: Blockfastened: BlockPoint[],也就是上面提到的運動中的方塊,和固定下來的若干小方塊。

Puzzle 本向不維護 blockfastened,但它要繪製這兩個重要數據對象中的全部 BlockPoint

Matrix 再也不是一個類,它是兩個數據。一個是 Puzzle 中的 matrix 屬性,維護着由 <div>(行) 和 <span>(單元) 組成的繪製區;另外一個是 Tetris 中的 matrix 屬性,維護着一個 BlockPoint 的矩陣,也就是 Puzzle::fastened 的矩陣形態,它更容易經過固化或刪除等操做來改變。

因爲 Tetris::matrix 在大部分時間是不變的,則 Puzzle 繪製的時候須要的只是其中其中非空部分的列表,因此這裏有一個比較好的業務邏輯是:在 Tetris::matrix 變化的時候,從它從新生成 Puzzle::fastened,由 Puzzle 繪製時使用。

有點遺憾,寫此博文的時候發現重構以後忘了實現這一優化處理,仍然是在每次 Tetris::render 的時候都會去從新生成 Puzzle::fastened。不過不要緊,下個版本必定記得處理這個事情。

Eventable

在重構和寫新功能的過程當中,發現了事件的重要性,好些處理都會用到事件。

好比在點擊暫停/恢復從新開始 的時候,須要去判斷當前遊戲的狀態,並根據狀態的狀況來觸發究竟是不是真的暫停或從新開始。

又好比,在計分和速度選擇功能中,若是計分達到必定程度,就須要觸發提速。

上面提到的這些均可以使用觀察者模式來設計,則事件就是觀察者模式的一個典型實現。要實現本身的事件處理機制其實不難,可是這裏能夠偷偷懶,直接借用 jQuery 的事件處理,因此定義了 Eventable 類用於封裝 jQuery 的事件處理,全部支持事件的業務類均可以從它繼承。

封裝很簡單,這裏採用的是封裝事件代理對象的方式,具體能夠看源代碼,一共只有 20 多行,很容易懂。也能夠在構造函數中把 this 封裝一個 jQuery 對象出來代理事件處理,這種方式能夠將事件處理函數中的 this 指向本身(本身指 Eventable 對象)。不過還好,這個項目中不須要關心事件處理函數中的 this

StateManager

在實現 Tetris 中的主要遊戲邏輯的時候,發現狀態管理並不簡單,尤爲是加了 暫停/恢復 按鈕以後,暫停狀態就分爲代碼暫停和人工暫停兩種狀況,對於兩種狀況的恢復操做也是有區別的。除此以外還有遊戲結束的狀態……因此乾脆就定義個 StateManager 來管理狀態了。

StateManager 維護着遊戲的狀態,提供改變狀態的方法,也提供判斷狀態的屬性。若是 JavaScript 有接口語法的話,這個接口大概是這樣的

interface IStateManager {
    get isPaused(): boolean;
    get isPausedByManual(): boolean;
    get isRestartable(): boolean;
    get isOver(): boolean;

    pause(byWhat);
    resume(byWhat);
    start();
    over();
}

我又開始想念 TypeScript 了

InfoPanel 和 CommandPanel

InfoPanel 主要用於積分和速度的管理,包括與用戶的交互(UI)。CommandPanel 則是負責兩個按鈕事件的處理。

Tetris

說實在的,我仍然認爲 Tetris 的代碼有點複雜,還須要重構簡化。不過嘗試了一下以後發現這並非一件很容易的事情,因此就留待後面的版原本處理了。

小結

此次對俄羅斯方塊遊戲的重構只是一個初步的重構,最初的目的只是想把模型定義清楚,不過也對業務處理進行了一些拆分。模型定義的目的是達到了,可是業務拆分仍然不盡滿意。

工做上以前的兩個項目都是用的 TypeScript 1.8,雖然是 TypeScript 1.8 有一些坑在那裏,可是 TypeScript 的靜態語言特性,尤爲是靜態檢查對大型 JavaScript 項目仍是有很大幫助的。以前一直認爲 TypeScript 增長了代碼量,也下降了 JavaScript 的靈活度,但此次用 ES6 重構俄羅斯方塊遊戲讓我深深的感覺到,這根本不是 TypeScript 的缺點,它至少能夠解決 JavaScript 中的這幾個問題:

  • 靜態檢查在開發階段就能發現不少潛在的問題,而不是在運行的時候才能發現問題。要知道,問題發現得越早改起來越容易。
  • 編輯器(我用的 VSCode)的智能提示和自動完成功能在 TypeScript 的嚴格語法下很是好用,一個點出來就知道哪些方法能夠調用,哪些不能。而對於 JavaScript 這方面就要弱一些了,編輯器不是按語義來分析,而是看代碼中出現了哪些,這樣不免會出現寫代碼不當心對象和方法不匹配的狀況。

因此,下個版本我準備嘗試用 TypeScript 2.0 來改寫。

新篇來啦:JavaScript 版俄羅斯方塊——轉換爲 TypeScript

傳送門

相關文章
相關標籤/搜索