10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「Develop fun!」を体現する! Works Human IntelligenceAdvent Calendar 2024

Day 22

Rust + WebAssembly で、ブラウザ上で動く「ラングトンのアリ」を実装する

Last updated at Posted at 2024-12-24

はじめに

先日、会社の同僚が「ラングトンのアリ」を紹介してくれました。
面白そうなので、Rust + WebAssembly を使い、ブラウザ上で動くものを実装してみました。

使用したRustのバージョンは、記事執筆時点で最新の安定版である1.83.0です。

「ラングトンのアリ ( Langton's ant )」とは

  • 2次元格子上で動作するシンプルなチューリングマシンの一種
  • 2次元格子上の各マスは「白」か「黒」の状態を持つ
  • 2次元格子上の任意の位置を「アリ」とする(赤などで表示する)
  • 下記のルールに従って「アリ」が動く
    1. 現在いるマスが白なら右に90°回転し、黒なら左に90°回転する
    2. 現在いるマスの色を反転する(白ならば黒、黒ならば白)
    3. 1マス前進する
      1. に戻る

準備

まだRustとCargoをインストールしていない場合、こちらなどを参考にインストールしておいて下さい。

wasm-packのインストール

今回はwasm-packという、wasm-bindgenを使用してRustでのWebAssembly開発を良い感じに扱ってくれるツールを使用するため、

$ cargo install wasm-pack

でインストールしておきます。

ローカルでHTTPサーバをホストできる環境の用意

WebAssemblyを動かすために、ローカルでHTTPサーバをホストできる環境を用意します。

これは好きな方法で良いのですが、

  1. Node.jsがインストールされている環境であればhttp-server
  2. Pythonがインストールされている環境であればhttp.server
  3. VS Codeで開発していればHTML Preview拡張機能

などを使うのが良いと思います。

Rustプロジェクトの新規作成

任意のディレクトリで

$ cargo new --lib langtons_ant

などで新しいRustプロジェクトを作成しておきます。

実装

Cargo.tomlの編集

作成されたディレクトリの中に、Cargo.tomlというファイルがあり、中身が下記のようになっているはずです。

Cargo.toml
[package]
name = "langtons_ant"
version = "0.1.0"
edition = "2021"

この下に次の中身をコピペします。

Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = [
  "CanvasRenderingContext2d",
]

# Windows環境では下記を書かないと`wasm-package build`時にエラーになる
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

index.htmlの作成

次に、Cargo.tomlと同じプロジェクトのルートディレクトリに、index.htmlという名前でHTMLファイルを作ります。

今回はサンプルプロジェクトのため、HTMLファイルの中にJavaScriptを埋め込むという簡易的な形を取りましたが、実際にWebAssemblyのプロジェクトを作成する際にはこのような形は避け、きちんとフロントエンド開発環境を整備するほうが良いでしょう。

index.html
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <title>Langton's Ant</title>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        canvas {
            background-color: #eee;
            display: block;
            margin: 0 auto;
            border: 1px solid #ccc;
        }
    </style>
</head>

<body>
    <canvas id="canvas" width="640" height="480"></canvas>
    <script type="module">
        import init, { LangtonsAnt } from "./pkg/langtons_ant.js";

        async function main() {
            await init();

            const canvas = document.getElementById("canvas");
            const ctx = canvas.getContext("2d");

            const cellSize = 4;

            const width = canvas.width / cellSize;
            const height = canvas.height / cellSize;

            const ant = new LangtonsAnt(width, height);

            function update() {
                ant.step();
                ant.render(ctx, cellSize);
                requestAnimationFrame(update);
            }

            update();
        }

        main();
    </script>
</body>

</html>

src/lib.rsの編集

src/lib.rsに次の内容を実装します。

src/lib.rs
use std::collections::HashSet;
use wasm_bindgen::prelude::*;
use web_sys::CanvasRenderingContext2d;

#[derive(Debug, PartialEq)]
enum Direction {
    Up,
    Right,
    Down,
    Left,
}

impl Direction {
    pub fn turn_right(&self) -> Direction {
        match &self {
            Direction::Up => Direction::Right,
            Direction::Right => Direction::Down,
            Direction::Down => Direction::Left,
            Direction::Left => Direction::Up,
        }
    }

    pub fn turn_left(&self) -> Direction {
        match &self {
            Direction::Up => Direction::Left,
            Direction::Right => Direction::Up,
            Direction::Down => Direction::Right,
            Direction::Left => Direction::Down,
        }
    }
}

#[wasm_bindgen]
pub struct LangtonsAnt {
    width: usize,
    height: usize,
    x: usize,
    y: usize,
    direction: Direction,
    black_cells: HashSet<(usize, usize)>,
}

#[wasm_bindgen]
impl LangtonsAnt {
    #[wasm_bindgen(constructor)]
    pub fn new(width: usize, height: usize) -> LangtonsAnt {
        LangtonsAnt {
            width,
            height,
            x: width / 2,
            y: height / 2,
            direction: Direction::Up,
            black_cells: HashSet::new(),
        }
    }

