十多年前曾经用 Turbo C++ 3.0 写过 DOS 下的俄罗斯方块,不久以后又用 VB 写了另外一个版本。十多年后决心用 JavaScript 再写一个并不是彻底心血来潮。原由是儿子提到了手掌游戏机,而从技术上来讲,主要是想尝试 使用 webpack + babel 构建的纯 es6 前端项目。javascript
这是一个纯静态项目,并且 HTML 只有一页,就是 index.html。样式表内容很少,仍是习惯用 LESS 来写,不喜欢用 sass 的缘由其实很直白——不想装逼(Ruby)。css
重点天然是在脚本上,一个是想尝试完整的 ES6 语法,包括 import/export 的模块管理;二个是想尝试像构建静态语言项目那样,使用构建的思想,经过 webpack + babel 构建出 es5 语法的目标脚本。html
源(es6语法,模块化)==> 目标(es5语法,打包)
项目中使用了 jQuery,可是由于习惯,不想把 jQuery 打包在目标脚本中,也不想手工去下载,因此干脆尝试了一下 bower。相比手工下载,使用 bower 是有好处的,至少 bower install
能够写入构建脚本。前端
一开始对项目目录结构考虑得不是特别清楚,因此建出来的目录结构其实有点乱。整个目录结构以下java
[root> |-- index.html : 入口 |-- js/ : 构建生成的脚本 |-- css/ : 构建生成的样式表 |-- lib/ : bower 引入的库 `-- app/ : 前端源文件 |-- less : 样式表源文件 `-- src : 脚本(es6)源文件
前端构建脚本部分使用的是 webpack + babel,样式表使用的 less,而后经过 gulp 组织起来。全部前端构建配置和源代码都放在 app 目录下。app 目录下是个 npm 项目,有 gulpfile.js 和 webpack.config.js 等构建配置。node
由于 gulp 以前用过,fulpfile.js 写起来还比较顺手,可是在配置 webpack 的时候费了点劲。jquery
先在网上抄了一个配置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,因而又在网上找了半天,抄了一句git
externals: { "jquery": "jQuery" }
不事后来看到说推荐用 ProvidePlugin
,之后再来研究了。es6
在代码初成,初次运行的时候,发现调试很是麻烦,由于编译过,找不到错误在 es6 的源码位置。这时候才发现缺乏了很是重要的 source map。因而又在网上搜了半天,加上了
devtool: "source-map"
由于之前写过,因此在数据结构上仍是有点映像,游戏区就对应着一个二维数组。每一个图形就是一组有着相对位置关系的坐标,固然还有颜色定义。
全部行为都是经过数据(坐标)的变化来实现的。而障碍物(已固定下来的小方块)判断则是经过当前图形位置及定义中全部小方块的相对位置计算出各小方块坐标以后检查大矩阵对应坐标是否存在小方块数据来判断。这须要提早计算出当前图形在下一个形态所须要占用的坐标列表。
方块的自动下落是经过时钟周期控制。若是还要处理消除动画,就可能须要两个时钟周期控制。固然能够取两个时钟周期的了大公约数来合并成一个公共时钟周期,但俄罗斯方块的动画至关简单,彷佛没有必要进行这么复杂的处理——能够考虑在消除时暂停下落时钟周期,消除完成以后再重启。
交互部分主要靠键盘处理,只须要给 document
绑定 keydown
事件处理就好。
传统的俄罗斯方块只有 7 种图形,加上旋转变形一共也才 19 个图形。因此须要定义的图形很少,懒得去写旋转算法,直接用坐标来定义了。因而先用WPS表格把图形画出来了:
而后照此图形,在 JavaScript 中定义结构。设想的数数据结构是这样的
SHAPES: [Shape] // 预约义全部图形 Shape: { // 图形的结构 colorClass: string, // 用于染色的 css class forms: [Form] // 旋转变形的组合 } Form: [Block] // 图形变形,是一组小方块的坐标 Block: { // 小方块坐标 x: number, // x 表示横向 y: number // y 表示纵向 }
其中 SHAPES
、Form
都直接用数组表示,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
根据构建传入的 width
和 height
来建立 DOM,每一行是一个 <div>
做为容器,但实际须要操做的是每一行中,由 <span>
表示的小方块。因此其实 Matrix
的结构也很简单,这里简单的列出接口,具体代码参考后面的源码连接
class Matrix { constructor(width, height) {} build(container) {} render(blockList) {} }
上面提到主游戏区有一些逻辑控制,而 Matrix
只处理了绘制的问题。因此另外定义了一个类:Puzzle
来处理控制和逻辑的问题,这些问题包括
其实比较关键的问题是图形和固定方块的显示、边界及障碍判断、动画处理。
已经肯定了 Matrix
用于处理绘制,但绘制须要数据,数据又分两部分。一部分是当前下落中的图形,其位置是动态的;另外一部分是以前落下的图形,已经固定在游戏区的。
从当前下落中的图形生成一个 blocks 数组,再将已经固定的小方块生成另外一个 blocks 数组,合并起来,就是 Matrix.render()
的数据。Matrix
拿到这个数据以后,先遍历全部 <span>
,清除颜色 class,再遍历获得的数据,根据每个 block 提供的位置和颜色,去设置对应的 <span>
的 css class。这样就完成了绘制。
以前提到的 Shape
只是一个形状的定义,而下落中的图形是另外一个实体,因为 Shape 命名已经被占用了,因此源代码中用 Block
来对它命名。
这个命名确实有点乱,须要这样解理:
Shape -> ShapeDefinition
;Block -> Shape
。
如今下落中的图形是一个 Block
的实例(对象)。在判断边界和障碍判断的过程当中须要用到其位置信息、边界信息(right、bottom)等;另外还须要知道它当前是哪个旋转形态……因此定义了一些属性。
不过关键问题是须要知道它的下个状态(位置、旋转)会占用哪些坐标的位置。因此定义了几个方法
fasten()
,不带参数的时候返回当前位置当前形态所占用的坐标,主要是绘图用;带参数时能够返回指定位置和指定形态所须要占用的坐标。fastenOffset()
,由于一般须要的位移坐标数据都相对原来的位置只都有少许的偏移,因此定义这个方法,以简化调用 fasten()
的参数。fastenRotate()
,简化旋转后对 fasten()
的调用。这里有一点须要注意,就是有图形在到在边界以后,旋转可能会形成出界。这种状况下须要对其进行位移,因此 Block
的 rotate()
和 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);
俄罗斯方块的算法并不难,但这个仓促完成的小游戏中仍然存在一些问题须要未来处理掉: