🧱

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

2023/11/06に公開

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

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

第 7 章 衝突検出

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

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

衝突検出です。元の JavaScript コードでは下記のようになっていました。

もしボールの中央がブロックの 1 つの座標の内部だったらボールの向きを変えます。ボールの中央がブロックの内部にあるためには次の 4 つの命題が全て真でなければなりません。

  • ボールの x 座標がブロックの x 座標より大きい
  • ボールの x 座標がブロックの x 座標とその幅の和より小さい
  • ボールの y 座標がブロックの y 座標より大きい
  • ボールの y 座標がブロックの y 座標とその高さの和より小さい

コードに書き下ろしてみましょう。

JavaScript
function collisionDetection() {
  for (let c = 0; c < brickColumnCount; c++) {
    for (let r = 0; r < brickRowCount; r++) {
      const b = bricks[c][r];
      if (
        x > b.x &&
        x < b.x + brickWidth &&
        y > b.y &&
        y < b.y + brickHeight
      ) {
        dy = -dy;
      }
    }
  }
}

これをそのままベタに Rust に置き換えてみます。impl Game内にcollision_detection()関数を実装します。ボールがブロックに衝突したら、ボールの Y 軸方向の軌道を反転しています。(self.ball.dy = -self.ball.dy)

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

    ... (省略) ...

+    fn collision_detection(&mut self) {
+        for brick in &mut self.bricks.inner {
+            if self.ball.x > brick.x
+                && self.ball.x < brick.x + brick.width
+                && self.ball.y > brick.y
+                && self.ball.y < brick.y + brick.height
+            {
+                // ボールがブロックに当たったらボールを反射
+                self.ball.dy = -self.ball.dy;
+            }
+        }
+    }

    ... (省略) ...

ボールの縦軸の進行方向dyを反転しています。
ゲームループ内からcollision_detection()関数を実行します。

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);

+        // ブロックとの衝突
+        self.collision_detection();

          ... (省略) ...

『ブロックが当たった後に消えるようにする』

ボールがブロックに衝突しても、ブロックは画面上に残り続けています。このままでは永遠にゲームが進行しないので、画面描画と衝突処理から衝突済みのブロックを除外します。元の記事では各ブロックにstatusという真偽値フラグを持たせ、falseならブロックは破壊済みとなり描画も衝突判定もしない、という変更を行っています。

Rust ではこのようにしました。Brick構造体にstatus変数を追加し、描画時、if self.status {}で判断します。Bricks::new()ではstatusの初期値がtrueになるようにします。

src/lib.rs(の一部)
 struct Brick {
     x: i32,
     y: i32,
     width: i32,
     height: i32,
+    status: bool, // falseならば描画しない
 }

 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();
+        // falseならば描画しない
+        if self.status {
+            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,
+                    status: true,
                 };

                 bricks.push(brick);
             }
         }

         Self { inner: bricks }
     }

     fn draw(&self, ctx: &web_sys::CanvasRenderingContext2d) {
         self.inner.iter().for_each(|b| b.draw(ctx));
     }
 }

衝突検出処理も変更します。ブロックのstatusfalseならば破壊済みなので衝突判定をスキップします。

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

     ... (省略) ...

     fn collision_detection(&mut self) {
         for brick in &mut self.bricks.inner {
-            if self.ball.x > brick.x
+            if brick.status
+                && self.ball.x > brick.x
                 && self.ball.x < brick.x + brick.width
                 && self.ball.y > brick.y
                 && self.ball.y < brick.y + brick.height
             {
                 // ボールがブロックに当たったらボールを反射
                 self.ball.dy = -self.ball.dy;

+                // ボールが当たったブロックを消すためにstatusをfalseにする
+                brick.status = false;
             }
         }
     }

     ... (省略) ...

第 8 章 スコアと勝ち負けを記録する

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

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

スコア記録し描画するようにします。Game構造体にscore変数を追加します。

src/lib.rs(の一部)
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
     paddle: Paddle,
     bricks: Bricks,
+    score: u16,
     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 {

         ... (省略) ...

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

impl Game内にdraw_score()関数を実装し、ゲームループから実行するようにします。こちらもほぼ元の記事の JavaScript と同じような変更になります。

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

     ... (省略) ...

+    // スコア描画
+    fn draw_score(&self) {
+        self.canvas_context.set_font("16px Arial");
+        self.canvas_context
+            .set_fill_style(&JsValue::from_str("#0095DD"));
+        self.canvas_context
+            .fill_text(&format!("Score: {}", self.score), 8.0, 20.0)
+            .unwrap();
+    }
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);
+        // スコアの描画
+        self.draw_score();

         // ブロックとの衝突
         self.collision_detection();

         ... (省略) ...

ボールとブロックの衝突検出時、スコアを加算するようにします。

