在 JavaScript 版俄羅斯方塊 中曾提到,因為臨時起意,所以項目結(jié)構(gòu)和很多命名都比較混亂。另外,計分等功能也未實現(xiàn)。這次抽空實現(xiàn)計分和速度設(shè)置,并在此之前進行了簡單的重構(gòu)。
項目結(jié)構(gòu)上主要是將原來的 app
更名為 src
,表示腳本和 less 源碼都在這里。當(dāng)然原來存放腳本源碼的 app/src
也相更名為 src/scripts
。
[root>
|-- index.html : 入口
|-- js/ : 構(gòu)建生成的腳本
|-- css/ : 構(gòu)建生成的樣式表
|-- lib/ : bower 引入的庫
`-- src/ : 前端源文件
|-- less : 樣式表源文件
`-- scripts : 腳本(es6)源文件
除此這外,基 scripts
中細分了模塊,在重構(gòu)的過程中創(chuàng)建了 model
和 tetris
兩個子目錄。
重構(gòu)之前先進行了簡單的結(jié)構(gòu)分析,主要是將幾個模塊劃分出來,放在 model
目錄下。重構(gòu)和寫新功能的過程中創(chuàng)建了 tetris
目錄,這里放的是功能類和輔助類。然而最主要的功能還是在 scrits/tetris.js
中。
下面是一開始分析模型時畫的圖:
寫程序,重構(gòu)總是非常需要但也非常容易出錯的部分。俄羅斯方塊的整個重構(gòu)的過程從 源碼中 working 分支 的提交日志中可以看到。
關(guān)于重構(gòu),最重要的一點是:改變代碼結(jié)構(gòu),但不改變邏輯。也就是說,每一步重構(gòu)都要在保證原有業(yè)務(wù)邏輯的基礎(chǔ)上對代碼進行修改——雖然并不是 100% 能達到,但要盡最大努力遵循這個原則,才不會在重構(gòu)的過程中產(chǎn)生莫名其妙的 BUG。關(guān)于這一點,應(yīng)該是在《重構(gòu) 改善既有代碼的設(shè)計》一書中提到的。
雖然不確定改代碼不改邏輯的原則是在 《重構(gòu) 改善既有代碼的設(shè)計》 這本書中提到的,但是這本書還是推薦大家去看一看。重構(gòu)對于開發(fā)有著很重要的作用,不過重構(gòu)過程中涉及到很多設(shè)計模式,所以設(shè)計模式也是需要讀一讀的。
在重構(gòu)的過程中,我為所有類都加入了私有成員定義。這樣做的目的是避免在使用它們的時候,不小心訪問了不該訪問的成員(一般指不小心改寫,但有時候不小心取值也可能造成錯誤)。
關(guān)于私有成員這個話題,我曾在 ES5 中模擬 ES6 的 Symbol 實現(xiàn)私有成員 中討論過。在這里我沒有用那篇博客中提到的方法,而是直接使用了 Symbol。Babel 對 Symbol()
做了兼容處理,如果是在支持 Symbol
的瀏覽器上,會直接使用 ES6 的 Symbol;不支持的,則用 Babel 實現(xiàn)的一個模擬的 Symbol 代替。
加入了私有化成員的代碼看起來有些奇怪,比如下面這個簡單的 Point
類的代碼。以下的實現(xiàn)主要是為了(盡可能)保證 Point
對象一但生成,其坐標(biāo)就不能隨意改動——也就是 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 中申明的私有成員僅限于靜態(tài)檢查,最終生成的 JavaScript 腳本中,這些成員都可以在外部訪問。不過沒關(guān)系,因為靜態(tài)檢查可以更好的幫我們規(guī)避錯誤。
只有 scripts/model
下面實現(xiàn)的幾個類是比較純粹的模型,除了用于存儲數(shù)據(jù)的字段(Field)和存取數(shù)據(jù)的屬性(Property)之外,方法也都是用于存取數(shù)據(jù)的。
model/point.js
和 model/blockpoint.js
里分別實現(xiàn)了用于描述點(小方塊)的兩個類,區(qū)別僅僅在于 BlockPoint
多一個顏色屬性。實際上 BlockPoint
是 Point
的子類。在 ES6 里實現(xiàn)繼承太容易了,下面是這兩個類的結(jié)構(gòu)示意
class Point {
constructor(x, y) {
// ....
}
}
class BlockPoint extends Point {
constructor(x = 0, y = 0, c = "c0") {
super(x, y);
// ....
}
}
繼氶的實現(xiàn)關(guān)鍵就兩點需要注意:
通過 extends
關(guān)鍵字實現(xiàn)繼承
如果子類中定義了構(gòu)造函數(shù) constructor
,記得第一句話一定要調(diào)用父類的構(gòu)造函數(shù) super(...)
。Javaer 應(yīng)該很熟悉這個要求的。
Form
在這里不是“表單”的意思,而是“形狀、外形”的意思,表示一個方塊圖形(Shape)通過旋轉(zhuǎn)形成的最多4 種形態(tài),每個 Form
對象是其中一種。所以 Form
其實是一組 Point
組成的。
上一個版本中沒有定義 Form
這個數(shù)據(jù)結(jié)構(gòu),是在生成 Shape 的時候生成的匿名對象。那段代碼看起來特別繞,雖然也可以提取個函數(shù)出來,不過現(xiàn)在通過 Form
類的構(gòu)造函數(shù)來生成,不僅達到了同樣的目的,也把 width
的 height
封裝起來了。
Shape
和 SHAPES
跟原來區(qū)別不大。SHAPES
的生成代碼通過定義 Form
類,簡化了不少。而 Shape
類在構(gòu)建后,也由于成員私有化的原因,color
和 forms
不能被改變了,只能獲取。
除了幾個比較純粹的模型類放在 model
中,主要入口 index.js
和 tetris.js
放在腳本源碼根目錄下,其它的游戲相關(guān)類都是放在 tetris
目錄下的。這只是用包(Java概念)或命名空間(C++/C#概念)的概念對源碼進行了一個基本的劃分。
Block
表示一個大方塊,是由四個小方塊組成的大方塊,它的原型(此原型非 JS 的 Prototype)就是 Shape
。所以一個 Block
會有一個 Shape
原型的引用,同時保存著當(dāng)前它的位置 position
和形態(tài) formIndex
,這兩個屬性在游戲過程中是可以改變的,直接影響著 Block
最終繪制出來的位置和樣子。
整有游戲中其實只有兩個 Block
,一個在預(yù)覽區(qū)中,另一個在游戲區(qū)定時下落并被玩家操作。
Block
對象下落到底之后就不再是 Block
了,它會被固化在游戲區(qū)。為什么要這樣設(shè)計呢?因為 Block
表示的是一個完整的大方塊,而游戲區(qū)下方的方塊一旦填滿一行就會被消除,大方塊將再也不完整。這種情況有兩個方案可以描述:
仍然以大方塊對象放在那里,但是標(biāo)記已被消除的塊,這樣在繪制的時候就可以不繪制已消除的塊。
大方塊下落完成之后就將其打散成一個個的 BlockPoint
,通過矩陣管理。
很明顯,第二種方法通過二維數(shù)組實現(xiàn),會更直觀,程序?qū)懫饋硪矔唵?。所以我選用了第二種方法。
Block
除了描述大方塊的位置和形態(tài)之外,也會配合游戲控制進行一些數(shù)據(jù)運算和變化,比如位置的變化:moveLeft()
、moveRight()
、moveDown()
等,以及形態(tài)的變化 rotate()
;還有幾個 fastenXxxx
方法,生成 BlockPoint[]
用于繪制或判斷下一個位置是否可以放置。關(guān)于這一點,在 JavaScript 版俄羅斯方塊 中已經(jīng)談過。
BlockFactory
功能未變,仍然是產(chǎn)生一個隨機方塊。
之前對 Puzzle 和 Matrix 的定義有點混淆,這里把它們區(qū)分開了。
Puzzle 用于繪制瀏覽區(qū)和預(yù)覽區(qū),它除了描述一個指定長寬的繪制區(qū)域之外,還有存儲著兩個重要的對象,block: Block
和 fastened: BlockPoint[]
,也就是上面提到的運動中的方塊,和固定下來的若干小方塊。
Puzzle 本向不維護 block
和 fastened
,但它要繪制這兩個重要數(shù)據(jù)對象中的所有 BlockPoint
。
Matrix 不再是一個類,它是兩個數(shù)據(jù)。一個是 Puzzle
中的 matrix
屬性,維護著由 <div>
(行) 和 <span>
(單元) 組成的繪制區(qū);另一個是 Tetris
中的 matrix
屬性,維護著一個 BlockPoint
的矩陣,也就是 Puzzle::fastened
的矩陣形態(tài),它更容易通過固化或刪除等操作來改變。
由于 Tetris::matrix
在大部分時間是不變的,則 Puzzle
繪制的時候需要的只是其中其中非空部分的列表,所以這里有一個比較好的業(yè)務(wù)邏輯是:在 Tetris::matrix
變化的時候,從它重新生成 Puzzle::fastened
,由 Puzzle
繪制時使用。
有點遺憾,寫此博文的時候發(fā)現(xiàn)重構(gòu)之后忘了實現(xiàn)這一優(yōu)化處理,仍然是在每次
Tetris::render
的時候都會去重新生成Puzzle::fastened
。不過沒關(guān)系,下個版本一定記得處理這個事情。
在重構(gòu)和寫新功能的過程中,發(fā)現(xiàn)了事件的重要性,好些處理都會用到事件。
比如在點擊暫停/恢復(fù) 和 重新開始 的時候,需要去判斷當(dāng)前游戲的狀態(tài),并根據(jù)狀態(tài)的情況來觸發(fā)到底是不是真的暫停或重新開始。
又比如,在計分和速度選擇功能中,如果計分達到一定程度,就需要觸發(fā)提速。
上面提到的這些都可以使用觀察者模式來設(shè)計,則事件就是觀察者模式的一個典型實現(xiàn)。要實現(xiàn)自己的事件處理機制其實不難,但是這里可以偷偷懶,直接借用 jQuery 的事件處理,所以定義了 Eventable
類用于封裝 jQuery 的事件處理,所有支持事件的業(yè)務(wù)類都可以從它繼承。
封裝很簡單,這里采用的是封裝事件代理對象的方式,具體可以看源代碼,一共只有 20 多行,很容易懂。也可以在構(gòu)造函數(shù)中把 this
封裝一個 jQuery 對象出來代理事件處理,這種方式可以將事件處理函數(shù)中的 this
指向自己(自己指 Eventable 對象)。不過還好,這個項目中不需要關(guān)心事件處理函數(shù)中的 this
。
在實現(xiàn) Tetris 中的主要游戲邏輯的時候,發(fā)現(xiàn)狀態(tài)管理并不簡單,尤其是加了 暫停/恢復(fù) 按鈕之后,暫停狀態(tài)就分為代碼暫停和人工暫停兩種情況,對于兩種情況的恢復(fù)操作也是有區(qū)別的。除此之外還有游戲結(jié)束的狀態(tài)……所以干脆就定義個 StateManager
來管理狀態(tài)了。
StateManager
維護著游戲的狀態(tài),提供改變狀態(tài)的方法,也提供判斷狀態(tài)的屬性。如果 JavaScript 有接口語法的話,這個接口大概是這樣的
interface IStateManager {
get isPaused(): boolean;
get isPausedByManual(): boolean;
get isRestartable(): boolean;
get isOver(): boolean;
pause(byWhat);
resume(byWhat);
start();
over();
}
我又開始想念 TypeScript 了
InfoPanel
主要用于積分和速度的管理,包括與用戶的交互(UI)。CommandPanel
則是負責(zé)兩個按鈕事件的處理。
說實在的,我仍然認為 Tetris
的代碼有點復(fù)雜,還需要重構(gòu)簡化。不過嘗試了一下之后發(fā)現(xiàn)這并不是一件很容易的事情,所以就留待后面的版本來處理了。
這次對俄羅斯方塊游戲的重構(gòu)只是一個初步的重構(gòu),最初的目的只是想把模型定義清楚,不過也對業(yè)務(wù)處理進行了一些拆分。模型定義的目的是達到了,但是業(yè)務(wù)拆分仍然不盡滿意。
工作上之前的兩個項目都是用的 TypeScript 1.8,雖然是 TypeScript 1.8 有一些坑在那里,但是 TypeScript 的靜態(tài)語言特性,尤其是靜態(tài)檢查對大型 JavaScript 項目還是有很大幫助的。之前一直認為 TypeScript 增加了代碼量,也降低了 JavaScript 的靈活度,但這次用 ES6 重構(gòu)俄羅斯方塊游戲讓我深深的感受到,這根本不是 TypeScript 的缺點,它至少可以解決 JavaScript 中的這幾個問題:
靜態(tài)檢查在開發(fā)階段就能發(fā)現(xiàn)很多潛在的問題,而不是在運行的時候才能發(fā)現(xiàn)問題。要知道,問題發(fā)現(xiàn)得越早改起來越容易。
編輯器(我用的 VSCode)的智能提示和自動完成功能在 TypeScript 的嚴(yán)格語法下非常好用,一個點出來就知道哪些方法可以調(diào)用,哪些不能。而對于 JavaScript 這方面就要弱一些了,編輯器不是按語義來分析,而是看代碼中出現(xiàn)了哪些,這樣難免會出現(xiàn)寫代碼不小心對象和方法不匹配的情況。
所以,下個版本我準(zhǔn)備嘗試用 TypeScript 2.0 來改寫。
更多建議: