9

十多年前曾经用 Turbo C++ 3.0 写过 DOS 下的俄罗斯方块,不久之后又用 VB 写了另一个版本。十多年后决心用 JavaScript 再写一个并非完全心血来潮。起因是儿子提到了手掌游戏机,而从技术上来说,主要是想尝试 使用 webpack + babel 构建的纯 es6 前端项目。

传送门

项目结构

这是一个纯静态项目,而且 HTML 只有一页,就是 index.html。样式表内容不多,还是习惯用 LESS 来写,不喜欢用 sass 的原因其实很直白——不想装逼(Ruby)。

重点自然是在脚本上,一个是想尝试完整的 ES6 语法,包括 import/export 的模块管理;二个是想尝试像构建静态语言项目那样,使用构建的思想,通过 webpack + babel 构建出 es5 语法的目标脚本。

源(es6语法,模块化)==> 目标(es5语法,打包)

项目中使用了 jQuery,但是因为习惯,不想把 jQuery 打包在目标脚本中,也不想手工去下载,所以干脆尝试了一下 bower。相比手工下载,使用 bower 是有好处的,至少 bower install 可以写入构建脚本。

一开始对项目目录结构考虑得不是特别清楚,所以建出来的目录结构其实有点乱。整个目录结构如下

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

构建配置

前端构建脚本部分使用的是 webpack + babel,样式表使用的 less,然后通过 gulp 组织起来。所有前端构建配置和源代码都放在 app 目录下。app 目录下是个 npm 项目,有 gulpfile.js 和 webpack.config.js 等构建配置。

因为 gulp 之前用过,fulpfile.js 写起来还比较顺手,但是在配置 webpack 的时候费了点劲。

先在网上抄了一个配置

const path = require("path");

module.exports = {
    context: path.resolve(__dirname, "src"),
    entry: [ "./index" ],
    output: {
        path: path.resolve(__dirname, "../js/"),
        filename: "tetris.js"
    },
    module: {
        loaders: [
            {
                test: /\.js$/,
                exclude: /(node_modules)/,
                loader: "babel",
                query: {
                    presets: ["es2015"]
                }
            }
        ]
    }
};

然后在写的过程中发现需要引入 jQuery,于是又在网上找了半天,抄了一句

    externals: {
        "jquery": "jQuery"
    }

不过后来看到说推荐用 ProvidePlugin,以后再来研究了。

在代码初成,初次运行的时候,发现调试非常麻烦,因为编译过,找不到错误在 es6 的源码位置。这时候才发现缺少了非常重要的 source map。于是又在网上搜了半天,加上了

    devtool: "source-map"

程序分析

因为以前写过,所以在数据结构上还是有点映像,游戏区就对应着一个二维数组。每个图形就是一组有着相对位置关系的坐标,当然还有颜色定义。

所有行为都是通过数据(坐标)的变化来实现的。而障碍物(已固定下来的小方块)判断则是通过当前图形位置及定义中所有小方块的相对位置计算出各小方块坐标之后检查大矩阵对应坐标是否存在小方块数据来判断。这需要提前计算出当前图形在下一个形态所需要占用的坐标列表。

方块的自动下落是通过时钟周期控制。如果还要处理消除动画,就可能需要两个时钟周期控制。当然可以取两个时钟周期的了大公约数来合并成一个公共时钟周期,但俄罗斯方块的动画相当简单,似乎没有必要进行这么复杂的处理——可以考虑在消除时暂停下落时钟周期,消除完成之后再重启。

交互部分主要靠键盘处理,只需要给 document 绑定 keydown 事件处理就好。

方块模型

传统的俄罗斯方块只有 7 种图形,加上旋转变形一共也才 19 个图形。所以需要定义的图形不多,懒得去写旋转算法,直接用坐标来定义了。于是先用WPS表格把图形画出来了:

clipboard.png

然后照此图形,在 JavaScript 中定义结构。设想的数数据结构是这样的

SHAPES: [Shape]     // 预定义所有图形
Shape: {                // 图形的结构
    colorClass: string,     // 用于染色的 css class    
    forms: [Form]           // 旋转变形的组合
}
Form: [Block]           // 图形变形,是一组小方块的坐标
Block: {                // 小方块坐标
    x: number,              // x 表示横向
    y: number               // y 表示纵向
}

其中 SHAPESForm 都直接用数组表示,Block 结构简单,直接使用字面对象表示,只需要定义一个 Shape 类(当时考虑加些方法在里面,但后来发现没必要)

class Shape {
    constructor(colorIndex, forms) {
        this.colorClass = `c${1 + colorIndex % 7}`;
        this.forms = forms;
    }
}

为了偷懒,SHAPE 是用一个三维数组的数据,通过 Array.prototype.map() 来得到的 Shape 数组

class Shape {
    constructor(colorIndex, forms) {
        this.colorClass = `c${1 + colorIndex % 7}`;
        this.forms = forms;
    }
}

export const SHAPES = [
    // 正方形
    [
        [[0, 0], [0, 1], [1, 0], [1, 1]]
    ],
    // |
    [
        [[0, 0], [0, 1], [0, 2], [0, 3]],
        [[0, 0], [1, 0], [2, 0], [3, 0]]
    ],
    
    // .... 省略,请参阅文末附上的源码地址
].map((defining, i) => {
    // data 就是上面提到的 forms 了,命名时没想好,后来也没改
    const data = defining.map(form => {
        // 计算 right 和 bottom 主要是为了后面的出界判断
        let right = 0;
        let bottom = 0;
        
        // point 就是 block,当时取名的时候没想好
        const points = form.map(point => {
            right = Math.max(right, point[0]);
            bottom = Math.max(bottom, point[1]);
            return {
                x: point[0],
                y: point[1]
            };
        });
        points.width = right + 1;
        points.height = bottom + 1;
        return points;
    });
    return new Shape(i, data);
});