    pub fn step(&mut self) {
        if self.black_cells.contains(&(self.x, self.y)) {
            self.black_cells.remove(&(self.x, self.y));
            self.direction = self.direction.turn_left();
        } else {
            self.black_cells.insert((self.x, self.y));
            self.direction = self.direction.turn_right();
        }

        match self.direction {
            Direction::Up => {
                if self.y > 0 {
                    self.y -= 1;
                } else {
                    self.y = self.height - 1;
                }
            }
            Direction::Right => {
                if self.x < self.width - 1 {
                    self.x += 1;
                } else {
                    self.x = 0;
                }
            }
            Direction::Down => {
                if self.y < self.height - 1 {
                    self.y += 1;
                } else {
                    self.y = 0;
                }
            }
            Direction::Left => {
                if self.x > 0 {
                    self.x -= 1;
                } else {
                    self.x = self.width - 1;
                }
            }
        }
    }

    pub fn render(&self, context: &CanvasRenderingContext2d, cell_size: f64) {
        context.set_fill_style_str("white");
        context.fill_rect(
            0.0,
            0.0,
            (self.width as f64) * cell_size,
            (self.height as f64) * cell_size,
        );

        context.set_fill_style_str("black");
        for &(x, y) in &self.black_cells {
            context.fill_rect(
                (x as f64) * cell_size,
                (y as f64) * cell_size,
                cell_size,
                cell_size,
            );
        }

        context.set_fill_style_str("red");
        context.fill_rect(
            (self.x as f64) * cell_size,
            (self.y as f64) * cell_size,
            cell_size,
            cell_size,
        );
    }
}

#[wasm_bindgen(start)]
pub fn start() -> Result<(), JsValue> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_turn_right() {
        assert_eq!(Direction::Up.turn_right(), Direction::Right);
        assert_eq!(Direction::Right.turn_right(), Direction::Down);
        assert_eq!(Direction::Down.turn_right(), Direction::Left);
        assert_eq!(Direction::Left.turn_right(), Direction::Up);
    }

    #[test]
    fn test_turn_left() {
        assert_eq!(Direction::Up.turn_left(), Direction::Left);
        assert_eq!(Direction::Right.turn_left(), Direction::Up);
        assert_eq!(Direction::Down.turn_left(), Direction::Right);
        assert_eq!(Direction::Left.turn_left(), Direction::Down);
    }

    #[test]
    fn test_new_langtons_ant() {
        let ant = LangtonsAnt::new(10, 10);
        assert_eq!(ant.width, 10);
        assert_eq!(ant.height, 10);
        assert_eq!(ant.x, 5);
        assert_eq!(ant.y, 5);
        assert_eq!(ant.direction, Direction::Up);
        assert!(ant.black_cells.is_empty());
    }

    #[test]
    fn test_step_ant_flips_and_turns_right_when_white() {
        let mut ant = LangtonsAnt::new(10, 10);
        ant.step();
        assert_eq!(ant.x, 6);
        assert_eq!(ant.y, 5);
        assert_eq!(ant.direction, Direction::Right);
        assert!(ant.black_cells.contains(&(5, 5)));
    }

    #[test]
    fn test_step_ant_flips_and_turns_left_when_black() {
        let mut ant = LangtonsAnt::new(10, 10);
        ant.black_cells.insert((5, 5));

        ant.step();
        assert_eq!(ant.x, 4);
        assert_eq!(ant.y, 5);
        assert_eq!(ant.direction, Direction::Left);
        assert!(!ant.black_cells.contains(&(5, 5)));
    }
}

以上で、一通りの実装が完了しました。

ユニットテストが通ることを確認

一応ユニットテストを実装したので、それが通ることを確認しましょう。

$ cargo test

を実行し、

running 5 tests
test tests::test_new_langtons_ant ... ok
test tests::test_step_ant_flips_and_turns_left_when_black ... ok
test tests::test_step_ant_flips_and_turns_right_when_white ... ok
test tests::test_turn_left ... ok
test tests::test_turn_right ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

のような表示が出ればOKです。

ビルド

下記を実行し、ビルドします。

$ wasm-pack build --target web
[INFO]: :-) Done in 8.42s
[INFO]: :-) Your wasm pkg is ready to publish at {プロジェクトのルートディレクトリへのパス}\pkg.

のような表示が出たら成功です。

実行

プロジェクトのルートディレクトリ(先ほど編集したCargo.tomlindex.htmlがあるディレクトリ)に移動し、下記の手順などでHTTPサーバを起動します。

Node.jsがインストールされている環境の場合

$ npx http-server .

Pythonがインストールされている環境の場合

$ python -m http.server 8000

VS Codeの拡張機能「HTML Preview」を使用する場合

index.htmlを開いた状態で、タブの右の方にあるプレビューボタンを押すか、Ctrl+K V(デフォルトの場合)

立ち上がったサーバー(http://127.0.0.1:8000/など)にアクセスし、下記のような画面が出れば成功です。

langstons_ant_demo.gif

その他

どこかのサーバーへデプロイする場合、index.htmlpkgディレクトリをデプロイしてやれば動くはずです。

おわりに

とても単純なアルゴリズムから、複雑な模様が描かれるのは見ていて楽しいですね。

今回はBevyなどのゲームエンジンを使用せず、あえてwasm-bindgenを使用してWebAssemblyを直接作成する方法を試してみましたが、WebAssemblyの作成も、JavaScriptからの呼び出しも(この程度であれば)意外とすんなりとできて驚きました。

今回も楽しんでコードを書くことができました。この記事が皆様の良きRustライフの一助になれば幸いです🦀

参考文献

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?