Rust初心者向け:Macroquadで作るブロック崩しゲームにUIやサウンドを追加してみた実践例

Published on: | Last updated:

「ブロック崩しって、ボール跳ねてブロック割れたら…もう完成でしょ」ってやつ。

それ、たぶん一番でかい誤解。ほんとに。

遊べる形にするのはそこからで、UIとスコアと残機とレベルと音…この辺が無いと、触った瞬間に指が帰る。静かに帰る。

結論:Rust×Macroquadのブロック崩しは、GameStateGameMode・スコア・残機・レベル・サウンドを一括管理すると、UIとリスタートまで破綻せずに実装できる。

  • 状態は Paused/Playing/GameOver/Win の4つで固定すると迷子になりにくい
  • reset_game()は「統計も全部リセット」か「残機だけ減らして再配置」かを分ける
  • レベルは Vec Vec> で持つと読み込みが軽い
  • 音は play_sound/stop_sound をモード遷移に紐づける
  • UIは measure_text で横並び位置を計算してズレを潰す
全体像:ゲームの状態がどう流れて、どこで音とUIが刺さるか
全体像:ゲームの状態がどう流れて、どこで音とUIが刺さるか

ゲームが壊れる場所はだいたい状態管理

Macroquadで規模が増えると、main.rsにロジックが散って「どの状態で何を描いて、何を更新してるの?」が崩れる。

だからgame_state.rsに寄せて、GameState一個にまとめるのが筋がいい。

中心の箱:原文のGameStateはこういう感じで、モード・スコア・レベル・残機・レベル生成関数・サウンド3種・ゲーム内オブジェクト全部を抱える。

pub struct GameState {
    pub mode: GameMode,
    pub total_score: u32,
    pub level: u32,
    pub max_level: u32,
    pub lives: u32,
    pub levels: Vec Vec>,
    bg_music: Sound,
    ball_sound: Sound,
    brick_sound: Sound,
    bricks: Vec,
    paddle: Paddle,
    ball: Ball,
}

ここ、冷静に見ると「公開する必要ある?」って項目が混ざってるのも分かる。

でもゲームって、UIに見せたい値(スコアとか残機)と、内部だけで回したい値(bricksとかsound)が同居するから、まずはこうなる。自然。

短く言うと、見せるものはpub、壊れるものは内側

ブロック崩しは「物理」より「状態遷移」がゲームっぽさを作る。音とUIは、その遷移に寄生してる。

Spaceキーの扱いが地味に難所

Spaceは、PausedPlayingの切替と、Win/GameOver時のリセットを兼任させると、入力処理がスッと一本になる。

原文はその方針で、handle_keys()にまとめてる。

ここが分岐の芯:「終端状態ならreset」「それ以外なら一時停止トグル」。これだけ覚えておくと、後で手が止まらない。

fn handle_keys(&mut self, delta: f32) {
    if is_key_pressed(KeyCode::Space) {
        if self.mode == GameMode::Win || self.mode == GameMode::GameOver {
            self.reset_game();
        } else {
            self.mode = if self.mode == GameMode::Playing {
                play_sound(&self.bg_music, PlaySoundParams { looped: true, volume: 1.0 });
                GameMode::Paused
            } else {
                stop_sound(&self.bg_music);
                GameMode::Playing
            };
        }
    } else if self.mode == GameMode::Playing {
        if is_key_down(KeyCode::Left)  { self.paddle.update(-PADDLE_SPEED * delta); }
        if is_key_down(KeyCode::Right) { self.paddle.update( PADDLE_SPEED * delta); }

        let is_ok = self.ball.update(delta, &self.paddle.rect, &self.ball_sound);
        if !is_ok {
            if self.lives == 0 {
                self.mode = GameMode::GameOver;
            } else {
                self.lives = self.lives.saturating_sub(1);
                self.ball.reset();
                self.paddle.reset_position();
            }
        }
    }
}

「残機0の判定、lives == 0で先に落としてるの、ちょっと怖くない?」って思う人いるかも。

そこはsaturating_subで下限を守ってるから、爆発はしない。しないけど。

しないけど、残機が何回でGameOverになるかは、仕様として文章で決めといた方が後で揉めない。

地味な揉め方するから。ほんとに。

核心:Spaceキーとモード遷移、音の再生停止が同じ線上にある
核心:Spaceキーとモード遷移、音の再生停止が同じ線上にある

リセットは2種類あると考えると気が楽

reset_game()は、ボールとパドル位置、GameMode、レベル、残機、スコア、ブロック配置を初期化する。

つまり「全部やり直し」のリセットを一箇所に閉じ込めてる。

全部初期化のやつ:

fn reset_game(&mut self) {
    self.ball.reset();
    self.paddle.reset_position();
    self.mode = GameMode::Playing;
    self.level = 1;
    self.lives = 3;
    self.total_score = 0;
    self.bricks = self.get_level_bricks();
}

で、もう一つのリセットがある。

落下したときのやつ。残機だけ減らして、ボールとパドルだけ戻す。

この二段構え、気持ちいい。

経験則:「全部リセット」と「配置だけリセット」が混ざると、デバッグが幽霊になる。出る。

突然スコアが巻き戻ったり、レベルだけ進んでたり。

こわ。

レベル設計は関数配列にすると増やしやすい

Macroquadのブロック崩しでレベルを増やすなら、levels: Vec Vec>で「レベル生成関数」を並べるのが素直。

get_level_bricks()が今のlevelに応じて呼ぶだけで済む。

ロードの芯:

fn get_level_bricks(&self) -> Vec {
    self.levels[self.level as usize - 1]()
}

ここ、level as usize - 1が刺さるポイントで、境界ミスると即死する。

だから原文はmax_levelを持って、クリア時にif self.level > self.max_levelでWinに落とす。

外科医みたいに、切る場所を先に決めてる感じ。

進行とスコア加算:ブロックに当たったらlivesを減らし、死んだブロックはretainで消しつつ、その瞬間にtotal_scoreへ加算する。

if hit_brick {
    self.ball.vel_y = -self.ball.vel_y;
    self.bricks.retain(|brick| {
        let is_alive = brick.lives > 0;
        if !is_alive {
            self.total_score += brick.score;
        }
        is_alive
    });
}

この「消す処理と加点を同じ場所でやる」やり方、バグりにくい。

加点だけ別にすると、二重加算の怪談が始まる。

…始まるんだよね。

レベルの中身:原文はlevel.rsで2レベル。レベル1はブロック1個だけで、次レベル読み込みのクラッシュ検出用。

こういう「テスト用レベル」作るの、地味に賢い。

派手さゼロ。効果は高い。

比較:レベル1(クラッシュ検出用)とレベル2(複数耐久ブロック)
比較:レベル1(クラッシュ検出用)とレベル2(複数耐久ブロック)

音は気分の問題じゃなくて状態の問題

Macroquadの音は、set_pc_assets_folderでアセット基準パスを決めて、audio::load_soundで読み込む。

この2つを外すと、まず鳴らない。静寂。

読み込み:

set_pc_assets_folder("src/assets");
let bg_music   = audio::load_sound("sounds/game_music.wav").await.unwrap();
let ball_sound = audio::load_sound("sounds/ball_sound.wav").await.unwrap();
let brick_sound= audio::load_sound("sounds/brick_sound.wav").await.unwrap();

で、BGMはループ。

play_sound(
    &bg_music,
    PlaySoundParams { looped: true, volume: 1.0 },
);

当たり音は単発。

play_sound(
    &self.brick_sound,
    PlaySoundParams { looped: false, volume: 1.0 },
);

ここで一個だけ:音のON/OFFを「プレイ中かどうか」じゃなくて「モード遷移」に紐づけるのがコツ。

原文も、Pausedに入るときにplay_soundPlayingに戻るときにstop_soundしてる。

一見逆っぽいけど、作者の中で「Space押して開始待ち=音鳴らす」って演出なのかもしれない。

ここは好み出る。

ただ、どっちでもいいから一貫させる。これ。

あ、音といえば。

PCだと音量1.0でも、環境によって「うるさっ」ってなる時ある。

