康威生命遊戲(Conway's Game of Life,又稱康威生命棋)是一個模擬生物細胞存活、繁殖與滅亡的遊戲。雖然這個稱為「遊戲」,但它其實不怎麼好玩,它是透過電腦來模擬出一個培養皿空間,這個培養皿中在一開始會被放置一些虛擬細胞,而這些細胞會遵循一些「遊戲規則」來決定其在下一個階段是持續生存、還是要繁殖、抑或是毀滅。藉由圖像化這個培養皿,我們可以從中觀察到一些有趣(?)的圖像。



康威生命遊戲的規則

康威生命遊戲的培養皿是一個二維矩形空間,矩形被整齊化分成無數個小方形格子,一個格子可以容納一個活細胞。

rust-webassembly-conways-game-of-life

如上圖,表示一個大小為5x5的培養皿。如果正中間的格子有活細胞存在,以黑色來填滿它,如下圖:

rust-webassembly-conways-game-of-life

每個格子都會與其周圍8格的空間進行互動。如下圖,綠色的格子會與紅色的格子進行互動:

rust-webassembly-conways-game-of-life

格子與格子間互動的結果會影響著下一個階段培養皿中活細胞的數量以及分佈。以下繼續介紹互動規則。

規則一:若綠色格子有活細胞,且紅色格子的區域中的活細胞數量未滿兩個時,綠色格子的活細胞將會死亡。(模擬生命數量稀少)

rust-webassembly-conways-game-of-life

規則二:若綠色格子有活細胞,且紅色格子的區域中的活細胞數量為兩個或是三個時,綠色格子的活細胞將繼續存活。(模擬生命數量適中)

rust-webassembly-conways-game-of-life

規則三:若綠色格子有活細胞,且紅色格子的區域中的活細胞數量超過三個時,綠色格子的活細胞將會死亡。(模擬生命數量過多)

rust-webassembly-conways-game-of-life

上圖的例子還有用到底下的規則四。

規則四:若綠色格子沒有活細胞,且紅色格子的區域中的活細胞數量剛好為三個時,綠色格子會出現新的活細胞。(模擬生命繁殖)

rust-webassembly-conways-game-of-life

再舉個例子,如下圖:

rust-webassembly-conways-game-of-life

這張圖的格子中,紫色的文字(R1R2R4),表示該格子符合第幾條規則。

動手做

理解康威生命遊戲的規則後,就可以開始動手寫程式了!

建立專案目錄

按照先前的章節介紹的方式,利用wasm-pack-template模板建立出一個新的Cargo程式專案,名為wasm-game-of-life

指令如下:

cargo generate --git https://github.com/rustwasm/wasm-pack-template.git -n wasm-game-of-life

Rust程式實作

結構化遊戲世界

Block列舉來表示培養皿中的每個格子,這個列舉會有兩個變體,表示該格子有活細胞或是沒有活細胞。程式如下:

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Block {
    NoCell,
    HasCell,
}

Universe結構體來表示整個培養皿。程式如下:

#[derive(Debug)]
pub struct Universe {
    width:  u32,
    height: u32,
    blocks: Vec<Block>,
}

這邊的blocks欄位所儲存的Vec陣列大小應為width * height,用一維空間來模擬二維空間。

功能實作

接下來要實作Universe結構體基本的關聯函數和方法。程式如下:

impl Universe {
    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn blocks(&self) -> &[Block] {
        &self.blocks
    }
}

impl Universe {
    pub fn new(width: u32, height: u32) -> Universe {
        assert!(width > 0);
        assert!(height > 0);

        let capacity = width as usize * height as usize;

        let blocks = vec![Block::NoCell; capacity];

        Universe {
            width,
            height,
            blocks,
        }
    }

    pub fn default_cells(&mut self) {
        for (i, block) in self.blocks.iter_mut().enumerate() {
            if i % 2 == 0 || i % 7 == 0 {
                *block = Block::HasCell;
            } else {
                *block = Block::NoCell;
            }
        }
    }
}

impl Universe {
    pub fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}

impl Universe {
    pub fn has_cell(&self, index: usize) -> bool {
        matches!(self.blocks[index], Block::HasCell)
    }
}

impl Universe {
    pub fn tick(&mut self) {
        let mut next_blocks = self.blocks.clone();

        let width_dec = self.width - 1;
        let height_dec = self.height - 1;

        for row in 0..self.height {
            for column in 0..self.width {
                let index = self.get_index(row, column);
                let has_cell = self.has_cell(index);

                let live_neighbor_cells_count = {
                    let mut counter = 0;

                    let left = if column > 0 { column - 1 } else { column };

                    let right = if column < width_dec { column + 1 } else { column };

                    let top = if row > 0 { row - 1 } else { row };

                    let bottom = if row < height_dec { row + 1 } else { row };

                    for r in top..=bottom {
                        for c in left..=right {
                            if row == r && column == c {
                                continue;
                            }

                            let index = self.get_index(r, c);
                            if self.has_cell(index) {
                                counter += 1;
                            }
                        }
                    }

                    counter
                };

                if has_cell {
                    if !(2..=3).contains(&live_neighbor_cells_count) {
                        // R1, R3
                        next_blocks[index] = Block::NoCell;
                    }

                    // R2 can be ignored
                } else if live_neighbor_cells_count == 3 {
                    // R4
                    next_blocks[index] = Block::HasCell;
                }
            }
        }

        self.blocks = next_blocks;
    }
}
加入#[wasm_bindgen]屬性

接下來就是把我們想要暴露給JavaScript程式使用的結構體、列舉、關聯函數和方法都加上#[wasm_bindgen]屬性。如下:

#[wasm_bindgen]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Block {
    NoCell,
    HasCell,
}

