🧱

(中編)MDNブロック崩しをJSからRustに移植してWebAssemblyに入門

2023/11/04に公開

趣味の Rust です。MDN ブロック崩しを JavaScript から Rust に移植して WebAssembly に入門したときのメモです。
この文章は前編、後編、中編の 3 つにわけて投稿する予定です。今回は中編です。

開発環境やビルド方法については前編をご覧ください。

第 4 章 パドルとキーボード操作

第 4 章の元の内容はこちらになります ↓

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Paddle_and_keyboard_controls

パドルの操作です。まずはパドルを画面上に描画します。パドルもボールと同じ様にPaddle構造体を作り、その中に座標などを保持します。

元の記事ではパドルの X 座標は Canvas の幅からパドルの幅を引いたものを半分にした(canvas.width - paddleWidth) / 2としていますので、ここでも同様にします。

src/lib.rs(の一部)
+struct Paddle {
+    x: i32,
+    //y: i32, // 縦軸方向には移動しないため、yは不要
+    width: i32,
+    height: i32,
+}
+
+impl Paddle {
+    fn draw(&self, ctx: &web_sys::CanvasRenderingContext2d) {
+        let canvas_height = ctx.canvas().unwrap().height() as i32;
+        ctx.begin_path();
+        ctx.rect(
+            self.x as f64,
+            (canvas_height - &self.height) as f64,
+            self.width as f64,
+            self.height as f64,
+        );
+        ctx.set_fill_style(&JsValue::from_str("#0095DD"));
+        ctx.fill();
+        ctx.close_path();
+    }
+}

Game構造体にpaddle変数を、new()関数にパドル初期化を追加します。

