はじめに
先日Rust製のゲームエンジンBevyを使ったサンプルゲームを公開しました。
github.com
こちらの公開したサンプルゲームをベースに、Bevyで実装する際の基礎的な知識となるECSアーキテクチャの実装を説明しようと思います。
Bevy
BevyはRust製のデータ指向なゲームエンジンです。
bevyengine.org
無料のオープンソースで、ソースコードはGitHub上に公開されています。
github.com
ECSアーキテクチャを採用しており、開発も結構盛んに行われています。執筆時点ではver0.5が最新の安定バージョンとして公開されています。
2Dおよび3Dにも対応しており、クロスプラットフォーム対応、ホットリロード可能など、発展途上ながらも多くの機能を備えています。筆者はAmethystというゲームエンジンの本も書いたりしたのですが、このBevyも触ってみて期待しているゲームエンジンのひとつです。
サンプルゲームについて
サンプルゲームではBevyの提供するECSアーキテクチャに則って、シンプルな2Dゲームのフローを実装しています。
プレイヤーを操作して落ちてくる岩を回避しながら林檎をとる、ごくごくシンプルなゲームです。
レベルデザインなどをしっかりやっている訳ではなく、ゲームのサンプルを実装したというよりはシステムのサンプルを実装したというのが正しいかもしれません。
Rustの文法としても比較的シンプルに実装しているので、初学者の方でも読み解きやすいかなと思います。(The Rust Programming Languageを一通り読めば多分大丈夫です。)
このゲームフローを構成するEntity、Component、Systemそれぞれの実装について説明します。
BevyにおけるECSの実装
BevyではEntityとComponentについては構造体、Systemについてはメソッドで定義を行います。それぞれの実装について、サンプルゲームの例を元に見ていきます。
EntityとComponent
まずはEntityですが、識別するidを持つシンプルなstructとして定義されています。
#[derive(Clone, Copy, Hash, Eq, Ord, PartialEq, PartialOrd)]
pub struct Entity {
pub(crate) generation: u32,
pub(crate) id: u32,
}
EntityはWorldというゲーム中のEntityとComponentを統括する機能で管理されます。
次にComponentです。サンプルゲーム内では林檎、岩、プレイヤーがあり、これらの機能はComponentとしてシンプルなstructで実装されています。
例としてプレイヤーの実装を示します。
const PLAYER_LIFE: i32 = 3;
pub struct Player {
pub life: i32,
}
impl Default for Player {
fn default() -> Self {
Player::new(PLAYER_LIFE)
}
}
impl Player {
pub fn new(life: i32) -> Self {
Player {
life
}
}
pub fn reset(&mut self) {
self.life = PLAYER_LIFE;
}
}
このPlayerをプレイヤーの機能を果たすComponentとして取り扱います。
structをComponentとして処理する工程は至ってシンプルで、Entityの生成時に紐付けるだけです。Unofficial Bevy Cheat Bookのサンプルを下記に示します。
struct SampleComponent;
pub fn setup(mut commands: Commands) {
commands
.spawn()
.insert(SampleComponent);
}
CommandはWorldのEntityとComponentに対してmutableな操作を行う機能です。Entityの生成や削除、Componentの操作などを行う際にはCommandを介して処理します。 実際のPlayerの紐付けの実装を次に示します。
let player_texture_handle = asset_server.load("textures/player.png");
let player_position = Vec3::new(0., -200., 0.);
let player_rotation = Quat::from_axis_angle(Vec3::Y, 0.);
let player_scale = Vec3::splat(1.);
commands
.spawn_bundle(SpriteBundle {
material: materials.add(player_texture_handle.into()),
sprite: Sprite::new(Vec2::new(90., 90.)),
transform: Transform {
translation: player_position,
rotation: player_rotation,
scale: player_scale,
},
..Default::default()
})
.insert_bundle((
Player::default(),
Mover::default(),
GameScene
));
CommandからEntityを生成してメソッドチェーンの形でComponentを紐付けています。ちなみにPlayerと一緒に紐づけているMoverはその名の通りサンプルゲーム中のプレイヤーや林檎といったものを動かすためのComponentです。このようにEntityを生成して必要な機能のComponentを紐付ける、というのが基本的なオブジェクトの生成フローとなります。
System
サンプルゲームではsrc/system配下にゲームロジックを形成する以下のsystemの実装があります。
├── collision.rs // プレイヤーと落ちてくるものの衝突判定
├── display.rs // UIなどの表示
├── object_spawn.rs // 落ちてくる林檎と岩の生成
├── player.rs // ユーザー入力の処理
├── time.rs // 時間の処理
└── translate.rs // プレイヤーや落ちてくるものの移動処理
それぞれがComponentへの作用を担っており、対象となるComponentの配列を舐めて処理を行います。ECSアーキテクチャの特徴的な処理ですね。
前述にもあるようにBevyではその作用をシンプルなメソッドとして実装します。例としてtranslate.rsの実装を示します。
pub fn translate_mover_system(mut query: Query<(&Mover, &mut Transform)>) {
for (mover, mut transform) in query.iter_mut(){
let velocity = &mover.velocity;
transform.translation.x += velocity.x;
transform.translation.y += velocity.y;
}
}
引数としてQueryという型を渡していますが、これがComponentの参照に用いる機能です。ジェネリクスで対象となるComponentの型を宣言し、イテレータを使用して作用を処理します。
また、Entityの生成や破棄を行いたい場合にもCommandを引数にして処理します。collision.rsの実装の一部を抜粋します。
pub fn collision(
mut commands: Commands,
mut game: ResMut<Game>,
mut player_query: Query<(&mut Player, &Transform, &Sprite)>,
mut apple_query: Query<(Entity, &mut Apple, &Transform, &Sprite)>,
mut block_query: Query<(Entity, &mut Block, &Transform, &Sprite)>) {
if let Ok((mut player, player_transform, player_sprite)) = player_query.single_mut() {
for (block_entity, block, block_transform, block_sprite) in block_query.iter_mut() {
if block_transform.translation.y < OBJECT_DESPAWN_Y {
commands.entity(block_entity).despawn();
break;
}
...
このように、Systemの実装ではCommandおよびComponentのQueryを引数としたメソッドを実装することになります。
実装したメソッドは専用の機能を用いてゲームフローに組み込みます。Unofficial Bevy Cheat Bookより引用した実装を以下に示します。
fn main() {
App::build()
.add_startup_system(init_menu.system())
.add_startup_system(debug_start.system())
.add_system(move_player.system())
.add_system(enemies_ai.system())
.run();
}
into traitを介してメソッドはSystemに変換されています。Amethystだと専用のtraitを実装したstructとして表現していますが、こちらはよりシンプルな形と言えます。
また、サンプルゲームではSystemSetという機能を用いてタイトル画面やゲーム中といったゲーム中の状態ごとのSystemの分割を行っています。
#[derive(Clone, Eq, PartialEq, Debug, Hash)]
pub enum GameState {
Title,
Playing,
GameOver,
}
pub fn title_enter_system_set() -> SystemSet {
SystemSet::on_enter(GameState::Title)
.with_system(setup_title_ui.system())
}
pub fn game_enter_system_set() -> SystemSet {
SystemSet::on_enter(GameState::Playing)
.with_system(setup_game_ui.system())
}
pub fn game_update_system_set() -> SystemSet {
SystemSet::on_update(GameState::Playing)
.with_system(collision.system())
.with_system(check_spawn_object.system())
.with_system(player_input_system.system())
.with_system(translate_mover_system.system())
.with_system(game_time_display_system.system())
.with_system(game_score_display_system.system())
.with_system(player_life_display_system.system())
.with_system(update_game.system())
}
fn main() {
App::build()
.add_system_set(title_enter_system_set())
.add_system_set(title_update_system_set())
.add_system_set(title_exit_system_set())
.add_system_set(game_enter_system_set())
.add_system_set(game_update_system_set())
.add_system_set(game_exit_system_set())
.add_system_set(game_over_enter_system_set())
.add_system_set(game_over_update_system_set())
.add_system_set(game_over_exit_system_set())
.run();
}
一定複雑なSystemの管理を行う場合にはSystemSetを活用するのがおすすめです。
以上がSystemの実装になります。
おわりに
ざっくりとBevyで開発する上で一番大切なECSアーキテクチャを実装する部分について説明しました。ゲーム内のオブジェクトとロジックをBevy上でどうやって組み上げるか、なんとなく伝わっていたら幸いです。
BevyはECSアーキテクチャをかなり直感的に実装できるな、という感覚が筆者的にはあって良かったです。
参考文献
公式ドキュメントのIntroductionです。Bevyを使う際にはまず一読しておくのがおすすめです。
bevyengine.org
Bevyの実装をする上で参考になる非公式のドキュメントです。Bevyの根幹となる機能についてある程度まとめてくれています。文中でも引用させていただきました。
Introduction - Unofficial Bevy Cheat Book
また、こちらのブログでもBevyでHello Worldするまでの手順を取り上げています。参考にさせていただきました。
blog.livedoor.jp