#[wasm_bindgen]
#[derive(Debug)]
pub struct Universe {
    width:  u32,
    height: u32,
    blocks: Vec<Block>,
}

#[wasm_bindgen]
impl Universe {
    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn blocks(&self) -> &[Block] {
        &self.blocks
    }
}

#[wasm_bindgen]
impl Universe {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> Universe {
        assert!(width > 0);
        assert!(height > 0);

        let capacity = width as usize * height as usize;

        let blocks = vec![Block::NoCell; capacity];

        Universe {
            width,
            height,
            blocks,
        }
    }

    pub fn default_cells(&mut self) {
        for (i, block) in self.blocks.iter_mut().enumerate() {
            if i % 2 == 0 || i % 7 == 0 {
                *block = Block::HasCell;
            } else {
                *block = Block::NoCell;
            }
        }
    }
}

#[wasm_bindgen]
impl Universe {
    pub fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}

#[wasm_bindgen]
impl Universe {
    pub fn has_cell(&self, index: usize) -> bool {
        matches!(self.blocks[index], Block::HasCell)
    }
}

#[wasm_bindgen]
impl Universe {
    pub fn tick(&mut self) {
        let mut next_blocks = self.blocks.clone();

        let width_dec = self.width - 1;
        let height_dec = self.height - 1;

        for row in 0..self.height {
            for column in 0..self.width {
                let index = self.get_index(row, column);
                let has_cell = self.has_cell(index);

                let live_neighbor_cells_count = {
                    let mut counter = 0;

                    let left = if column > 0 { column - 1 } else { column };

                    let right = if column < width_dec { column + 1 } else { column };

                    let top = if row > 0 { row - 1 } else { row };

                    let bottom = if row < height_dec { row + 1 } else { row };

                    for r in top..=bottom {
                        for c in left..=right {
                            if row == r && column == c {
                                continue;
                            }

                            let index = self.get_index(r, c);
                            if self.has_cell(index) {
                                counter += 1;
                            }
                        }
                    }

                    counter
                };

                if has_cell {
                    if !(2..=3).contains(&live_neighbor_cells_count) {
                        // R1, R3
                        next_blocks[index] = Block::NoCell;
                    }

                    // R2 can be ignored
                } else if live_neighbor_cells_count == 3 {
                    // R4
                    next_blocks[index] = Block::HasCell;
                }
            }
        }

        self.blocks = next_blocks;
    }
}

不過當我們使用cargo check指令嘗試編譯程式專案時,卻會發現blocks方法編譯失敗了。這是因為Rust的參考並不能直接被回傳給JavaScript程式,我們必須要把&[Block]參考改成*const Block指標才行。如下:

