在這個章節中,我們要替康威生命遊戲加入播放、暫停和逐格播放這幾個時間控制的功能,並且讓培養皿中的格子會因玩家滑鼠游標的點擊事件而改變狀態。



在網頁上新增按鈕

為了能夠控制康威生命遊戲的時間變化,我們需要在HTML網頁中加入一些按鈕。如下:

<!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>
    <div id="controls">
        <button id="play-pause">Play</button>
        <button id="next">Next</button>
    </div>
    <canvas id="game-of-life-canvas"></canvas>
    <script src="./js/bundle.min.js"></script>
</body>
</html>

接著是改寫SCSS/CSS。如下:

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: calc(100% - 40px);
}

#controls {
    height: 40px;
    margin: 0 -5px;
    padding: 5px 0 1px 0;
    display: flex;
    flex-direction: row;
}

#controls button {
    width: 120px;
    height: 100%;
    margin: 0 5px;
}

再來是TypeScript程式的部份也要修改,因為我們是用TypeScript程式來畫Canvas的。如下:

...

export const init = () => {
    ...

    const getHeight = () => {
        return Math.max(
            document.body.scrollHeight,
            document.documentElement.scrollHeight,
            document.body.offsetHeight,
            document.documentElement.offsetHeight,
            document.documentElement.clientHeight
        ) - 40;
    };

    ...
};

從按鈕的標籤中可以看出我們是想讓這個康威生命遊戲在一開始是靜止的,當玩家按了按鈕之後才會開始動作,因此以下這段的TypeScript程式也要改寫。

...

export const init = () => {
    ...

    const renderLoop = () => {
        universe.tick();

        drawCells();

        requestAnimationFrame(renderLoop);
    };

    drawCells();
    requestAnimationFrame(renderLoop);
};

改寫如下:

...

export const init = () => {
    ...

    let pause = true;

    const renderLoop = () => {
        universe.tick();

        drawCells();

        if (!pause) {
            requestAnimationFrame(renderLoop);
        }
    };

    drawCells();

};

這邊另外宣告了一個pause變數,用來控制renderLoop函數是否要繼續呼叫requestAnimationFrame函數來重複執行renderLoop函數,等等就會撰寫按鈕的事件程式來改變pause變數的值。

此時的HTML網頁的畫面看起來如下圖:

rust-webassembly-conways-game-of-life-interact

增加按鈕事件

播放與暫停

www/src/lib.ts中的init函數的最下方,新增以下程式:

...

export const init = () => {
    ...

    const buttonPlayPause = document.getElementById("play-pause") as HTMLButtonElement;
    const buttonNext = document.getElementById("next") as HTMLButtonElement;

    buttonPlayPause.addEventListener("click", () => {
        if (pause) {
            pause = false;
            requestAnimationFrame(renderLoop);

            buttonPlayPause.innerHTML = "Pause";

            buttonNext.setAttribute("disabled", "disabled");
        } else {
            pause = true;

            buttonPlayPause.innerHTML = "Play";

            buttonNext.removeAttribute("disabled");
        }
    });
};

如此一來Play按鈕就有作用了!以下是播放遊戲時的截圖:

rust-webassembly-conways-game-of-life-interact

逐格播放

在遊戲暫停時,我們希望可以按下Next按鈕來將遊戲時間推移一個階段。在TypeScript程式中,Play按鈕的事件程式下方,再新增以下程式:

...

export const init = () => {
    ...

    buttonNext.addEventListener("click", () => {
        universe.tick();

        drawCells();
    });
};

這樣每按一次Next按鈕,就會進行逐格播放的動作。

用滑鼠切換格子狀態

由於我們的Universe結構體沒有提供能夠切換格字狀態的方法,所以要先實作一個。程式如下:

#[wasm_bindgen]
impl Universe {
    pub fn toggle_block(&mut self, index: usize) {
        if self.has_cell(index) {
            self.blocks[index] = Block::NoCell;
        } else {
            self.blocks[index] = Block::HasCell;
        }
    }
}

接著在TypeScript程式中,Next按鈕的事件程式下方,替Canvas加入滑鼠點擊事件。如下:

...

export const init = () => {
    ...

    canvas.addEventListener("click", (event) => {
        const boundingRect = canvas.getBoundingClientRect();

        const scaleX = canvas.width / boundingRect.width;
        const scaleY = canvas.height / boundingRect.height;

        const canvasLeft = (event.clientX - boundingRect.left) * scaleX;
        const canvasTop = (event.clientY - boundingRect.top) * scaleY;

        const row = Math.floor(canvasTop / size);
        const column = Math.floor(canvasLeft / size);

        const index = universe.get_index(row, column);

        universe.toggle_block(index);

        drawCells();
    });
};

