Bevyを使った個人でゲーム開発を行なっている登尾(のぼりお)です。
QiitaにてここまでRustとBevyを使ったゲーム開発のレシピ集ということで記事をまとめていましたが、今回はその10回目ということで、ここまでの内容を踏襲した、タイピングゲームを作ってみましょう。
また、ここまでに書いた記事を10回目記念と称して時系列順で並べていますので、参考にしていただければ幸いです。
タイピングゲームの概要
今回使うテーマは記事でまとめられる範囲を考えて、極力シンプルにしたタイピングゲームです。
- 以前記事でも扱ったbevy_ascii_terminalを使ったターミナル風の表示
- ランダムに単語が出てくるのでそれを入力していく
- 入力に誤りがあれば赤文字、誤りがなければ緑文字で入力中の文字を表現
- 入力に成功すればスコアが増えて30秒以内のスコアを競う
- 30秒経つとリスタートするかどうかの画面に切り替わる
ゲーム中の状態管理
ゲームの状態を管理するため以下のBevy用Resourceを定義します。
#[derive(Resource)]
struct State {
target: String,
input: String,
score: u32,
time_left: f32,
running: bool,
}
- target: 入力すべき文字列
- input: ユーザが入力中の文字列
- score: ゲーム中のスコア
- time_left: 経過時間
- running: プレイ中かどうかのフラグ
という形で使います。
Stateに対して、以下のように実装しておき、ゲーム内での状態の変更をここで全て管理します。
impl State {
fn new() -> Self {
let mut state = Self {
target: String::new(),
input: String::new(),
score: 0,
time_left: 0.0,
running: false,
};
state.start();
state
}
fn start(&mut self) {
self.score = 0;
self.time_left = GAME_SECONDS;
self.input.clear();
self.target = eff_wordlist::large::random_word().to_string();
self.running = true;
}
fn next_target(&mut self) {
self.score += 1;
self.input.clear();
self.target = eff_wordlist::large::random_word().to_string();
}
}
- start: 初回時、リスタートするタイミングでスコアを0にし新しい問題を設定する
- next_target: プレイ中に問題をクリアしたので次の問題を出す
targetの中身は今回、 eff_wordlist
というクレートを使っていてそこから問題を生成しています。
状態の挿入
bevy_ascii_terminalを使うのでそのセットアップの中で、一緒にStateを insert_resource
で挿入します。
const WIDTH: usize = 40;
const HEIGHT: usize = 7;
const GAME_SECONDS: f32 = 30.0;
fn setup(mut commands: Commands) {
commands.spawn((
Terminal::new([WIDTH, HEIGHT]),
TerminalBorder::single_line(),
));
commands.spawn(TerminalCamera::new());
commands.insert_resource(State::new());
}
時間の経過
以下のシステムを作り、ゲームが進行中であれば、残り秒数を減らし、その結果がゲームが終了したと判断すればStateのrunningをfalseとすることでプレイ中ではなくなるようにしています。
fn tick_timer(time: Res<Time>, mut state: ResMut<State>) {
state.time_left -= time.delta().as_secs_f32();
if state.time_left <= 0.0 {
state.time_left = 0.0;
state.running = false;
}
}
また、上記のシステムが、プレイ中のみ動くようにするため、以下のようにman関数に記述しています。
fn main() {
App::new()
.add_plugins((DefaultPlugins, TerminalPlugins))
.add_systems(Startup, setup)
.add_systems(
Update,
(tick_timer, input, draw).run_if(|state: Res<State>| state.running),
)
.add_systems(
Update,
(input_pause, draw_pause).run_if(|state: Res<State>| !state.running),
)
.run();
}
次の項で扱うinput、drawは run_if
を使いゲームプレイ中のみに実行されるようにしています。
プレイ中の画面描画とキー入力
スコア、残り秒数、問題、入力している内容は、以下のdraw関数で実装しています。
bevy_ascii_terminalを使う注意点として、put_stringとput_charで座標系がyに関して反転しているようで注意が必要でした。
fn draw(mut q_term: Query<&mut Terminal>, state: Res<State>) {
let mut term = q_term.single_mut().unwrap();
term.clear();
term.put_string([1, 1], format!("SCORE {:03}", state.score));
term.put_string(
[WIDTH as i32 - 8, 1],
format!("TIME {:02}", state.time_left.ceil() as i32),
);
term.put_string([1, 3], "WORD:");
term.put_string([7, 3], state.target.as_str());
term.put_string([1, 5], "TYPE:");
for (i, ch) in state.input.chars().enumerate() {
let x = 7 + i as i32;
let is_ok = state.target.chars().nth(i).map(|t| t == ch).unwrap_or(false);
let col = if is_ok { color::GREEN } else { color::RED };
term.put_char([x, 1], ch).fg(col);
}
}
Appに登録しているdraw関数はrunninngの時だけ実行されるため、プレイ中の内容だけにフォーカスすればよく必要な情報を記載している形です。
キー入力は以下の通りで、
- アルファベット文字の入力であればそれをstate.inputにつめる
- バックスペースが入力されれば一文字削る
- 都度正解しているかどうかのチェックを行い、正解なら、state.next_targetを呼び出す
という挙動です。
fn input(mut key_events: EventReader<KeyboardInput>, mut state: ResMut<State>) {
for key in key_events.read() {
if key.state != bevy::input::ButtonState::Pressed {
continue;
}
match key.key_code {
KeyCode::Backspace => {
state.input.pop();
}
_ => {
if let Character(ch) = &key.logical_key {
let ch = ch.chars().next().unwrap();
if ch.is_ascii_alphanumeric() {
state.input.push(ch.to_ascii_lowercase());
}
}
}
}
}
if state.input == state.target {
state.next_target();
}
}
プレイ中ではない時の画面描画とキー入力
r
キーが押されれば再度プレイ中となり、そうでない場合は、プレイ直後のスコアを出すくらいのものなので以下の通りシンプルです。
fn input_pause(mut key_events: EventReader<KeyboardInput>, mut state: ResMut<State>) {
for key in key_events.read() {
if key.state == bevy::input::ButtonState::Released && key.key_code == KeyCode::KeyR {
state.start();
}
}
}
fn draw_pause(mut q_term: Query<&mut Terminal>, state: Res<State>) {
let mut term = q_term.single_mut().unwrap();
term.clear();
term.put_string([1, 1], format!("SCORE {:03}", state.score));
let msg = "*** TIME UP ***".to_string();
let hint = "Press R to restart";
term.put_string([(WIDTH as i32 - msg.len() as i32) / 2, 2], msg);
term.put_string([(WIDTH as i32 - hint.len() as i32) / 2, 4], hint);
}
あらかじめ用意していた、Stateのstartを呼ぶことで再度プレイ中に戻る、という点がポイントでしょうか。
おしまい
今回もこれまで同様以下の個人リポジトリで公開しています。
cloneした後に、
% cargo run --example typing
で挙動を確認できます。