「ブロック崩しって、ボール跳ねてブロック割れたら…もう完成でしょ」ってやつ。
それ、たぶん一番でかい誤解。ほんとに。
遊べる形にするのはそこからで、UIとスコアと残機とレベルと音…この辺が無いと、触った瞬間に指が帰る。静かに帰る。
結論:Rust×Macroquadのブロック崩しは、GameStateでGameMode・スコア・残機・レベル・サウンドを一括管理すると、UIとリスタートまで破綻せずに実装できる。
- 状態は
Paused/Playing/GameOver/Winの4つで固定すると迷子になりにくい reset_game()は「統計も全部リセット」か「残機だけ減らして再配置」かを分ける- レベルは
Vecで持つと読み込みが軽いVec > - 音は
play_sound/stop_soundをモード遷移に紐づける - UIは
measure_textで横並び位置を計算してズレを潰す
ゲームが壊れる場所はだいたい状態管理
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は、Paused↔Playingの切替と、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になるかは、仕様として文章で決めといた方が後で揉めない。
地味な揉め方するから。ほんとに。
リセットは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で「レベル生成関数」を並べるのが素直。
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個だけで、次レベル読み込みのクラッシュ検出用。
こういう「テスト用レベル」作るの、地味に賢い。
派手さゼロ。効果は高い。
音は気分の問題じゃなくて状態の問題
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_sound、Playingに戻るときに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つがあると、素材周りの詰まりが減る。
あ、そうだ。
日本の夏、湿度が高いから…って関係ないか。
でも深夜の作業で窓開けると、虫が入る。
あれはゲームオーバー。🫠
最後に そのゲームいまどの状態?
核心:ブロック崩しの完成度は、ボールの跳ね方より、GameModeとGameStateが破綻せず回るかで決まる。
で、そこに音とUIがちゃんと乗ると、急に「ゲーム」になる。
いま作ってるやつ、どこで止まってる?
レベル追加で崩れた? UIでズレた? それとも「Space押したら音が二重に鳴る」みたいな怪談?
状況だけ、ぽつっと教えて。そこから話が進むから。
