(中編)MDNブロック崩しをJSからRustに移植してWebAssemblyに入門
趣味の Rust です。MDN ブロック崩しを JavaScript から Rust に移植して WebAssembly に入門したときのメモです。
この文章は前編、後編、中編の 3 つにわけて投稿する予定です。今回は中編です。
開発環境やビルド方法については前編をご覧ください。
第 4 章 パドルとキーボード操作
第 4 章の元の内容はこちらになります ↓
パドルの操作です。まずはパドルを画面上に描画します。パドルもボールと同じ様にPaddle
構造体を作り、その中に座標などを保持します。
元の記事ではパドルの X 座標は Canvas の幅からパドルの幅を引いたものを半分にした(canvas.width - paddleWidth) / 2
としていますので、ここでも同様にします。
+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()
関数にパドル初期化を追加します。
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,
}
}
... (省略) ...
ゲームループにパドル描画を追加します。
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);
... (省略) ...
ソースコードを変更するたびにビルドし直してください。ビルド方法は前編をご覧ください。
『パドルを操作できるようにする』
キーボードの左右キーでパドルを操作できるようにします。元の記事を引用すると…
押されているボタンはこのとおり、論理値として定義、初期化できます。このコードをどこか他の変数の近くに追記してください。
JavaScriptlet rightPressed = false; let leftPressed = false;
最初は制御ボタンは押されていないため、どちらにおいても既定値は false です。ボタンが押されたのを検知するため、 2 つのイベントリスナーを設定します。 JavaScript の最後にある setInterval() の行のちょうど上に次のコードを追記してください。
JavaScriptdocument.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()
を使ってみます。
ここではUserInput
構造体を作り、これもGame
構造体に持たせるようにします。
UserInput
の中身は左右キーの状態を保持するkeyboard_right
とkeyboard_left
変数のみです。new()
関数にuser_input
として初期化します。
+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()
に登録される関数はこの様になっています。
JavaScriptfunction 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 章でも引用したこちらのページの内容を再び参考にさせていただきます。
impl Game {}
内にset_input_event()
関数を定義します。
仕組みは第 2 章で実装したset_game_loop_and_start()
と同じようなものですが、今回はクロージャを保持せずclosure.forget()
を使ってみます。これで関数のスコープを抜けてもクロージャが破棄されなくなります。
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()
関数内から実行します。
#[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 ピクセル移動する。これは今のところうまく動作しているが、どちらかのキーを長く押しているとパドルがキャンバスの端から消えてしまいます。これを改善し、パドルをキャンバスの枠内だけ移動させるには、次のようなコードに変更します。
JavaScriptif (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()
を使えばよさそうです。
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 章の元の内容はこちらになります ↓
画面の下部にボールが当たった場合、ゲームオーバーとします。元のコードではゲームオーバーになった時にclearInterval()
でゲームループを止めています。ですので、まずはstop_game_loop()
という関数をimpl Game
内に定義します。
Rust ではweb_sys::Window
のclear_interval_with_handle()
を使います。
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 節にしましょう。ここでは簡単に、アラートメッセージを表示して、ページの再読み込みによりゲームを再開するだけにしましょう。
(中略)
JavaScriptif (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 では下記のようにしました。
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::Window
のalert_with_message()
を使っています。
『パドルをボールに当てる』
先程追加したばかりのゲームオーバー時の処理ですが、ボールがパドルに当たった場合は、ボールを反射し、ゲームオーバーにならないようにしています。
Rust ではこのように、ゲームオーバー用に追加したelse if {}
の中をこの用に変更しました。
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 章の元の内容はこちらになります ↓
ブロックを作ります。ここではBrick
とBricks
構造体を作り、Brick
構造体の中で座標や幅と高さ、衝突時に画面に描画するかを判断する変数(ここではstatus
変数)を保持します。
複数のブロックを管理するためにBricks
構造体を作り、その中にVec<Brick>
として保持します。
+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));
+ }
+}
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,
}
}
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 章になります。
Discussion