Unityのコンポーネント指向について考える
Unityは、「コンポーネント指向プログラミング」の設計だそうだ。
「コンポーネント(Component)」とは、部品のことである。つまり、「コンポーネント指向」とは、部品を寄せ集めて、ひとつの製品を造り上げることであるそうな。
MonoBehaviourに、AddComponent関数やGetComponent関数が実装されていることからも、Unityがコンポーネント指向であることは明白である。
…と、ここまで前フリ。
じゃあ、具体的に「コンポーネント指向」ってなんだよ(なんだよ)ってことで、設計を考えてみる。
オブジェクト指向のおさらい
ゲームにおいて、「プレイヤー(Player)」と「敵(Enemy)」が存在しているとする。
オブジェクト指向的な考えでは、これらはひとつの「オブジェクト」だ。プレイヤーも敵も、ひとつのオブジェクトとして捉えられる。
シンプルにプログラムを設計するなら、
public class Player : MonoBehaviour { // プレイヤーの処理 } public class Enemy : MonoBehaviour { // 敵の処理 }
と、こんな感じになるかと思う。
ここで、「プレイヤー」と「敵」の共通の仕様があるとしよう。
たとえば、「当たり判定」や「体力」などだ。プレイヤーは、敵の攻撃によりダメージを受ける。敵も同様に、プレイヤーの攻撃によりダメージを受け、どちらもライフがゼロになると死亡する。
これを実装する場合、「オブジェクト指向プログラミング」なら、「クラスの継承」を行い、実装するはずだ。
たとえば、基底クラスとなる「キャラクター(Character)」クラスを定義する。
public class Character : MonoBehaviour { // プレイヤーと敵の共通項目を書く public int Health; // 残り体力 // virtualで定義、具体的な処理は派生先で書く protected virtual void Attack() { } //攻撃 protected virtual void Damage() { } //ダメージ protected virtual void Death() { } //死亡 }
分かりやすくするために、MonoBehaviourを継承したクラスを用意した。
そして、このCharacterクラスを継承すれば、共通処理は実装完了だ。
public class Player : Character { // ここにはプレイヤーのみ行う処理を書く void OnInput() { } // 例えば入力処理など // overrideで、具体的な処理の実装 protected override void Attack() { base.Attack(); } protected override void Damage() { base.Damage(); } protected override void Death() { base.Death(); } } public class Enemy : Character { // ここには敵のみ行う処理を書く void ChasePlayer() { } // 例えばプレイヤーを追いかけるなど // overrideで、具体的な処理の実装 protected override void Attack() { base.Attack(); } protected override void Damage() { base.Damage(); } protected override void Death() { base.Death(); } }
…とまぁ、ここまではオブジェクト指向による実装パターンだ。
オブジェクト指向の問題点
オブジェクト指向の問題点は、機能が次々と追加されることにより、継承元のクラスがいわゆる「スーパークラス化」してしまうことだ。
たとえば、プレイヤークラスでいうと、二人同時プレイに対応する場合は?プレイヤークラスを継承した、「プレイヤー2」というクラスを新たに作る、などが考えられる。
ボスキャラクターを登場させるには?こちらも、敵クラスを継承し、新たにボスクラスを作る、など。
機能が追加される毎に、どんどん継承が行われ、膨らんでいってしまうのだ。
また、オブジェクト指向プログラミングは、多重継承もできない。敵Aと敵Bの共通仕様を持った敵Cを作るには、AとBを同時に継承することは出来ないのだ。
インターフェースは多重継承可能だが、実装は継承先で行う必要があるので、結局、AとBに実装した処理と全く同じコードを書かなければならない。非常に面倒くさい。
「コンポーネント指向」での実装方法
一方、コンポーネント指向は、機能一つ一つを「部品」と捉える。たとえば、プレイヤーの体力などの「ステータス」でひとつの部品だ。ステータスには、体力があり、コンポーネントは体力の増減処理しか行わない。体力を監視し、プレイヤーが死んだかどうかを判定したり、死亡アニメーションを再生するのは、また別のコンポーネントがそれぞれ行うのだ。
Unityによるコンポーネント指向による実装だと、以下のようにするのが良いと思う。(個人的な見解です、ご了承下さい)
まず、「クラスの継承」は使用しない(MonoBehaviourは除く)。機能の共通部分は、ひとつの「コンポーネント」として実装し、使い回すようにする。
// キャラクターの体力を管理するコンポーネント public class CharacterStatus : MonoBehaviour { // 体力の増減のみ行う public int HP; public void Damage() { HP--; } }
// キャラクターの当たり判定を行うコンポーネント [RequireComponent(typeof(Collider))] public class CharacterHitCheck : MonoBehaviour { public UnityEvent OnDamage; // Collisionに接触すると呼ばれる void OnCollisionEnter(Collision collision) { OnDamage.Invoke(); } }
// キャラクター制御コンポーネント [RequireComponent(typeof(CharacterStatus))] public class CharacterControl : MonoBehaviour { CharacterStatus _status; void Start() { // CharacterStatusコンポーネントを取得する _status = GetComponent<characterstatus>(); } // ダメージを受けた時に呼ばれる public void OnDamage() { // 体力を減らして、ゼロになったら死亡 _status.Damage(); if (_status.HP == 0) { Death(); } // 現在の体力をわかりやすくログ出力 Debug.Log($"Damage! : 残り体力 = {_status.HP}"); } // 死亡処理 void Death() { Destroy(gameObject); } }
キー入力を受け取りRigidbodyを使った移動を行う、CharacterLocomotionコンポーネントは内容を割愛。
上記のコンポーネントを実装し、GameObjectにアタッチする。
(下図では、PlayerオブジェクトにCharacterStatus, CharacterHitCheck, CharacterControl, CharacterLocomotionをそれぞれアタッチしている)
「Player」ゲームオブジェクトは「CharacterStatus」コンポーネントにより体力を管理する。赤い「Cube」に触れると、「CharacterHitCheck」コンポーネントがUnityEventにより「CharacterControl.OnDamage()」イベントを発火、ダメージを受ける。実際のダメージを受けた際の処理は、「CharacterContol」コンポーネントが担当する。
上記のCharacterStatusやCharacterHitCheckは、プレイヤーにも敵にも使い回すことが出来る。また、当たり判定はアイテムにも使い回せる。
ひとつのコンポーネントには、可能な限り一つの機能に抑えるのがベストだ。機能を絞ることで、コンポーネントの目的が明確になり、使いまわしやすくなる。
また、オブジェクト指向で不可能だった「多重継承」についても、この方法なら実装することが可能だ。
まとめ
Playerクラスに、キー入力と移動と体力管理とアニメーション…と、複数の機能を詰め込むよりも、
- 「キー入力を受け取るだけのPlayerInputコンポーネント」
- 「移動させるだけのCharacterLocomotionコンポーネント」
- 「体力管理だけのCharacterStatusコンポーネント」
- 「アニメーションを制御するだけのCharacterAnimationコンポーネント」
のように、機能毎に切り分けておくと、Unity上のマウス操作でGameObjectにアタッチするだけで、複数の組み合わせを自由に切り替えることができるようになる。
また、単一機能に絞っているので、デバッグも行いやすい。
それぞれのコンポーネント間でのやりとりは、UnityEventを使うことで、例えばCharacterHitCheckにより当たり判定チェック→CharacterStatusのダメージ処理、のように、相互間通信を行うことも可能になる。
ここまででなんだかよく分からない人は、手っ取り早く、「クラスの継承は使わない」と意識すれば、「コンポーネント指向」はそんなに難しくないと思う。