曾經用 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表格把图形画出来了:
然后照此图形,在 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 中创建用来显示每一个小方块的 以及根据数据绘制小方块。当然所谓的“绘制”其实只是设置 的 css class 而已,让浏览器来处理绘制的事情。
Matrix 根据构建传入的 width 和 height 来创建 DOM,每一行是一个
作为容器,但实际需要操作的是每一行中,由 表示的小方块。所以其实 Matrix 的结构也很简单,这里简单的列出接口,具体代码参考后面的源码链接
class Matrix { constructor(width, height) {} build(container) {} render(blockList) {} }
逻辑控制
上面提到主游戏区有一些逻辑控制,而 Matrix 只处理了绘制的问题。所以另外定义了一个类: Puzzle 来处理控制和逻辑的问题,这些问题包括
预览图形的生成的显示
游戏图形和已经固定的方块显示
进行中的图形行为(旋转、左移、右移、下移等)
边界及障碍判断
下落结束后可消除行的判断
下落动画处理
消除动画处理
消除后的数据重算(因为位置改变)
Game Over 判断
......
其实比较关键的问题是图形和固定方块的显示、边界及障碍判断、动画处理。
游戏区方块绘制
已经确定了 Matrix 用于处理绘制,但绘制需要数据,数据又分两部分。一部分是当前下落中的图形,其位置是动态的;另一部分是之前落下的图形,已经固定在游戏区的。
从当前下落中的图形生成一个 blocks 数组,再将已经固定的小方块生成另一个 blocks 数组,合并起来,就是 Matrix.render() 的数据。 Matrix 拿到这个数据之后,先遍历所有 ,清除颜色 class,再遍历得到的数据,根据每一个 block 提供的位置和颜色,去设置对应的 的 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);
小结
俄罗斯方块的算法并不难,但这个仓促完成的小游戏中仍然存在一些问题需要将来处理掉:
没有交互方式的开始和结束,页面一旦打开就会持续运行。
还没有引入计分
每次绘制都是全部重绘,应该可以优化为局部(变化的部分)重绘
更多用JavaScript寫一個俄羅斯方塊相关文章请关注PHP中文网!