TypeScript(JavaScript) 版俄罗斯方块——深入重构
你一定注意到博文的标题变了成了“TypeScript 版 ...”。在上一篇 JavaScript 版俄罗斯方块——转换为 TypeScript 中,它就变成了 TypeScript 实现。而在之前的 JavaScript 版俄罗斯方块——重构 中,只重构了数据结构部分,控制(业务逻辑)部分因为过于复杂,只是进行了表面的重构。所以现在来对控制部分进行更深入的重构。
传送门
- 本文源码
- 演示地址(最新发布,不一定与本文源码对应)
- 如何构建 - 参考首篇博文
逻辑结构分析
重构不是盲目的,一定还是要先进行一些分析。
Puzzle
职责很明确,负责绘制,除此之外,剩下的就是数据、状态和对它们的控制。
从上图可以看出来,用于绘制的数据主要就是 block
和 matrix
了。对于 block
,需要控制它的位置变动和旋转,而 block
下降到底之后,会通过 固化
变成 matrix
的部分数据,而由于 固化
造成 matrix
数据变动之后,可能会产生若干整行有效数据,这时候需要触发 删除行
操作。所有 block
和 matrix
的变动,都应该引起 Puzzle
的重绘。处理这部分控制过程的对象,且称之为 BlockController
。
游戏过程中方块会定时下落,这是由 Timer
控制的。Timer
每达到一个 interval
所指示的时间,就会向 BlockController
发送消息,通知它执行一次 moveDown
操作。
block
从 固化
操作开始,直到 删除行
操作完成这一段时间,不应处理 Timer
的消息。考虑到这一过程结束时最好不需要等到下一时钟周期,所以在这段时间最好停止 Timer
,所以这里应该通知暂停。
说到暂停,在之前就分析过,除了 BlockController
要求的暂停外,还有可能是用户手工请求暂暂停。只有当两种暂停状态都取消的时候,才应该继续下落方块。所以这里需要一个 StateManager
来管理状态,除了暂停外,顺便把游戏的 over
状态一并管理了。所以 StateManager
需要接受 BlockController
和 CommandPanel
的消息,并根据状态计算结果来通知 Timer
是暂停还是继续。
另一方面,由于 BlockController
有 删除行
操作,这个操作的发生意味着要给用户加分,所以需要通知 InfoPanel
加分。而 InfoPanel
加分到一定程度会引起加速,它需要自己内部判断并处理这个过程。不过加速就意味着时钟周期的变动,所以需要通知 Timer
。
仍然存在的问题
按照图示及上述过程,其实在之前的版本已经基本实现,相互之间的通知实现得并不十分清晰,部分是通过事件来实现的,也有部分是通过直接的方法调用来实现的。显然,深入重构就是要把这个结构搞清楚。
1. 处理复杂的通知结构
各控制器之间需要要相互通知,并根据得到的通知来进行处理。如果有一个统一的消息(通知)处理中心,结构会不会看起来更简单一些呢?
BlockController
其实上已经处理了大部分之前 Tetris
所做的工作。所以不妨把 Tetris
更名为 BlockController
,再新建个 Tetris
来专门处理各种通知。通知统一通过事件来实现,不过如果涉及到一些较长的过程(比如删除动画),可以考虑通过 Promise 来实现。
2. BlockController 过于复杂
BlockController
要管理 block
和 matrix
两个数据,还要处理 block
的移动和变形,以及处理 block
的固化,以及 matrix
的删除行操作等,甚至还负责了删除行动画的实现。
所以为了简化代码结构,BlockController
应该专注于 block
的管理,其它的操作,应该由别的类来完成,比如 MatrixController
、EraseAnimator
等。
深入重构 - 事件中心
为了将 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 es2017
、stage-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()
- 提供
width
和height
- 将
block
的各个点固化下来 -addBlockPoints()
- 设置/取消某个坐标的
BlockPoint
对象 -set()
- 判断并获取满行 -
getFullRows()
- 删除行,数据层面的操作 -
removeRows()
- 提取有效(有小方块的)
BlockPoint
列表 -fasten()
- 判断某个/某些点是否为空(可以放置新小方块) -
isPutable()
小结
JavaScript/TypeScript 版俄罗斯方块是以技术研究为目的而写,到此已经可以告一段落了。由于它不是以游戏体验为目的写的一个游戏程序,所以在体验上还有很多需要改进的地方,就留给有兴趣的朋友们研究了。
传送门
- 本文源码
- 演示地址(最新发布,不一定与本文源码对应)
- 如何构建 - 参考首篇博文