Rust のゲーム作成フレームワーク Bevy を使って「2048」を作ります。シリーズ第二回の今回は、タイルの出現処理を行います。
本記事で作成しているプログラムは GitHub で公開しています。
シリーズ記事
- 第一回: タイルを動かす
- 第二回: タイルを生成する(この記事です)
Step 4. State を切り分ける
4-1. State の設計
今回は新しく State という機能を使います。 State とはその名の通り、「ゲームがいまどういう状態か」を表す情報です。
例えば RPG のゲームでは「タイトル画面」「マップを歩き回る」「戦闘中」といった State が考えられますが、それぞれの State では全く異なる処理が必要になります。 System を State ごとに切り分け、必要に応じて State を切り替えることで、 System を効率よく整理することができます。
「2048」のゲーム中の処理は以下のサイクルを繰り返しながら進んでいくので、これを State に切り分けましょう。
以下の2つの State を行き来させることにします。
各 State は「その状態でどの System を動かすか」といった情報の他に、以下のように切り替え時の処理を細かく書くことができます。
-
on_enter
: その State に移行した際一度だけ実行 -
on_update
: その State である間毎フレーム実行 -
on_exit
: その State を抜ける際一度だけ実行
今回は以下のように System を組むことにしました。
早速 State を定義し、ゲーム開始時の State を与えます。
// StateにはこれらのTraitが必要
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum GameState {
Move,
Spawn,
}
fn main() {
App::new()
// ...
.add_startup_system(setup)
.add_state(GameState::Move) // 初期State
.add_event::<MoveEvent>()
// ...
.run();
}
余談: State Stack
State は一場面に1つとは限りません。 State の管理領域は stack のようになっており、一度に複数の State を同時に持つことが可能です。これを利用すればゲーム中にポーズ画面を挟んだり、戦闘中の階層的なコマンド入力画面を管理したりすることが簡単にできます。
4-2. Move State
まずは前回書いた System を Move State の update の時だけ実行するようにします。
fn main() {
App::new()
// .add_system(send_move_event)
// .add_system(move_tiles_system)
.add_system_set(
SystemSet::on_update(GameState::Move)
.with_system(send_move_event)
.with_system(move_tiles_system),
)
// ...
}
さらに移動が完了した時に Spawn State に切り替える処理を書きましょう。
pub fn move_tiles_system(
mut ev_move: EventReader<MoveEvent>,
mut query: Query<(&mut Transform, &mut Position), With<Tile>>,
mut app_state: ResMut<State<GameState>>, // State変更に必要
) {
let mut moved = false;
if let Some(ev) = ev_move.iter().next() {
// 中略
moved = true;
}
if moved {
// 移動処理が起こったらSpawn Stateに移行
app_state.set(GameState::Spawn).unwrap();
}
}
これで移動すると Spawn State に移るようになりました。Spawn State では移動の System が働かないので、一回移動するともう何も動きません。
Step5. タイルランダム生成処理
5-1. Spawn State
まずは Spawn State に入ったら即 Move State に戻す System を作ります。
pub fn return_to_move_state(mut app_state: ResMut<State<GameState>>) {
app_state.set(GameState::Move).unwrap();
}
fn main() {
App::new()
// ...
.add_system_set(
SystemSet::on_update(GameState::Spawn)
.with_system(return_to_move_state)
)
// ...
}
これで再び無限に移動ができるようになりました。
そして Spawn State を抜ける時、ランダムな位置にタイルを1枚作ります。今のところは新しいタイルの数字は2で固定にしておきます。
move_tiles_system()
と同様に現在の盤面を取得し、開いているマスを一つ選びタイルを Commands::spawn()
で作成します。この辺の処理も重複が多いのでいずれ共通化したいですね……
pub fn create_random_tile(
mut commands: Commands,
query: Query<&Position, With<Tile>>,
mut rng: ResMut<Xoshiro256StarStar>,
) {
let mut map = vec![false; TILE_NUM];
for pos in query.iter() {
map[pos.index()] = true;
}
let candidates: Vec<usize> = (0..TILE_NUM).filter(|&i| !map[i]).collect();
let idx = candidates[rng.next_u64() as usize % candidates.len()];
let position = Position::from_index(idx);
let tile = Tile(2);
commands
.spawn()
.insert(tile.clone())
.insert(position.clone())
.insert_bundle(SpriteBundle {
sprite: Sprite {
color: Color::from(tile),
custom_size: Some(Vec2::new(TILE_SIZE, TILE_SIZE)),
..Default::default()
},
transform: position.into(),
..Default::default()
});
}
fn main() {
App::new()
// ...
.insert_resource(Xoshiro256StarStar::from_entropy())
// ...
.add_system_set(
SystemSet::on_exit(GameState::Spawn)
.with_system(create_random_tile)
)
//...
}
乱数を生成するため、 rand_xoshiro
crate を使っています。乱数生成器は必要になるたびに初期化していては効率が悪いため、 Resourse として App に追加しておきます。幸い Bevy は Rng
トレイトを持つ struct をそのまま Resourse として使えるので、自作 struct で包む必要もありません。
うおお!!!
5-2. クラッシュの回避
盤面がいっぱいになるとどこにも新しいタイルを置けなくなり、置くことのできる0個の候補から1つを選ぼうとするため panic します。
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 1.11s
Running `\home\user\bevy_2048\target\debug\bevy_2048`
2022-02-14T00:00:00.000000Z INFO bevy_render::renderer: AdapterInfo # ...(略)
!error: process didn't exit successfully: `\home\user\bevy_2048\target\debug\bevy_2048` (exit code: 101)
$
そこで、タイルが動かせなくなったら GameOver State に移るようにしましょう。 GameOver State を追加します。
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum GameState {
Move,
Spawn,
GameOver,
}
Move State に入る時に、タイルを動かす余地があるかをチェックします。条件は
- 空きマスがある
- 同じ数字が隣り合っているところがある
のいずれかです。どちらも満たされていない場合、 GameOver State に移行させます。今はまだタイルの合成ができないので、空きマスがなくなったらゲームオーバーとして、空きマスの有無だけを確認しましょう。
pub fn check_game_over(
query: Query<&Position, With<Tile>>,
mut app_state: ResMut<State<GameState>>,
) {
let mut map = vec![false; TILE_NUM];
for pos in query.iter() {
map[pos.index()] = true;
}
if (0..TILE_NUM).all(|i| map[i]) {
app_state.set(GameState::GameOver).unwrap();
}
}
fn main() {
App::new()
// ...
.add_system_set(
SystemSet::on_enter(GameState::Move)
.with_system(check_game_over)
)
// ...
}
GameOver State ではゲームオーバーになったことがわかるようにコンソールに出力させて、 App を終了させてみましょう。 App の終了も Event を送信することで実現できます。
use bevy::app::AppExit;
pub fn end_game(mut exit: EventWriter<AppExit>) {
println!("GAME OVER!");
exit.send(AppExit); // App を終了
}
fn main() {
App::new()
// ...
.add_system_set(
SystemSet::on_enter(GameState::GameOver)
.with_system(end_game)
)
// ...
}
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 1.11s
Running `\home\user\bevy_2048\target\debug\bevy_2048`
2022-02-14T00:00:00.000000Z INFO bevy_render::renderer: AdapterInfo # ...(略)
GAME OVER!
$
盤面がいっぱいになるとウィンドウが自動で閉じるようになりました。挙動としては全く同じですが、 panic せずに正常に終了しています。将来的にはここでゲームオーバー画面やスコア、リトライボタンを表示したいです。
5-3. 無効な移動の拒否
現在の実装だと、タイルが1つも動かなくても Move State を抜けてしまいます。
これは move_tiles_system()
内で MoveEvent
を消化したら Spawn State に移行してしまっており、処理中に実際にタイルが動いたかどうかを確認していないためです。そこで、タイルが動かなかった場合は Move State を抜けないようにします。
pub fn move_tiles_system(
mut ev_move: EventReader<MoveEvent>,
mut query: Query<(&mut Transform, &mut Position), With<Tile>>,
mut app_state: ResMut<State<GameState>>,
) {
let mut moved = false;
if let Some(ev) = ev_move.iter().next() {
// ...
for (mut trans, mut pos) in query.iter_mut() {
let idx = pos.index();
pos.x += dx * map[idx];
pos.y += dy * map[idx];
*trans = pos.clone().into();
if map[idx] != 0 { // 今見ているタイルの移動距離が0でない
moved = true;
}
}
}
if moved {
app_state.set(GameState::Spawn).unwrap();
}
}
これで、同じ方向にボタンを連打してもタイルが出現しなくなりました。
Step 6. タイル大量出現バグの修正
6-1. バグの原因
しばらく動かしてみると、稀に一度しか動いていないのに複数のタイルがいっぺんに出現してしまうことがあります。
関数呼び出しの際や毎フレームの開始時に適当なメッセージをコンソールに出力したりして確認すると、どうやら1フレームの間で複数回 State の往復が起こってしまっているようです。
つまり、以下のようになっているのではないか?と考えられます。
- キー入力を受け取り、 Move Event を送信
- タイルが移動し、 Spawn State に移行
- Spawn 処理を行い、 Move State に移行
- この時点ではまだフレームを跨いでおらず、
Event Queue にInput に 1. の入力が残っている。 -
- で出現したタイルが 2. の方向に移動できてしまう場合、再度移動処理が行われ Spawn State に移行
- Spawn 処理を行い、 Move State に移行
- (繰り返し)
そこで、 State の移行処理をあらゆる System の最後に持ってくることにしましょう。
追記: この方針は最適ではなかったので 6-3. までご覧ください。
6-2. Stage
Stage とは、各フレーム内の時間経過を表す情報です。ある System を「フレーム内のいつ実行するか?」という情報を、 Stage を利用することで定義することができます。
基本的には以下の5つの Stage があります。それぞれの Stage 開始時、それ以前の Stage に定義された System は全て完了していることが保証されます。また、自分で新しい Stage を定義することも可能です。
-
First
: 全ての System に先立って実行される -
PreUpdate
:Update
の前に実行される -
Update
: 基本的に全ての System はここに追加される -
PostUpdate
:Update
の後に実行される -
Last
: 全ての System が終了した後に実行される
そこで、これまで行ってきた State 移行処理はすべて「 State Change Event を送信する」ように書き換え、 PostUpdate
内で「送信された Event に基づいて State を変更する」としましょう。こうすれば、1フレーム中に何度も State を往復してしまう心配はなくなります。
// 直接書き換えるのではなく遷移を依頼する
pub fn check_game_over(
query: Query<&Position, With<Tile>>,
// mut state: ResMut<State<GameState>>,
mut ev_state: EventWriter<GameState>
) {
let mut map = vec![false; TILE_NUM];
for pos in query.iter() {
map[pos.index()] = true;
}
if (0..TILE_NUM).all(|i| map[i]) {
// state.set(GameState::GameOver).unwrap();
ev_state.send(GameState::GameOver);
}
} // 他のsystemも同様に書き換える
// すべてのState変更を担うsystem
pub fn change_state(
mut ev_state: EventReader<GameState>,
mut app_state: ResMut<State<GameState>>
) {
if let Some(state) = ev_state.iter().next() {
app_state.set(state.clone()).unwrap();
}
}
fn main() {
App::new()
// ...
.add_event::<GameState>() // Eventとしても追加
// ...
.add_system_to_stage(CoreStage::PostUpdate, change_state)
// ...
}
これで、確実に1つのタイルだけが出現するようになりました。
6-3. タイルがちらつく
gif 画像ではわかりにくいですが、今度は出現するタイルが一瞬チラついて見えるようになってしまいました。これは当然で、 PostUpdate
内で State の切り替えを行うようになったことで
- タイルが移動
- Spawn Stateに移動
- フレーム切り替え
- タイルを出現させる
- Move Stateに移動
- フレーム切り替え
というように、タイルの移動から出現までの間に必ず1フレームの誤差が生まれてしまうためです。
将来的にはタイルにアニメーションを付けて出現させようとしているので、1フレームだけズレることはそこまで気にならなそうですが、ちょっと気持ち悪いです。
タイルが大量に出現している部分についていろいろと原因を探っていると、以下のIssueを発見しました。
どうやら State 遷移に Input が嚙む場合、State を跨って Input が残存してしまい予期せぬ挙動を起こしうるため、手動で入力値をリセットするべきのようです。
結局 State 遷移専用の System を PostUpdate
に用意したりする必要はなく、 spawn 処理に入ったタイミングで入力をリセットさせることでバグを解消できました。
pub fn return_to_move_state(
mut keyboard: ResMut<Input<KeyCode>>,
mut app_state: ResMut<State<GameState>>,
) {
keyboard.clear(); // 追加
app_state.set(GameState::Move).unwrap();
}
// State遷移を行うsystemたちも元の記述に戻す
pub fn check_game_over(
query: Query<&Position, With<Tile>>,
mut state: ResMut<State<GameState>>,
) {
let mut map = vec![false; TILE_NUM];
for pos in query.iter() {
map[pos.index()] = true;
}
if (0..TILE_NUM).all(|i| map[i]) {
state.set(GameState::GameOver).unwrap();
}
}
すっきり!
おわり
ここまで読んでいただきありがとうございました。次回はタイルの合成処理を書きます。