Rustゲームエンジンbevyでテトリスを作る
bevyは 2020 年に登場した rust 製のゲームエンジンです。
Entity-Component-System(ECS) によるデータ指向設計を採用しており、そのような rust 製ゲームエンジンは他にもいくつかあるのですが、中でも bevy はピュアな rust の関数や構造体で振る舞いを記述するという特徴を持っています。私はいくつかの rust 製ゲームエンジンを触ってみて、bevy の書きごこちが一番気に入りました。
一方、現状の公式 bevy チュートリアルでは、コンソール上にhello world!
などのメッセージを表示するところまでしか扱っておらず、ゲームエンジンに期待する機能であるオブジェクトの描画・生成・削除、毎フレームごとの処理の記述などを理解するにはexampleや本体のコードを参照するなど、それなりに険しい道をいく必要があります。
私の場合、bevy でスネークゲームを作るというこちらの記事が bevy を理解するにあたり非常に助けになりました。この記事では、前述の記事と同じようにこれから bevy を学ぶ方 の基本機能の理解の助けとなれるよう、bevy で 1 からテトリスを作っていく過程をチュートリアルとして解説していきたいと思います。
手順の通りに進めていくと、最終的に下記のようなアプリケーションが出来上がります。
前提知識 Entity-Component-System とデータ指向設計について
bevyのイントロダクションページには、以下のような記述があります。
Data Focused: Data-oriented architecture using the Entity Component System paradigm
ここで述べられているEntity Component Systemについて雰囲気を理解しておくと bevy の理解が非常にスムーズになるので、まずはEntity Component Systemについて簡単に説明したいと思います。
Data-oriented architecture は、ハードウェア(インフラ)、データベースなどを含んだアプリケーション全体をデータを中心に捉えて構築するアーキテクチャです。
一方、Entity Component System(以下 ECS)は様々なゲームエンジンで用いられているアーキテクチャパターンで、単なる id のみの存在である(ことが多い)エンティティに、付加的な振る舞いを追加するコンポーネントをペタペタとつけていく形でゲーム世界を実装します。システムはエンティティの追加・削除やコンポーネントの着脱・変更の役割を担っており、ゲームシーンに変化を起こす実装は全てシステムに集約されます。
一般的に、ECS の文脈でコンポーネントはプリミティブなデータやその組で表現されることが多く、その場合 json ファイルや RDB などでも扱える形式になります。このことから前述のData-oriented architectureとの相性は抜群です。
ECS で表現されたゲームにおいて、ゲーム内に登場するモノは UI、カーソルなどを含め何もかもがエンティティです。
Bevy における ECS について、簡単な例を図示してみます。左上の数字がエンティティの番号(id)を表しており、色付きの四角がコンポーネントを表しています。コンポーネントは値を持つことも、持たないこともでき、持たない場合は単なるタグのように機能します。
上記の図は、自機と敵機が存在し、自機が弾を放って敵機を撃墜していくようなシューティングゲームを想定しています。シューティングゲームにおいて、基本的なゲームの動きには以下のようなものがあります。
- プレイヤーが自機を移動させる。
- プレイヤーが弾を発射する。
- 敵機は弾が当たるとダメージを受ける。
このようなフレーム毎に必要な動きの一つ一つを、システムによって記述できます。 システムの具体的な実装方法は後々説明しますが、基本的には、
- 操作対象のコンポーネントを選択するクエリ
- クエリによって抽出されたコンポーネントに対して行う処理
の 2 つによって記述されます。例えば先述の「プレイヤーが自機を移動させる。」を担うシステムであれば、以下のようになるでしょう。
クエリ: 「POS」コンポーネントを持ち、かつ「Player」コンポーネントを持つエンティティの「POS」コンポーネント
処理: キー入力で「↑」を検知していれば pos.y を 10 増加、「↓」を検知していれば pos.y を 10 減少...
このように、bevy ではゲーム上で発生するあらゆる変化がクエリと処理=(システム)によって実装されます。なんとなく雰囲気が伝わったでしょうか?
それでは、ここから実際の実装に入っていきます。
STEP.1 プロジェクト作成と画面表示
まずは Rust プロジェクトを作成します。
mkdir tetris
cd tetris
cargo init
Cargo.toml にはbevy
、rand
クレートを追加しておきます。
// ...
[dependencies]
bevy = "0.4"
rand = "0.7.3"
main.rs は以下のように書き換えます。
use bevy::prelude::*;
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: 500.,
height: 500.,
..Default::default()
})
.add_plugins(DefaultPlugins)
.run();
}
この状態でビルドしてみます。
cargo run
ウインドウが立ち上がり、アプリケーション画面が表示されました。
STEP.2 画面サイズ調整とリソースの追加
画面上のマスの数は 10×18、1 マスの大きさは 40×40 ピクセルにしようと思います。この情報を定数としてプログラム内に定義しておきます。
const UNIT_WIDTH: u32 = 40;
const UNIT_HEIGHT: u32 = 40;
const X_LENGTH: u32 = 10;
const Y_LENGTH: u32 = 18;
const SCREEN_WIDTH: u32 = UNIT_WIDTH * X_LENGTH;
const SCREEN_HEIGHT: u32 = UNIT_HEIGHT * Y_LENGTH;
// ...
上記に合わせ、画面サイズを変更します。
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32, // <--追加
height: SCREEN_HEIGHT as f32, // <--追加
..Default::default()
})
.add_plugins(DefaultPlugins)
.run();
指定した通り、アプリケーション画面が縦長になりました!
STEP.3 矩形を描画する
ここまでで、アプリケーションのウインドウを表示することができました。これ以降は、このウインドウ内で動くゲームを実装していきます。
初めての system
実は、現状のゲーム画面にはプレイヤーの目となるカメラが存在しません。まずはカメラを配置するために、setup という関数を用意します。
fn setup(commands: &mut Commands) {
commands.spawn(Camera2dBundle::default());
}
fn main() {
// ...
この setup 関数が、前述したシステムにあたります。bevy におけるシステムは通常の関数のように表現され、そのシステムが扱う対象が関数の引数で表されます。引数のcommandsを使うことで、例えば以下のような事ができます。
- エンティティをゲーム内に追加する。
- エンティティをゲームから取り除く。
- エンティティにコンポーネントを付与する。
- エンティティに付与されているコンポーネントを取り外す。
一方、コンポーネントの値をいじることは commands を使わなくてもできます。引数にcommandsが含まれている場合、それは上記のようなコンポーネントの値の変更ですまない操作を含む システム だと考えられます。
setup 関数の中身について、簡単に解説します。
commands.spawn(bundle) は、Bundleから エンティティ を生成するメソッドです。Bundleという新しい単語が出ましたが、これはいくつかのコンポーネントの集合を表しています。commands.spawn によって、その bundle に含まれるコンポーネント達を持った新しい Entity が生成されます。
よって、この setup 関数では、Camera2dBundleという、エンティティ がカメラとして機能するためのコンポーネント群を装備した エンティティ を一つ作成していることになります。
アプリへの system 適用
では、この setup システムをゲームに適用してみましょう。以下のように記載します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_plugins(DefaultPlugins)
.add_startup_system(setup.system()) // <--追加
.run();
}
なんと、pure な関数に system()というメソッドが生えています! これはbevy::prelude::*;を use すると関数に対して実装されるもので、関数からシステムを生成します。
add_startup_system は、ただ一度だけ実行したいシステムを追加する時に用います。毎フレームごとに実行するシステムの場合はadd_systemを使い、これも後々利用します。
この状態で実行しても、特に画面に代わりはありません。(setup の中に println!などを書けば、setup が正しく追加されていることを確認できます。)せっかくカメラを配置したのに何も表示するべきものがないからです。
次は、ゲーム内に物を配置してみましょう。
fn setup(commands: &mut Commands) {
commands.spawn(Camera2dBundle::default());
commands // <--追加
.spawn(SpriteBundle {
sprite: Sprite::new(Vec2::new(20.0, 20.0)),
..Default::default()
})
.current_entity()
.unwrap();
}
再度実行してみると、以下のように矩形が出現しました!
ここでカメラエンティティの生成をコメントアウトしてみると矩形が消え、カメラが無くなっている事が分かります。
STEP.4 矩形の位置管理
画面に矩形を表示させる事ができましたが、矩形の座標は特に指定していません。これは、以下のように SpriteBundle の transform を指定することで設定できます。
commands
.spawn(SpriteBundle {
sprite: Sprite::new(Vec2::new(20.0, 20.0)),
transform: Transform { // <--一時的に追加
translation: Vec3::new(80.0, -90.0, 0.0),
scale: Vec3::new(1.0, 1.0, 0.0),
rotation: Quat::from_rotation_x(0.0),
},
..Default::default()
})
ここで transform.translation の値を色々と変えてみると分かりますが、厄介なことにデフォルトの中心座標が画面中央になっています。
また、テトリスのブロックは浮動小数点のようなじわじわとした動きではなく 1 マスずつの動きになるので、できれば整数で座標を扱いたいです。
そこで、コンポーネントの機能を使って座標を扱いやすくしてみましょう。
初めてのコンポーネント
コンポーネントは以下のように定義します。
struct Position {
x: i32,
y: i32,
}
なんと普通の構造体です!!!bevy では、コンポーネントもシステムと同様、rust のピュアな機能を使って記述する事ができます。では、この Position コンポーネントをエンティティに付けてみましょう。
fn setup(commands: &mut Commands) {
commands.spawn(Camera2dBundle::default());
commands
.spawn(SpriteBundle {
sprite: Sprite::new(Vec2::new(20.0, 20.0)),
..Default::default()
})
.with(Position { x: 1, y: 5 }) // <--追加
.current_entity()
.unwrap();
}
このように、with()を使うことでエンティティにコンポーネントを付与した状態で生成できます。
この状態で実行しても、依然として矩形は画面中央にそびえ立っています。Position コンポーネントに対応する振る舞いを実装していないので当然ですね。続いては、Position コンポーネントの値に応じて位置を変更させるシステムを作ってみましょう。
位置を変更するシステム
setup に続く 2 つ目のシステムを作ってみます。
fn position_transform(mut position_query: Query<(&Position, &mut Transform, &mut Sprite)>) {
let origin_x = UNIT_WIDTH as i32 / 2 - SCREEN_WIDTH as i32 / 2;
let origin_y = UNIT_HEIGHT as i32 / 2 - SCREEN_HEIGHT as i32 / 2;
position_query
.iter_mut()
.for_each(|(pos, mut transform, mut sprite)| {
transform.translation = Vec3::new(
(origin_x + pos.x as i32 * UNIT_WIDTH as i32) as f32,
(origin_y + pos.y as i32 * UNIT_HEIGHT as i32) as f32,
0.0,
);
sprite.size = Vec2::new(UNIT_WIDTH as f32, UNIT_HEIGHT as f32)
});
}
まずは、position_transform関数の引数に注目してください。これがクエリであり、このシステムがどのようなコンポーネントを対象に取るかを表しています。
この関数の場合、Position、Transform、Spriteコンポーネントの全てを持つエンティティ(のコンポーネント)が扱う対象です。Transform、Spriteについては可変の参照をとっているため、このシステム内で書き換える事が可能です。
position_query はイテレータとして扱う事ができ、ここでは Position の値に合わせて補正を加えた座標に transform.translation を書き換えています。
なお、ここでクエリは列挙した全てのコンポーネントを持つという AND の役割を果たしていますが、ORで扱うことも可能です。
position_transformをシステムとして登録します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_plugins(DefaultPlugins)
.add_startup_system(setup.system())
.add_system(position_transform.system()) // <--追加
.run();
}
position_transform は、毎フレーム実行するシステムとして、add_systemで登録しています。これは、ゲーム内で座標が変更された場合に描画される位置を追従させるためです。
setup 関数で設定していた sprite も削除しておきます。
fn setup(commands: &mut Commands) {
commands.spawn(Camera2dBundle::default());
commands
.spawn(SpriteBundle {
//sprite: Sprite::new(Vec2::new(0.0, 0.0)), // <--削除
..Default::default()
})
.with(Position { x: 1, y: 5 })
.current_entity()
.unwrap();
}
実行してみると、位置が変わっています!Position コンポーネントの x を 0〜9、y を 0〜17 の範囲で変更してみてください。画面左下を(0,0)として、矩形の位置が変化します。
STEP.5 カラーの導入
続いては、ブロックに色を付けてみましょう。その前に、現状ブロックを生成する処理が setup システム内に記述されています。ブロック生成はゲーム開始直後以外にも何度も発生するため、別システムにしておきましょう。
fn setup(commands: &mut Commands) {
commands.spawn(Camera2dBundle::default());
/* 削除
commands
.spawn(SpriteBundle {
..Default::default()
})
.with(Position { x: 1, y: 5 })
.current_entity()
.unwrap();
*/
}
fn spawn_block_element(commands: &mut Commands) {
commands // <--setupに書かれていた処理を追加
.spawn(SpriteBundle {
..Default::default()
})
.with(Position { x: 1, y: 5 })
.current_entity()
.unwrap();
}
.add_startup_system(setup.system())
.add_system(spawn_block_element.system()) // <--追加
.add_system(position_transform.system())
リソースによる Color の追加
bevy には、ゲーム進行中に動的に増える事が無いようなものを扱うためのResourceという機能があります。Resource で扱えるものは幅広く、例えばゲームのグラフィック画像、BGM、サウンドなどもリソースです。ここでは、色の情報をリソースで扱ってみることにします。
まずは、リソースの構造体を作ります。
struct Materials {
colors: Vec<Handle<ColorMaterial>>,
}
そして、setup システム内で Materials リソースをゲーム内に追加します。
fn setup(commands: &mut Commands, mut materials: ResMut<Assets<ColorMaterial>>) { // <--引数を追加
commands.spawn(Camera2dBundle::default());
commands.insert_resource(Materials { // <--追加
colors: vec![
materials.add(Color::rgb_u8(64, 230, 100).into()),
materials.add(Color::rgb_u8(220, 64, 90).into()),
materials.add(Color::rgb_u8(70, 150, 210).into()),
materials.add(Color::rgb_u8(220, 230, 70).into()),
materials.add(Color::rgb_u8(35, 220, 241).into()),
materials.add(Color::rgb_u8(240, 140, 70).into()),
],
});
}
setup システムの引数に追加した materials は、ゲーム内で利用しているアセットを表します。ResMut<Assets
定義したリソースを、spawn_block_element システムで使ってみます。
use rand::prelude::*; // <--追加
// ...
fn spawn_block_element(commands: &mut Commands, materials: Res<Materials>) { // <--引数を追加
// 追加
let mut rng = rand::thread_rng();
let mut color_index: usize = rng.gen();
color_index %= materials.colors.len();
commands
.spawn(SpriteBundle {
material: materials.colors[color_index].clone(), // <--追加
..Default::default()
})
.with(Position { x: 1, y: 5 })
.current_entity()
.unwrap();
}
ブロックに色がついています!なお、add_system で動かしているため色が目まぐるしく変わっています。
STEP.6 テトリスブロックの生成
テトリスでは、複数の矩形が連なったものが落下、回転操作の対象になります。複数の矩形が連なったもの(ブロック)を生成してみましょう。
各ブロックは、回転の中心となる座標を(0,0)として、テトリスブロックを構成する矩形の座標の相対位置の配列で表すことにします。
テトリスブロックのパターンを構造体として定義します。
struct BlockPatterns(Vec<Vec<(i32, i32)>>);
これを add_resource を使い、リソースとして使用します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![ // <--追加
vec![(0, 0), (0, -1), (0, 1), (0, 2)], // I
vec![(0, 0), (0, -1), (0, 1), (-1, 1)], // L
vec![(0, 0), (0, -1), (0, 1), (1, 1)], // 逆L
vec![(0, 0), (0, -1), (1, 0), (1, 1)], // Z
vec![(0, 0), (1, 0), (0, 1), (1, -1)], // 逆Z
vec![(0, 0), (0, 1), (1, 0), (1, 1)], // 四角
vec![(0, 0), (-1, 0), (1, 0), (0, 1)], // T
]))
.add_plugins(DefaultPlugins)
.add_startup_system(setup.system())
.add_system(spawn_block_element.system())
.add_system(position_transform.system())
.run();
}
ここで定義したブロックのパターンに従ってテトリスブロックを生成するようにします。 その前に、ブロックに含まれる矩形は全て同じ色にしたいので、前節で定義したランダムな色を生成する箇所を別関数に切り出しておきます。
fn spawn_block_element(commands: &mut Commands, color: Handle<ColorMaterial>) { //materials: Res<Materials>引数をcolorに変更
/* 削除
let mut rng = rand::thread_rng();
let mut color_index: usize = rng.gen();
color_index %= materials.colors.len();
*/
commands
.spawn(SpriteBundle {
material: color, // materials.colors[color_index].clone()をcolorに変更
..Default::default()
})
.with(Position { x: 1, y: 5 })
.current_entity()
.unwrap();
}
fn next_color(colors: &Vec<Handle<ColorMaterial>>) -> Handle<ColorMaterial> {
let mut rng = rand::thread_rng();
let mut color_index: usize = rng.gen();
color_index %= colors.len();
colors[color_index].clone()
}
また、spawn_block_element を、 Position コンポーネント が引数として与えられるように変更します。
fn spawn_block_element(commands: &mut Commands, color: Handle<ColorMaterial>, position: Position) { // position引数を追加
commands
.spawn(SpriteBundle {
material: color,
..Default::default()
})
.with(position) // 引数で与えられたpositionコンポーネントを使うよう変更
.current_entity()
.unwrap();
}
新たに spawn_block システムを実装します。
fn spawn_block(
commands: &mut Commands,
materials: Res<Materials>,
block_patterns: Res<BlockPatterns>,
) {
let new_block = next_block(&block_patterns.0);
let new_color = next_color(&materials.colors);
// ブロックの初期位置
let initial_x = X_LENGTH / 2;
let initial_y = Y_LENGTH - 4;
new_block.iter().for_each(|(r_x, r_y)| {
spawn_block_element(
commands,
new_color.clone(),
Position {
x: (initial_x as i32 + r_x),
y: (initial_y as i32 + r_y),
},
);
});
}
このシステムでは、ランダムな色とブロックパターンを生成し、これらを使って複数の矩形を作っています。 spawn_block をシステムとして登録します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![
// ...
]))
.add_plugins(DefaultPlugins)
.add_startup_system(setup.system())
// .add_system(spawn_block_element.system()) <--削除
.add_system(spawn_block.system()) // <--追加
.add_system(position_transform.system())
.run();
}
実行してみると、テトリスブロックが作られていることが分かります。
STEP.7 イベントによるブロック生成の制御
現状、テトリスブロックは毎フレーム生成される状態になっています。しかし、ゲーム中ではテトリスブロックが生成されるべきタイミング、つまり
- ゲーム開始時
- テトリスブロックが落下しきった時
のみでブロックが生成されるようにしたいです。ここでは、bevy のイベントを使って、ブロックの生成タイミングを制御できるようにしていきます。
イベントは複数のシステムにまたがる処理を書きやすくするものです。pubsub のように、
- あるシステムがイベントを送信する。
- 別のシステムがイベントを受信し、それをトリガーに何らかの処理を行う。
という形で様々なシステムに関連して呼び出したい処理を記述でき、イベントを送信するシステムと受信するシステムが密結合になることを防げます。
イベントの定義
イベントも、コンポーネントやリソースと同様、単純な構造体として定義できます!
struct NewBlockEvent;
イベントを使用する場合、add_event を使って登録します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![
// ...
]))
.add_plugins(DefaultPlugins)
.add_event::<NewBlockEvent>() // <--追加
.add_startup_system(setup.system())
.add_system(spawn_block.system())
.add_system(position_transform.system())
.run();
}
続いては、spawn_block システムがこのイベントをトリガーに動くようにしてみましょう。
fn spawn_block(
commands: &mut Commands,
materials: Res<Materials>,
block_patterns: Res<BlockPatterns>,
new_block_events: Res<Events<NewBlockEvent>>, // <--追加
mut new_block_events_reader: Local<EventReader<NewBlockEvent>>, // <--追加
) {
// ↓追加
if new_block_events_reader
.iter(&new_block_events)
.next()
.is_none()
{
return;
}
// ...
}
イベントは、暗黙にリソースの一種として定義されており、ここではリソースと同様に new__block_event
として取得しています。
イベントはEventReaderを使って検知します。コード内では、new_block_events_reader
変数がイベントリーダーです。
ここで追加された処理により、NewBlockEventが送信された場合のみブロック生成処理を行うようになりました。この状態で実行すると、イベントがどこからも送信されていないため、画面に何も表示されません。
イベントの送信
setup システムで、イベントの送信を行うようにしてみます。
fn setup(
commands: &mut Commands,
mut materials: ResMut<Assets<ColorMaterial>>,
mut new_block_events: ResMut<Events<NewBlockEvent>>, // <--追加
) {
commands.spawn(Camera2dBundle::default());
commands.insert_resource(Materials {
colors: vec![
// ...
],
});
new_block_events.send(NewBlockEvent); // <--追加
}
無事イベントが送信され、ゲーム開始時に一つだけブロックが生成されるようになりました!
STEP.8 テトリスブロックの落下
テトリスブロックは、何もしていない場合下に落ちていきます。続いてはこの落下処理を実装していきます。
タイマーの導入
ブロックの落下は一定時間ごとに進みます。この一定時間を 1 フレームにしてしまうとゲーム速度がとてつもない速さになり、とても人間がプレイするゲームではなくなってしまいます。タイマーという機能で一定間隔の時間を計測できるようにします。
タイマーはリソースとして定義します。これまでのリソースと同様、構造体で定義します。
struct GameTimer(Timer);
タイマーをリソースとして登録します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![
// ...
]))
.add_resource(GameTimer(Timer::new( // <--追加
std::time::Duration::from_millis(400),
true,
)))
.add_plugins(DefaultPlugins)
.add_event::<NewBlockEvent>()
.add_startup_system(setup.system())
.add_system(spawn_block.system())
.add_system(position_transform.system())
.run();
}
.from_millis(400)
で、400 ミリ秒周期を計測するタイマーを定義しています。
タイマーの作動
タイマーはリソースとして登録しただけでは時間計測が行われません。システムの中で時を進める必要があります。タイマーの時間を進めるgame_timer
システムを実装します。
fn game_timer(time: Res<Time>, mut timer: ResMut<GameTimer>) {
timer.0.tick(time.delta_seconds());
}
game_timer
システムを登録します。
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![
// ...
]))
.add_resource(GameTimer(Timer::new(
std::time::Duration::from_millis(400),
true,
)))
.add_plugins(DefaultPlugins)
.add_event::<NewBlockEvent>()
.add_startup_system(setup.system())
.add_system(spawn_block.system())
.add_system(position_transform.system())
.add_system(game_timer.system()) // <--追加
.run();
}
ブロックの落下
ブロックを落下させるblock_fall
システムを実装します。
各ブロックの position を 400ms の経過を検知するごとに y 方向に 1 減らしています。
fn block_fall(timer: ResMut<GameTimer>, mut block_query: Query<(Entity, &mut Position)>) {
if !timer.0.finished() {
return;
}
block_query.iter_mut().for_each(|(_, mut pos)| {
pos.y -= 1;
});
}
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
// ...
.add_system(game_timer.system())
.add_system(block_fall.system()) // <--追加
.run();
}
ブロックが落下しています!
STEP.9 ブロックが落ちきった時に新しいブロックを生成する
ブロックが落ちきった時に、新しいブロックを生成するようにしてみます。 ブロックが落下しきったことを判定するには、ゲーム画面上のどこにブロックエレメントが存在するかを把握する必要があります。まずは、各座標のブロックエレメントの有無を管理するようにしましょう。
GameBoard リソースの追加
ゲーム画面の 10×18 マスの各座標にブロックエレメントが存在するかを bool 値で管理することにします。bool 値の 2 次元配列をリソースとして用意します。
struct GameBoard(Vec<Vec<bool>>);
fn main() {
App::build()
.add_resource(WindowDescriptor {
title: "Tetris".to_string(),
width: SCREEN_WIDTH as f32,
height: SCREEN_HEIGHT as f32,
..Default::default()
})
.add_resource(BlockPatterns(vec![
// ...
]))
.add_resource(GameTimer(Timer::new(
std::time::Duration::from_millis(400),
true,
)))
.add_resource(GameBoard(vec![vec![false; 25]; 25])) // <--追加
// ...
.run();
}
落下の検知とイベントの送信
block_fall
システムでこれ以上ブロックが落下できないことを検知するようにします。さらにこれ以上落下できない場合は落下を行わず、 GameBoard のブロックが存在する座標を true にして NewBlockEvent を送信しています。
fn block_fall(
timer: ResMut<GameTimer>,
mut block_query: Query<(Entity, &mut Position)>,
mut game_board: ResMut<GameBoard>,
mut new_block_events: ResMut<Events<NewBlockEvent>>,
) {
if !timer.0.finished() {
return;
}
// ↓追加
let cannot_fall = block_query.iter_mut().any(|(_, pos)| {
if pos.x as u32 >= X_LENGTH || pos.y as u32 >= Y_LENGTH {
return false;
}
// yが0、または一つ下にブロックがすでに存在する
pos.y == 0 || game_board.0[(pos.y - 1) as usize][pos.x as usize]
});
// ↓の分岐を追加
if cannot_fall {
block_query.iter_mut().for_each(|(entity, pos)| {
game_board.0[pos.y as usize][pos.x as usize] = true;
});
new_block_events.send(NewBlockEvent);
} else {
block_query.iter_mut().for_each(|(_, mut pos)| {
pos.y -= 1;
});
}
}
この状態で実行してみると、確かにブロックが落ちきった後新しいブロックが生成されました。しかし最初のブロックが落下しきった後、それ以降に生成されたブロックは全て「落下しきっている」扱いになってしまっています。
これは、落下判定のイテレータ中で、すでに落下し終わっているブロックも判定対象になっているためです。 正常に動作させるため、落下処理が適用されているブロックと、すでにゲーム画面上で停止しているブロックを区別するようにします。
Fix コンポーネントと Free コンポーネントの導入
コンポーネントによって、ブロックが固定されている、自由落下しているという状態を表現するようにしてみましょう。
新たに 2 つのコンポーネントを用意します。
struct Fix;
struct Free;
Fix コンポーネントを固定されているブロックに、Free コンポーネントを自由落下対象になっているブロックに付けることにします。
生成したてのブロックは自由落下している状態なので、初期状態では Free コンポーネントを付与するようにします。
fn spawn_block_element(commands: &mut Commands, color: Handle<ColorMaterial>, position: Position) {
commands
.spawn(SpriteBundle {
material: color,
..Default::default()
})
.with(position)
.with(Free) // <--追加
.current_entity()
.unwrap();
}
block_fall システムで扱うブロックのクエリに Free を追加し、扱うエンティティを絞り込みます。
fn block_fall(
timer: ResMut<GameTimer>,
mut block_query: Query<(Entity, &mut Position, &Free)>, // <--Freeを追加
mut game_board: ResMut<GameBoard>,
mut new_block_events: ResMut<Events<NewBlockEvent>>,
) {
if !timer.0.finished() {
return;
}
let cannot_fall = block_query.iter_mut().any(|(_, pos, _)| { // <--_を追加
if pos.x as u32 >= X_LENGTH || pos.y as u32 >= Y_LENGTH {
return false;
}
// yが0、または一つ下にブロックがすでに存在する
pos.y == 0 || game_board.0[(pos.y - 1) as usize][pos.x as usize]
});
if cannot_fall {
block_query.iter_mut().for_each(|(entity, pos, _)| { // <--_を追加
game_board.0[pos.y as usize][pos.x as usize] = true;
});
new_block_events.send(NewBlockEvent);
} else {
block_query.iter_mut().for_each(|(_, mut pos, _)| { // <--_を追加
pos.y -= 1;
});
}
}
さらに、これ以上落下できないブロックは Free コンポーネントを取り外し、Fix コンポーネントを付与するようにします。
fn block_fall(
commands: &mut Commands, // <--追加
timer: ResMut<GameTimer>,
mut block_query: Query<(Entity, &mut Position, &Free)>,
mut game_board: ResMut<GameBoard>,
mut new_block_events: ResMut<Events<NewBlockEvent>>,
) {
if !timer.0.finished() {
return;
}
let cannot_fall = block_query.iter_mut().any(|(_, pos, _)| {
if pos.x as u32 >= X_LENGTH || pos.y as u32 >= Y_LENGTH {
return false;
}
// yが0、または一つ下にブロックがすでに存在する
pos.y == 0 || game_board.0[(pos.y - 1) as usize][pos.x as usize]
});
if cannot_fall {
block_query.iter_mut().for_each(|(entity, pos, _)| {
commands.remove_one::<Free>(entity); // <--追加 (Freeコンポーネントを外す)
commands.insert_one(entity, Fix); // <--追加 (Fixコンポーネントを付ける)
game_board.0[pos.y as usize][pos.x as usize] = true;
});
new_block_events.send(NewBlockEvent);
} else {
block_query.iter_mut().for_each(|(_, mut pos, _)| {
pos.y -= 1;
});
}
}
再度実行すると、ブロックの落下判定が正常に動作するようになります。
STEP.10 テトリスブロックの左右移動
今の状態では、落ちるブロックをただ見ていることしかできません。ユーザーの入力を受け付け、テトリスブロックを移動できるようにしてみましょう。
まずは、入力を受け付けるタイミングを管理する新しいタイマーを導入します。
struct InputTimer(Timer);
fn game_timer(
time: Res<Time>,
mut game_timer: ResMut<GameTimer>,
mut input_timer: ResMut<InputTimer>, // <--追加
) {
game_timer.0.tick(time.delta_seconds());
input_timer.0.tick(time.delta_seconds()); // <--追加
}
App::build()
// ...
.add_resource(GameTimer(Timer::new(
std::time::Duration::from_millis(400),
true,
)))
.add_resource(InputTimer(Timer::new( // <--追加
std::time::Duration::from_millis(100),
true,
)))
.add_resource(GameBoard(vec![vec![false; 25]; 25]))
// ...
.run();
また、現在初期ブロックの生成位置が画面内になってしまっているので変更しておきます。
fn spawn_block(
// ...
) {
// ...
// ブロックの初期位置
let initial_x = X_LENGTH / 2;
let initial_y = Y_LENGTH; // <--変更
// ...
}
キー入力を受け取る
キーボード入力を受け付けて左右の移動を行うシステムを用意します。
fn block_horizontal_move(
key_input: Res<Input<KeyCode>>,
timer: ResMut<InputTimer>,
game_board: ResMut<GameBoard>,
mut free_block_query: Query<(Entity, &mut Position, &Free)>,
) {
if !timer.0.finished() {
return;
}
}
fn main() {
App::build()
// ...
.add_system(spawn_block.system())
.add_system(position_transform.system())
.add_system(game_timer.system())
.add_system(block_fall.system())
.add_system(block_horizontal_move.system()) // <--追加
.run();
}
上記のkey_input
を通して、特定のキーが押されているかを bool 値として受け取ることができます。
fn block_horizontal_move(
key_input: Res<Input<KeyCode>>,
timer: ResMut<InputTimer>,
game_board: ResMut<GameBoard>,
mut free_block_query: Query<(Entity, &mut Position, &Free)>,
) {
if !timer.0.finished() {
return;
}
// ↓追加
if key_input.pressed(KeyCode::Left) {
// 左に移動できるか判定
let ok_move_left = free_block_query.iter_mut().all(|(_, pos, _)| {
if pos.y as u32 >= Y_LENGTH {
return pos.x > 0;
}
if pos.x == 0 {
return false;
}
!game_board.0[(pos.y) as usize][pos.x as usize - 1]
});
if ok_move_left {
free_block_query.iter_mut().for_each(|(_, mut pos, _)| {
pos.x -= 1;
});
}
}
// ↓追加
if key_input.pressed(KeyCode::Right) {
// 右に移動できるか判定
let ok_move_right = free_block_query.iter_mut().all(|(_, pos, _)| {
if pos.y as u32 >= Y_LENGTH {
return pos.x as u32 <= X_LENGTH;
}
if pos.x as u32 == X_LENGTH - 1 {
return false;
}
!game_board.0[(pos.y) as usize][pos.x as usize + 1]
});
if ok_move_right {
free_block_query.iter_mut().for_each(|(_, mut pos, _)| {
pos.x += 1;
});
}
}
}
このシステムでは、左右どちらかのキーが押されている時、左/右に移動できるか判定し、可能なら移動させています。今回は左右の移動を一つのシステムとして実装しましたが、例えばleft_move
、right_move
のように 2 つのシステムにすることもできます。
これでブロックが左右に移動するようになりました!
STEP.11 テトリスブロックの落下操作
続いてはブロックを落下させる操作を作ります。これは左右移動と同様、下キー入力が押された時にブロックを最大まで下に落とせばいいです。
fn block_vertical_move(
key_input: Res<Input<KeyCode>>,
mut game_board: ResMut<GameBoard>,
mut free_block_query: Query<(Entity, &mut Position, &Free)>,
) {
if !key_input.just_pressed(KeyCode::Down) {
return;
}
let mut down_height = 0;
let mut collide = false;
// ブロックが衝突する位置を調べる
while !collide {
down_height += 1;
free_block_query.iter_mut().for_each(|(_, pos, _)| {
if pos.y < down_height {
collide = true;
return;
}
if game_board.0[(pos.y - down_height) as usize][pos.x as usize] {
collide = true;
}
});
}
// ブロックが衝突しないギリギリの位置まで移動
down_height -= 1;
free_block_query.iter_mut().for_each(|(_, mut pos, _)| {
game_board.0[pos.y as usize][pos.x as usize] = false;
pos.y -= down_height;
game_board.0[pos.y as usize][pos.x as usize] = true;
});
}
fn main() {
App::build()
// ...
.add_system(spawn_block.system())
.add_system(position_transform.system())
.add_system(game_timer.system())
.add_system(block_fall.system())
.add_system(block_horizontal_move.system())
.add_system(block_vertical_move.system()) // <--追加
.run();
}
これでブロック落下もできるようになりました。
STEP.12 テトリスブロックの回転操作
あとはブロック回転ができればプレイヤーが行うことのできる全ての操作が揃います。 回転操作を block_rotate システムとして実装していきます。
fn block_rotate(
key_input: Res<Input<KeyCode>>,
game_board: ResMut<GameBoard>,
mut free_block_query: Query<(Entity, &mut Position, &Free)>,
) {
if !key_input.just_pressed(KeyCode::Up) {
return;
}
}
fn main() {
App::build()
// ...
.add_system(block_horizontal_move.system())
.add_system(block_vertical_move.system())
.add_system(block_rotate.system()) // <--追加
.run();
}
ブロックの回転
回転操作をさせたいのですが、現在の座標から適切にブロックエレメントに変形を加えるのは難しそうです。困りました。
しかし、ブロックを生成する時に使っている情報を使えば、回転がうまく実装できそうです。
.add_resource(BlockPatterns(vec![
vec![(0, 0), (0, -1), (0, 1), (0, 2)], // I
vec![(0, 0), (0, -1), (0, 1), (-1, 1)], // L
vec![(0, 0), (0, -1), (0, 1), (1, 1)], // 逆L
vec![(0, 0), (0, -1), (1, 0), (1, 1)], // Z
vec![(0, 0), (1, 0), (0, 1), (1, -1)], // 逆Z
vec![(0, 0), (0, 1), (1, 0), (1, 1)], // 四角
vec![(0, 0), (-1, 0), (1, 0), (0, 1)], // T
]))
ブロック生成時、相対座標の情報から生成を行っていました。
この各座標に回転行列をかけることで、回転後の座標が得られそうです!
上キーを 1 回押すたびに 90 度回転させたいので、行列は以下のようになります。
回転後の座標計算に利用するため、ブロックエレメントにRelativePosition コンポーネントを追加します。
struct RelativePosition {
x: i32,
y: i32,
}
RelativePosition コンポーネントをブロックエレメント生成時にエンティティに付与するようにします。
fn spawn_block_element(
commands: &mut Commands,
color: Handle<ColorMaterial>,
position: Position,
relative_position: RelativePosition, // <--追加
) {
commands
.spawn(SpriteBundle {
material: color,
..Default::default()
})
.with(position)
.with(relative_position) // <--追加
.with(Free)
.current_entity()
.unwrap();
}
fn spawn_block(
// ...
) {
// ...
new_block.iter().for_each(|(r_x, r_y)| {
spawn_block_element(
commands,
new_color.clone(),
Position {
x: (initial_x as i32 + r_x),
y: (initial_y as i32 + r_y),
},
RelativePosition { x: *r_x, y: *r_y }, // <--追加
);
});
}
あとは、block_rotate システムで RelativePosition の情報を使って回転操作を行えば良いです。
fn block_rotate(
key_input: Res<Input<KeyCode>>,
game_board: ResMut<GameBoard>,
mut free_block_query: Query<(Entity, &mut Position, &mut RelativePosition, &Free)>, // <--RelativePositionを追加
) {
if !key_input.just_pressed(KeyCode::Up) {
return;
}
// 回転行列を使って新しい絶対座標と相対座標を計算
fn calc_rotated_pos(pos: &Position, r_pos: &RelativePosition) -> ((i32, i32), (i32, i32)) {
// cos,-sin,sin,cos (-90)
let rot_matrix = vec![vec![0, 1], vec![-1, 0]];
let origin_pos_x = pos.x - r_pos.x;
let origin_pos_y = pos.y - r_pos.y;
let new_r_pos_x = rot_matrix[0][0] * r_pos.x + rot_matrix[0][1] * r_pos.y;
let new_r_pos_y = rot_matrix[1][0] * r_pos.x + rot_matrix[1][1] * r_pos.y;
let new_pos_x = origin_pos_x + new_r_pos_x;
let new_pos_y = origin_pos_y + new_r_pos_y;
((new_pos_x, new_pos_y), (new_r_pos_x, new_r_pos_y))
};
// 回転操作可能かどうか判定
let rotable = free_block_query.iter_mut().all(|(_, pos, r_pos, _)| {
let ((new_pos_x, new_pos_y), _) = calc_rotated_pos(&pos, &r_pos);
let valid_index_x = new_pos_x >= 0 && new_pos_x < X_LENGTH as i32;
let valid_index_y = new_pos_y >= 0 && new_pos_y < Y_LENGTH as i32;
if !valid_index_x || !valid_index_y {
return false;
}
!game_board.0[new_pos_y as usize][new_pos_x as usize]
});
if !rotable {
return;
}
// 相対座標と絶対座標を更新
free_block_query
.iter_mut()
.for_each(|(_, mut pos, mut r_pos, _)| {
let ((new_pos_x, new_pos_y), (new_r_pos_x, new_r_pos_y)) =
calc_rotated_pos(&pos, &r_pos);
r_pos.x = new_r_pos_x;
r_pos.y = new_r_pos_y;
pos.x = new_pos_x;
pos.y = new_pos_y;
});
}
これでブロックを回転できるようになりました!
STEP.13 ブロック行の消去
一通りの操作ができるようになりましたが、肝心のブロック行を消去する機能がありません。ブロックは積み上がっていくばかりです。次はdelete_line
システムとしてブロック行を消去するシステムを作っていきます。
fn delete_line(
commands: &mut Commands,
timer: ResMut<GameTimer>,
mut game_board: ResMut<GameBoard>,
mut fixed_block_query: Query<(Entity, &mut Position, &Fix)>,
) {
if !timer.0.finished() {
return;
}
}
fn main() {
App::build()
// ...
.add_system(block_fall.system())
.add_system(block_horizontal_move.system())
.add_system(block_vertical_move.system())
.add_system(block_rotate.system())
.add_system(delete_line.system())
.run();
}
消すべきブロック行の判定は容易です。単に全ての行にブロックが存在することを確認すればよいです。
しかし、ブロック行を消去した後のブロック位置の移動では注意が必要です。消去するべきブロック行は複数行になることもあり、しかも飛び飛びの行になることもあります。これは、消去されていない行の Y 座標が、ブロック消去適用後にどう変化するかの配列を持っておけばうまく動作します。
これを踏まえて、ブロック行の消去とブロックエレメントの位置更新を実装します。
fn delete_line(
commands: &mut Commands,
timer: ResMut<GameTimer>,
mut game_board: ResMut<GameBoard>,
mut fixed_block_query: Query<(Entity, &mut Position, &Fix)>,
) {
if !timer.0.finished() {
return;
}
// 消去対象のブロック行をHashSetに入れていく
let mut delete_line_set = std::collections::HashSet::new();
for y in 0..Y_LENGTH {
let mut delete_current_line = true;
for x in 0..X_LENGTH {
if !game_board.0[y as usize][x as usize] {
delete_current_line = false;
break;
}
}
if delete_current_line {
delete_line_set.insert(y);
}
}
// 消去対象ブロック行に含まれるブロックをゲーム盤面から削除する
fixed_block_query.iter_mut().for_each(|(_, pos, _)| {
if delete_line_set.get(&(pos.y as u32)).is_some() {
game_board.0[pos.y as usize][pos.x as usize] = false;
}
});
// 各Y座標について、ブロック消去適用後の新しいY座標を調べる
let mut new_y = vec![0i32; Y_LENGTH as usize];
for y in 0..Y_LENGTH {
let mut down = 0;
delete_line_set.iter().for_each(|line| {
if y > *line {
down += 1;
}
});
new_y[y as usize] = y as i32 - down;
}
fixed_block_query.iter_mut().for_each(|(entity, mut pos, _)| {
if delete_line_set.get(&(pos.y as u32)).is_some() {
// 消去の対象のブロックをゲームから取り除く
commands.despawn(entity);
} else {
// ブロック消去適用後の新しいY座標を適用
game_board.0[pos.y as usize][pos.x as usize] = false;
pos.y = new_y[pos.y as usize];
game_board.0[pos.y as usize][pos.x as usize] = true;
}
});
}
動かしてみると、奇妙な動作になっています。ブロック行消去時に、ブロック行消去のきっかけになったブロックのブロックエレメントが消去されていないというバグが発生しています。
これは、クエリの結果は前のシステムが処理した結果が適用されないことが原因です。
fn delete_line(
commands: &mut Commands,
timer: ResMut<GameTimer>,
mut game_board: ResMut<GameBoard>,
mut fixed_block_query: Query<(Entity, &mut Position, &Fix)>, //このクエリの結果は各フレーム開始時点での結果!!!
) {
// ...
}
クエリは各フレームの最初でクエリ結果の計算を行っています。したがって、同じフレームの別のシステムによって生じた変化はクエリに反映されません。
これを解決するためには、単にシステムの実行順序を変更すれば良いです。
fn main() {
App::build()
// ...
.add_startup_system(setup.system())
.add_system(delete_line.system()) // <--追加 (システムの一番最初に持ってくる)
.add_system(spawn_block.system())
.add_system(position_transform.system())
.add_system(game_timer.system())
.add_system(block_fall.system())
.add_system(block_horizontal_move.system())
.add_system(block_vertical_move.system())
.add_system(block_rotate.system())
//.add_system(delete_line.system()) // <--削除
.run();
}
再度実行してみると、正常にブロック行が消去されるようになりました。
これで、テトリスの一通りの振る舞いが実装できました!!
STEP.14 ゲームオーバーの実装
一通りゲームが動くようになりましたが、ブロックが限界まで積まれてもゲームが止まりません。 最後にゲームオーバーの処理を作ります。
ゲームオーバーイベントの追加
ゲームオーバーを検知時、GameOverEvent イベントを発行するようにします。
struct GameOverEvent;
fn main() {
App::build()
// ...
.add_event::<NewBlockEvent>()
.add_event::<GameOverEvent>() // <--追加
// ...
.run();
}
ゲームオーバーの条件は、ブロック生成時、すでにそのマスにブロックエレメントが存在することです。そこで、spawn_block
システムでゲームオーバー判定を行うことにします。
fn spawn_block(
commands: &mut Commands,
// ...
mut game_board: ResMut<GameBoard>, // <--追加
mut gameover_events: ResMut<Events<GameOverEvent>>, // <--追加
) {
// ...
// ブロックの初期位置
let initial_x = X_LENGTH / 2;
let initial_y = Y_LENGTH;
// ゲームオーバー判定
let gameover = new_block.iter().any(|(r_x, r_y)| { // <--追加
let pos_x = (initial_x as i32 + r_x) as usize;
let pos_y = (initial_y as i32 + r_y) as usize;
game_board.0[pos_y][pos_x]
});
if gameover { // <--追加
gameover_events.send(GameOverEvent);
return;
}
// ...
}
ゲームエリア外にテトリスブロックが存在することを一瞬許容することになるため、delete_line
システムの new_y 配列の長さに余裕を持たせておきます。
fn delete_line(
// ...
) {
// ...
let mut new_y = vec![0i32; Y_LENGTH as usize + 10]; // +10を追加
// ...
}
gameover システムの作成
GameOverEvent を受け取る gameover システムを実装します。全てのエンティティを消去し、NewBlockEvent を送信しています。
fn gameover(
commands: &mut Commands,
gameover_events: Res<Events<GameOverEvent>>,
mut game_board: ResMut<GameBoard>,
mut all_block_query: Query<(Entity, &mut Position)>,
mut new_block_events: ResMut<Events<NewBlockEvent>>,
) {
let mut gameover_events_reader = gameover_events.get_reader();
if gameover_events_reader
.iter(&gameover_events)
.next()
.is_none()
{
return;
}
game_board.0 = vec![vec![false; 25]; 25];
all_block_query.iter_mut().for_each(|(ent, _)| {
commands.despawn(ent);
});
new_block_events.send(NewBlockEvent);
}
fn main() {
App::build()
// ...
.add_resource(GameBoard(vec![vec![false; 25]; 25]))
.add_plugins(DefaultPlugins)
.add_event::<NewBlockEvent>()
.add_event::<GameOverEvent>()
.add_startup_system(setup.system())
.add_system(gameover.system()) // <--追加
.add_system(delete_line.system())
// ...
.run();
}
実行してみると、ブロックが限界まで積み上がるとゲームがリセットされるようになりました!
最後に
一通り遊べるものができましたが、まだ次のブロック表示、落下直後の移動・回転入力など必要な機能はたくさんあります。もしよければさらにゲームを改善してみてください。
このチュートリアルの中に、bevy の基本的な機能である ECS システム、リソース、イベントなどの使い方をうまく入れ込むことができたと思います。この記事が誰かの bevy エンジン理解の助けとなれば幸いです。