JavaScript 版俄羅斯方塊

2018-06-08 14:52 更新

十多年前曾經(jīng)用 Turbo C++ 3.0 寫過 DOS 下的俄羅斯方塊,不久之后又用 VB 寫了另一個版本。十多年后決心用 JavaScript 再寫一個并非完全心血來潮。起因是兒子提到了手掌游戲機,而從技術(shù)上來說,主要是想嘗試 使用 webpack + babel 構(gòu)建的純 es6 前端項目。

傳送門

項目結(jié)構(gòu)

這是一個純靜態(tài)項目,而且 HTML 只有一頁,就是 index.html。樣式表內(nèi)容不多,還是習慣用 LESS 來寫,不喜歡用 sass 的原因其實很直白——不想裝逼(Ruby)。

重點自然是在腳本上,一個是想嘗試完整的 ES6 語法,包括 import/export 的模塊管理;二個是想嘗試像構(gòu)建靜態(tài)語言項目那樣,使用構(gòu)建的思想,通過 webpack + babel 構(gòu)建出 es5 語法的目標腳本。

源(es6語法,模塊化)==> 目標(es5語法,打包)

項目中使用了 jQuery,但是因為習慣,不想把 jQuery 打包在目標腳本中,也不想手工去下載,所以干脆嘗試了一下 bower。相比手工下載,使用 bower 是有好處的,至少 bower install 可以寫入構(gòu)建腳本。

一開始對項目目錄結(jié)構(gòu)考慮得不是特別清楚,所以建出來的目錄結(jié)構(gòu)其實有點亂。整個目錄結(jié)構(gòu)如下

[root>
  |-- index.html    : 入口
  |-- js/           : 構(gòu)建生成的腳本
  |-- css/          : 構(gòu)建生成的樣式表
  |-- lib/          : bower 引入的庫
  `-- app/          : 前端源文件
        |-- less    : 樣式表源文件
        `-- src     : 腳本(es6)源文件

構(gòu)建配置

前端構(gòu)建腳本部分使用的是 webpack + babel,樣式表使用的 less,然后通過 gulp 組織起來。所有前端構(gòu)建配置和源代碼都放在 app 目錄下。app 目錄下是個 npm 項目,有 gulpfile.js 和 webpack.config.js 等構(gòu)建配置。

因為 gulp 之前用過,fulpfile.js 寫起來還比較順手,但是在配置 webpack 的時候費了點勁。

先在網(wǎng)上抄了一個配置

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,以后再來研究了。

在代碼初成,初次運行的時候,發(fā)現(xiàn)調(diào)試非常麻煩,因為編譯過,找不到錯誤在 es6 的源碼位置。這時候才發(fā)現(xiàn)缺少了非常重要的 source map。于是又在網(wǎng)上搜了半天,加上了

    devtool: "source-map"

程序分析

因為以前寫過,所以在數(shù)據(jù)結(jié)構(gòu)上還是有點映像,游戲區(qū)就對應著一個二維數(shù)組。每個圖形就是一組有著相對位置關(guān)系的坐標,當然還有顏色定義。

所有行為都是通過數(shù)據(jù)(坐標)的變化來實現(xiàn)的。而障礙物(已固定下來的小方塊)判斷則是通過當前圖形位置及定義中所有小方塊的相對位置計算出各小方塊坐標之后檢查大矩陣對應坐標是否存在小方塊數(shù)據(jù)來判斷。這需要提前計算出當前圖形在下一個形態(tài)所需要占用的坐標列表。

方塊的自動下落是通過時鐘周期控制。如果還要處理消除動畫,就可能需要兩個時鐘周期控制。當然可以取兩個時鐘周期的了大公約數(shù)來合并成一個公共時鐘周期,但俄羅斯方塊的動畫相當簡單,似乎沒有必要進行這么復雜的處理——可以考慮在消除時暫停下落時鐘周期,消除完成之后再重啟。

交互部分主要靠鍵盤處理,只需要給 document 綁定 keydown 事件處理就好。

方塊模型

傳統(tǒng)的俄羅斯方塊只有 7 種圖形,加上旋轉(zhuǎn)變形一共也才 19 個圖形。所以需要定義的圖形不多,懶得去寫旋轉(zhuǎn)算法,直接用坐標來定義了。于是先用WPS表格把圖形畫出來了:

然后照此圖形,在 JavaScript 中定義結(jié)構(gòu)。設想的數(shù)數(shù)據(jù)結(jié)構(gòu)是這樣的

SHAPES: [Shape]     // 預定義所有圖形
Shape: {                // 圖形的結(jié)構(gòu)
    colorClass: string,     // 用于染色的 css class    
    forms: [Form]           // 旋轉(zhuǎn)變形的組合
}
Form: [Block]           // 圖形變形,是一組小方塊的坐標
Block: {                // 小方塊坐標
    x: number,              // x 表示橫向
    y: number               // y 表示縱向
}

其中 SHAPES、Form 都直接用數(shù)組表示,Block 結(jié)構(gòu)簡單,直接使用字面對象表示,只需要定義一個 Shape 類(當時考慮加些方法在里面,但后來發(fā)現(xiàn)沒必要)

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

為了偷懶,SHAPE 是用一個三維數(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 了,命名時沒想好,后來也沒改
    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);
});

游戲區(qū)模型

雖然游戲區(qū)只有一塊,但是就畫圖的這部分行為來說,還有一個預覽區(qū)的行為與之相仿。游戲區(qū)除了顯示外還需要處理方塊下落、響應鍵盤操作左、右、下移及變形、堆積、消除等。

