Closed16

Bevyを使ったタイミングゲームを作る

ittokunvimittokunvim

まずはタイミングゲームを作るにあたって、参考になるリンクを探す。

いろいろ調べた結果、Bevy examplesを参考に進めていくと良さそう。

ittokunvimittokunvim

あとは、ChatCPTの力も借りて、開発を進めていこう。

試しに「Bevyでタイミングゲームを作るにはどうすれば良いですか」とリクエストを投げたところ、以下のような汎用的な答えが返ってきた。コードとかは返ってこなかったなぁ。

ChatGPT

ittokunvimittokunvim

以下の手順でタイミングゲームを実装してみる。

  1. プロジェクトを作成
  2. セットアップを実装
  3. Cueの動く処理を実装
  4. ボタンを押した時の処理を実装
  5. スコアボードを実装

だいぶ大まかな内容になった。

ittokunvimittokunvim

セットアップには最小限の構成で作成する。

つまり、スライダーとキューのみで実装する。

スライダーはタイミングゲームの細長い長方形のバーで、キューはタイミングを決める小さな細長いバーのことである。

ittokunvimittokunvim

動く処理だが、まずはキューを左右に動かす処理を書く。
そしてキューがスライダーの各両端に到達すると左右反対に動く処理を書きたい。

そのためには、キューの位置とスライダーの両端の位置を取得と、キューがスライダーの端に到達した時のイベントフラグが必要になる。

あとはキューを動かすには速度(Velocity)が必要になる。

ittokunvimittokunvim

キューを左右に動かす処理を実装した。

どのようなシステムなのかというと、まずはキューを右に動かして、スライダーの右端まで来たら次は左に反転して、スライダーの左端まで来たら反転して右へ・・・を繰り返します。

どのように実装したのかというと、まずはSlider, Refrector, Cueコンポーネントを定義します。
Sliderは画面中央に配置している細長いバーのことです。
RefrectorはSliderの左右の両端に配置しております。
Cueは左右に動く短いバーのことです。

実装した内容はこちら。

https://github.com/ittokun/bevy-games/commit/146b1a8cb032f07f1cafaa68a0f5a23eff61c665

ittokunvimittokunvim

コンポーネントは以下のようなものが定義されました。

#[derive(Component)]
struct Slider;

#[derive(Component)]
struct Cue;

#[derive(Component)]
struct Reflector;

#[derive(Component, Deref, DerefMut)]
struct Velocity(Vec2);

#[derive(Component)]
struct Collider;

Velocity(Vec2)とは、速度のことで、これを適用したコンポーネントは動くことができます。
適用するには次のように書きます。

// Cue
commands.spawn((
    SpriteBundle {
        sprite: Sprite {
            color: Color::YELLOW,
            custom_size: Some(CUE_SIZE),
            ..default()
        },
        ..default()
    },
    Cue,
    Velocity(INITIAL_CUE_DIRECTION.normalize() * CUE_SPEED),
));

Colliderは、衝突を判定するのに使用します。これを適用したコンポーネントは、コンポーネント同士がぶつかった時にどのように振る舞うか定義することができます。
適用するには次のように書きます。

commands.spawn((
    SpriteBundle {
        sprite: Sprite {
            color: Color::RED,
            custom_size: Some(REFLECTOR_SIZE),
            ..default()
        },
        transform: Transform {
            translation: Vec3::new(-SLIDER_SIZE.x / 2.0, 0.0, 0.0),
            ..default()
        },
        ..default()
    },
    Reflector,
    Collider,
));
ittokunvimittokunvim

タイミングを決める処理はdecide_timing関数に定義。

just_pressedメソッドを用いて、キー入力を検知し、キューの位置を取得、その位置をもとにprintlnで文字を出力する

fn main() {
    App::new()
        // ...
        .add_systems((
            check_for_collisions,
            apply_velocity.before(check_for_collisions),
            decide_timing
                .before(check_for_collisions)
                .after(apply_velocity),
        ))
        // ...
        .run();
}

fn decide_timing(keyboard_input: Res<Input<KeyCode>>, query: Query<&Transform, With<Cue>>) {
    let cue_transform = query.single();

    if keyboard_input.just_pressed(KeyCode::Space) {
        let cue_translation_x = cue_transform.translation.x;
        println!("{}", cue_translation_x);

        if cue_translation_x < PERFECT_TIMING_RANGE && cue_translation_x > -PERFECT_TIMING_RANGE {
            println!("Perfect timing!");
        } else if cue_translation_x < GOOD_TIMING_RANGE && cue_translation_x > -GOOD_TIMING_RANGE {
            println!("Good timing!");
        } else if cue_translation_x < OK_TIMING_RANGE && cue_translation_x > -OK_TIMING_RANGE {
            println!("OK timing!");
        } else {
            println!("Bad timing!");
        }
    }
}
ittokunvimittokunvim