pub fn blocks(&self) -> *const Block {
    self.blocks.as_ptr()
}

修改之後,程式專案就能成功編譯了!

建置專案

寫完Rust程式後,就可以執行以下指令將它編譯成.wasm檔案,以及產生出相對應的膠水代碼!

wasm-pack build

把WebAssembly套用在HTML網頁

依照第一章介紹的方式,我們可以在Cargo程式專案目錄中,新增一個www目錄,並完成基本的TypeScript+Webpack程式專案。然後再繼續進行下面的修改。

www目錄執行以下指令加入其他必要的套件:

pnpm i -D sass-loader node-sass postcss postcss-loader autoprefixer cssnano css-loader mini-css-extract-plugin
import { Block, Universe } from "../../pkg/wasm_game_of_life.js";

// @ts-expect-error it can be load in Webpack
import { memory } from "../../pkg/wasm_game_of_life_bg.wasm";

const WIDTH = 32;
const HEIGHT = 32;
    
const BORDER_COLOR = "#888888";
const BLOCK_COLOR = "#CCCCCC";
const CELL_COLOR = "#000000";

export const init = () => {
    const getWidth = () => {
        return Math.max(
            document.body.scrollWidth,
            document.documentElement.scrollWidth,
            document.body.offsetWidth,
            document.documentElement.offsetWidth,
            document.documentElement.clientWidth,
        );
    };
        
    const getHeight = () => {
        return Math.max(
            document.body.scrollHeight,
            document.documentElement.scrollHeight,
            document.body.offsetHeight,
            document.documentElement.offsetHeight,
            document.documentElement.clientHeight,
        );
    };
        
    const canvas = document.getElementById("game-of-life-canvas") as HTMLCanvasElement;
        
    let size: number;
    const screenWidth = getWidth();
    const screenHeight = getHeight();
        
    if (screenWidth < screenHeight) {
        size = screenWidth / WIDTH;
    } else {
        size = screenHeight / HEIGHT;
    }
        
    const borderSize = size * 0.05;
    const marginSize = size * 0.1;
        
    const blockSize = size - (borderSize * 2) - (marginSize * 2);
        
    const universe = new Universe(WIDTH, HEIGHT);
        
    universe.default_cells();
        
    canvas.height = size * WIDTH;
    canvas.width = size * HEIGHT;
        
    const ctx = canvas.getContext("2d");

    if (ctx === null) {
        return;
    }
        
    const drawCells = () => {
        const blocksPtr = universe.blocks();
        const blocks = new Uint8Array((memory as WebAssembly.Memory).buffer, blocksPtr, WIDTH * HEIGHT) as unknown as Block[];
        
        ctx.beginPath();
        
        ctx.strokeStyle = BORDER_COLOR;
        ctx.lineWidth = borderSize;
        
        for (let row = 0;row < HEIGHT;row++) {
            for (let column = 0;column < WIDTH;column++) {
                const index = universe.get_index(row, column);
        
                ctx.fillStyle = blocks[index] === Block.NoCell ? BLOCK_COLOR : CELL_COLOR;
        
                const x = (column * size) + borderSize + marginSize;
                const y = (row * size) + borderSize + marginSize;
        
                ctx.fillRect(x, y, blockSize, blockSize);
                ctx.strokeRect(x, y, blockSize, blockSize);
            }
        }
    };
        
    const renderLoop = () => {
        universe.tick();
        
        drawCells();
        
        requestAnimationFrame(renderLoop);
    };

    drawCells();
    requestAnimationFrame(renderLoop);
};

注意這邊我們除了引用膠水代碼外,還直接引用了.wasm檔(wasm_game_of_life_bg),這是為了要能夠存取它暴露(export)的memory常數,也就是WebAssembly所使用到的記憶體物件(WebAssembly.Memory)實體。我們必須要有這個記憶體物件實體才能夠在JavaScript中利用指標存取指定位址的資料,而存取的方式是透過Uint8Array物件。但為什麼會是用Uint8Array物件呢?我們的Block應該要是個列舉,而不是u8不是嗎?要怎麼才能夠知道一個列舉值該用多少個位元組來表示?為什麼可以確定這邊的Block列舉值是一個位元組呢?