然後再把我們預設的活細胞拿掉,也就是不要在TypeScript程式中呼叫Universe結構實體的default_cells方法,甚至也可以直接把default_cells方法從WebAssembly程式中移除掉了。因為我們現在已經可以用滑鼠任意地點出我們要的圖形了!

rust-webassembly-conways-game-of-life-interact

無邊界的培養皿

目前我們的培養皿是有限大小的,因此培養皿的邊界會被視為沒有活細胞存在,而這通常會讓我們的好不容易畫出來的圖形撞到邊界就消失了,一點都不好玩!事實上,我們只要稍微修改一下計算鄰近格子有無活細胞的程式碼,就可以讓左右邊界連接起來、上下邊界也連接起來。

修改方式如下:

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

        for row in 0..self.height {
            for column in 0..self.width {
                ...

                let live_neighbor_cells_count = {
                    let mut counter = 0;

                    let left = column as i64 - 1;

                    let right = column as i64 + 1;

                    let top = row as i64 - 1;

                    let bottom = row as i64 + 1;

                    for r in top..=bottom {
                        for c in left..=right {
                            let r = ((r + self.height as i64) % self.height as i64) as u32;
                            let c = ((c + self.width as i64) % self.width as i64) as u32;

                            if row == r && column == c {
                                continue;
                            }

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

                    counter
                };

                ...
            }
        }

        ...
    }
}

清除活細胞

無邊界的培養皿容易使活細胞的分佈變得雜亂,我們可以在HTML網頁上增加一個清除活細胞的按鈕。

修改HTML網頁,如下:

<!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>
    <div id="controls">
        <button id="play-pause">Play</button>
        <button id="next">Next</button>
        <button id="clear">Clear</button>
    </div>
    <canvas id="game-of-life-canvas"></canvas>
    <script src="./js/bundle.min.js"></script>
</body>
</html>

src/lib.rs加入以下程式:

#[wasm_bindgen]
impl Universe {
    pub fn clear(&mut self) {
        for block in self.blocks.iter_mut() {
            *block = Block::NoCell;
        }
    }
}

接著在TypeScript程式中,Next按鈕的事件程式下方,替清除按鈕加入事件。如下:

...

export const init = () => {
    ...

    const buttonClear = document.getElementById("clear") as HTMLButtonElement;

    ...

    buttonClear.addEventListener("click", () => {
        universe.clear();

        drawCells();
    });

    ...
};

這樣我們的康威生命遊戲就有清除活細胞的功能了!

rust-webassembly-conways-game-of-life-interact

用鍵盤+滑鼠切換格子狀態

雖然我們現在已經可以用滑鼠來點擊格子來畫圖了,但要一個一個慢慢點實在很麻煩。我們可以設計一些圖形範本在WebAssembly程式之中,當玩家按住鍵盤上的Ctrl鍵時,再用滑鼠左鍵去點擊格子的話,就會隨機將我們預設的圖形範本插進培養皿中,這樣遊戲應該會比較好玩一點。

圖形範本的型別可以設計如下:

type Pattern = ((u32, u32), &'static [usize]);

在這個元組(tuple)中,第一個欄位又是一個元組,表示圖形範本的的寬和高。而第二個欄位是一個陣列,表示在這個寬和高的空間中,活細胞存在的索引位置。

有了Pattern型別之後,就可以開始內置我們範本,如下:

#[rustfmt::skip]
const PATTERNS: [Pattern; 8] = [
    (
        (3, 3),
        &[
            1,
            5,
            6, 7, 8
        ]
    ),
    (
        (3, 4),
        &[
            1,
            3, 4, 5,
            6, 8,
            10
        ]
    ),
    (
        (5, 5),
        &[
            0, 2, 4,
            5, 9,
            10, 14,
            15, 19,
            20, 22, 24
        ]
    ),
    (
        (10, 1),
        &[
            0, 1, 2, 3, 4, 5, 6, 7, 8, 9
        ]
    ),
    (
        (5, 4),
        &[
            2, 3,
            5, 6, 8, 9,
            10, 11, 12, 13,
            16, 17
        ]
    ),
    (
        (7, 6),
        &[
            1, 2, 4, 5,
            8, 9, 11, 12,
            16, 18,
            21, 23, 25, 27,
            28, 30, 32, 34,
            35, 36, 40, 41
        ]
    ),
    (
        (7, 6),
        &[
            1, 2, 4, 5,
            8, 9, 11, 12,
            16, 18,
            21, 23, 25, 27,
            28, 30, 32, 34,
            35, 36, 40, 41
        ]
    ),
    (
        (23, 9),
        &[
            1, 2,
            23, 24, 26, 27, 40,
            47, 48, 49, 50, 54, 63,
            71, 72, 76, 78, 81, 82,
            98, 102, 103, 106, 113, 114,
            117, 118, 122, 124, 127, 128,
            139, 140, 141, 142, 146, 155,
            161, 162, 164, 165, 178,
            185, 186
        ]
    ),
];

PATTERNS就是我們用來儲存所有圖形範本的陣列。

不過由於圖形範本有大有小,而且我們的培養皿的空間大小也是可變的,為了避免插入圖形範本時去使用到比培養皿還大的圖形範本,我們最好在建立Universe結構實體時就去篩選。因此,我們要將Universe結構體的定義改寫如下:

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

然後修改Universe結構體的new關聯函數。如下:

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

        let mut patterns = Vec::new();

        for ps in PATTERNS.iter() {
            if (ps.0).0 <= width && (ps.0).1 <= height {
                patterns.push(ps);
            }
        }

        Universe {
            width,
            height,
            blocks,
            patterns,
        }
    }

    ...
}

