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

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

传送门

逻辑结构分析

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

图片描述

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

从上图能够看出来,用于绘制的数据主要就是 blockmatrix 了。对于 block,须要控制它的位置变更和旋转,而 block 降低到底以后,会经过 固化 变成 matrix 的部分数据,而因为 固化 形成 matrix 数据变更以后,可能会产生若干整行有效数据,这时候须要触发 删除行 操做。全部 blockmatrix 的变更,都应该引发 Puzzle 的重绘。处理这部分控制过程的对象,且称之为 BlockControllernode

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

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

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

另外一方面,因为 BlockController删除行 操做,这个操做的发生意味着要给用户加分,因此须要通知 InfoPanel 加分。而 InfoPanel 加分到必定程度会引发加速,它须要本身内部判断并处理这个过程。不过加速就意味着时钟周期的变更,因此须要通知 Timertypescript

仍然存在的问题

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

1. 处理复杂的通知结构

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

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 版俄罗斯方块是以技术研究为目的而写,到此已经能够告一段落了。因为它不是以游戏体验为目的写的一个游戏程序,因此在体验上还有不少须要改进的地方,就留给有兴趣的朋友们研究了。

传送门

相关文章
相关标签/搜索