src/lib.rs(の一部)
     fn collision_detection(&mut self) {
         for brick in &mut self.bricks.inner {
             if brick.status
                 && self.ball.x > brick.x
                 && self.ball.x < brick.x + brick.width
                 && self.ball.y > brick.y
                 && self.ball.y < brick.y + brick.height
             {
                 // ボールがブロックに当たったらボールを反射
                 self.ball.dy = -self.ball.dy;

                 // ボールが当たったブロックを消すためにstatusをfalseにする
                 brick.status = false;

+                // スコアを加算
+                self.score += 1;
             }
         }
     }

『全てのブロックが崩されたときに勝利を伝えるメッセージを表示する』

いわゆるゲームクリアを実装します。元の記事の JavaScript では…

JavaScript
function collisionDetection() {
  for (let c = 0; c < brickColumnCount; c++) {
    for (let r = 0; r < brickRowCount; r++) {
      const b = bricks[c][r];
      if (b.status === 1) {
        if (
          x > b.x &&
          x < b.x + brickWidth &&
          y > b.y &&
          y < b.y + brickHeight
        ) {
          dy = -dy;
          b.status = 0;
          score++;
          if (score === brickRowCount * brickColumnCount) {
            alert("YOU WIN, CONGRATULATIONS!");
            document.location.reload();
            clearInterval(interval); // Needed for Chrome to end game
          }
        }
      }
    }
  }
}

このようにスコア加算の直後に。スコアとブロックの数が同じならばゲームクリアとしています。

私が書いた Rust のコードでは、所有権の都合上、ブロックとの衝突判定のforを抜けた後に判定するようにしています。(もっと上手いやり方があるのかもしれませんが……)

src/lib.rs(の一部)
     fn collision_detection(&mut self) {
         for brick in &mut self.bricks.inner {
             if brick.status
                 && self.ball.x > brick.x
                 && self.ball.x < brick.x + brick.width
                 && self.ball.y > brick.y
                 && self.ball.y < brick.y + brick.height
             {
                 // ボールがブロックに当たったらボールを反射
                 self.ball.dy = -self.ball.dy;

                 // ボールが当たったブロックを消すためにstatusをfalseにする
                 brick.status = false;

                 // スコアを加算
                 self.score += 1;
             }
         }

+        // スコアがブロックの数と同じになったらゲームクリア
+        if self.score == self.bricks.inner.len() as u16 {
+            let window = web_sys::window().unwrap();
+            let document = window.document().unwrap();
+
+            self.stop_game_loop();
+            window
+                .alert_with_message("YOU WIN, CONGRATULATIONS!")
+                .unwrap();
+            document.location().unwrap().reload().unwrap();
+        }
     }

第 9 章 マウス操作

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

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

マウスでもパドルを動かせるようにします。元の記事ではdocument.addEventListener()を使用し、mousemoveイベントを監視しています。

Rust では、第 4 章のキーボード操作のコードと同じようにしたいと思います。web_sys::Documentset_onmousemove()を使います。

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

まずはUserInput構造体にマウスの X 座標のを保持するmouse_x変数を追加します。パドルの移動は横方向のみなのでmouse_y変数は使いませんが、一応追加しておきます。set_mouse_position()mouse_xmouse_yに値をセットするようにします。

src/lib.rs(の一部)
 struct UserInput {
     keyboard_right: bool,
     keyboard_left: bool,
+    mouse_x: i32,
+    mouse_y: i32, // パドルの移動は横方向のみなので、今回は使わない
 }

 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;
     }
+    fn set_mouse_position(&mut self, x: i32, y: i32) {
+        self.mouse_x = x;
+        self.mouse_y = y;
+    }
 }
src/lib.rs(の一部)
 impl Game {
     pub fn new() -> Self {

         ... (省略) ...

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

         ... (省略) ...

     }

set_input_event()関数にマウス移動を監視するset_onmousemove()を追加します。クロージャの中でuser_inputを変更するように、先程追加したset_mouse_position()を実行します。

src/lib.rs(の一部)
     // メソッドではなく、関連関数なので Game::set_input_event() として呼び出す
     // 引数には自分自身を Rc<RefCell<>> で包んだものを渡す
     pub fn set_input_event(game: Rc<RefCell<Self>>) {
         let game_key_down = game.clone();
         let game_key_up = game.clone();
+        let game_mouse_move = 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();

+        let closure = Closure::new(Box::new(move |event: web_sys::MouseEvent| {
+            let mut g = game_mouse_move.borrow_mut();
+            g.user_input
+                .set_mouse_position(event.client_x(), event.client_y());
+        }) as Box<dyn FnMut(_)>);
+
+        document.set_onmousemove(Some(&closure.as_ref().unchecked_ref()));
+        // forget()するとRust側はdropされるが、into_js_value()されてブラウザ側に残る
+        closure.forget();
     }
}

