未加星标

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

字体大小 | |
[前端(javascript) 所属分类 前端(javascript) | 发布者 店小二05 | 时间 2016 | 作者 红领巾 ] 0人收藏点击收藏

你一定注意到博文的标题变了成了“TypeScript 版 ...”。在上一篇 javascript 版俄罗斯方块――转换为 TypeScript 中,它就变成了 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 版俄罗斯方块是以技术研究为目的而写,到此已经可以告一段落了。由于它不是以游戏体验为目的写的一个游戏程序,所以在体验上还有很多需要改进的地方,就留给有兴趣的朋友们研究了。

传送门

本文源码

演示地址(最新发布,不一定与本文源码对应)

如何构建 - 参考首篇博文

本文前端(javascript)相关术语:javascript是什么意思 javascript下载 javascript权威指南 javascript基础教程 javascript 正则表达式 javascript设计模式 javascript高级程序设计 精通javascript javascript教程

主题: JavaJavaScriptSpringjQuery数据删除C#硬盘XMLUnity
分页:12
转载请注明
本文标题:TypeScript(JavaScript) 版俄罗斯方块――深入重构
本站链接:http://www.codesec.net/view/482858.html
分享请点击:


1.凡CodeSecTeam转载的文章,均出自其它媒体或其他官网介绍,目的在于传递更多的信息,并不代表本站赞同其观点和其真实性负责;
2.转载的文章仅代表原创作者观点,与本站无关。其原创性以及文中陈述文字和内容未经本站证实,本站对该文以及其中全部或者部分内容、文字的真实性、完整性、及时性,不作出任何保证或承若;
3.如本站转载稿涉及版权等问题,请作者及时联系本站,我们会及时处理。
登录后可拥有收藏文章、关注作者等权限...
技术大类 技术大类 | 前端(javascript) | 评论(0) | 阅读(20)