Unityでのコンポーネント指向のあれこれ

ここ最近コンポーネント指向にハマってたことがあって、あれこれいろいろ試してた。
そこらへんをまとめておく。

己の肉体と技術に限界を感じ
悩みに悩み抜いた結果
彼がたどり着いた結果(さき)はコンポーネント指向であった

継承指向

自分はUnity以前のゲーム開発では継承指向でオブジェクトを作っていた。
例えばキャラクタにはプレイヤーと敵がいて、攻撃時は武器の当たり判定を有効にしつつアニメーションをする、という共通処理があった場合

public abstract class Charactor : MonoBehaviour
{
    [SerializeField] Animation animation;
    [SerializeField] Collider weapon;
    Coroutine animationCoroutine;

    public virtual void Attack()
    {
        weapon.enabled = true;
        if(animationCoroutine != null) StopCoroutine(animationCoroutine);
        animationCoroutine = StartCoroutine(PlayAnimation("Attack", () => weapon.enabled = false));
    }

    IEnumerator PlayAnimation(string name, Action onFinish)
    {
        animation.Play(name);
        yield return new WaitUntil(() => !animation.IsPlaying(name));
        onFinish?.Invoke();
    }
}

public class Player : Charactor { }
public class Enemy  : Charactor { }

上記のように基底クラスCharactorに共通処理を記述していく。
すると、共通処理が増える度に基底クラスを拡張していくため基底クラスが肥大化していく。
そして、継承の性質上、基底クラスの変更は派生クラスに影響するので、プログラムの結びつきが大きく、変更のリスクが高い。

基底クラスの複雑化と、変更のしにくさがネックとなる。

コンポーネント指向

そこで先ほどのオブジェクトを継承せずにコンポーネント化して分ける。
「キャラクタのアニメーションを再生させる」という処理は汎用的なので

public class CharactorAnimation : MonoBehaviour
{
    [SerializeField] Animation animation;
    Coroutine animationCoroutine;

    public void Play(string name, Action onFinish = null)
    {
        if(animationCoroutine != null) StopCoroutine(animationCoroutine);
        animationCoroutine = StartCoroutine(PlayAnimation(name, onFinish));
    }

    IEnumerator PlayAnimation(string name, Action onFinish)
    {
        animation.Play(name);
        yield return new WaitUntil(() => !animation.IsPlaying(name));
        onFinish?.Invoke();
    }
}

public class Player : MonoBehaviour
{
    [SerializeField] CharactorAnimation animation;
    [SerializeField] Collider weapon;

    public void Attack()
    {
        weapon.enabled = true;
        animation.Play("Attack", () => weapon.enabled = false);
    }
}

public class Enemy : MonoBehaviour
{
    [SerializeField] CharactorAnimation animation;
    [SerializeField] Collider weapon;

    public void Attack()
    {
        weapon.enabled = true;
        animation.Play("Attack", () => weapon.enabled = false);
    }
}

というふうにコンポーネント化する。コンポーネント化したことで、

  • CharactorAnimation自体がやりたいことが限定されるのでクラスが小さくなりやすい
  • コンポーネントの取り外しが可能で変更しやすい
  • コンポーネントの再利用が可能

のようなメリットがある。自分は何より1つ1つのクラス(コンポーネント)が細分化されることで、コードが読みやすくなるのが嬉しい。
(クラス数が増えることによるメモリ増加、何をコンポーネント化するかに時間を食う、等のデメリットもある)

Playmaker:ステートをコンポーネント化

Playmakerはプログラムのステート(状態)をコンポーネント化したアセット(内部的には全てがコンポーネントではないけど概念的に)。

  • ボタンを押したら〜
  • 攻撃したら〜
  • イベント条件を満たしたら〜

みたいな1つ1つのステートをコンポーネント化しておくことで、同じステートをノードで使い回すことが出来る。

f:id:okamura0510:20191014203637j:plain
↑こんな感じでステートを矢印で繋げて表現

ステートが視覚化されて分かりやすくなった!
しかし、ステートが増えてきた時にノードが複雑に絡まって迷子になる。。
なので、次にBehavior Designerを試してみた。

Behavior Designer:AIをコンポーネント化

Behavior DesignerはAIなどに使用されるBehaviorTreeをコンポーネント化したアセット。Playmakerはノードがループする可能性があるけど、BehaviorTreeの場合は最終的に1つのノードに行き着く。

f:id:okamura0510:20191014210541j:plain
↑こんな感じで一番上のノードから下のノードに順次実行されていって、最後まで実行された場合は一番下のノードに行き着く

