目次
その1 〜 序文
その2 〜 キャラの移動
その3−1 〜 コンポーネントの設計
その3−2 〜 システムの設計
その3−3 〜 メイン部分
その4−1 〜 剣を表示
その4−2 〜 アニメーションコンポーネント
その4-3 〜 アニメーションを動かす
その5-1~ あたり判定
その5-2~ やられアニメーション
【イマココ】その6 〜 これまでの振り返り
これまでに作ったもの
緑はプレイヤーキャラ、赤が敵キャラです。
ソースはこちらにあります。
https://github.com/mas-yo/rust-ecs-game/tree/step-5
今まで、主にコンポーネントとシステムの設計について紹介してきましたが、このあたりで、ゲーム全体がどのように設計されているか、改めて見てみましょう。
Game 構造体
Game
構造体には、今回作ったコンポーネントがすべて入っています。
#[derive(Default)]
struct Game {
next_entity_id: EntityID, //次にエンティティを追加するときのエンティティID
//各種コンポーネント
inputs: CContainer<Input>, //入力
teams: CContainer<Team>, //チーム(敵味方の区別)
sword_colliders: CContainer<SwordCollider>, //剣コライダー
body_weapon_colliders: CContainer<BodyWeaponCollider>, //体を武器とするコライダー
body_defense_colliders: CContainer<BodyDefenseCollider>, //体で攻撃を受けるコライダー
move_targets: CContainer<MoveTarget>, //移動先(AI用)
positions: CContainer<Position>, //位置
directions: CContainer<Direction>, //向き
velocities: CContainer<Velocity>, //速度
character_animators: CContainer<CharacterAnimator>, //アニメーション
character_views: CContainer<CharacterView>, //キャラ表示
}
これが、ゲームの世界のすべてです。
コンポーネントが並んでいるだけ。(CContainer
の中はVector
です)
オブジェクト指向だと、Player
クラスやEnemy
クラスが登場しますが、そういったものはありません。
ただ機能を表すコンポーネントだけが置いてあります。
entity_id
はプレイヤーキャラや敵キャラなどのオブジェクトを識別するためのIDで、オブジェクトを生成するたびにインクリメントしています。
エンティティの生成
プレイヤーキャラや敵キャラを生成する処理は、どうなっているのでしょうか。
impl Game {
//プレイヤーキャラを生成
fn create_hero(&mut self) {
let entity_id = self.next_entity_id;
//同じ entity_id で、必要なコンポーネントを生成、登録
self.inputs.push(entity_id, Input::default());
self.teams.push(entity_id, Team::new(0));
//位置はとりあえずこうしてますが外から受け取った方が良いでしょう
self.positions.push(
entity_id,
Position {
x: 150f32,
y: 150f32,
},
);
self.body_defense_colliders
.push(entity_id, BodyDefenseCollider::default());
self.sword_colliders
.push(entity_id, SwordCollider::default());
self.directions.push(entity_id, Direction::default());
self.velocities.push(entity_id, Velocity::default());
let mut animator = CharacterAnimator::default();
//アニメーションデータを登録
//Self::wait_animation()等は別途定義してあります
animator.register(CharacterAnimID::Wait, Self::wait_animation());
animator.register(CharacterAnimID::Attack, Self::attack_animation());
animator.register(CharacterAnimID::Damaged, Self::damaged_animation());
animator.play(CharacterAnimID::Wait);
self.character_animators.push(entity_id, animator);
self.character_views.push(
entity_id,
CharacterView {
color: Color::GREEN,
radius: 10f32,
radius_scale: 1f32,
..Default::default()
},
);
self.next_entity_id = self.next_entity_id + 1;
}
//敵を生成
fn create_enemy(&mut self, x: f32, y: f32) {
let entity_id = self.next_entity_id;
self.move_targets.push(entity_id, MoveTarget::default());
self.teams.push(entity_id, Team::new(1));
self.body_defense_colliders
.push(entity_id, BodyDefenseCollider::default());
self.body_weapon_colliders
.push(entity_id, BodyWeaponCollider::default());
self.positions
.push(entity_id, Position { x: x, y: y });
self.directions.push(entity_id, Direction::default());
self.velocities.push(entity_id, Velocity::default());
let mut animator = CharacterAnimator::default();
animator.register(CharacterAnimID::Wait, Self::wait_animation());
animator.register(CharacterAnimID::Attack, Self::attack_animation());
animator.register(CharacterAnimID::Damaged, Self::damaged_animation());
animator.play(CharacterAnimID::Wait);
self.character_animators.push(entity_id, animator);
self.character_views.push(
entity_id,
CharacterView {
color: Color::RED,
radius: 15f32,
radius_scale: 1f32,
..Default::default()
},
);
self.next_entity_id = self.next_entity_id + 1;
}
}
今はプレイヤーキャラと敵キャラしかいないので、こんな感じになっていますが、キャラの種類が増えたら、Builder
パターンみたいな感じにしても良いかと思います。
さて、プレイヤーキャラと敵キャラの違いとは何でしょうか?
それは移動と攻撃の仕方です。それ以外は同じコンポーネントを持っています。
表にすると、下記のようになります。
エンティティ | 移動 | 攻撃の仕方 |
---|---|---|
プレイヤーキャラ | キー入力(Input) | 剣(SwordCollider) |
敵キャラ | 自動(MoveTarget) | 体自体(BodyWeaponCollider) |
もし、今後自動で動く味方キャラ、みたいなものを作ることになった場合は、
エンティティ | 移動 | 攻撃の仕方 |
---|---|---|
味方キャラ | 自動(MoveTarget) | 剣(SwordCollider) |
この様なコンポーネントの構成になります。
このように、コンポーネントの組み合わせによって、いろんな機能をもったエンティティを作れるのが、Entity Component Systemの特徴です。
もしこれを、オブジェクト指向で設計した場合はどうなるでしょうか。
各キャラの共通部分をベースクラスとし、個別の部分を子クラスに持たせるのが一般的ですね。
//※擬似的なコードになっています : の右側が基底クラスです。
//ベースクラス
class Character
位置
//プレイヤーキャラ
class Hero : Character
入力
剣コライダ
//敵キャラ
class Enemy : Character
AI
体コライダ
//味方キャラ
class Ally : Character
AI
剣コライダ
ここで、例えば剣によって攻撃する敵を作りたい、となったらどうしましょうか?
すでにEnemyは、体を武器とするコライダを持つ様な設計になってしまっています。
仕方ないから、
class EnemyWithSword : Character
{
AI
剣コライダ
}
こうですかね?
すでにEnemy
があるのに、なんとも気持ち悪いですね。
ちゃんとやるなら、敵を表すベースクラスを用意して、
class EnemyBase : Character
AI
class EnemyWithBodyWeapon : EnemyBase
体コライダ
class EnemyWithSword : EnemyBase
剣コライダ
でしょうか。
しかし、継承関係を作り直すというのは、ソフトウェアが大規模になればなるほど、簡単にはできないものです。
影響範囲が大きすぎるため、それまでテストした部分を全部やりなおし、なんてことになってしまいます。
開発の初期なら大丈夫ですが、後の方になればなるほど、こういった修正は難しくなります。
Entity Component System であれば、コンポーネントが違う別のエンティティを生成するだけです。
つまり、
エンティティ | 移動 | 攻撃の仕方 |
---|---|---|
剣を持つ敵キャラ | 自動(MoveTarget) | 剣(SwordCollider) |
この様なエンティティを生成すれば良いわけですね。
※たまたま「味方キャラ」と同じ構成になりました。チームIDが違うことで、敵として動く様になります。
これの修正による影響範囲は、限定的です。なにせ、既存のコードは何一つ変えません。
create_sword_enemy()
を作って呼ぶだけです(関数名はちょっとあれですが)
システムの呼び出し
では、各コンポーネントをどのように更新していくか、見ていきましょう。
今回はquicksilver
を使っているので、Game
にState
トレイトを実装する形になります。
impl State for Game {
//初期化 プレイヤーキャラ(hero)と敵を2体生成します
fn new() -> Result<Game> {
let mut game = Self::default();
game.create_hero();
game.create_enemy(20f32, 20f32);
game.create_enemy(100f32, 20f32);
Ok(game)
}
//毎フレーム呼ばれる`update`メソッド
fn update(&mut self, _window: &mut Window) -> Result<()> {
//各種コライダの更新(当たり判定)
System::process(
&mut self.sword_colliders,
&(&self.character_views, &self.character_animators),
);
System::process(&mut self.body_weapon_colliders, &self.character_views);
System::process(
&mut self.body_defense_colliders,
&(
&self.character_views,
&self.sword_colliders,
&self.body_weapon_colliders,
&self.teams,
),
);
//AIの更新
System::process(&mut self.move_targets, &(&self.teams, &self.positions));
//速度の更新(self.inputsは、State::eventで更新されています)
System::process(&mut self.velocities, &self.inputs);
System::process(&mut self.velocities, &(&self.positions, &self.move_targets));
System::process(
&mut self.velocities,
&(&self.character_views, &self.character_animators),
);
//位置の更新
System::process(&mut self.positions, &self.velocities);
//向きの更新
System::process(&mut self.directions, &self.inputs);
System::process(&mut self.directions, &(&self.positions, &self.move_targets));
//アニメーションの更新
System::process(&mut self.character_animators, &self.inputs);
System::process(&mut self.character_animators, &self.body_defense_colliders);
System::process(&mut self.character_animators, &());
//表示用データの更新
System::process(&mut self.character_views, &self.character_animators);
System::process(
&mut self.character_views,
&(&self.positions, &self.directions),
);
Ok(())
}
//キー入力の処理
fn event(&mut self, event: &Event, _: &mut Window) -> Result<()> {
//入力に従って、Inputコンポーネントを更新します
match event {
Event::Key(key, state) => {
let mut pressed = false;
if *state == ButtonState::Pressed {
pressed = true;
} else if *state == ButtonState::Released {
pressed = false;
}
match key {
Key::A => {
self.inputs.iter_mut().for_each(|(_, i)| {
i.left = pressed;
});
}
Key::D => {
self.inputs.iter_mut().for_each(|(_, i)| {
i.right = pressed;
});
}
Key::W => {
self.inputs.iter_mut().for_each(|(_, i)| {
i.up = pressed;
});
}
Key::S => {
self.inputs.iter_mut().for_each(|(_, i)| {
i.down = pressed;
});
}
Key::Space => {
// log::info!("space");
self.inputs.iter_mut().for_each(|(_, i)| {
i.attack = pressed;
});
}
_ => {}
}
}
_ => {}
}
Ok(())
}
//描画
fn draw(&mut self, window: &mut Window) -> Result<()> {
//最終的に描画に使うデータは、CharacterViewだけです
window.clear(Color::WHITE)?;
System::process(window, &self.character_views);
Ok(())
}
}
update()
メソッドの中身をみると、今回作ったゲームの全体像が見えてきます。
System::process
を各コンポーネントを引数として呼び出していますね。
第一引数は、&mut
となっていることからもわかるように、中身を更新するコンポーネントです。
それ以外の引数は、更新のために必要な情報ですね。こちらは&
なので変更されません。
Rustでは、mut
キーワードによって、その変数(メモリ)のミュータビリティを明確に意識して設計することが求められます。
System::process
の呼び出しの流れを見れば、どのコンポーネントがどの順番で更新されるか、それぞれがどんなコンポーネントに依存しているかが、一目瞭然ですね。これはEntity Component Systemの恩恵です。
もしオブジェクト指向で設計したなら、この様な見通しの良いソースにはなりません。
なぜなら、あるオブジェクトの状態が更新されたとき、一定の条件で、他のオブジェクトの状態が一緒に更新される、ということが往々にして起こってしまうのです。(オブジェクト指向でもうまく設計すれば大丈夫ですが・・)
例えば、
攻撃ボタンが押されていたら、Player.Attack()が呼ばれる
↓
Player.Attack()の中で攻撃範囲にいる敵を取ってきて、Enemy.Damage()を呼ぶ
みたいな処理を、書いてしまうんですね。普通に。
これは、Playerの更新中にEnemyの状態を変えているわけで、スパゲッティコードの温床です。
開発が進めば進むほど、処理を追いかけるのが難しくなってきます。
まとめ
以上、見てきたように、Entity Component System と Rust の組み合わせによって、見通しの良い、変化に対応しやすいゲーム設計ができることが、わかってきました。
Rustは習得が難しく、これを現場で採用するのは、まだまだ難しいかもしれません。
しかし、採用例も少しづつ増えて来ています。
是非ゲーム開発の現場でも、Rustが普及することを願っています。
次は、HPや攻撃力などを実装して、UI表示も作ってみようと思います。