パドルをマウスに追従させます。こちらは元の JavaScript とほぼ同じようなコードになりました。

この値は、ビューポート内のマウスの水平位置 (e.clientX) からキャンバスの左端とビューポートの左端間の距離 (canvas.offsetLeft) を引いたもので、実質的にこれはキャンバス左端からマウスポインターまでの距離と等しくなっています。ポインタの相対 X 位置が 0 より大きく、キャンバスの幅よりも小さい場合、ポインタはキャンバスの境界内にあり、paddleX 位置(パドルの左端に固定)は relativeX 値からパドルの幅の半分を引いた値に設定され、実際の移動はパドルの中央に対して相対的に行われることになります。

ゲームループ内に追加します。

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

         ... (省略) ...

+        // パドルをマウスに追従させる
+        let canvas = self.canvas_context.canvas().unwrap();
+        let relative_x = self.user_input.mouse_x.saturating_sub(canvas.offset_left());
+        if relative_x > 0 && relative_x < self.canvas_width {
+            self.paddle.x = relative_x.saturating_sub(self.paddle.width / 2);
+        }

         // ボールの移動
         self.ball.x = self.ball.x.saturating_add(self.ball.dx);
         self.ball.y = self.ball.y.saturating_add(self.ball.dy);
     }

第 10 章 仕上げ

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

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

いよいよ最終章です。

プレイヤーにライフを与える

何回か失敗してもやり直せるように、ゲームをライフ制にしています。Game構造体にlives変数を追加し、livesが 0 になったらゲームオーバーとします。元の記事に合わせて初期値は 3 とします。

src/lib.rs(の一部)
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
     paddle: Paddle,
     bricks: Bricks,
     score: u16,
+    lives: u16,
     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 {

         ... (省略) ...

         Self {
             canvas_context,
             canvas_width,
             canvas_height,
             ball,
             paddle,
             bricks,
             score: 0,
+            lives: 3,
             user_input,
             game_loop_closure: None,
             game_loop_interval_millis: 10,
             game_loop_interval_handle: None,
         }
     }

impl Game内にdraw_lives()関数を追加しライフを描画します。

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

     ... (省略) ...

+    // ライフ描画
+    fn draw_lives(&self) {
+        self.canvas_context.set_font("16px Arial");
+        self.canvas_context
+            .set_fill_style(&JsValue::from_str("#0095DD"));
+        self.canvas_context
+            .fill_text(
+                &format!("Lives: {}", self.lives),
+                self.canvas_width as f64 - 65.0,
+                20.0,
+            )
+            .unwrap();
+    }
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);
         // スコア描画
         self.draw_score();
+        // ライフの描画
+        self.draw_lives();

         // ブロックとの衝突
         self.collision_detection();

         ... (省略) ...

元の記事と同じです。ゲームループ内の元々ゲームオーバー時の処理があった箇所を変更します。ライフが 0 になった場合はゲームオーバーとし、それ以外はボールを初期位置に戻してゲーム再開とします。

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 {
             // ボールがパドルに当たった場合は反射
             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();
+                // ボールが画面下部に当たった場合はライフを減らす
+                self.lives = self.lives.saturating_sub(1);
+
+                // ライフが0になったらゲームオーバー
+                if self.lives == 0 {
+                    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();
+                } else {
+                    // ライフがまだ残っている場合はボールとパドルを初期位置に戻す
+                    self.ball.x = self.canvas_width / 2;
+                    self.ball.y = self.canvas_height - 30;
+                    self.ball.dx = 2;
+                    self.ball.dy = -2;
+                    self.paddle.x = (self.canvas_width - self.paddle.width) / 2;
+                }
             }
         }

         ... (省略) ...

『requestAnimationFrame()で描画を改善する』

これが最後の課題となります。

setInterval()requestAnimationFrame()へ置き換えます。requestAnimationFrame()はディスプレイのリフレッシュレートに合わせて 1 回だけ実行されます。連続して実行したい場合はrequestAnimationFrame()をゲームループ内で何度も呼び出す必要があります。

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

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

第 2 章で実装したset_game_loop_and_start()set_game_loop()start_game_loop()を変更します。実行間隔のミリ秒を指定するmillisはもう不要ですので削除し、set_interval_with_callback_and_timeout_and_arguments_0()request_animation_frame()に置き換えます。

stop_game_loop()も不要ですのでまるごと削除します。ゲームループ中request_animation_frame()を呼び出すか呼び出さないかを判断すれば、ゲームループを停止することができます。

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

     ... (省略) ...

     // メソッドではなく、関連関数なので Game::set_game_loop_and_start() として呼び出す
     // 引数には自分自身を Rc<RefCell<>> で包んだものを渡す