Behavior Designerの場合は行き着く先が1つなので、迷子になりにくい。その分、ノードを重複して使う可能性があるので、ノード数が増える傾向。

MVVM 4 uGUI:データをコンポーネント化

MVVM 4 uGUI(M4u)はuGUIにMVVM(Model-View-ViewModel)パターンを導入したアセット。
PlaymakerやBehavior Designerは状態をコンポーネント化してるが、M4uはデータをコンポーネント化してる(正確にはデータではなくViewModelをコンポーネント化)。

http://okamura0510.jp/tempura/wp-content/uploads/M4uDemoText.gif ↑ M4uTextBindingにデータを登録しておくと、値が変更された時にTextへ自動反映される

通常はText.text = "Saiya Power is 5"みたいなコードを書かないといけないところを、そういうコードを省ける(コード量が削減出来る)。
便利な分、値の反映にリフレクションを使ってるのでコストがかかる。あと、MVVMはデータ設計がとても大事なので、開発初期段階では使いづらい(頻繁にデータが修正されるため)。

UnityEvent:コンポーネントとコンポーネントを繋げる

コンポーネント指向は1つ1つのクラスが小さくなる反面、コンポーネント間の繋がりをどうするかが問題になる。
例えば、マップには地面があって、地面に何かぶつかった時の処理を書こうとした場合

using UnityEngine;

public class Map : MonoBehaviour
{
    [SerializeField] Ground ground;

    void Start() => ground.Init(this);

    public void OnGroundCollide(Collision collision)
    {
        // 地面に何かぶつかった時の処理
    }
}

public class Ground : MonoBehaviour
{
    Map map;

    public void Init(Map map) => this.map = map;

    void OnCollisionEnter(Collision collision)
    {
        map.OnGroundCollide(collision);
    }
}

MonoBehaviourの衝突メソッドOnCollisionEnterを使って上記のように書いたとする。
実際、自分もよくこう書いてたんだけど、これだと

  • MapはGroundを持ってる
  • GroundはMapを持ってる

というように2重に参照を持ってしまっている。
すると、Mapに変更が入った場合もGroundに変更が入った場合も両方修正する必要がある。
あと単純に「MapはGroundを持ってる」は自然だけど、「GroundはMapを持ってる」はなんか変(意外とこういう直感的な部分も大事だったりする)。

これをUnityEventで表現するとこうなる。

using UnityEngine;
using UnityEngine.Events;
using System;

[Serializable] public class OnGroundCollideEvent : UnityEvent<Collision> { }

public class Map : MonoBehaviour
{
    public void OnGroundCollide(Collision collision)
    {
        // 地面に何かぶつかった時の処理
    }
}

public class Ground : MonoBehaviour
{
    [SerializeField] OnGroundCollideEvent onGroundCollide;

    void OnCollisionEnter(Collision collision)
    {
        onGroundCollide?.Invoke(collision);
    }
}

MapにもGroundにも参照がなくなった!
これは実際にはUnityEventを使うことで、エディタ側で参照を解決してる。

f:id:okamura0510:20191020124017j:plain ↑ UnityEventを継承したクラスを作ると、こんな感じでエディタ側にリストが出てきて、参照を指定出来るようになる

UnityEventでコンポーネントとコンポーネントを安全に繋げることが出来るようになる。

現状の自分

いろいろ試してきた結果、自分は

  • アセットは使わず
  • 必要なもののみコンポーネント化して
  • データの繋がりはUnityEventを使う

に落ち着いてる。

PlaymakerやBehavior Designerを使ってゲームを作ってみたけど、ゲームのロジックは複雑なので、細かいところに手が届かなくて困る(時間が掛かりすぎる)。プログラマ以外の人のために使うツールなんだと思う。

コンポーネント化は無理のない程度で。理想的にはコンポーネントの組み合わせのみでオブジェクトを組み立てられるのがいいんだけど、ゲームのロジックは変化しやすいので設計してもすぐに変わってしまう。なので無理しない。

UnityEventがシンプルでUnity公式だから好き。MVVMのようなデータバインドはリフレクションを使ってるのでコストがかかる。UnityEventの内部構造を見てないけど、公式が提供するものならゲームエンジン側に手を加えられるのでコストの面も期待できるだろうし。

最後に

大事なのはその状況で最善の手段を使うことなんだと思う。

開発初期段階でガチガチにコンポーネントを作っても無駄になるし、
チーム開発で作業分担したい場合はPlaymakerやBehavior Designerは有効だろうし、
とにかくコード量を少なくしたい!ってことならMVVMを使うのもあり。

(自分への教訓)