JavaScript 版俄羅斯方塊

十多年前曾經用 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表格把圖形畫出來了:

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);

小結

俄羅斯方塊的算法並不難,但這個倉促完成的小遊戲中仍然存在一些問題須要未來處理掉:

  • 沒有交互方式的開始和結束,頁面一旦打開就會持續運行。
  • 尚未引入計分
  • 每次繪製都是所有重繪,應該能夠優化爲局部(變化的部分)重繪

傳送門

相關文章
相關標籤/搜索