次にスコアボードを追加。
これはタイミングを決めた時に位置を取得し、真ん中に近ければ近いほど高いポイントがもらえるという実装。

まずはアプリにScoreboardリソースを追加します。

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .insert_resource(Scoreboard { score: 0 })
        // ...
 }

#[derive(Resource)]
struct Scoreboard {
    score: isize,
}

fn setup() {
    // ...

    // Scoreboard
    commands.spawn(
        TextBundle::from_sections([
            TextSection::new(
                "Score: ",
                TextStyle {
                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                    font_size: SCOREBOARD_FONT_SIZE,
                    color: Color::BLACK,
                    ..default()
                },
            ),
            TextSection::from_style(TextStyle {
                font: asset_server.load("fonts/FiraMono-Medium.ttf"),
                font_size: SCOREBOARD_FONT_SIZE,
                color: Color::GRAY,
                ..default()
            }),
        ])
        .with_style(Style {
            position_type: PositionType::Absolute,
            position: UiRect {
                top: SCOREBOARD_TEXT_PADDING,
                left: SCOREBOARD_TEXT_PADDING,
                ..default()
            },
            ..default()
        }),
    );
}
ittokunvimittokunvim

次にdecide_timing関数を変更し、タイミングを決めた場所に応じてスコアボードの値を加算、減算を行う。

fn main() {
    App::new()
        // ...
        .add_system(update_scoreboard)
        // ...
}

fn decide_timing(
    keyboard_input: Res<Input<KeyCode>>,
    mut scoreboard: ResMut<Scoreboard>,
    query: Query<&Transform, With<Cue>>,
) {
    let cue_transform = query.single();

    if keyboard_input.just_pressed(KeyCode::Space) {
        let cue_translation_x = cue_transform.translation.x;
        println!("{}", cue_translation_x);

        if cue_translation_x < PERFECT_TIMING_RANGE && cue_translation_x > -PERFECT_TIMING_RANGE {
            scoreboard.score += 100;
        } else if cue_translation_x < GOOD_TIMING_RANGE && cue_translation_x > -GOOD_TIMING_RANGE {
            scoreboard.score += 50;
        } else if cue_translation_x < OK_TIMING_RANGE && cue_translation_x > -OK_TIMING_RANGE {
            scoreboard.score += 10;
        } else {
            scoreboard.score += -100;
        }
    }
}

fn update_scoreboard(scoreboard: Res<Scoreboard>, mut query: Query<&mut Text>) {
    let mut text = query.single_mut();
    text.sections[1].value = scoreboard.score.to_string();
}
ittokunvimittokunvim

以上で基本的な機能の実装は完了した。

ここからは、見た目を良くしたり、音を追加したり、アニメーションを追加したりしていきたい。

ittokunvimittokunvim

音を追加してみた。

変更内容はこちら。

https://github.com/ittokun/bevy-games/commit/cde0f8032fa3f3ff6ddf05ca94fb8ef72d6c4413

実装の手順は以下のとおり。

まずは、オーディオとイベントを定義し、セットアップします。

#[derive(Default)]
struct TimingEvent;

#[derive(Resource)]
struct TimingSound(Handle<AudioSource>);

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    // Sound
    let cue_timing_sound = asset_server.load("sounds/timing_decide.ogg");
    commands.insert_resource(TimingSound(cue_timing_sound));
    // ...
}
ittokunvimittokunvim

そして、セットアップしたオーディオとイベントをアプリのシステムにplay_timing_soundという名前で登録します。
タイミングを決めたところで、TimingEventを発火し、それをplay_timing_sound関数で処理を実装、つまりセットしたオーディオを再生するといった流れです。

fn main() {
    App::new()
        // ...
        .add_systems((
            play_timing_sound.after(check_for_collisions),
        ))
        // ...
}

fn decide_timing(
    // ...
    mut timing_events: EventWriter<TimingEvent>,
) {
    // ...
    if keyboard_input.just_pressed(KeyCode::Space) {
        // Sends a timing event so that other systems can react to the timing
        timing_events.send_default();
        // ...
    }
}

fn play_timing_sound(
    mut timing_events: EventReader<TimingEvent>,
    audio: Res<Audio>,
    sound: Res<TimingSound>,
) {
    // Play a sound once per frame if a timing occurred.
    if !timing_events.is_empty() {
        // This prevents events staying active on the next frame.
        timing_events.clear();
        audio.play(sound.0.clone());
    }
}
ittokunvimittokunvim

ポップアップテキストを追加しようとしたが無理っぽい。

コンポーネントは生成できるが、フォントの読み込みがどうも不安定で、行けたり行けなかったりする。

いろいろ試したがギブアップである。

このスクラップは2023/07/25にクローズされました