はじめに
先日、会社の同僚が「ラングトンのアリ」を紹介してくれました。
面白そうなので、Rust + WebAssembly を使い、ブラウザ上で動くものを実装してみました。
使用したRustのバージョンは、記事執筆時点で最新の安定版である1.83.0
です。
「ラングトンのアリ ( Langton's ant )」とは
- 2次元格子上で動作するシンプルなチューリングマシンの一種
- 2次元格子上の各マスは「白」か「黒」の状態を持つ
- 2次元格子上の任意の位置を「アリ」とする(赤などで表示する)
- 下記のルールに従って「アリ」が動く
- 現在いるマスが白なら右に90°回転し、黒なら左に90°回転する
- 現在いるマスの色を反転する(白ならば黒、黒ならば白)
- 1マス前進する
-
- に戻る
準備
まだRustとCargoをインストールしていない場合、こちらなどを参考にインストールしておいて下さい。
wasm-pack
のインストール
今回はwasm-packという、wasm-bindgen
を使用してRustでのWebAssembly開発を良い感じに扱ってくれるツールを使用するため、
$ cargo install wasm-pack
でインストールしておきます。
ローカルでHTTPサーバをホストできる環境の用意
WebAssemblyを動かすために、ローカルでHTTPサーバをホストできる環境を用意します。
これは好きな方法で良いのですが、
- Node.jsがインストールされている環境であればhttp-server
- Pythonがインストールされている環境であればhttp.server
- VS Codeで開発していればHTML Preview拡張機能
などを使うのが良いと思います。
Rustプロジェクトの新規作成
任意のディレクトリで
$ cargo new --lib langtons_ant
などで新しいRustプロジェクトを作成しておきます。
実装
Cargo.toml
の編集
作成されたディレクトリの中に、Cargo.toml
というファイルがあり、中身が下記のようになっているはずです。
[package]
name = "langtons_ant"
version = "0.1.0"
edition = "2021"
この下に次の中身をコピペします。
[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のプロジェクトを作成する際にはこのような形は避け、きちんとフロントエンド開発環境を整備するほうが良いでしょう。
<!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
に次の内容を実装します。
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.toml
やindex.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/
など)にアクセスし、下記のような画面が出れば成功です。
その他
どこかのサーバーへデプロイする場合、index.html
とpkg
ディレクトリをデプロイしてやれば動くはずです。
おわりに
とても単純なアルゴリズムから、複雑な模様が描かれるのは見ていて楽しいですね。
今回はBevyなどのゲームエンジンを使用せず、あえてwasm-bindgen
を使用してWebAssemblyを直接作成する方法を試してみましたが、WebAssemblyの作成も、JavaScriptからの呼び出しも(この程度であれば)意外とすんなりとできて驚きました。
今回も楽しんでコードを書くことができました。この記事が皆様の良きRustライフの一助になれば幸いです🦀
参考文献