つきくさのぶろぐ

個人ブログです ゲーム実況動画、ゲーム制作(Unity)、イラストなど 現在改装中

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クラスに、キー入力と移動と体力管理とアニメーション…と、複数の機能を詰め込むよりも、

のように、機能毎に切り分けておくと、Unity上のマウス操作でGameObjectにアタッチするだけで、複数の組み合わせを自由に切り替えることができるようになる。

また、単一機能に絞っているので、デバッグも行いやすい

それぞれのコンポーネント間でのやりとりは、UnityEventを使うことで、例えばCharacterHitCheckにより当たり判定チェック→CharacterStatusのダメージ処理、のように、相互間通信を行うことも可能になる。

 

ここまででなんだかよく分からない人は、手っ取り早く、「クラスの継承は使わない」と意識すれば、「コンポーネント指向」はそんなに難しくないと思う。