-    pub fn set_game_loop_and_start(millis: u32, game: Rc<RefCell<Self>>) {
+    pub fn set_game_loop_and_start(game: Rc<RefCell<Self>>) {
         let cloned_game = game.clone();
         let mut game_borrow = game.borrow_mut();

-        game_borrow.set_game_loop(millis, move || cloned_game.borrow_mut().game_loop());
+        game_borrow.set_game_loop(move || cloned_game.borrow_mut().game_loop());
         game_borrow.start_game_loop();
     }

-    fn set_game_loop<F: 'static>(&mut self, millis: u32, f: F)
+    fn set_game_loop<F: 'static>(&mut self, f: F)
     where
         F: FnMut(),
     {
         self.game_loop_closure = Some(Closure::new(f));
-        self.game_loop_interval_millis = millis;
     }

     fn start_game_loop(&mut self) {
         // クロージャの参照を取り出す
         let closure = self.game_loop_closure.as_ref().unwrap();
-        let millis = 0i32.saturating_add_unsigned(self.game_loop_interval_millis);
         let window = web_sys::window().unwrap();

-        let handle = window.set_interval_with_callback_and_timeout_and_arguments_0(
-            closure.as_ref().unchecked_ref(),
-            millis,
-        );
+        let handle = window.request_animation_frame(closure.as_ref().unchecked_ref());

         self.game_loop_interval_handle = Some(handle.unwrap());
     }

-    // 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;
-        }
-    }

     ... (省略) ...

run()関数から実行するset_game_loop_and_start()からも 10 ミリ秒の指定を削除します。

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_game_loop_and_start(game.clone());
     Game::set_input_event(game.clone());
 }

最後にゲームループの最後でstart_game_loop()を実行するようにします。これでrequest_animation_frame()が何度も実行され、ゲームループが画面のリフレッシュレートに合わせて連続実行されます。

game_loop_interval_millis変数とstop_game_loop()はもう不要ですので削除します。

src/lib.rs(の一部)
 struct Game {
     canvas_context: web_sys::CanvasRenderingContext2d,
     canvas_width: i32,
     canvas_height: i32,
     ball: Ball,
     paddle: Paddle,
     bricks: Bricks,
     score: u16,
     lives: u16,
     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 {

         ... (省略) ...

         Self {
             canvas_context,
             canvas_width,
             canvas_height,
             ball,
             paddle,
             bricks,
             score: 0,
             lives: 3,
             user_input,
             game_loop_closure: None,
-            game_loop_interval_millis: 10,
             game_loop_interval_handle: None,
         }
     }

     // ゲームループ
     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 {
             // ボールがパドルに当たった場合は反射
             if self.ball.x > self.paddle.x && self.ball.x < self.paddle.x + self.paddle.width {
                 self.ball.dy = -self.ball.dy;
             } else {
                 // ボールが画面下部に当たった場合はライフを減らす
                 self.lives = self.lives.saturating_sub(1);

                 // ライフが0になったらゲームオーバー
                 if self.lives == 0 {
                     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();
                 } else {
                     // ライフがまだ残っている場合はボールとパドルを初期位置に戻す
                     self.ball.x = self.canvas_width / 2;
                     self.ball.y = self.canvas_height - 30;
                     self.ball.dx = 2;
                     self.ball.dy = -2;
                     self.paddle.x = (self.canvas_width - self.paddle.width) / 2;
                 }
             }
         }

         ... (省略) ...

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

+        self.start_game_loop();
     }

     fn collision_detection(&mut self) {

         ... (省略) ...

         // スコアがブロックの数と同じになったらゲームクリア
         if self.score == self.bricks.inner.len() as u16 {
             let window = web_sys::window().unwrap();
             let document = window.document().unwrap();

-            self.stop_game_loop();
             window
                 .alert_with_message("YOU WIN, CONGRATULATIONS!")
                 .unwrap();
             document.location().unwrap().reload().unwrap();
         }
     }

おわりに

ここまでかなり試行錯誤しました。Rust 自体の難しさや、wasm_bindgen の関数から目的のものを見つけ出すまでに時間がかかったりと、悩みどころも多かったのですが、これで Rust + WebAssembley に入門したと言えるところまではできたと思います。

特に前編第 2 章のゲームループの実装が一番難しく感じました。Rust のクロージャ+所有権の難しさなのか、wasm_bindgen の Closure の難しさなのか……未だに理解しきれたと言えないところが今後の課題でしょうか。

実際にゲームが動き始めるとテンション上がります。

今回作成したコードの全行は GitHub リポジトリのこちらになります。(2023 年 11 月 06 日時点のコミットです)

https://github.com/craneduck/wasm-mdn-2d-breakout-game-rust/blob/f79472495b75cd8c75f2210dec5f8506ff10936a/src/lib.rs

以上です。ありがとうございました。

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

Discussion