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

2018-06-08 13:57 更新

你一定注意到博文的标题变了成了“TypeScript 版 ...”。在上一篇 JavaScript 版俄罗斯方块——转换为 TypeScript 中,它就变成了 TypeScript 实现。而在之前的 JavaScript 版俄罗斯方块——重构 中,只重构了数据结构部分,控制(业务逻辑)部分因为过于复杂,只是进行了表面的重构。所以现在来对控制部分进行更深入的重构。

传送门

逻辑结构分析

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

Puzzle 职责很明确,负责绘制,除此之外,剩下的就是数据、状态和对它们的控制。

从上图可以看出来,用于绘制的数据主要就是 blockmatrix 了。对于 block,需要控制它的位置变动和旋转,而 block 下降到底之后,会通过 固化 变成 matrix 的部分数据,而由于 固化 造成 matrix 数据变动之后,可能会产生若干整行有效数据,这时候需要触发 删除行 操作。所有 blockmatrix 的变动,都应该引起 Puzzle 的重绘。处理这部分控制过程的对象,且称之为 BlockController

游戏过程中方块会定时下落,这是由 Timer 控制的。Timer 每达到一个 interval 所指示的时间,就会向 BlockController 发送消息,通知它执行一次 moveDown 操作。

block固化 操作开始,直到 删除行 操作完成这一段时间,不应处理 Timer 的消息。考虑到这一过程结束时最好不需要等到下一时钟周期,所以在这段时间最好停止 Timer,所以这里应该通知暂停。

说到暂停,在之前就分析过,除了 BlockController 要求的暂停外,还有可能是用户手工请求暂暂停。只有当两种暂停状态都取消的时候,才应该继续下落方块。所以这里需要一个 StateManager 来管理状态,除了暂停外,顺便把游戏的 over 状态一并管理了。所以 StateManager 需要接受 BlockControllerCommandPanel 的消息,并根据状态计算结果来通知 Timer 是暂停还是继续。

另一方面,由于 BlockController删除行 操作,这个操作的发生意味着要给用户加分,所以需要通知 InfoPanel 加分。而 InfoPanel 加分到一定程度会引起加速,它需要自己内部判断并处理这个过程。不过加速就意味着时钟周期的变动,所以需要通知 Timer

仍然存在的问题

按照图示及上述过程,其实在之前的版本已经基本实现,相互之间的通知实现得并不十分清晰,部分是通过事件来实现的,也有部分是通过直接的方法调用来实现的。显然,深入重构就是要把这个结构搞清楚。

1. 处理复杂的通知结构

各控制器之间需要要相互通知,并根据得到的通知来进行处理。如果有一个统一的消息(通知)处理中心,结构会不会看起来更简单一些呢?

BlockController 其实上已经处理了大部分之前 Tetris 所做的工作。所以不妨把 Tetris 更名为 BlockController,再新建个 Tetris 来专门处理各种通知。通知统一通过事件来实现,不过如果涉及到一些较长的过程(比如删除动画),可以考虑通过 Promise 来实现。

2. BlockController 过于复杂

BlockController 要管理 blockmatrix 两个数据,还要处理 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 可以用接口来描述这个结构

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 es2017stage-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()

  • 提供 widthheight

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

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

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

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

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

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

小结

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

传送门


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号