十多年前曾經(jīng)用 Turbo C++ 3.0 寫過 DOS 下的俄羅斯方塊,不久之后又用 VB 寫了另一個(gè)版本。十多年后決心用 JavaScript 再寫一個(gè)并非完全心血來潮。起因是兒子提到了手掌游戲機(jī),而從技術(shù)上來說,主要是想嘗試 使用 webpack + babel 構(gòu)建的純 es6 前端項(xiàng)目。
這是一個(gè)純靜態(tài)項(xiàng)目,而且 HTML 只有一頁,就是 index.html。樣式表內(nèi)容不多,還是習(xí)慣用 LESS 來寫,不喜歡用 sass 的原因其實(shí)很直白——不想裝逼(Ruby)。
重點(diǎn)自然是在腳本上,一個(gè)是想嘗試完整的 ES6 語法,包括 import/export 的模塊管理;二個(gè)是想嘗試像構(gòu)建靜態(tài)語言項(xiàng)目那樣,使用構(gòu)建的思想,通過 webpack + babel 構(gòu)建出 es5 語法的目標(biāo)腳本。
源(es6語法,模塊化)==> 目標(biāo)(es5語法,打包)
項(xiàng)目中使用了 jQuery,但是因?yàn)榱?xí)慣,不想把 jQuery 打包在目標(biāo)腳本中,也不想手工去下載,所以干脆嘗試了一下 bower。相比手工下載,使用 bower 是有好處的,至少 bower install
可以寫入構(gòu)建腳本。
一開始對項(xiàng)目目錄結(jié)構(gòu)考慮得不是特別清楚,所以建出來的目錄結(jié)構(gòu)其實(shí)有點(diǎn)亂。整個(gè)目錄結(jié)構(gòu)如下
[root>
|-- index.html : 入口
|-- js/ : 構(gòu)建生成的腳本
|-- css/ : 構(gòu)建生成的樣式表
|-- lib/ : bower 引入的庫
`-- app/ : 前端源文件
|-- less : 樣式表源文件
`-- src : 腳本(es6)源文件
前端構(gòu)建腳本部分使用的是 webpack + babel,樣式表使用的 less,然后通過 gulp 組織起來。所有前端構(gòu)建配置和源代碼都放在 app 目錄下。app 目錄下是個(gè) npm 項(xiàng)目,有 gulpfile.js 和 webpack.config.js 等構(gòu)建配置。
因?yàn)?gulp 之前用過,fulpfile.js 寫起來還比較順手,但是在配置 webpack 的時(shí)候費(fèi)了點(diǎn)勁。
先在網(wǎng)上抄了一個(gè)配置
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"]
}
}
]
}
};
然后在寫的過程中發(fā)現(xiàn)需要引入 jQuery,于是又在網(wǎng)上找了半天,抄了一句
externals: {
"jquery": "jQuery"
}
不過后來看到說推薦用 ProvidePlugin
,以后再來研究了。
在代碼初成,初次運(yùn)行的時(shí)候,發(fā)現(xiàn)調(diào)試非常麻煩,因?yàn)榫幾g過,找不到錯誤在 es6 的源碼位置。這時(shí)候才發(fā)現(xiàn)缺少了非常重要的 source map。于是又在網(wǎng)上搜了半天,加上了
devtool: "source-map"
因?yàn)橐郧皩戇^,所以在數(shù)據(jù)結(jié)構(gòu)上還是有點(diǎn)映像,游戲區(qū)就對應(yīng)著一個(gè)二維數(shù)組。每個(gè)圖形就是一組有著相對位置關(guān)系的坐標(biāo),當(dāng)然還有顏色定義。
所有行為都是通過數(shù)據(jù)(坐標(biāo))的變化來實(shí)現(xiàn)的。而障礙物(已固定下來的小方塊)判斷則是通過當(dāng)前圖形位置及定義中所有小方塊的相對位置計(jì)算出各小方塊坐標(biāo)之后檢查大矩陣對應(yīng)坐標(biāo)是否存在小方塊數(shù)據(jù)來判斷。這需要提前計(jì)算出當(dāng)前圖形在下一個(gè)形態(tài)所需要占用的坐標(biāo)列表。
方塊的自動下落是通過時(shí)鐘周期控制。如果還要處理消除動畫,就可能需要兩個(gè)時(shí)鐘周期控制。當(dāng)然可以取兩個(gè)時(shí)鐘周期的了大公約數(shù)來合并成一個(gè)公共時(shí)鐘周期,但俄羅斯方塊的動畫相當(dāng)簡單,似乎沒有必要進(jìn)行這么復(fù)雜的處理——可以考慮在消除時(shí)暫停下落時(shí)鐘周期,消除完成之后再重啟。
交互部分主要靠鍵盤處理,只需要給 document
綁定 keydown
事件處理就好。
傳統(tǒng)的俄羅斯方塊只有 7 種圖形,加上旋轉(zhuǎn)變形一共也才 19 個(gè)圖形。所以需要定義的圖形不多,懶得去寫旋轉(zhuǎn)算法,直接用坐標(biāo)來定義了。于是先用WPS表格把圖形畫出來了:
然后照此圖形,在 JavaScript 中定義結(jié)構(gòu)。設(shè)想的數(shù)數(shù)據(jù)結(jié)構(gòu)是這樣的
SHAPES: [Shape] // 預(yù)定義所有圖形
Shape: { // 圖形的結(jié)構(gòu)
colorClass: string, // 用于染色的 css class
forms: [Form] // 旋轉(zhuǎn)變形的組合
}
Form: [Block] // 圖形變形,是一組小方塊的坐標(biāo)
Block: { // 小方塊坐標(biāo)
x: number, // x 表示橫向
y: number // y 表示縱向
}
其中 SHAPES
、Form
都直接用數(shù)組表示,Block
結(jié)構(gòu)簡單,直接使用字面對象表示,只需要定義一個(gè) Shape 類(當(dāng)時(shí)考慮加些方法在里面,但后來發(fā)現(xiàn)沒必要)
class Shape {
constructor(colorIndex, forms) {
this.colorClass = `c${1 + colorIndex % 7}`;
this.forms = forms;
}
}
為了偷懶,SHAPE
是用一個(gè)三維數(shù)組的數(shù)據(jù),通過 Array.prototype.map()
來得到的 Shape 數(shù)組
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 了,命名時(shí)沒想好,后來也沒改
const data = defining.map(form => {
// 計(jì)算 right 和 bottom 主要是為了后面的出界判斷
let right = 0;
let bottom = 0;
// point 就是 block,當(dāng)時(shí)取名的時(shí)候沒想好
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);
});
雖然游戲區(qū)只有一塊,但是就畫圖的這部分行為來說,還有一個(gè)預(yù)覽區(qū)的行為與之相仿。游戲區(qū)除了顯示外還需要處理方塊下落、響應(yīng)鍵盤操作左、右、下移及變形、堆積、消除等。
對于顯示,定義了一個(gè) Matrix
類來處理。Matrix
主要是用來在 HTML 中創(chuàng)建用來顯示每一個(gè)小方塊的 <span>
以及根據(jù)數(shù)據(jù)繪制小方塊。當(dāng)然所謂的“繪制”其實(shí)只是設(shè)置 <span>
的 css class 而已,讓瀏覽器來處理繪制的事情。
Matrix
根據(jù)構(gòu)建傳入的 width
和 height
來創(chuàng)建 DOM,每一行是一個(gè) <div>
作為容器,但實(shí)際需要操作的是每一行中,由 <span>
表示的小方塊。所以其實(shí) Matrix
的結(jié)構(gòu)也很簡單,這里簡單的列出接口,具體代碼參考后面的源碼鏈接
class Matrix {
constructor(width, height) {}
build(container) {}
render(blockList) {}
}
上面提到主游戲區(qū)有一些邏輯控制,而 Matrix
只處理了繪制的問題。所以另外定義了一個(gè)類:Puzzle
來處理控制和邏輯的問題,這些問題包括
預(yù)覽圖形的生成的顯示
游戲圖形和已經(jīng)固定的方塊顯示
進(jìn)行中的圖形行為(旋轉(zhuǎn)、左移、右移、下移等)
邊界及障礙判斷
下落結(jié)束后可消除行的判斷
下落動畫處理
消除動畫處理
消除后的數(shù)據(jù)重算(因?yàn)槲恢酶淖儯?/p>
Game Over 判斷
......
其實(shí)比較關(guān)鍵的問題是圖形和固定方塊的顯示、邊界及障礙判斷、動畫處理。
已經(jīng)確定了 Matrix
用于處理繪制,但繪制需要數(shù)據(jù),數(shù)據(jù)又分兩部分。一部分是當(dāng)前下落中的圖形,其位置是動態(tài)的;另一部分是之前落下的圖形,已經(jīng)固定在游戲區(qū)的。
從當(dāng)前下落中的圖形生成一個(gè) blocks 數(shù)組,再將已經(jīng)固定的小方塊生成另一個(gè) blocks 數(shù)組,合并起來,就是 Matrix.render()
的數(shù)據(jù)。Matrix
拿到這個(gè)數(shù)據(jù)之后,先遍歷所有 <span>
,清除顏色 class,再遍歷得到的數(shù)據(jù),根據(jù)每一個(gè) block 提供的位置和顏色,去設(shè)置對應(yīng)的 <span>
的 css class。這樣就完成了繪制。
之前提到的 Shape
只是一個(gè)形狀的定義,而下落中的圖形是另一個(gè)實(shí)體,由于 Shape 命名已經(jīng)被占用了,所以源代碼中用 Block
來對它命名。
這個(gè)命名確實(shí)有點(diǎn)亂,需要這樣解理:
Shape -> ShapeDefinition
;Block -> Shape
。
現(xiàn)在下落中的圖形是一個(gè) Block
的實(shí)例(對象)。在判斷邊界和障礙判斷的過程中需要用到其位置信息、邊界信息(right、bottom)等;另外還需要知道它當(dāng)前是哪一個(gè)旋轉(zhuǎn)形態(tài)……所以定義了一些屬性。
不過關(guān)鍵問題是需要知道它的下個(gè)狀態(tài)(位置、旋轉(zhuǎn))會占用哪些坐標(biāo)的位置。所以定義了幾個(gè)方法
fasten()
,不帶參數(shù)的時(shí)候返回當(dāng)前位置當(dāng)前形態(tài)所占用的坐標(biāo),主要是繪圖用;帶參數(shù)時(shí)可以返回指定位置和指定形態(tài)所需要占用的坐標(biāo)。
fastenOffset()
,因?yàn)橥ǔP枰奈灰谱鴺?biāo)數(shù)據(jù)都相對原來的位置只都有少量的偏移,所以定義這個(gè)方法,以簡化調(diào)用 fasten()
的參數(shù)。
fastenRotate()
,簡化旋轉(zhuǎn)后對 fasten()
的調(diào)用。
這里有一點(diǎn)需要注意,就是有圖形在到在邊界之后,旋轉(zhuǎn)可能會造成出界。這種情況下需要對其進(jìn)行位移,所以 Block
的 rotate()
和 fastenRotate()
都可以輸入邊界參數(shù),用于計(jì)算修正位置。而修正位置則是通過模塊中一個(gè)局部函數(shù) getRotatePosition()
來實(shí)現(xiàn)的。
前面已經(jīng)提到了,動畫時(shí)鐘分兩個(gè),下落動畫時(shí)鐘和消除動畫時(shí)鐘。對于人工操作引起的動畫,在操作之后直接重繪,就不需要通過時(shí)鐘來進(jìn)行了。
考慮到在開始消除動畫時(shí)需要暫停下落動畫,之后又要重新開始。所以為下落動畫時(shí)鐘定義為一個(gè) Timer
類來控制 stop()
和 start()
,內(nèi)部實(shí)現(xiàn)當(dāng)然是用的 setInterval()
和 clearInterval()
。當(dāng)然 Timer
也可以用于消除動畫,但是因?yàn)樵趯懴齽赢嫷臅r(shí)候發(fā)現(xiàn)代碼比較簡單,就直接寫 setInterval()
和 clearInterval()
解決了。
在 Puzzle
類中,某個(gè)圖形下圖到底的時(shí)候,通過 fastenCurent()
為固定它,這個(gè)方法里固定了當(dāng)前圖形之后會調(diào)用 eraseRows()
來檢查和刪除已經(jīng)填滿的行。從數(shù)據(jù)上消除和壓縮行都是在這里處理的,同時(shí)這里還進(jìn)行了消除行的動畫處理——對需要消除的行從左到右清除數(shù)據(jù)并立即重繪。
let columnIndex = 0;
const t = setInterval(() => {
// fulls 是找出來的需要消除的行
fulls.forEach((rowIndex) => {
matrix[rowIndex][columnIndex] = null;
this.render();
});
// 消除列達(dá)到右邊界時(shí)結(jié)束動畫
if (++columnIndex >= this.puzzle.width) {
clearInterval(t);
reduceRows();
this.render();
this.process();
}
}, 10);
俄羅斯方塊的算法并不難,但這個(gè)倉促完成的小游戲中仍然存在一些問題需要將來處理掉:
沒有交互方式的開始和結(jié)束,頁面一旦打開就會持續(xù)運(yùn)行。
還沒有引入計(jì)分
每次繪制都是全部重繪,應(yīng)該可以優(yōu)化為局部(變化的部分)重繪
更多建議: