Home > Article > Web Front-end > Tetris for JavaScript - Refactored
Reconstruct the project structure
The main project structure is to rename the original app to src, which means that the script and less source code are here. Of course, app/src, which originally stored the script source code, has also been renamed src/scripts.
[root> |-- index.html : 入口 |-- js/ : 构建生成的脚本 |-- css/ : 构建生成的样式表 |-- lib/ : bower 引入的库 `-- src/ : 前端源文件 |-- less : 样式表源文件 `-- scripts : 脚本(es6)源文件
In addition, the base scripts is subdivided into modules, and two subdirectories, model and tetris, were created during the reconstruction process.
Structural Analysis
Before refactoring, a simple structural analysis was performed, mainly dividing several modules and placing them in the model directory. During the process of refactoring and writing new functions, the tetris directory was created, where functional classes and auxiliary classes are placed. However, the most important function is still in scrits/tetris.js.
The following is the picture I drew when I first analyzed the model:
Refactoring
When writing a program, refactoring is always a very necessary but also very error-prone part. The entire reconstruction process of Tetris can be seen from the commit log of the working branch in the source code.
The most important point about refactoring is: change the code structure, but not the logic. In other words, every step of refactoring must modify the code on the basis of ensuring the original business logic - although it is not 100% achievable, we must do our best to follow this principle so that we will not be confused during the refactoring process. An inexplicable BUG occurs. This point should be mentioned in the book "Refactoring to Improve the Design of Existing Code".
Although I am not sure that the principle of changing the code without changing the logic is mentioned in the book "Refactoring to Improve the Design of Existing Code", I still recommend you to read this book. Refactoring plays a very important role in development, but the refactoring process involves many design patterns, so design patterns also need to be read.
Private members
During the refactoring process, I added private member definitions to all classes. The purpose of this is to avoid accidentally accessing members that should not be accessed when using them (usually refers to accidental overwriting, but sometimes accidentally setting values may also cause errors).
Regarding the topic of private members, I have discussed it in Simulating ES6 Symbol Implementation of Private Members in ES5. Here I did not use the method mentioned in that blog, but used Symbol directly. Babel has made compatibility processing for Symbol(). If it is on a browser that supports Symbol, ES6 Symbol will be used directly; if it is not supported, a simulated Symbol implemented by Babel will be used instead.
Code that adds private members looks a bit strange, such as the following code for the simple Point class. The following implementation is mainly to ensure (as much as possible) that once the Point object is generated, its coordinates cannot be changed at will - that is, 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); } }
Models
Only scripts/model The few classes implemented below are relatively pure models. In addition to the fields (Field) used to store data and the properties (Property) used to access data, the methods are also It is used to access data.
Point 和 BlockPoint,继承
model/point.js 和 model/blockpoint.js 里分别实现了用于描述点(小方块)的两个类,区别仅仅在于 BlockPoint 多一个颜色属性。实际上 BlockPoint 是 Point 的子类。在 ES6 里实现继承太容易了,下面是这两个类的结构示意
class Point { constructor(x, y) { // .... } }class BlockPoint extends Point { constructor(x = 0, y = 0, c = "c0") { super(x, y); // .... } }
继氶的实现关键就两点需要注意:
通过 extends 关键字实现继承
如果子类中定义了构造函数 constructor,记得第一句话一定要调用父类的构造函数 super(...)。Javaer 应该很熟悉这个要求的。
Form
Form 在这里不是“表单”的意思,而是“形状、外形”的意思,表示一个方块图形(Shape)通过旋转形成的最多4 种形态,每个 Form对象是其中一种。所以 Form 其实是一组 Point 组成的。
上一个版本中没有定义 Form 这个数据结构,是在生成 Shape 的时候生成的匿名对象。那段代码看起来特别绕,虽然也可以提取个函数出来,不过现在通过 Form 类的构造函数来生成,不仅达到了同样的目的,也把 width 的 height 封装起来了。
Shape 和 SHAPES
Shape 和 SHAPES 跟原来区别不大。SHAPES 的生成代码通过定义 Form 类,简化了不少。而 Shape 类在构建后,也由于成员私有化的原因,color 和 forms 不能被改变了,只能获取。
Tetris 中的游戏相关类
除了几个比较纯粹的模型类放在 model 中,主要入口 index.js 和 tetris.js 放在脚本源码根目录下,其它的游戏相关类都是放在tetris 目录下的。这只是用包(Java概念)或命名空间(C++/C#概念)的概念对源码进行了一个基本的划分。
Block 和 BlockFactory
Block 表示一个大方块,是由四个小方块组成的大方块,它的原型(此原型非 JS 的 Prototype)就是 Shape。所以一个 Block 会有一个 Shape 原型的引用,同时保存着当前它的位置 position 和形态 formIndex,这两个属性在游戏过程中是可以改变的,直接影响着 Block 最终绘制出来的位置和样子。
整有游戏中其实只有两个 Block,一个在预览区中,另一个在游戏区定时下落并被玩家操作。
Block 对象下落到底之后就不再是 Block 了,它会被固化在游戏区。为什么要这样设计呢?因为 Block 表示的是一个完整的大方块,而游戏区下方的方块一旦填满一行就会被消除,大方块将再也不完整。这种情况有两个方案可以描述:
仍然以大方块对象放在那里,但是标记已被消除的块,这样在绘制的时候就可以不绘制已消除的块。
大方块下落完成之后就将其打散成一个个的 BlockPoint,通过矩阵管理。
很明显,第二种方法通过二维数组实现,会更直观,程序写起来也会更简单。所以我选用了第二种方法。
Block 除了描述大方块的位置和形态之外,也会配合游戏控制进行一些数据运算和变化,比如位置的变化:moveLeft()、moveRight()、moveDown() 等,以及形态的变化 rotate();还有几个 fastenXxxx 方法,生成BlockPoint[] 用于绘制或判断下一个位置是否可以放置。关于这一点,在 JavaScript 版俄罗斯方块 中已经谈过。
BlockFactory 功能未变,仍然是产生一个随机方块。
Puzzle 和 Matrix
之前对 Puzzle 和 Matrix 的定义有点混淆,这里把它们区分开了。
Puzzle 用于绘制浏览区和预览区,它除了描述一个指定长宽的绘制区域之外,还有存储着两个重要的对象,block: Block 和fastened: BlockPoint[],也就是上面提到的运动中的方块,和固定下来的若干小方块。
Puzzle 本向不维护 block 和 fastened,但它要绘制这两个重要数据对象中的所有 BlockPoint。
Matrix 不再是一个类,它是两个数据。一个是 Puzzle 中的 matrix 属性,维护着由
由于 Tetris::matrix 在大部分时间是不变的,则 Puzzle 绘制的时候需要的只是其中其中非空部分的列表,所以这里有一个比较好的业务逻辑是:在 Tetris::matrix 变化的时候,从它重新生成 Puzzle::fastened,由 Puzzle 绘制时使用。
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(); }
InfoPanel 和 CommandPanel
InfoPanel 主要用于积分和速度的管理,包括与用户的交互(UI)。CommandPanel 则是负责两个按钮事件的处理。
Tetris
说实在的,我仍然认为 Tetris 的代码有点复杂,还需要重构简化。不过尝试了一下之后发现这并不是一件很容易的事情,所以就留待后面的版本来处理了。
小结
This reconstruction of the Tetris game is only a preliminary reconstruction. The original purpose is just to clearly define the model, but it also splits the business processing. The purpose of model definition is achieved, but the business split is still not satisfactory.
The two previous projects at work both used TypeScript 1.8. Although TypeScript 1.8 has some pitfalls, the static language features of TypeScript, especially static checking, are still very helpful for large JavaScript projects. I used to think that TypeScript increased the amount of code and reduced the flexibility of JavaScript. But this time using ES6 to reconstruct the Tetris game made me deeply feel that this is not a shortcoming of TypeScript at all. It can at least solve this problem in JavaScript. A few questions:
Static inspection can find many potential problems during the development phase, rather than during runtime. You know, the earlier a problem is discovered, the easier it is to correct it.