事實上,Rust程式語言的列舉,每個變體都可以對應一個isize整數值,如果這些isize整數值都在i8u8範圍內,那麼這個列舉就會以8位元來儲存,同樣地,若這些isize整數值都在i16u16範圍內,那麼這個列舉就會以16位元來儲存,依此類推。

還有一個要注意的地方是,我們在繪製Canvas的前後均呼叫了requestAnimationFrame函數,這個是網頁瀏覽器提供的JS函數,可以根據螢幕刷新頻率來觸發某個函數,就算重複呼叫也不必擔心有堆疊溢出的問題,用於網頁畫面的繪製。

最後要注意的是,JavaScript其實並不能直接以import關鍵字引入.wasm檔,必須透過Webassembly物件才行,但在Webpack中因為有內建的WASM載入器的幫助,才使得直接import變成可能。在之後的章節會以其它的方式來取代現在這個直接以import關鍵字引入.wasm檔的作法,讓程式寫起來更漂亮一些。

body {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    margin: 0;
}

canvas {
    max-width: 100%;
    max-height: 100%;
}
import "./index.scss";

import { init } from "./lib.js";

init();
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Conway's Game of Life</title>
    <link href="./css/bundle.min.css" rel="stylesheet">
</head>
<body>
    <canvas id="game-of-life-canvas"></canvas>
    <script src="./js/bundle.min.js"></script>
</body>
</html>
import autoprefixer from "autoprefixer";
import cssnano from "cssnano";
import HtmlWebpackPlugin from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import TerserPlugin from "terser-webpack-plugin";
import { Configuration } from "webpack";

const config: Configuration = {
    entry: "./src/index.ts",
    output: {
        clean: true,
        filename: "./js/bundle.min.js",
        webassemblyModuleFilename: "./wasm/game-of-life.wasm",
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: "./views/index.html",
            filename: "./index.html",
            minify: {
                collapseBooleanAttributes: true,
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true,
                removeEmptyAttributes: true,
                removeRedundantAttributes: true,
                removeScriptTypeAttributes: true,
                removeStyleLinkTypeAttributes: true,
                minifyCSS: true,
                minifyJS: true,
                sortAttributes: true,
                useShortDoctype: true,
            },
            inject: false,
        }),
        new MiniCssExtractPlugin({ filename: "./css/bundle.min.css" }),
    ],
    module: {
        rules: [
            {
                test: /\.ts$/i,
                use: [
                    {
                        loader: "babel-loader",
                        options: { presets: ["@babel/preset-env", "@babel/preset-typescript"] },
                    },
                ],
            },
            {
                test: /\.js$/i,
                use: [
                    {
                        loader: "babel-loader",
                        options: { presets: ["@babel/preset-env"] },
                    },
                ],
            },
            {
                test: /\.(sa|sc|c)ss$/i,
                use: [
                    MiniCssExtractPlugin.loader,
                    "css-loader",
                    {
                        loader: "postcss-loader",
                        options: {
                            postcssOptions: {
                                plugins: [
                                    autoprefixer,
                                    cssnano({ preset: ["default", { discardComments: { removeAll: true } }] }),
                                ],
                            },
                        },
                    },
                    "sass-loader",
                ],
            },
        ],
    },
    resolve: { extensionAlias: { ".js": [".ts", ".js"] } },
    optimization: {
        minimizer: [
            new TerserPlugin({
                extractComments: false,
                terserOptions: { format: { comments: false } },
            }),
        ],
    },
    experiments: { asyncWebAssembly: true },
};

export default config;

然後就可以在www目錄中執行npm run build:webpack指令來打包專案。接著再使用VSCode的Live Server開啟Webpack輸出的index.html來瀏覽結果,結果如下圖:

rust-webassembly-conways-game-of-life

總結

我們在這個章節中,成功用Rust程式實作了康威生命遊戲,並且也將其編譯成WebAssembly程式,在HTML網頁Canvas上正確顯示出遊戲畫面。不過現在這個遊戲的功能實在是太少了,完全沒有辦法跟玩家互動嘛!先別著急,愈強大的程式開發起來愈複雜,因此我們在下一個章節必須要先學習WebAssembly程式的測試和偵錯方式才行。

下一章:測試(Testing)與偵錯(Debugging)