1. 概要
スイカゲームの Rust / Bevy クローンを作りました。物理演算に bevy_rapier2d、音声に bevy_kira_audio を使い、単なる動作確認にとどまらず「自分が遊びたいと思えるレベル」を目標に機能を足しています。
サクランボだけスプライトを描きました
技術スタック
| 役割 | ライブラリ |
|---|---|
| ゲームエンジン | Bevy 0.17.3 |
| 物理演算 | bevy_rapier2d 0.32.0 |
| 音声 | bevy_kira_audio 0.24.0 |
| 設定ファイル | RON 0.12.0 |
| 永続化 | serde_json 1.0 |
コードは GitHub で公開しています
https://github.com/itsakeyfut/suika-game
2. AI エージェントを使用した理由
理由は単純で、ゲームデザインに専念したかったからです。今回のプロジェクトを立ち上げたのは、ゲームアセットを自分で作りゲームに統合したいという思いがあったためで、プログラミングは Claude Code に任せ、私は設計と実装方針・テスト戦略・フォールバック戦略・レベルデザインなどを担当しました。コードに冗長性や統一感のなさが見えたら Claude Code に修正を指示し、頻繁に調整するパラメータは RON に外部化し、機能はなるべくプラグイン単位で分割するなど、設計には一定のこだわりを持っていました。
3. 構造
プロジェクトは 5 クレートの Cargo ワークスペースで構成しています。
suika-game/
├── Cargo.toml ← ワークスペース定義・共通バージョン管理
└── app/
├── core/ ← suika-game-core ゲームロジック
├── ui/ ← suika-game-ui 画面・HUD
├── audio/ ← suika-game-audio BGM/SFX
├── assets/ ← suika-game-assets スプライトローダー
└── suika-game/ ← バイナリ本体(エントリポイント)
└── assets/
└── config/ ← RON 設定ファイル群
各クレートの役割
suika-game-core
ゲームの状態管理・物理・当たり判定・スコア・エフェクト・設定の永続化など、ゲームロジックの全体を担います。他のクレートは core に依存しますが、core は他のクレートを一切知りません。
内部の systems/ ディレクトリはシステムごとにファイルを分割しています。
core/src/
├── systems/
│ ├── collision.rs ← 衝突検知(ポーリング方式)
│ ├── merge.rs ← フルーツ合体・進化処理
│ ├── score.rs ← スコア・コンボ計算
│ ├── spawn.rs ← フルーツ生成
│ ├── boundary.rs ← ゲームオーバー境界判定
│ ├── input.rs ← マウス・キーボード入力
│ ├── pause.rs ← ポーズ/再開
│ └── effects/
│ ├── bounce.rs ← スクワッシュ&ストレッチ
│ ├── droplet.rs ← 水しぶきパーティクル
│ ├── flash.rs ← フラッシュエフェクト
│ ├── shake.rs ← カメラシェイク
│ └── watermelon.rs ← スイカ専用爆発演出
├── config/ ← RON アセット定義・ホットリロード
├── resources/ ← GameState, SettingsResource, ComboTimer など
├── persistence.rs ← JSON セーブ/ロード
└── fruit.rs ← FruitType 定義・進化チェーン
suika-game-ui
タイトル・設定・遊び方・HUD・ポーズ・ゲームオーバーの各画面を実装します。多言語対応(日本語 / 英語)の文字列テーブルもここに置いています。
suika-game-audio
BGM と SFX の 2 チャンネル独立制御、状態遷移に応じたトラック切り替え、ユーザーのボリューム設定の適用を担います。
suika-game-assets
起動時にフルーツスプライトを一括ロードし FruitSprites リソースに格納します。スプライト未登録のフルーツはプレースホルダーの白円で表示されます。
suika-game(バイナリ)
上記 4 つのプラグインをつないで App::run() するだけの薄いエントリポイントです。
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { /* 800×600 */ }))
.add_plugins(RapierPhysicsPlugin::<NoUserData>::pixels_per_meter(100.0))
.add_plugins(GameAssetsPlugin)
.add_plugins(GameConfigPlugin)
.add_plugins(GameCorePlugin)
.add_plugins(GameUIPlugin)
.add_plugins(GameAudioPlugin)
.add_plugins(DebugPlugin) // dev-tools feature のみ有効
.run();
アプリケーション状態遷移
AppState は 7 つの状態を持ちます。
#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum AppState {
#[default]
Loading, // RON アセットの読み込み待ち
Title, // タイトル画面
Settings, // 設定画面
HowToPlay, // 遊び方画面
Playing, // ゲームプレイ中
Paused, // ポーズ中(物理停止)
GameOver, // ゲームオーバー画面
}
※ Mermaid だと文字が被ってみづらいので、後日 Draw.io で描いておきます。
Loading は RON 設定ファイルがすべてロードされたら自動的に Title へ遷移します。コンテナ(壁)のサイズや重力は RON から読むため、ロード完了後の OnExit(Loading) で生成します。
画面エンティティのライフサイクル管理
各画面に属するエンティティには DespawnOnExit(AppState::X) タグを付けています。状態遷移時にこのタグを持つエンティティを一括削除するシステムが走るため、画面ごとに cleanup_xxx 関数を書く必要がありません。
// タイトル画面のルートエンティティに付与
commands.spawn((
Node { /* ... */ },
DespawnOnExit(AppState::Title), // Title を抜けたら自動削除
));
4. クレートを分けた理由
ゲームをひとつのクレートにまとめると実装は単純になります。それでも分割を選んだのには理由があります。
コンパイル時間の短縮
Rust のインクリメンタルコンパイルはクレート単位で行われます。たとえば UI の文字列を修正したとき、core や audio は再コンパイルされません。変更の局所性がそのまま待ち時間に直結します。
開発序盤に [profile.dev] の最適化レベルも調整しています。
[profile.dev]
opt-level = 1 # バイナリ本体
[profile.dev.package."*"]
opt-level = 3 # 依存クレート(Bevy, Rapier)
依存クレートだけ最適化レベルを上げることで、デバッグビルドでも許容できる実行速度になります。
依存関係の一方向化
クレート間の依存は ui / audio / assets → core の一方向のみです。UI が物理エンジンの詳細を知る必要はなく、audio が UI のウィジェット構造を知る必要もありません。このような分離がないと、機能追加のたびに無関係なクレートのコードを巻き込んで変更が広がりやすくなります。
また、依存が循環しないことで Cargo がクレートを並列コンパイルできる余地が生まれます。
テスタビリティ
core クレートは Bevy の描画や音声に依存しないため、MinimalPlugins だけで単体テストが書けます。score.rs・collision.rs・bounce.rs などのテストはすべてこの形式です。
#[test]
fn test_combo_bonus_applied() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_systems(Update, update_score_on_merge);
// FruitsConfig を直接 insert してシステムを検証
app.insert_resource(fruits_assets);
app.insert_resource(FruitsConfigHandle(handle));
app.world_mut().write_message(FruitMergeEvent { /* ... */ });
app.update();
let score = app.world().resource::<GameState>().score;
assert_eq!(score, 21);
}
UI や音声の絡むテストを書くときだけ必要なプラグインを追加すれば良いので、テストの起動が速くなります。
Bevy のプラグインアーキテクチャとの相性
Bevy はシステムをプラグインとして分割することを推奨しています。クレート分割とプラグイン分割を一致させることで、add_plugins(GameCorePlugin) の一行でゲームロジック全体を登録できます。機能の追加・削除がプラグインの付け外しで完結するため、コードの見通しが良くなります。
デバッグツールの feature フラグによる分離
開発時だけ有効にしたいツール(bevy-inspector-egui の GUI インスペクタ、Rapier の物理デバッグ描画)は dev-tools フィーチャーフラグで切り離しています。
# app/suika-game/Cargo.toml
[features]
dev-tools = ["bevy-inspector-egui", "bevy_rapier2d/debug-render-2d"]
DebugPlugin はこのフィーチャーが有効なときだけシステムを登録します。リリースビルドにデバッグコードが混入しないうえ、依存クレートごとコンパイルから除外されるため、リリースビルドのバイナリサイズも抑えられます。
5. Bevy の ECS 設計
Bevy は ECS(Entity Component System) アーキテクチャを採用しています。Unity などのオブジェクト指向型ゲームエンジンとは設計の発想が異なるので、実際にどう使ったかを説明します。
ECS の基本概念
| 概念 | 役割 | 本プロジェクトの例 |
|---|---|---|
| Entity | データのID(整数) | フルーツ1個、壁1枚など |
| Component | Entityに付くデータ |
FruitType, Transform, Collider
|
| System | Componentを処理する関数 |
detect_fruit_contact, update_score_on_merge
|
| Resource | グローバルな単一データ |
GameState, SettingsResource
|
| Event | システム間のメッセージ |
FruitMergeEvent, ScoreEarnedEvent
|
Unity との比較で言えば、MonoBehaviour の代わりに「Component にデータだけ持たせ、System でまとめて処理する」というのが基本的な考え方です。「フルーツ A とフルーツ B が接触したか」を調べるとき、各フルーツが自分で判定するのではなく、detect_fruit_contact システムが全フルーツをまとめてスキャンします。
Component でエンティティの種類を表す
FruitType はフルーツの種類(チェリー・ストロベリーなど)を表す enum で、それ自体が Component になっています。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Component)]
pub enum FruitType {
Cherry,
Strawberry,
Grape,
Dekopon,
Persimmon,
Apple,
Pear,
Peach,
Pineapple,
Melon,
Watermelon,
}
Component を derive するだけで、Query でフィルタリングできるようになります。「Fruit コンポーネントを持ち、かつ MergeCandidate を持たないエンティティ」のような問い合わせをシステムの引数で宣言的に書けるのが ECS の強みです。
pub fn detect_fruit_contact(
fruit_query: Query<
(&FruitType, &FruitSpawnState),
(With<Fruit>, Without<MergeCandidate>) // 宣言的なフィルタ
>,
// ...
)
フルーツのスポーン状態管理
プレイヤーが「次に落とすフルーツ」を狙っている間は、そのフルーツを合体対象から外す必要があります。そのために FruitSpawnState という Component を用意しています。
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub enum FruitSpawnState {
Held, // プレイヤーが保持中(落下前)
Falling, // 落下中・着地後
}
衝突検知システムは state == Held のフルーツをスキップします。プレイヤーが狙いを定めている間に「まだ落としていないのに合体が起きる」という事態を防いでいます。
合体で生成されたフルーツは即座に Falling で生まれます。
状態ごとにシステムのスケジュールを分離する
Bevy の States を使うと、特定の状態のときだけ実行するシステムを宣言的に書けます。
// Playing 状態のときのみ実行
app.add_systems(
Update,
detect_fruit_contact
.run_if(in_state(AppState::Playing))
);
// Playing に入るときに一度だけ実行
app.add_systems(OnEnter(AppState::Playing), setup_hud);
// Playing から出るときに一度だけ実行
app.add_systems(OnExit(AppState::Playing), cleanup_game_entities);
「ポーズ中は物理を止める」「ゲームオーバー後はスコアを表示する」といった状態依存のロジックが、if 文を書かずに表現できます。
エフェクトの条件付き実行
run_if はクロージャで任意の条件を書けます。エフェクト機能のオン/オフを設定画面から切り替えられるようにするため、エフェクト系システムはすべてこのパターンで制御しています。
app.add_systems(
Update,
animate_camera_shake
.run_if(|settings: Res<SettingsResource>| settings.effects_enabled)
.run_if(in_state(AppState::Playing)),
);
イベントでシステムを疎結合にする
当たり判定システムが直接スコアを書き換えると、当たり判定とスコア計算が密結合になります。代わりにイベントを経由することで、それぞれのシステムが独立してテストできるようになります。
detect_fruit_contact → FruitMergeEvent → update_score_on_merge
→ handle_fruit_merge(エンティティ削除・生成)
→ spawn_merge_effects(エフェクト)
FruitMergeEvent には位置・フルーツの種類・エンティティIDが含まれており、これを受け取ったシステムがそれぞれの仕事をします。スコアシステムはエンティティの存在を知らなくてよく、エフェクトシステムはスコアを知らなくてよい、という関心の分離が自然に実現されています。
ScoreEarnedEvent はさらにスコアシステムが下流へ流すイベントで、「この合体で何点得られたか・コンボ数はいくつか・位置はどこか」を UI のスコアポップアップアニメーションに伝える役割を担います。
Commands による遅延処理
Bevy のシステムは並列実行されるため、エンティティの生成・削除はすぐには反映されません。Commands に積んだ操作はフレームの終わりに一括で適用されます。
これが衝突検知の二重防止に影響します。commands.entity(e).insert(MergeCandidate) を呼んでも、同フレーム内では Without<MergeCandidate> クエリはまだそのエンティティを見えています。そのため、コマンドバッファとは別にフレームローカルな HashSet を使って「このフレームで処理済みのエンティティ」を追跡しています(詳細は物理エンジンの章で後述)。
SystemParam でパラメータをまとめる
Bevy のシステムは引数が 16 個を超えるとコンパイルエラーになります。RON 設定ファイルは「ハンドル」と「アセットストア」の 2 つを組み合わせて参照するため、設定の種類が増えると引数があっという間に増えます。
これを SystemParam を derive した構造体にまとめることで回避しています。
#[derive(SystemParam)]
pub struct FruitsParams<'w> {
handle: Option<Res<'w, FruitsConfigHandle>>,
assets: Option<Res<'w, Assets<FruitsConfig>>>,
}
impl<'w> FruitsParams<'w> {
pub fn get(&self) -> Option<&FruitsConfig> {
self.handle
.as_ref()
.and_then(|h| self.assets.as_ref().and_then(|a| a.get(&h.0)))
}
}
システムの引数に fruits: FruitsParams と書くだけで設定にアクセスでき、ロード中は None を返すので起動直後のパニックも防げます。
6. 物理エンジン(bevy_rapier2d)との連携
コンテナの構成
※ ここも Mermaid が見づらいので後日 Draw.io で描いておきます。
ゲーム画面は底壁・左壁・右壁の 3 枚の静的コライダーで構成しています。壁のサイズと位置は physics.ron から読み込み、ホットリロードにも対応しています。重力は pixels_per_meter(100.0) に合わせて -980 px/s² に設定しています。
PhysicsConfig(
gravity: -980.0,
container_width: 400.0,
container_height: 600.0,
wall_thickness: 20.0,
boundary_line_y: 200.0,
wall_restitution: 0.2,
wall_friction: 0.5,
fruit_linear_damping: 0.5,
fruit_angular_damping: 1.0,
// ...
)
fruit_linear_damping と fruit_angular_damping は少し高め(それぞれ 0.5・1.0)に設定しています。コンテナ内でフルーツが永遠にぐるぐる回転し続けると見た目が落ち着かないためです。
フルーツの物理パラメータ
フルーツは円形の動的リジッドボディです。質量は radius² × mass_multiplier で計算しており、大きいフルーツほど重くなります。
// Stage 1: Cherry(最小・スポーン可能)
(
name: "Cherry",
radius: 20.0,
points: 10,
restitution: 0.3, // 反発係数
friction: 0.5, // 摩擦係数
mass_multiplier: 0.01,
),
// Stage 11: Watermelon(最大・合体のみ)
(
name: "Watermelon",
radius: 120.0,
points: 10240,
restitution: 0.2,
friction: 0.5,
mass_multiplier: 0.01,
),
小さいフルーツほど restitution を高め(チェリーは 0.3)、大きいフルーツは低め(スイカは 0.2)に設定しています。重くて跳ねにくい挙動が自然に出るようになります。質量計算式(radius² に比例)を採用しているため、スイカ(radius=120)はチェリー(radius=20)の 36 倍の質量になります。
フルーツの進化チェーン
※ ここも Mermaid が見づらいので後日 Draw.io で描いておきます。
フルーツには next() メソッドがあり、進化先を返します。スイカは最終段階なので None を返します。
pub fn next(&self) -> Option<FruitType> {
match self {
FruitType::Cherry => Some(FruitType::Strawberry),
FruitType::Strawberry => Some(FruitType::Grape),
// ...
FruitType::Melon => Some(FruitType::Watermelon),
FruitType::Watermelon => None, // 最終段階
}
}
また、プレイヤーが直接スポーンできるのはチェリー〜柿の 5 種類だけです(spawnable_fruits() が返す配列)。リンゴ以上は合体によってのみ生成されます。
イベント駆動からポーリングへ
当たり判定の実装で最初に試みたのは Rapier の CollisionEvent::Started を使う方法でした。しかし実際に動かすと、コンテナが果物でいっぱいになったあたりで合体が発生しないケースが出てきました。
原因は CollisionEvent::Started の仕様にあります。このイベントは接触が始まったときに一度だけ発火します。ところが、密に詰まった状態では「第3のフルーツが上から降ってきて、すでに接触していた2個を押しつけた」という状況が起きます。このとき新しい接触イベントは発生しないため、合体が永久にスキップされます。
解決策として、rapier_context.simulation.contact_pairs() を毎フレームポーリングする方式に切り替えました。
pub fn detect_fruit_contact(
rapier_context: ReadRapierContext,
fruit_query: Query<(&FruitType, &FruitSpawnState), (With<Fruit>, Without<MergeCandidate>)>,
mut merge_events: MessageWriter<FruitMergeEvent>,
mut processed: ResMut<ProcessedCollisions>,
) {
let Ok(ctx) = rapier_context.single() else { return; };
let mut claimed: HashSet<Entity> = HashSet::new();
for contact_pair in ctx.simulation.contact_pairs(ctx.colliders, ctx.rigidbody_set) {
// 接触点がない(センサー接触など)はスキップ
if !contact_pair.has_any_active_contact() { continue; }
// (小エンティティ, 大エンティティ) の順に正規化して重複チェック
let pair = if entity1 < entity2 { (entity1, entity2) } else { (entity2, entity1) };
if processed.pairs.contains(&pair) { continue; }
// このフレームで既に使ったエンティティはスキップ
if claimed.contains(&entity1) || claimed.contains(&entity2) { continue; }
// 同種フルーツかつ Held でないことを確認
if type1 != type2 { continue; }
if state1 == Held || state2 == Held { continue; }
merge_events.write(FruitMergeEvent { entity1, entity2, fruit_type, position });
claimed.insert(entity1);
claimed.insert(entity2);
processed.pairs.insert(pair);
}
}
同フレーム内の重複防止も注意が必要でした。commands.entity(...).insert(MergeCandidate) はコマンドバッファを通じて次フレームに適用されるため、Without<MergeCandidate> クエリは「同フレーム内でさっき処理したエンティティ」をまだフィルタリングできません。フレームローカルな HashSet<Entity> で「このフレームで既に使ったエンティティ」を追跡することで二重マージを防いでいます。
ProcessedCollisions リソース(HashSet<(Entity, Entity)>)はフレームの終わりに clear_processed_collisions システムがクリアします。これにより同フレームの重複は防ぎつつ、翌フレームには再度チェックが入ります。
合体処理の流れ
衝突検知から新フルーツ生成までの一連の流れは 3 つのシステムに分かれています。
[detect_fruit_contact]
└→ FruitMergeEvent を発行
[handle_fruit_merge]
├─ entity1, entity2 を despawn
└─ fruit_type.next() が Some なら次の段階のフルーツを spawn
└─ SquashStretchAnimation::for_merge() を付与(ポップインアニメーション)
[update_score_on_merge]
├─ ComboTimer を更新
├─ 獲得ポイント = base_points × combo_multiplier
└─ ScoreEarnedEvent を発行(UI のスコアポップアップへ)
スイカ(最終段階)を合体させると next() が None を返すため、2個のスイカが消えて何も生成されません。
ポーズ中の物理停止
ポーズ状態では RapierConfiguration の physics_pipeline_active を false にすることで物理シミュレーション全体を止めています。フルーツの位置はそのまま保持され、再開すると即座に動き始めます。
7. ゲームバランス調整プロセス
なぜ RON か
設定ファイルの形式として RON(Rusty Object Notation)を選びました。JSON と比べると以下の利点があります。
- コメントが書ける(JSON は仕様上コメント不可)
-
末尾カンマが許容される(リスト末尾に
,を付けても構文エラーにならない) -
Rust の型名をそのまま使える(
FruitsConfig(...)のように型名付きで書けます。正直これが大きい)
// Fruit configuration — コメントが書ける
FruitsConfig(
fruits: [
(
name: "Cherry",
radius: 20.0,
points: 10,
restitution: 0.3,
// 末尾カンマ OK
),
],
)
JSON や TOML でも同等の構成は作れますが、RON はゲームデータの記述に特化しているぶんパラメータ調整中のコメントアウト操作がしやすいです。
RON による外部化と即時反映
ゲームの挙動に関わるパラメータはすべて RON ファイルに外部化しています。
assets/config/
├── fruits.ron ← 11種フルーツの物理・得点パラメータ
├── physics.ron ← 重力・コンテナサイズ・壁の物性
├── game_rules.ron ← コンボ倍率・スポーン設定
├── audio.ron ← 各サウンドの設計音量(dB)
└── effects/
├── bounce.ron ← スクワッシュ&ストレッチのバネ係数
├── droplet.ron ← 水しぶきパーティクルの設定
├── flash.ron ← フラッシュエフェクトの設定
├── shake.ron ← カメラシェイクの強度・減衰
└── watermelon.ron ← スイカ合体時の特殊爆発エフェクト
Bevy の file_watcher フィーチャーを有効にしているため、ファイルを保存するとゲームを再起動しなくても変更が即座に反映されます。たとえばチェリーの半径を変えると、画面上のチェリーのコライダーとスプライトが同フレーム内で更新されます。
パラメータ調整の実際
フルーツのポイント設計はほぼ等比数列になっています。
| フルーツ | 半径 (px) | 獲得ポイント |
|---|---|---|
| チェリー | 20 | 10 |
| ストロベリー | 30 | 20 |
| ぶどう | 40 | 40 |
| でこぽん | 50 | 80 |
| 柿 | 60 | 160 |
| リンゴ | 70 | 320 |
| 梨 | 80 | 640 |
| 桃 | 90 | 1,280 |
| パイナップル | 100 | 2,560 |
| メロン | 110 | 5,120 |
| スイカ | 120 | 10,240 |
大きいフルーツは小さいフルーツを何度も合体させないと作れないため、ポイントが指数的に増加するのは自然な設計かと思います。
あと、本家スイカゲームを購入してプレイしている筆者ですが、詳しいポイントルールに関しては惰性で調査していないため。上記のようなポイントルールにしました。
そして、当該プロジェクトでは本家スイカゲームにはない(多分ない)コンボシステムを搭載しています。
一定間隔内でフルーツを合体させ続けることでポイント倍率が上昇していきます。
コンボシステムの倍率も game_rules.ron で設定しています。
GameRulesConfig(
combo_window: 5.0, // コンボが継続する秒数
combo_max: 99,
combo_bonuses: {
2: 1.1, // 2連続: +10%
3: 1.2, // 3連続: +20%
4: 1.3, // 4連続: +30%
5: 1.5, // 5連続以上: +50%
},
// ...
)
倍率のテーブルはコード上では HashMap<u32, f32> で持ち、「テーブルの中でコンボ数以下の最大キー」を拾うステップ関数で実装しています。キーに存在しない値(たとえばコンボ 7)は自動的に最も近い下のキー(5)の倍率を使うので、テーブルに書いていない値でもギャップが生じません。
フォールバックというやつです。
ホットリロードの実装
設定の変更を検知するシステムは AssetEvent<T> を listen するパターンで書いています。
pub fn hot_reload_fruits_config(
mut events: MessageReader<AssetEvent<FruitsConfig>>,
config_assets: Res<Assets<FruitsConfig>>,
config_handle: Res<FruitsConfigHandle>,
mut fruits: Query<(&FruitType, &mut Collider, &mut Restitution, ...)>,
) {
for event in events.read() {
if let AssetEvent::Modified { .. } = event {
if let Some(config) = config_assets.get(&config_handle.0) {
// 既存のフルーツエンティティのコンポーネントを直接書き換える
for (fruit_type, mut collider, mut restitution, ..) in fruits.iter_mut() {
let params = fruit_type.try_parameters_from_config(config)?;
*collider = Collider::ball(params.radius);
restitution.coefficient = params.restitution;
}
}
}
}
}
physics.ron の変更時はコンテナからはみ出たフルーツを先に削除してから壁の位置とコライダーを更新する必要があります。削除を後回しにすると壁が縮んだ瞬間に物理的に埋まったフルーツが爆発的な速度で飛び出すため、更新順序に注意が必要でした。
それはもうものすごい勢いでフルーツが吹っ飛んでいくので、それはそれで面白いです。
RON アセットのカスタムローダー
FruitsConfig のような RON アセットは Bevy 標準では読み込めないため、AssetLoader を自前で実装しています。幸い各設定の実装はほぼ同じ構造なのでマクロで生成しています。
// AssetLoader を自動実装するマクロ
impl_ron_asset_loader!(FruitsConfig, FruitsConfigLoader, &["ron"]);
impl_ron_asset_loader!(PhysicsConfig, PhysicsConfigLoader, &["ron"]);
impl_ron_asset_loader!(GameRulesConfig, GameRulesConfigLoader, &["ron"]);
マクロの中身は ron::de::from_str() で文字列からデシリアライズするだけの数行ですが、各設定ごとに同じ実装を繰り返さなくて済みます。
正直に言うと、RONのホットリロードは非常に便利で、Unity や Unreal Engine だとゲームバランスを調整するために GUI でパラメータを調整して即時に目視確認できるため、Bevy だとその点が面倒なのではないかと思っていましたが、RON にゲームパラメータを集約して、ホットリロード化すれば難なくゲームバランスの調整ができて、開発が捗りました。
8. 本家スイカゲームにはない機能
以下は基本的なゲームクローンにはない、今回独自で追加した機能です。
設定画面と永続化
BGM 音量・SFX 音量・ビジュアルエフェクトのオン/オフ・言語(日本語 / 英語)を設定画面から変更できます。変更は save/settings.json に即座に保存され、次回起動時に復元されます。
永続化の仕組み
SettingsResource 自体に Serialize / Deserialize を derive しており、別のデータ転送用構造体を作らずに直接 JSON に変換しています。
#[derive(Resource, Serialize, Deserialize, Clone)]
pub struct SettingsResource {
pub bgm_volume: u8, // 0-10
pub sfx_volume: u8, // 0-10
pub effects_enabled: bool,
pub language: Language,
}
設定ファイルが存在しない場合や JSON が壊れていた場合は Default 実装の値にフォールバックします。これにより初回起動時や手動でファイルを削除した場合でも安全に動作します。
pub fn load_settings(save_dir: &Path) -> SettingsResource {
match fs::read_to_string(save_dir.join("settings.json")) {
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
Err(_) => SettingsResource::default(),
}
}
ハイスコアも同様のパターンで save/highscore.json に保存しています。新しいハイスコアは現在の保存値と比較してから書き込むため、万が一複数フレームで同時に書き込みが起きても最高値が維持されます。
音量のdB変換
音量は 0〜10 のスライダー値を dB に変換して bevy_kira_audio のチャンネルに適用しています。
pub fn volume_to_db(vol: u8) -> f32 {
if vol == 0 { return -100.0; } // 完全ミュート
(vol as f32 / 10.0 - 1.0) * 40.0
// vol=10 → 0 dB(フル)
// vol=8 → -8 dB(デフォルト)
// vol=5 → -20 dB(半分)
}
BGM と SFX は独立したチャンネルで管理しているため、BGM だけ絞って SE は大きめにする、という設定が可能です。なお bevy_kira_audio の制約上、ボリューム変更は再生中の BGM には反映されず、次の BGM 再生から適用されます。
変更検知による無駄な set_volume 呼び出しの防止
apply_volume_settings システムは SettingsResource が変更されるたびに走ります。しかし言語やエフェクトのフラグが変わっただけでもこのシステムが走るため、変更のない音量値を毎回 set_volume に送り込む恐れがあります。PreviousVolume リソースで前回値を保持し、変わったときだけ呼び出すように制御しています。
pub fn apply_volume_settings(
settings: Res<SettingsResource>,
bgm_channel: Res<AudioChannel<BgmChannel>>,
mut prev: ResMut<PreviousVolume>,
) {
if settings.bgm_volume != prev.bgm {
bgm_channel.set_volume(volume_to_db(settings.bgm_volume));
prev.bgm = settings.bgm_volume;
}
// sfx も同様
}
多言語対応(日本語 / 英語)
UI の文字列はすべて t(key, language) 関数を通して取得します。文字列テーブルはコンパイル時の静的な match 式で実装しており、ランタイムのオーバーヘッドがありません。
pub fn t(key: &'static str, lang: Language) -> &'static str {
match (key, lang) {
("btn_start", Language::Japanese) => "スタート",
("btn_start", Language::English) => "Start",
("game_title", Language::Japanese) => "スイカゲーム",
("game_title", Language::English) => "Suika Game",
// ... 全画面分のキーを網羅
_ => key, // 未知のキーはキー名をそのまま返す(開発時のデバッグ用)
}
}
キーが存在しない場合にキー名をそのまま返すフォールバックにより、新しいキーを追加したときに画面に文字列が出ない(空白になる)バグを防いでいます。
もっと大きいシステムなら assets/ 配下に locales ディレクトリを作って、そこで管理すべきかと思いますが、
上記の手段のメリットは Rust の型安全性をフル活用できるため、多言語対応が漏れることがないことです(日本語キーに英語を設定している場合は画面に英語で表示されます。まあ、それは目視確認して修正できるので問題ない範囲かと思います)。
関数の引数・戻り値の型がいずれも &'static str なのは意図的です。key をリテラルに限定することで、戻り値もコンパイル時に確定した文字列スライスになり、アロケーションなしに返せます。
コンボスコアシステム
上述した通りですが、コンボシステムも本家スイカゲームにはないシステム(のはず)です。
5 秒以内に連続してフルーツを合体させるとコンボが積み上がり、スコア倍率が上がります。
| コンボ数 | 倍率 |
|---|---|
| 1(なし) | 1.0× |
| 2 | 1.1× |
| 3 | 1.2× |
| 4 | 1.3× |
| 5 以上 | 1.5× |
スコア計算は FruitMergeEvent を受け取った update_score_on_merge システムが担当します。コンボタイマーはフレームごとに経過時間を更新し、5 秒を超えるとリセットされます。
スコア加算式は (base_points × multiplier).round() as u32 で、u32::saturating_add で加算しているのでオーバーフローしてもスコアがゼロに戻ることはありません。
ビジュアルエフェクト
設定画面のエフェクトスイッチで全エフェクトをまとめて ON/OFF できます。
スクワッシュ&ストレッチ
フルーツが合体・着地するときに弾むような変形アニメーションが再生されます。バネ・減衰の式を毎フレーム計算し、変位が閾値を下回ったらコンポーネントを自動的に除去します。
deform(t) = amplitude × sin(freq × t) × exp(-damping × t)
-
SpawnIn(合体時):スケールが 0→1 に成長しながら振動する。
base = 1 - exp(-12t)で全体サイズを成長させつつ、deform(t)で X/Y に差分を作ることで体積保存近似のスクワッシュを再現しています。 - Impact(着地時):スケール 1 を中心に上下に潰れて戻る。最初の半波で Y が縮み X が膨らみ、その後に収束します。
アニメーションが収束したと判断するのは「deform(t).abs() < settle_threshold かつ経過時間が settle_min_elapsed を超えた」ときです。経過時間の下限を設けているのは t=0 の瞬間(deform=0)を誤って収束と判定しないためです。
カメラシェイク
合体したフルーツが大きいほど揺れが強くなります。Trauma ベースの実装で、シェイクのリアルな非線形感を出しています。
trauma の蓄積: merge_event ごとに (fruit_index - min_index + 1) × intensity_step を加算
trauma の減衰: trauma -= decay × delta_secs(毎フレーム)
カメラオフセット = trauma² × max_offset × random(-1..1)
trauma² の二乗が重要で、trauma が 0.5 のときオフセットは最大値の 25%、0.2 のときは 4% になります。小さな合体では揺れがほとんど感じられず、大きな合体のみドラマチックに揺れる非線形な応答が得られます。チェリー〜柿(インデックス 0〜4)はしきい値未満なのでシェイクをトリガーしません。
pub fn add_camera_shake(mut merge_events: MessageReader<FruitMergeEvent>, ...) {
for event in merge_events.read() {
let fruit_index = event.fruit_type as usize;
if fruit_index < min_index { continue; } // 小さいフルーツは無視
let steps_above_min = (fruit_index - min_index + 1) as f32;
let intensity = (steps_above_min * intensity_step).clamp(0.0, 1.0);
shake.add_trauma(intensity); // trauma は 1.0 でクランプ
}
}
apply_camera_shake はポーズ中・ゲームオーバー中でも毎フレーム実行されます。ゲームオーバーになった瞬間のシェイクが途中で止まらないよう、状態に関係なく trauma を減衰させ続けるためです。
水しぶきパーティクル
フルーツ着地・合体時に小さな円形パーティクルが飛び散ります。パーティクル数はフルーツのステージ(大きさ)に比例します。
スイカ専用爆発演出
スイカ(最大フルーツ)が合体したときだけ、リング状のエフェクトと多数のパーティクルが広がります。条件は FruitType::Watermelon.next() == None の判定で決めています。
スコアポップアップアニメーション
合体が起きると、その位置に「+320」のような獲得ポイントが浮かび上がり、上にフロートしながらフェードアウトします。コンボ中はテキストの色が変わります。
このポップアップは ScoreEarnedEvent を受け取った UI システムが生成します。イベントに position・earned_points・combo_count が含まれているため、ゲームロジックに触れることなく UI 側だけで実装できています。
ゲームオーバー警告
フルーツが境界ラインを超えた状態が 3 秒継続するとゲームオーバーになります。ただし超えた瞬間に即終了ではなく、フルーツが落ち着けばカウントがリセットされます。境界ラインの位置も physics.ron の boundary_line_y で調整可能です。
多分、本家スイカゲームでは即終了だった気がします。
当該プロジェクトはギリギリアウトまで遊べます。まあ、システムの欠陥とも言えます。
9. ハマったポイント
ハマったポイントは特になく、おおむねスムーズに開発が進みました。ただ、1点気を付けていたことは 安易に Bevy 系のバージョンを上げない ことです。
バージョンを上げすぎると Claude Code が古い API を呼んでしまいます。初期は存在しない API を呼び出して失敗し、WebSearch して、また存在しない API を呼び出して失敗する、というループにはまりました。挙句の果てにはトークンを散々消費して「シンプルな実装に変更します。」と吐き捨てて仕様違いの実装にされてしまいました。
そのため、v0.17 系で固定して開発するようにしました。執筆時点(2026年3月)の Bevy 最新は v0.18.1 ですが、bevy_rapier2d や bevy_kira_audio との互換性を考えると、安易なバージョンアップは避けた方が無難です。
10. 今後の展望
スイカゲーム自体はスプライトの全種対応・WebAssembly 化など、まだやりたいことが残っていますが、他にも作って学びたいプロジェクトがあるため、並行して Vampire Survivors や 東方紅魔郷 のクローンも同じ要領で開発を進めています。
一段落すればそれぞれ記事にしたいと思います。
Vampire Survivors
武器の調整がまったくできていないので、放置でクリアできてしまう。。。

ただ、スイカゲームクローンの実装を活かして、設定ファイルからのパラメータ読み込みを Config の getter メソッドにしたり、xp システムやインベントリシステムを実装しているため、レベルアップしたプロジェクトかと思います。
東方紅魔郷
弾幕は Rust でパターン化してスポーンすれば簡単に実現できますが、
WGSL シェーダーをマテリアルスポーン時に付与してやるととても綺麗な弾幕がスポーンされます。
恐らくこっちで本気出すかも








