(後編)MDNブロック崩しをJSからRustに移植してWebAssemblyに入門
趣味の Rust です。MDN ブロック崩しを JavaScript から Rust に移植して WebAssembly に入門したときのメモです。
この文章は前編、後編、中編の 3 つにわけて投稿する予定です。今回は後編です。
開発環境やビルド方法については前編をご覧ください。
第 7 章 衝突検出
第 7 章の元の内容はこちらになります ↓
衝突検出です。元の JavaScript コードでは下記のようになっていました。
もしボールの中央がブロックの 1 つの座標の内部だったらボールの向きを変えます。ボールの中央がブロックの内部にあるためには次の 4 つの命題が全て真でなければなりません。
- ボールの x 座標がブロックの x 座標より大きい
- ボールの x 座標がブロックの x 座標とその幅の和より小さい
- ボールの y 座標がブロックの y 座標より大きい
- ボールの y 座標がブロックの y 座標とその高さの和より小さい
コードに書き下ろしてみましょう。
JavaScriptfunction 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
)
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()
関数を実行します。
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
になるようにします。
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));
}
}
衝突検出処理も変更します。ブロックのstatus
がfalse
ならば破壊済みなので衝突判定をスキップします。
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 章の元の内容はこちらになります ↓
スコア記録し描画するようにします。Game
構造体にscore
変数を追加します。
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 と同じような変更になります。
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();
+ }
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();
... (省略) ...
ボールとブロックの衝突検出時、スコアを加算するようにします。
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 では…
JavaScriptfunction 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
を抜けた後に判定するようにしています。(もっと上手いやり方があるのかもしれませんが……)
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 章の元の内容はこちらになります ↓
マウスでもパドルを動かせるようにします。元の記事ではdocument.addEventListener()
を使用し、mousemove
イベントを監視しています。
Rust では、第 4 章のキーボード操作のコードと同じようにしたいと思います。web_sys::Document
のset_onmousemove()
を使います。
まずはUserInput
構造体にマウスの X 座標のを保持するmouse_x
変数を追加します。パドルの移動は横方向のみなのでmouse_y
変数は使いませんが、一応追加しておきます。set_mouse_position()
でmouse_x
とmouse_y
に値をセットするようにします。
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;
+ }
}
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()
を実行します。
// メソッドではなく、関連関数なので 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 値からパドルの幅の半分を引いた値に設定され、実際の移動はパドルの中央に対して相対的に行われることになります。
ゲームループ内に追加します。
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 章の元の内容はこちらになります ↓
いよいよ最終章です。
プレイヤーにライフを与える
何回か失敗してもやり直せるように、ゲームをライフ制にしています。Game
構造体にlives
変数を追加し、lives
が 0 になったらゲームオーバーとします。元の記事に合わせて初期値は 3 とします。
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()
関数を追加しライフを描画します。
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();
+ }
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 になった場合はゲームオーバーとし、それ以外はボールを初期位置に戻してゲーム再開とします。
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::Window
の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()
を呼び出すか呼び出さないかを判断すれば、ゲームループを停止することができます。
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 ミリ秒の指定を削除します。
#[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()
はもう不要ですので削除します。
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 日時点のコミットです)
以上です。ありがとうございました。
Discussion