接著替Universe結構體加入能在指定位置隨機插入圖形範本的方法,這邊我們要使用js-sys套件來綁定標準的JavaScript API,才可以使用JavaScript的Math.random來取得隨機亂數。注意js-sysweb-sys是不同的套件,前者用來綁定標準的JavaScript API,後者用來綁定網頁瀏覽器提供的API。

編輯Cargo.toml,在[dependencies]區塊下加上:

js-sys = "*"

src/lib.rs檔案中,加上:

#[wasm_bindgen]
impl Universe {
    pub fn insert_random_pattern(&mut self, index: usize) {
        let patterns_len = self.patterns.len();

        if patterns_len > 0 {
            let rnd = (js_sys::Math::random() * patterns_len as f64).floor() as usize;

            let pattern = self.patterns[rnd];

            let row = index as u32 / self.width;
            let column = index as u32 % self.width;

            for &i in pattern.1.iter() {
                let w = (pattern.0).0;
                let r = i as u32 / w;
                let c = i as u32 % w;

                let row = (row + r) % self.width;
                let column = (column + c) % self.height;

                let index = self.get_index(row, column);

                self.blocks[index] = Block::HasCell;
            }
        }
    }
}

最後就是修改TypeScript程式中的Canvas滑鼠點擊事件。如下:

...

export const init = () => {
    ...

    canvas.addEventListener("click", (event) => {
        const boundingRect = canvas.getBoundingClientRect();

        const scaleX = canvas.width / boundingRect.width;
        const scaleY = canvas.height / boundingRect.height;

        const canvasLeft = (event.clientX - boundingRect.left) * scaleX;
        const canvasTop = (event.clientY - boundingRect.top) * scaleY;

        const row = Math.floor(canvasTop / size);
        const column = Math.floor(canvasLeft / size);

        const index = universe.get_index(row, column);

        if (event.ctrlKey) {
            universe.insert_random_pattern(index);
        } else {
            universe.toggle_block(index);
        }

        drawCells();
    });
};

如此一來,當我們按住鍵盤Ctrl鍵並用滑鼠左鍵點擊格子時,就可以在那個地方隨機插入我們寫死在WebAssembly程式中的圖形範本了!

rust-webassembly-conways-game-of-life-interact

避免直接用import關鍵字引入.wasm檔案

由於js-sys套件也有提供Unit8Array,所以我們的WebAssembly程式就不必裸傳Block陣列的指標出來了,也就不需要去用import關鍵字引入.wasm檔案來用它的memory常數。

將程式改寫如下:

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

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

    #[wasm_bindgen]
    pub fn blocks(&mut self) -> js_sys::Uint8Array {
        unsafe {
            js_sys::Uint8Array::view_mut_raw(self.blocks.as_mut_ptr() as *mut u8, self.blocks.len())
        }
    }
}
// import { memory } from "../../pkg/wasm_game_of_life_bg.wasm"; // remove this line

...

export const init = () => {
    ...

    const drawCells = () => {
        const blocks = universe.blocks() as unknown as Block[];
    
        ctx.beginPath();

        ...
    };

    ...
};

總結

我們終於把康威生命遊戲與玩家的互動功能實作出來了!在這個章節中,我們還用了js-sys套件綁定JavaScript的標準API。

在下一個章節中,要來介紹如何偵測、優化HTML網頁畫面的FPS(Frames Per Second),以及控制康威生命遊戲的播放速度。

下一章:康威生命遊戲的效能