對于顯示,定義了一個 Matrix 類來處理。Matrix 主要是用來在 HTML 中創(chuàng)建用來顯示每一個小方塊的 <span> 以及根據(jù)數(shù)據(jù)繪制小方塊。當然所謂的“繪制”其實只是設置 <span> 的 css class 而已,讓瀏覽器來處理繪制的事情。

Matrix 根據(jù)構(gòu)建傳入的 widthheight 來創(chuàng)建 DOM,每一行是一個 <div> 作為容器,但實際需要操作的是每一行中,由 <span> 表示的小方塊。所以其實 Matrix 的結(jié)構(gòu)也很簡單,這里簡單的列出接口,具體代碼參考后面的源碼鏈接

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

邏輯控制

上面提到主游戲區(qū)有一些邏輯控制,而 Matrix 只處理了繪制的問題。所以另外定義了一個類:Puzzle 來處理控制和邏輯的問題,這些問題包括

  • 預覽圖形的生成的顯示

  • 游戲圖形和已經(jīng)固定的方塊顯示

  • 進行中的圖形行為(旋轉(zhuǎn)、左移、右移、下移等)

  • 邊界及障礙判斷

  • 下落結(jié)束后可消除行的判斷

  • 下落動畫處理

  • 消除動畫處理

  • 消除后的數(shù)據(jù)重算(因為位置改變)

  • Game Over 判斷

  • ......

其實比較關(guān)鍵的問題是圖形和固定方塊的顯示、邊界及障礙判斷、動畫處理。

游戲區(qū)方塊繪制

已經(jīng)確定了 Matrix 用于處理繪制,但繪制需要數(shù)據(jù),數(shù)據(jù)又分兩部分。一部分是當前下落中的圖形,其位置是動態(tài)的;另一部分是之前落下的圖形,已經(jīng)固定在游戲區(qū)的。

從當前下落中的圖形生成一個 blocks 數(shù)組,再將已經(jīng)固定的小方塊生成另一個 blocks 數(shù)組,合并起來,就是 Matrix.render() 的數(shù)據(jù)。Matrix 拿到這個數(shù)據(jù)之后,先遍歷所有 <span>,清除顏色 class,再遍歷得到的數(shù)據(jù),根據(jù)每一個 block 提供的位置和顏色,去設置對應的 <span> 的 css class。這樣就完成了繪制。

邊界和障礙判斷

之前提到的 Shape 只是一個形狀的定義,而下落中的圖形是另一個實體,由于 Shape 命名已經(jīng)被占用了,所以源代碼中用 Block 來對它命名。

這個命名確實有點亂,需要這樣解理:Shape -> ShapeDefinition;Block -> Shape

現(xiàn)在下落中的圖形是一個 Block 的實例(對象)。在判斷邊界和障礙判斷的過程中需要用到其位置信息、邊界信息(right、bottom)等;另外還需要知道它當前是哪一個旋轉(zhuǎn)形態(tài)……所以定義了一些屬性。

不過關(guān)鍵問題是需要知道它的下個狀態(tài)(位置、旋轉(zhuǎn))會占用哪些坐標的位置。所以定義了幾個方法

  • fasten(),不帶參數(shù)的時候返回當前位置當前形態(tài)所占用的坐標,主要是繪圖用;帶參數(shù)時可以返回指定位置和指定形態(tài)所需要占用的坐標。

  • fastenOffset(),因為通常需要的位移坐標數(shù)據(jù)都相對原來的位置只都有少量的偏移,所以定義這個方法,以簡化調(diào)用 fasten() 的參數(shù)。

  • fastenRotate(),簡化旋轉(zhuǎn)后對 fasten() 的調(diào)用。

這里有一點需要注意,就是有圖形在到在邊界之后,旋轉(zhuǎn)可能會造成出界。這種情況下需要對其進行位移,所以 Blockrotate()fastenRotate() 都可以輸入邊界參數(shù),用于計算修正位置。而修正位置則是通過模塊中一個局部函數(shù) getRotatePosition() 來實現(xiàn)的。

動畫控制

前面已經(jīng)提到了,動畫時鐘分兩個,下落動畫時鐘和消除動畫時鐘。對于人工操作引起的動畫,在操作之后直接重繪,就不需要通過時鐘來進行了。

考慮到在開始消除動畫時需要暫停下落動畫,之后又要重新開始。所以為下落動畫時鐘定義為一個 Timer 類來控制 stop()start(),內(nèi)部實現(xiàn)當然是用的 setInterval()clearInterval()。當然 Timer 也可以用于消除動畫,但是因為在寫消除動畫的時候發(fā)現(xiàn)代碼比較簡單,就直接寫 setInterval()clearInterval() 解決了。

Puzzle 類中,某個圖形下圖到底的時候,通過 fastenCurent() 為固定它,這個方法里固定了當前圖形之后會調(diào)用 eraseRows() 來檢查和刪除已經(jīng)填滿的行。從數(shù)據(jù)上消除和壓縮行都是在這里處理的,同時這里還進行了消除行的動畫處理——對需要消除的行從左到右清除數(shù)據(jù)并立即重繪。

let columnIndex = 0;
const t = setInterval(() => {
    // fulls 是找出來的需要消除的行
    fulls.forEach((rowIndex) => {
        matrix[rowIndex][columnIndex] = null;
        this.render();
    });
    
    // 消除列達到右邊界時結(jié)束動畫
    if (++columnIndex >= this.puzzle.width) {
        clearInterval(t);
        reduceRows();
        this.render();
        this.process();
    }
}, 10);

小結(jié)

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

  • 沒有交互方式的開始和結(jié)束,頁面一旦打開就會持續(xù)運行。

  • 還沒有引入計分

  • 每次繪制都是全部重繪,應該可以優(yōu)化為局部(變化的部分)重繪

傳送門


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號