夜中にやるとね。あるある。😅

UIはmeasure_textがないとズレる、静かに

Macroquadでスコア・レベル・残機を横一列に並べるなら、measure_textで前のテキスト幅を測って次のX座標に足す。

固定値で置くと、数字が増えた瞬間に破綻する。

トップバー:

pub fn draw_top_bar(lives: u32, level: u32, total_score: u32) {
    let lives_text = format!("Lives: {}", lives);
    let level_text = format!("Level: {}", level);
    let level_size = measure_text(&level_text, None, 20, 1.0);
    let lives_size = measure_text(&lives_text, None, 20, 1.0);

    draw_line(0.0, UI_HEIGHT, SCREEN_WIDTH, UI_HEIGHT, 1.0, WHITE);
    draw_text(&lives_text, 20.0, UI_HEIGHT - 15.0, 20.0, WHITE);
    draw_text(&level_text, 20.0 + lives_size.width + 10.0, UI_HEIGHT - 15.0, 20.0, WHITE);

    draw_text(
        &format!("Score: {}", total_score),
        20.0 + level_size.width + lives_size.width + 20.0,
        UI_HEIGHT - 15.0,
        20.0,
        WHITE,
    );
}

こういうUIって、完成した後に一番見られる場所なのに、実装中は最後まで放置されがち。

で、最後に入れて、ズレて、直して、またズレて。

地獄。

数字は増える。テキストは伸びる。固定座標は、いつも負ける。

ゲーム内メッセージ:Pausedなら「Press Space to start」、GameOverなら「Game Over press Space to restart」、Winなら「You won! press Space to restart」。

これもmeasure_textで中央寄せ。

地味だけど、見た目が一段マシになる。

終盤まとめ:トップバーと中央メッセージの描画関係
終盤まとめ:トップバーと中央メッセージの描画関係

内行人の避雷ガイド 日本でRustとMacroquadを触るとき

ここ、技術というより「詰まり方」が地域っぽく出る。変な話だけど。

通路:Rustの日本語情報は、公式のRust Book日本語版と、Zenn/Qiita、それからGitHub Issuesの流れが強い。

Macroquadは日本語記事が厚いわけじゃないから、結局英語READMEとサンプルコードに戻ることが多い。

戻るのが早い。ほんと。

避雷その1:Windows環境で音が鳴らない・アセットが見つからない系は、まずset_pc_assets_folder("src/assets")と実行ディレクトリのズレを疑う。

「コードは合ってるのに無音」って時、だいたいパス。だいたい。😑

避雷その2:音ファイルはWAVで揃えると手戻りが少ない。MP3でいけるでしょ?って軽く踏むと、環境差で静かに死ぬことがある。

静かに、が怖い。

避雷その3:フォントや文字幅の違いでUIがズレるの、地味に「日本語を混ぜた瞬間」起きやすい。

今回のUI文字列は英語だけど、あとで「残機」とか入れたくなるじゃん?

その時に固定座標だと、泣く。

価格感の話:Macroquad自体は無料。

でも音素材をちゃんとしようとすると、BOOTHやDLsiteの効果音素材(数百円〜数千円くらい)に手が伸びる人が多い印象。

フリー素材でもいけるけど、ライセンス確認をサボると後で面倒が増える。増えるんだよね。

ツール定錨:音の波形編集はAudacity、フォーマット変換はffmpeg、あとはCargoで普通に回す。

この3つがあると、素材周りの詰まりが減る。

あ、そうだ。

日本の夏、湿度が高いから…って関係ないか。

でも深夜の作業で窓開けると、虫が入る。

あれはゲームオーバー。🫠

最後に そのゲームいまどの状態?

核心:ブロック崩しの完成度は、ボールの跳ね方より、GameModeGameStateが破綻せず回るかで決まる。

で、そこに音とUIがちゃんと乗ると、急に「ゲーム」になる。

いま作ってるやつ、どこで止まってる?

レベル追加で崩れた? UIでズレた? それとも「Space押したら音が二重に鳴る」みたいな怪談?

状況だけ、ぽつっと教えて。そこから話が進むから。

Related to this topic:

Comments