src/lib.rs(の一部)
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
+    paddle: Paddle,
     game_loop_closure: Option<Closure<dyn FnMut()>>, // ゲームループクローザ
     game_loop_interval_millis: u32,                  // ゲームループの間隔(ミリ秒)
     game_loop_interval_handle: Option<i32>,          // ゲームループのハンドル
 }

 impl Game {
     pub fn new() -> Self {

         ... (省略) ...

         let ball = Ball {
             x: canvas_width / 2,
             y: canvas_height - 30,
             radius: 10,
             dx: 2,
             dy: -2,
         };

+        let paddle = Paddle {
+            width: 75,
+            height: 10,
+            x: (canvas_width - 10) / 2,
+        };

         Self {
             canvas_context,
             canvas_width,
             canvas_height,
             ball,
+            paddle,
             game_loop_closure: None,
             game_loop_interval_millis: 10,
             game_loop_interval_handle: None,
         }
     }

     ... (省略) ...

ゲームループにパドル描画を追加します。

src/lib.rs(の一部)
     fn game_loop(&mut self) {
         // 画面を初期化
         self.canvas_context.clear_rect(
             0.0,
             0.0,
             self.canvas_width as f64,
             self.canvas_height as f64,
         );

         // ボールの描画
         self.ball.draw(&self.canvas_context);
+        // パドルの描画
+        self.paddle.draw(&self.canvas_context);

         // ボールの移動先
         let moved_ball_x = self.ball.x.saturating_add(self.ball.dx);
         let moved_ball_y = self.ball.y.saturating_add(self.ball.dy);

         ... (省略) ...

ソースコードを変更するたびにビルドし直してください。ビルド方法は前編をご覧ください。

『パドルを操作できるようにする』

キーボードの左右キーでパドルを操作できるようにします。元の記事を引用すると…

押されているボタンはこのとおり、論理値として定義、初期化できます。このコードをどこか他の変数の近くに追記してください。

JavaScript
let rightPressed = false;
let leftPressed = false;

最初は制御ボタンは押されていないため、どちらにおいても既定値は false です。ボタンが押されたのを検知するため、 2 つのイベントリスナーを設定します。 JavaScript の最後にある setInterval() の行のちょうど上に次のコードを追記してください。

JavaScript
document.addEventListener("keydown", keyDownHandler, false);
document.addEventListener("keyup", keyUpHandler, false);

元のコードの JavaScript ではdocument.addEventListener()を使い、keydownイベントとkeyupイベントの発生時にどのキーが押されたか/離されたかを検出しているようです。そして押されたキー情報を真偽値として変数に保持しています。

wasm_bindgen ではweb_sys::Document.set_onkeydown()web_sys::Document.set_onkeyup()を使ってみます。

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Document.html#method.set_onkeydown

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Document.html#method.set_onkeyup

ここではUserInput構造体を作り、これもGame構造体に持たせるようにします。
UserInputの中身は左右キーの状態を保持するkeyboard_rightkeyboard_left変数のみです。new()関数にuser_inputとして初期化します。

src/lib.rs(の一部)
+struct UserInput {
+    keyboard_right: bool,
+    keyboard_left: bool,
+}
+
+impl UserInput {
+    fn set_keydoard_right(&mut self, press: bool) {
+        self.keyboard_right = press;
+    }
+    fn set_keydoard_left(&mut self, press: bool) {
+        self.keyboard_left = press;
+    }
+}
+
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
     paddle: Paddle,
+    user_input: UserInput,
     game_loop_closure: Option<Closure<dyn FnMut()>>, // ゲームループクローザ
     game_loop_interval_millis: u32,                  // ゲームループの間隔(ミリ秒)
     game_loop_interval_handle: Option<i32>,          // ゲームループのハンドル
 }

 impl Game {
     pub fn new() -> Self {

         ... (省略) ...

+        let user_input = UserInput {
+            keyboard_right: false,
+            keyboard_left: false,
+        };

         Self {
             canvas_context,
             canvas_width,
             canvas_height,
             ball,
             paddle,
+            user_input,
             game_loop_closure: None,
             game_loop_interval_millis: 10,
             game_loop_interval_handle: None,
         }
     }

     ... (省略) ...

元の JavaScript コードでaddEventListener()に登録される関数はこの様になっています。

JavaScript
function keyDownHandler(e) {
  if (e.key === "Right" || e.key === "ArrowRight") {
    rightPressed = true;
  } else if (e.key === "Left" || e.key === "ArrowLeft") {
    leftPressed = true;
  }
}

function keyUpHandler(e) {
  if (e.key === "Right" || e.key === "ArrowRight") {
    rightPressed = false;
  } else if (e.key === "Left" || e.key === "ArrowLeft") {
    leftPressed = false;
  }
}

このコードを Rust に置き換えてみます。

第 2 章でも引用したこちらのページの内容を再び参考にさせていただきます。
https://qiita.com/poccariswet/items/aefa48835320aead4155#コードを見る

impl Game {}内にset_input_event()関数を定義します。

仕組みは第 2 章で実装したset_game_loop_and_start()と同じようなものですが、今回はクロージャを保持せずclosure.forget()を使ってみます。これで関数のスコープを抜けてもクロージャが破棄されなくなります。

src/lib.rs(の一部)
 impl Game {

     ... (省略) ...

+    // メソッドではなく、関連関数なので Game::set_input() として呼び出す
+    // 引数には自分自身を Rc<RefCell<>> で包んだものを渡す
+    pub fn set_input_event(game: Rc<RefCell<Self>>) {
+        let game_key_down = game.clone();
+        let game_key_up = game.clone();
+        let document = web_sys::window().unwrap().document().unwrap();
+
+        let closure = Closure::new(Box::new(move |event: web_sys::KeyboardEvent| {
+            let mut g = game_key_down.borrow_mut();
+            if event.code() == "Right" || event.code() == "ArrowRight" {
+                g.user_input.set_keydoard_right(true);
+            } else if event.code() == "Left" || event.code() == "ArrowLeft" {
+                g.user_input.set_keydoard_left(true);
+            }
+        }) as Box<dyn FnMut(_)>);
+
+        document.set_onkeydown(Some(&closure.as_ref().unchecked_ref()));
+        // forget()するとRust側はdropされるが、into_js_value()されてブラウザ側に残る
+        closure.forget();
+
+        let closure = Closure::new(Box::new(move |event: web_sys::KeyboardEvent| {
+            let mut g = game_key_up.borrow_mut();
+            if event.code() == "Right" || event.code() == "ArrowRight" {
+                g.user_input.set_keydoard_right(false);
+            } else if event.code() == "Left" || event.code() == "ArrowLeft" {
+                g.user_input.set_keydoard_left(false);
+            }
+        }) as Box<dyn FnMut(_)>);
+
+        document.set_onkeyup(Some(&closure.as_ref().unchecked_ref()));
+        // forget()するとRust側はdropされるが、into_js_value()されてブラウザ側に残る
+        closure.forget();
+    }
}

run()関数内から実行します。

src/lib.rs(の一部)
 #[wasm_bindgen]
 pub fn run() {
     let game = Game::new();
     let game = Rc::new(RefCell::new(game));
     Game::set_game_loop_and_start(10, game.clone());
+    Game::set_input_event(game.clone());
 }

さて、これでキーボード入力を検出できるようになりました。ゲームループ内に左右キーの入力に応じてパドルを動かす処理を書いていきます。

左カーソルを押すとパドルは左に 7 ピクセル移動し、右カーソルを押すとパドルは右に 7 ピクセル移動する。これは今のところうまく動作しているが、どちらかのキーを長く押しているとパドルがキャンバスの端から消えてしまいます。これを改善し、パドルをキャンバスの枠内だけ移動させるには、次のようなコードに変更します。

JavaScript
if (rightPressed) {
  paddleX = Math.min(paddleX + 7, canvas.width - paddleWidth);
} else if (leftPressed) {
  paddleX = Math.max(paddleX - 7, 0);
}

元の JavaScript コードではこのようになっていました。Math.min()Math.max()は Rust ではstd::cmp::min()std::cmp::max()を使えばよさそうです。

src/lib.rs(の一部)
     fn game_loop(&mut self) {
         // 画面を初期化
         self.canvas_context.clear_rect(
             0.0,
             0.0,
             self.canvas_width as f64,
             self.canvas_height as f64,
         );

         // ボールの描画
         self.ball.draw(&self.canvas_context);
         // パドルの描画
         self.paddle.draw(&self.canvas_context);

         // ボールの移動先
         let moved_ball_x = self.ball.x.saturating_add(self.ball.dx);
         let moved_ball_y = self.ball.y.saturating_add(self.ball.dy);

         // ボールと左右の壁の衝突
         if moved_ball_x > self.canvas_width - self.ball.radius || moved_ball_x < self.ball.radius {
             self.ball.dx = -self.ball.dx;
         }

         // ボールと上下の壁の衝突
         if moved_ball_y > self.canvas_height - self.ball.radius || moved_ball_y < self.ball.radius {
             self.ball.dy = -self.ball.dy;
         }

+        // パドルの左右移動
+        if self.user_input.keyboard_right == true {
+            self.paddle.x = std::cmp::min(
+                self.paddle.x.saturating_add(7),
+                self.canvas_width.saturating_sub(self.paddle.width),
+            );
+        } else if self.user_input.keyboard_left == true {
+            self.paddle.x = std::cmp::max(self.paddle.x.saturating_sub(7), 0);
+        }
+
         // ボールの移動
         self.ball.x = self.ball.x.saturating_add(self.ball.dx);
         self.ball.y = self.ball.y.saturating_add(self.ball.dy);
     }

これでバドルの操作はできました。

第 5 章 ゲームオーバー

第 5 章の元の内容はこちらになります ↓

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Game_over

画面の下部にボールが当たった場合、ゲームオーバーとします。元のコードではゲームオーバーになった時にclearInterval()でゲームループを止めています。ですので、まずはstop_game_loop()という関数をimpl Game内に定義します。

Rust ではweb_sys::Windowclear_interval_with_handle()を使います。

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Window.html#method.clear_interval_with_handle

src/lib.rs(の一部)
 impl Game {

     ... (省略) ...

+    // set_intervalを止める
+    fn stop_game_loop(&mut self) {
+        if self.game_loop_interval_handle.is_some() {
+            let handle = self.game_loop_interval_handle.unwrap();
+
+            let window = web_sys::window().unwrap();
+            window.clear_interval_with_handle(handle);
+
+            self.game_loop_interval_handle = None;
+        }
+    }

     ... (省略) ...

clear_interval_with_handle()にはGame構造体のgame_loop_interval_handle変数(setInterval の返り値のハンドル)を渡します。ここではgame_loop_interval_handle変数がNoneではないなら setInterval が実行中と判断します。

元の JavaScript のコードはこのようになっていました。

4 辺全てでボールを弾ませるのではなく、 3 辺、すなわち上端と左右のみで跳ね返るようにしましょう。底を打ったときゲームは終わりになります。 2 番目の if 節を編集して、ボールがキャンバスの下端で衝突したときにゲームオーバー状態が発動する if else 節にしましょう。ここでは簡単に、アラートメッセージを表示して、ページの再読み込みによりゲームを再開するだけにしましょう。

(中略)

JavaScript
if (y + dy < ballRadius) {
  dy = -dy;
} else if (y + dy > canvas.height - ballRadius) {
  alert("GAME OVER");
  document.location.reload();
  clearInterval(interval); // Needed for Chrome to end game
}

Rust では下記のようにしました。

src/lib.rs(の一部)
     fn game_loop(&mut self) {

         ... (省略) ...

         // ボールと左右の壁の衝突
         if moved_ball_x > self.canvas_width - self.ball.radius || moved_ball_x < self.ball.radius {
             self.ball.dx = -self.ball.dx;
         }

         // ボールと上下の壁の衝突
-        if moved_ball_y > self.canvas_height - self.ball.radius || moved_ball_y < self.ball.radius {
-            self.ball.dy = -self.ball.dy;
-        }
+        if moved_ball_y < self.ball.radius {
+            self.ball.dy = -self.ball.dy;
+        } else if moved_ball_y > self.canvas_height - self.ball.radius {
+            // ボールが画面下部に当たった場合はゲームオーバー
+            let window = web_sys::window().unwrap();
+            let document = window.document().unwrap();
+
+            self.stop_game_loop();
+            window.alert_with_message("GAME OVER").unwrap();
+            document.location().unwrap().reload().unwrap();
+        }

         ... (省略) ...

JavaScript のalert()の代わりにweb_sys::Windowalert_with_message()を使っています。

『パドルをボールに当てる』

先程追加したばかりのゲームオーバー時の処理ですが、ボールがパドルに当たった場合は、ボールを反射し、ゲームオーバーにならないようにしています。

Rust ではこのように、ゲームオーバー用に追加したelse if {}の中をこの用に変更しました。

src/lib.rs(の一部)
     fn game_loop(&mut self) {

         ... (省略) ...

         // ボールと上下の壁の衝突
         if moved_ball_y < self.ball.radius {
             self.ball.dy = -self.ball.dy;
         } else if moved_ball_y > self.canvas_height - self.ball.radius {
-            // ボールが画面下部に当たった場合はゲームオーバー
-            let window = web_sys::window().unwrap();
-            let document = window.document().unwrap();
-
-            self.stop_game_loop();
-            window.alert_with_message("GAME OVER").unwrap();
-            document.location().unwrap().reload().unwrap();
+            // ボールがパドルに当たった場合は反射
+            if self.ball.x > self.paddle.x && self.ball.x < self.paddle.x + self.paddle.width {
+                self.ball.dy = -self.ball.dy;
+            } else {
+                // ボールが画面下部に当たった場合はゲームオーバー
+                let window = web_sys::window().unwrap();
+                let document = window.document().unwrap();
+
+                self.stop_game_loop();
+                window.alert_with_message("GAME OVER").unwrap();
+                document.location().unwrap().reload().unwrap();
+            }
         }

         ... (省略) ...

第 6 章 ブロックのかたまりを作る

第 6 章の元の内容はこちらになります ↓

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Build_the_brick_field

ブロックを作ります。ここではBrickBricks構造体を作り、Brick構造体の中で座標や幅と高さ、衝突時に画面に描画するかを判断する変数(ここではstatus変数)を保持します。

複数のブロックを管理するためにBricks構造体を作り、その中にVec<Brick>として保持します。

src/lib.rs(の一部)
+struct Brick {
+    x: i32,
+    y: i32,
+    width: i32,
+    height: i32,
+}
+
+impl Brick {
+    fn draw(&self, ctx: &web_sys::CanvasRenderingContext2d) {
+        ctx.begin_path();
+        ctx.rect(
+            self.x as f64,
+            self.y as f64,
+            self.width as f64,
+            self.height as f64,
+        );
+        ctx.set_fill_style(&JsValue::from_str("#0095DD"));
+        ctx.fill();
+        ctx.close_path();
+    }
+}
+
+struct Bricks {
+    inner: Vec<Brick>,
+}
+
+impl Bricks {
+    pub fn new() -> Self {
+        let mut bricks: Vec<Brick> = vec![];
+
+        let row = 3;
+        let column = 5;
+        let width = 75;
+        let height = 20;
+        let padding = 10;
+        let offset_top = 30;
+        let offset_left = 30;
+
+        for r in 0..row {
+            for c in 0..column {
+                let brick = Brick {
+                    x: c * (width + padding) + offset_left,
+                    y: r * (height + padding) + offset_top,
+                    width,
+                    height,
+                };
+                bricks.push(brick);
+            }
+        }
+
+        Self { inner: bricks }
+    }
+
+    fn draw(&self, ctx: &web_sys::CanvasRenderingContext2d) {
+        self.inner.iter().for_each(|b| b.draw(ctx));
+    }
+}
src/lib.rs(の一部)
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
     paddle: Paddle,
+    bricks: Bricks,
     user_input: UserInput,
     game_loop_closure: Option<Closure<dyn FnMut()>>, // ゲームループクローザ
     game_loop_interval_millis: u32,                  // ゲームループの間隔(ミリ秒)
     game_loop_interval_handle: Option<i32>,          // ゲームループのハンドル
 }

 impl Game {
     pub fn new() -> Self {

         ... (省略) ...

         let user_input = UserInput {
             keyboard_right: false,
             keyboard_left: false,
         };

+        let bricks = Bricks::new();

         Self {
             canvas_context,
             canvas_width,
             canvas_height,
             ball,
             paddle,
+            bricks,
             user_input,
             game_loop_closure: None,
             game_loop_interval_millis: 10,
             game_loop_interval_handle: None,
         }
     }
src/lib.rs(の一部)
     fn game_loop(&mut self) {
         // 画面を初期化
         self.canvas_context.clear_rect(
             0.0,
             0.0,
             self.canvas_width as f64,
             self.canvas_height as f64,
         );

         // ボールの描画
         self.ball.draw(&self.canvas_context);
         // パドルの描画
         self.paddle.draw(&self.canvas_context);
+        // ブロックの描画
+        self.bricks.draw(&self.canvas_context);

         ... (省略) ...
     }

ブロックが描画されますが、まだボールがブロックを突き抜けていく状態です。

続きは後編へ

続きの後編はは第 7 章〜第 10 章になります。

https://zenn.dev/craneduck/articles/bedbaccccaad7b

Discussion