JavaScript 版俄罗斯方块——重构

6

JavaScript 版俄罗斯方块 中曾提到,因为临时起意,所以项目结构和很多命名都比较混乱。另外,计分等功能也未实现。这次抽空实现计分和速度设置,并在此之前进行了简单的重构。

传送门

重构项目结构

项目结构上主要是将原来的 app 更名为 src,表示脚本和 less 源码都在这里。当然原来存放脚本源码的 app/src 也相更名为 src/scripts

[root>
  |-- index.html    : 入口
  |-- js/           : 构建生成的脚本
  |-- css/          : 构建生成的样式表
  |-- lib/          : bower 引入的库
  `-- src/          : 前端源文件
        |-- less    : 样式表源文件
        `-- scripts : 脚本(es6)源文件

除此这外,基 scripts 中细分了模块,在重构的过程中创建了 modeltetris 两个子目录。

结构分析

重构之前先进行了简单的结构分析,主要是将几个模块划分出来,放在 model 目录下。重构和写新功能的过程中创建了 tetris 目录,这里放的是功能类和辅助类。然而最主要的功能还是在 scrits/tetris.js 中。

下面是一开始分析模型时画的图:

clipboard.png

重构

写程序,重构总是非常需要但也非常容易出错的部分。俄罗斯方块的整个重构的过程从 源码中 working 分支 的提交日志中可以看到。

关于重构,最重要的一点是:改变代码结构,但不改变逻辑。也就是说,每一步重构都要在保证原有业务逻辑的基础上对代码进行修改——虽然并不是 100% 能达到,但要尽最大努力遵循这个原则,才不会在重构的过程中产生莫名其妙的 BUG。关于这一点,应该是在《重构 改善既有代码的设计》一书中提到的。

虽然不确定改代码不改逻辑的原则是在 《重构 改善既有代码的设计》 这本书中提到的,但是这本书还是推荐大家去看一看。重构对于开发有着很重要的作用,不过重构过程中涉及到很多设计模式,所以设计模式也是需要读一读的。

私有成员

在重构的过程中,我为所有类都加入了私有成员定义。这样做的目的是避免在使用它们的时候,不小心访问了不该访问的成员(一般指不小心改写,但有时候不小心取值也可能造成错误)。

关于私有成员这个话题,我曾在 ES5 中模拟 ES6 的 Symbol 实现私有成员 中讨论过。在这里我没有用那篇博客中提到的方法,而是直接使用了 Symbol。Babel 对 Symbol() 做了兼容处理,如果是在支持 Symbol 的浏览器上,会直接使用 ES6 的 Symbol;不支持的,则用 Babel 实现的一个模拟的 Symbol 代替。

加入了私有化成员的代码看起来有些奇怪,比如下面这个简单的 Point 类的代码。以下的实现主要是为了(尽可能)保证 Point 对象一但生成,其坐标就不能随意改动——也就是 Immutable。

const __ = {
    x: Symbol("x"),
    y: Symbol("y")
};

export default class Point {
    constructor(x, y) {
        this[__.x] = x;
        this[__.y] = y;
    }

    get x() {
        return this[__.x];
    }

    get y() {
        return this[__.y];
    }

    move(offsetX = 0, offsetY = 0) {
        return new Point(this.x + offsetX, this.y + offsetY);
    }
}

这段代码还好,在写了很多 const __ = { ... } 之后,我突然觉得非常思念 TypeScript。在 TypeScript 中只需要简单的 private _x; 就可以申明私有成员。

TypeScript 中申明的私有成员仅限于静态检查,最终生成的 JavaScript 脚本中,这些成员都可以在外部访问。不过没关系,因为静态检查可以更好的帮我们规避错误。

Models

只有 scripts/model 下面实现的几个类是比较纯粹的模型,除了用于存储数据的字段(Field)和存取数据的属性(Property)之外,方法也都是用于存取数据的。

Point 和 BlockPoint,继承

model/point.jsmodel/blockpoint.js 里分别实现了用于描述点(小方块)的两个类,区别仅仅在于 BlockPoint 多一个颜色属性。实际上 BlockPointPoint 的子类。在 ES6 里实现继承太容易了,下面是这两个类的结构示意

class Point {
    constructor(x, y) {
        // ....
    }
}

class BlockPoint extends Point {
    constructor(x = 0, y = 0, c = "c0") {
        super(x, y);
        // ....
    }
}

继氶的实现关键就两点需要注意:

  1. 通过 extends 关键字实现继承
  2. 如果子类中定义了构造函数 constructor,记得第一句话一定要调用父类的构造函数 super(...)。Javaer 应该很熟悉这个要求的。

Form

Form 在这里不是“表单”的意思,而是“形状、外形”的意思,表示一个方块图形(Shape)通过旋转形成的最多4 种形态,每个 Form 对象是其中一种。所以 Form 其实是一组 Point 组成的。

上一个版本中没有定义 Form 这个数据结构,是在生成 Shape 的时候生成的匿名对象。那段代码看起来特别绕,虽然也可以提取个函数出来,不过现在通过 Form 类的构造函数来生成,不仅达到了同样的目的,也把 widthheight 封装起来了。

Shape 和 SHAPES

ShapeSHAPES 跟原来区别不大。SHAPES 的生成代码通过定义 Form 类,简化了不少。而 Shape 类在构建后,也由于成员私有化的原因,colorforms 不能被改变了,只能获取。

Tetris 中的游戏相关类

除了几个比较纯粹的模型类放在 model 中,主要入口 index.jstetris.js 放在脚本源码根目录下,其它的游戏相关类都是放在 tetris 目录下的。这只是用包(Java概念)或命名空间(C++/C#概念)的概念对源码进行了一个基本的划分。

Block 和 BlockFactory

Block 表示一个大方块,是由四个小方块组成的大方块,它的原型(此原型非 JS 的 Prototype)就是 Shape。所以一个 Block 会有一个 Shape 原型的引用,同时保存着当前它的位置 position 和形态 formIndex,这两个属性在游戏过程中是可以改变的,直接影响着 Block 最终绘制出来的位置和样子。

整有游戏中其实只有两个 Block,一个在预览区中,另一个在游戏区定时下落并被玩家操作。

Block 对象下落到底之后就不再是 Block 了,它会被固化在游戏区。为什么要这样设计呢?因为 Block 表示的是一个完整的大方块,而游戏区下方的方块一旦填满一行就会被消除,大方块将再也不完整。这种情况有两个方案可以描述:

  1. 仍然以大方块对象放在那里,但是标记已被消除的块,这样在绘制的时候就可以不绘制已消除的块。
  2. 大方块下落完成之后就将其打散成一个个的 BlockPoint,通过矩阵管理。

很明显,第二种方法通过二维数组实现,会更直观,程序写起来也会更简单。所以我选用了第二种方法。

Block 除了描述大方块的位置和形态之外,也会配合游戏控制进行一些数据运算和变化,比如位置的变化:moveLeft()moveRight()moveDown() 等,以及形态的变化 rotate();还有几个 fastenXxxx 方法,生成 BlockPoint[] 用于绘制或判断下一个位置是否可以放置。关于这一点,在 JavaScript 版俄罗斯方块 中已经谈过。

BlockFactory 功能未变,仍然是产生一个随机方块。

Puzzle 和 Matrix

之前对 Puzzle 和 Matrix 的定义有点混淆,这里把它们区分开了。

Puzzle 用于绘制浏览区和预览区,它除了描述一个指定长宽的绘制区域之外,还有存储着两个重要的对象,block: Blockfastened: BlockPoint[],也就是上面提到的运动中的方块,和固定下来的若干小方块。

Puzzle 本向不维护 blockfastened,但它要绘制这两个重要数据对象中的所有 BlockPoint

Matrix 不再是一个类,它是两个数据。一个是 Puzzle 中的 matrix 属性,维护着由 <div>(行) 和 <span>(单元) 组成的绘制区;另一个是 Tetris 中的 matrix 属性,维护着一个 BlockPoint 的矩阵,也就是 Puzzle::fastened 的矩阵形态,它更容易通过固化或删除等操作来改变。

由于 Tetris::matrix 在大部分时间是不变的,则 Puzzle 绘制的时候需要的只是其中其中非空部分的列表,所以这里有一个比较好的业务逻辑是:在 Tetris::matrix 变化的时候,从它重新生成 Puzzle::fastened,由 Puzzle 绘制时使用。

有点遗憾,写此博文的时候发现重构之后忘了实现这一优化处理,仍然是在每次 Tetris::render 的时候都会去重新生成 Puzzle::fastened。不过没关系,下个版本一定记得处理这个事情。

Eventable

在重构和写新功能的过程中,发现了事件的重要性,好些处理都会用到事件。

比如在点击暂停/恢复重新开始 的时候,需要去判断当前游戏的状态,并根据状态的情况来触发到底是不是真的暂停或重新开始。

又比如,在计分和速度选择功能中,如果计分达到一定程度,就需要触发提速。

上面提到的这些都可以使用观察者模式来设计,则事件就是观察者模式的一个典型实现。要实现自己的事件处理机制其实不难,但是这里可以偷偷懒,直接借用 jQuery 的事件处理,所以定义了 Eventable 类用于封装 jQuery 的事件处理,所有支持事件的业务类都可以从它继承。

封装很简单,这里采用的是封装事件代理对象的方式,具体可以看源代码,一共只有 20 多行,很容易懂。也可以在构造函数中把 this 封装一个 jQuery 对象出来代理事件处理,这种方式可以将事件处理函数中的 this 指向自己(自己指 Eventable 对象)。不过还好,这个项目中不需要关心事件处理函数中的 this

StateManager

在实现 Tetris 中的主要游戏逻辑的时候,发现状态管理并不简单,尤其是加了 暂停/恢复 按钮之后,暂停状态就分为代码暂停和人工暂停两种情况,对于两种情况的恢复操作也是有区别的。除此之外还有游戏结束的状态……所以干脆就定义个 StateManager 来管理状态了。

StateManager 维护着游戏的状态,提供改变状态的方法,也提供判断状态的属性。如果 JavaScript 有接口语法的话,这个接口大概是这样的

interface IStateManager {
    get isPaused(): boolean;
    get isPausedByManual(): boolean;
    get isRestartable(): boolean;
    get isOver(): boolean;

    pause(byWhat);
    resume(byWhat);
    start();
    over();
}

我又开始想念 TypeScript 了

InfoPanel 和 CommandPanel

InfoPanel 主要用于积分和速度的管理,包括与用户的交互(UI)。CommandPanel 则是负责两个按钮事件的处理。

Tetris

说实在的,我仍然认为 Tetris 的代码有点复杂,还需要重构简化。不过尝试了一下之后发现这并不是一件很容易的事情,所以就留待后面的版本来处理了。

小结

这次对俄罗斯方块游戏的重构只是一个初步的重构,最初的目的只是想把模型定义清楚,不过也对业务处理进行了一些拆分。模型定义的目的是达到了,但是业务拆分仍然不尽满意。

工作上之前的两个项目都是用的 TypeScript 1.8,虽然是 TypeScript 1.8 有一些坑在那里,但是 TypeScript 的静态语言特性,尤其是静态检查对大型 JavaScript 项目还是有很大帮助的。之前一直认为 TypeScript 增加了代码量,也降低了 JavaScript 的灵活度,但这次用 ES6 重构俄罗斯方块游戏让我深深的感受到,这根本不是 TypeScript 的缺点,它至少可以解决 JavaScript 中的这几个问题:

  • 静态检查在开发阶段就能发现很多潜在的问题,而不是在运行的时候才能发现问题。要知道,问题发现得越早改起来越容易。
  • 编辑器(我用的 VSCode)的智能提示和自动完成功能在 TypeScript 的严格语法下非常好用,一个点出来就知道哪些方法可以调用,哪些不能。而对于 JavaScript 这方面就要弱一些了,编辑器不是按语义来分析,而是看代码中出现了哪些,这样难免会出现写代码不小心对象和方法不匹配的情况。

所以,下个版本我准备尝试用 TypeScript 2.0 来改写。

新篇来啦:JavaScript 版俄罗斯方块——转换为 TypeScript

传送门


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

载入中...