游戏区模型

虽然游戏区只有一块,但是就画图的这部分行为来说,还有一个预览区的行为与之相仿。游戏区除了显示外还需要处理方块下落、响应键盘操作左、右、下移及变形、堆积、消除等。

对于显示,定义了一个 Matrix 类来处理。Matrix 主要是用来在 HTML 中创建用来显示每一个小方块的 <span> 以及根据数据绘制小方块。当然所谓的“绘制”其实只是设置 <span> 的 css class 而已,让浏览器来处理绘制的事情。

Matrix 根据构建传入的 widthheight 来创建 DOM,每一行是一个 <div> 作为容器,但实际需要操作的是每一行中,由 <span> 表示的小方块。所以其实 Matrix 的结构也很简单,这里简单的列出接口,具体代码参考后面的源码链接

class Matrix {
    constructor(width, height) {}
    build(container) {}
    render(blockList) {}
}

逻辑控制

上面提到主游戏区有一些逻辑控制,而 Matrix 只处理了绘制的问题。所以另外定义了一个类:Puzzle 来处理控制和逻辑的问题,这些问题包括

  • 预览图形的生成的显示
  • 游戏图形和已经固定的方块显示
  • 进行中的图形行为(旋转、左移、右移、下移等)
  • 边界及障碍判断
  • 下落结束后可消除行的判断
  • 下落动画处理
  • 消除动画处理
  • 消除后的数据重算(因为位置改变)
  • Game Over 判断
  • ......

其实比较关键的问题是图形和固定方块的显示、边界及障碍判断、动画处理。

游戏区方块绘制

已经确定了 Matrix 用于处理绘制,但绘制需要数据,数据又分两部分。一部分是当前下落中的图形,其位置是动态的;另一部分是之前落下的图形,已经固定在游戏区的。

从当前下落中的图形生成一个 blocks 数组,再将已经固定的小方块生成另一个 blocks 数组,合并起来,就是 Matrix.render() 的数据。Matrix 拿到这个数据之后,先遍历所有 <span>,清除颜色 class,再遍历得到的数据,根据每一个 block 提供的位置和颜色,去设置对应的 <span> 的 css class。这样就完成了绘制。

clipboard.png

边界和障碍判断

之前提到的 Shape 只是一个形状的定义,而下落中的图形是另一个实体,由于 Shape 命名已经被占用了,所以源代码中用 Block 来对它命名。

这个命名确实有点乱,需要这样解理:Shape -> ShapeDefinitionBlock -> Shape

现在下落中的图形是一个 Block 的实例(对象)。在判断边界和障碍判断的过程中需要用到其位置信息、边界信息(right、bottom)等;另外还需要知道它当前是哪一个旋转形态……所以定义了一些属性。

不过关键问题是需要知道它的下个状态(位置、旋转)会占用哪些坐标的位置。所以定义了几个方法

  • fasten(),不带参数的时候返回当前位置当前形态所占用的坐标,主要是绘图用;带参数时可以返回指定位置和指定形态所需要占用的坐标。
  • fastenOffset(),因为通常需要的位移坐标数据都相对原来的位置只都有少量的偏移,所以定义这个方法,以简化调用 fasten() 的参数。
  • fastenRotate(),简化旋转后对 fasten() 的调用。

这里有一点需要注意,就是有图形在到在边界之后,旋转可能会造成出界。这种情况下需要对其进行位移,所以 Blockrotate()fastenRotate() 都可以输入边界参数,用于计算修正位置。而修正位置则是通过模块中一个局部函数 getRotatePosition() 来实现的。

动画控制

前面已经提到了,动画时钟分两个,下落动画时钟和消除动画时钟。对于人工操作引起的动画,在操作之后直接重绘,就不需要通过时钟来进行了。

考虑到在开始消除动画时需要暂停下落动画,之后又要重新开始。所以为下落动画时钟定义为一个 Timer 类来控制 stop()start(),内部实现当然是用的 setInterval()clearInterval()。当然 Timer 也可以用于消除动画,但是因为在写消除动画的时候发现代码比较简单,就直接写 setInterval()clearInterval() 解决了。

Puzzle 类中,某个图形下图到底的时候,通过 fastenCurent() 为固定它,这个方法里固定了当前图形之后会调用 eraseRows() 来检查和删除已经填满的行。从数据上消除和压缩行都是在这里处理的,同时这里还进行了消除行的动画处理——对需要消除的行从左到右清除数据并立即重绘。

let columnIndex = 0;
const t = setInterval(() => {
    // fulls 是找出来的需要消除的行
    fulls.forEach((rowIndex) => {
        matrix[rowIndex][columnIndex] = null;
        this.render();
    });
    
    // 消除列达到右边界时结束动画
    if (++columnIndex >= this.puzzle.width) {
        clearInterval(t);
        reduceRows();
        this.render();
        this.process();
    }
}, 10);

小结

俄罗斯方块的算法并不难,但这个仓促完成的小游戏中仍然存在一些问题需要将来处理掉:

  • 没有交互方式的开始和结束,页面一旦打开就会持续运行。
  • 还没有引入计分
  • 每次绘制都是全部重绘,应该可以优化为局部(变化的部分)重绘

传送门


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!