Bevyを使ったタイミングゲームを作る
まずはタイミングゲームを作るにあたって、参考になるリンクを探す。
いろいろ調べた結果、Bevy examplesを参考に進めていくと良さそう。
あとは、ChatCPTの力も借りて、開発を進めていこう。
試しに「Bevyでタイミングゲームを作るにはどうすれば良いですか」とリクエストを投げたところ、以下のような汎用的な答えが返ってきた。コードとかは返ってこなかったなぁ。
以下のソースコードを参考にしたら、バーが作れそう。
https://github.com/bevyengine/bevy/blob/main/examples/2d/2d_shapes.rs
変数に名前をつけるのって難しいな
以下の手順でタイミングゲームを実装してみる。
- プロジェクトを作成
- セットアップを実装
- Cueの動く処理を実装
- ボタンを押した時の処理を実装
- スコアボードを実装
だいぶ大まかな内容になった。
セットアップには最小限の構成で作成する。
つまり、スライダーとキューのみで実装する。
スライダーはタイミングゲームの細長い長方形のバーで、キューはタイミングを決める小さな細長いバーのことである。
動く処理だが、まずはキューを左右に動かす処理を書く。
そしてキューがスライダーの各両端に到達すると左右反対に動く処理を書きたい。
そのためには、キューの位置とスライダーの両端の位置を取得と、キューがスライダーの端に到達した時のイベントフラグが必要になる。
あとはキューを動かすには速度(Velocity)が必要になる。
キューを左右に動かす処理を実装した。
どのようなシステムなのかというと、まずはキューを右に動かして、スライダーの右端まで来たら次は左に反転して、スライダーの左端まで来たら反転して右へ・・・を繰り返します。
どのように実装したのかというと、まずはSlider, Refrector, Cue
コンポーネントを定義します。
Sliderは画面中央に配置している細長いバーのことです。
RefrectorはSliderの左右の両端に配置しております。
Cueは左右に動く短いバーのことです。
実装した内容はこちら。
https://github.com/ittokun/bevy-games/commit/146b1a8cb032f07f1cafaa68a0f5a23eff61c665
コンポーネントは以下のようなものが定義されました。
#[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,
));
タイミングを決める処理は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!");
}
}
}
次にスコアボードを追加。
これはタイミングを決めた時に位置を取得し、真ん中に近ければ近いほど高いポイントがもらえるという実装。
まずはアプリに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()
}),
);
}
次に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();
}
以上で基本的な機能の実装は完了した。
ここからは、見た目を良くしたり、音を追加したり、アニメーションを追加したりしていきたい。
音を追加してみた。
変更内容はこちら。
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));
// ...
}
そして、セットアップしたオーディオとイベントをアプリのシステムに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());
}
}
ポップアップテキストを追加しようとしたが無理っぽい。
コンポーネントは生成できるが、フォントの読み込みがどうも不安定で、行けたり行けなかったりする。
いろいろ試したがギブアップである。