TypeScript(JavaScript) 版俄罗斯方块——深入重构

2016/10/17 07:39
阅读数 85

在上一篇 JavaScript 版俄罗斯方块——转换为 TypeScripthttps://segmentfault.com/a/1190000007074816 中,程序就变成了 TypeScript 实现。而在之前的 JavaScript 版俄罗斯方块——重构https://segmentfault.com/a/1190000007063852)中,只重构了数据结构部分,控制(业务逻辑)部分因为过于复杂,只是进行了表面的重构。所以现在来对控制部分进行更深入的重构。

受微信权限限制,文内链接不能打开,请移步原文(https://segmentfault.com/a/1190000007167312)通过链接阅读相关博文。

也可通过文末的“阅读原文”连接进入原文阅读

逻辑结构分析

重构不是盲目的,一定还是要先进行一些分析。


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 的管理,其它的操作,应该由别的类来完成,比如 MatrixControllerEraseAnimator 等。

深入重构 - 事件中心

为了将 BlockController 从“繁忙的事务”中解救出来,首先是解耦。解耦比较流行的思想是 IoC(Inversion of Control,控制反转) 或者 DI(Dependency Injection,依赖注入)。不过这里用的是另一种思想,消息驱动,或者事件驱动。一般情况下消息驱动用于异步处理,而事件驱动用于同步处理。这个程序中基本上都是同步过程,所以采用事件即可。

改写 Eventable,返回 this 的方法

虽然之前的 JavaScript 版就已经用到了事件,不过处理的过程有限。经常上图的分析,对需要处理的事件进行了扩展。另外由于之前是直接使用的 jQuery 的事件,用起来有点繁琐,处理函数的第一个参数一定是是 event 对象,而 event 对象其实是很少用的。所以先实现一个自己的 Eventable

自己实现的 Eventable

事件支持看起来好像多复杂一样,但实际上非常简单。

首先,事件处理的外部接口就三个:

  • on 注册事件处理函数,就是将事件处理函数添加到事件处理函数列表

  • off 注销事件处理函数,即从事件处理函数列表中删除处理函数

  • trigger 触发事件(通常是内部调用),依次调用对应的事件处理函数

事件都有名称,对应着一个事件处理函数列表。为了便于查找事件,这应该定义为一个映射表,其键是事件名称,值为处理函数列表。TypeScript 可以用接口来描述这个结构

  
    
  
  
  
  1. interface IEventMap {

  2.    [type: string]: Array<(data?: any) => any>;

  3. }

Eventable 对象中会维护一上述的映射表对象

  
    
  
  
  
  1. private _events: IEventMap;

on(type: string, handler: Function) 注册一个事件名为 type 的处理函数。所以,是从 _events 里找到(或添加)指定名称的列表,并在列表里添加handler

  
    
  
  
  
  1. (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 的方法,也可以识别为返回类型是子类类型。

  
    
  
  
  
  1. class Father {

  2.    test(): this { return this; }

  3. }

  4. class Son extends Father {

  5.    doMore(): this { return this; }

  6. }

  7. // 这会识别出 test() 返回 Son 类型而不是 Father 类型

  8. // 所以可以直接调用 doMore()

  9. new Son().test().doMore();

集中处理事件

IoC 和 DI 实现,像 Java 的 Spring,.NET 的 Unity,通常都会有一个集中配置的地方,有可能是 XML,也有可能是 @Configure 注释的 Config 类(Spring 4)等……

这里也采用这种思想,写一个类来集中配置事件。之前已经将 Tetris 的事情交给了 BlockController 去处理,这里用 Tetris 来处理这个事情正好。

  
    
  
  
  
  1. class Tetris {

  2.    constructor() {

  3.        // 生成各部件的实例

  4.    }

  5.    private setup() {

  6.        this.setupEvents();

  7.        this.setupKeyEvents();

  8.    }

  9.    private setupEvents() {

  10.        // 将各部件的实例之间用事件关联起来

  11.    }

  12.    private setupKeyEvents() {

  13.        // 处理键盘事件

  14.        // 从 BlockController 中拆分出来的键盘事件处理部分

  15.    }

  16.    run() {

  17.        // 开始 BlockController 的工作

  18.        // 并启动 Timer

  19.    }

  20. }

用 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 的 presetses2017stage-3 等都支持将 async/await 和 Promise 转换成 es5 语法。

不过这次使用 Babel 不是从 JavaScript 源文件编译成目标文件。而是利用 gulp 的流管道功能,将 TypeScript 的编译结果直接送给 Babel,再由 Babel 转换之后输出。

这里需要安装 3 个包

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

同时需要修改 gulpfile.js 中的 typescript 任务

  
    
  
  
  
  1. gulp.task("typescript", callback => {

  2.    const ts = require("gulp-typescript");

  3.    const tsProj = ts.createProject("tsconfig.json", {

  4.        outFile: "./tetris.js"

  5.    });

  6.    const babel = require("gulp-babel");

  7.    const result = tsProj.src()

  8.        .pipe(sourcemaps.init())

  9.        .pipe(tsProj());

  10.    return result.js

  11.        .pipe(babel({

  12.            presets: ["es2015", "stage-3"]

  13.        }))

  14.        .pipe(sourcemaps.write("../js", {

  15.            sourceRoot: "../src/scripts"

  16.        }))

  17.        .pipe(gulp.dest("../js"));

  18. });

请注意到 typescript 任务中 ts.createProject() 中覆盖了配置中的 outFile选项,将结果输出为 npm 项目所在目录的文件。这是一个 gulp 处理过程中虚拟的文件,并不会真的存储于硬盘上,但 Babel 会以为它得到的是这个路径的文件,会根据这个路径去 node_modules 中寻找依赖库。

编译没问题了,但运行会有问题,因为缺少 babel-polyfill,也就是 Babel 的 Promise 实现部分。先通过 npm 添加包

  
    
  
  
  
  1. 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 实现:

  
    
  
  
  
  1. (async () => {

  2.    // do something before

  3.    this._matrix = await eraser.erase();

  4.    // do something after

  5.    // do more things

  6. })();

上面的代码 IIFE 中 await 后面的部分相当于被封装成了一个 lambda,作为eraser.erase().then() 的第一个回调,即

  
    
  
  
  
  1. // 等效代码

  2. (() => {

  3.    // do something before

  4.    eraser.erase().then(r => {

  5.        this._matrix = r;

  6.        // do something after

  7.        // do more things

  8.    });

  9. })();

这个程序结构比较简单,并不能很好的体现 async/await 的好处,不过它对于简化瀑布式回调和 Promise 的 then 链确实非常有效。

封装矩阵操作 - Matrix

以前对于 Matrix 这个类是加了删、删了加,一直没能很好的定位。现在由于程序结构已经发生了较大的变化,Matrix 的功能也能更清晰的定义出来了。

  • 创建矩阵行及矩阵 - createRow()createMatrix()

  • 提供 width 和 height

  • 将 Block 的各个点固化下来 - addBlockPoints()

  • 设置/取消某个坐标的 BlockPoint 对象 - set()

  • 判断并获取满行 - getFullRows()

  • 删除行,数据层面的操作 - removeRows()

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

  • 判断某个/某些点是否为空(可以放置新小方块) - isPutable()

小结

JavaScript/TypeScript 版俄罗斯方块是以技术研究为目的而写,到此已经可以告一段落了。由于它不是以游戏体验为目的写的一个游戏程序,所以在体验上还有很多需要改进的地方,就留给有兴趣的朋友们研究了。

传送门 
- 本文源码(https://git.oschina.net/jamesfancy/tetris)

- 演示地址(http://jamesfancy.oschina.io/tetris)
- 如何构建 - 参考首篇博文(https://segmentfault.com/a/1190000006919702)


本文分享自微信公众号 - 边城客栈(fancyidea-full)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部