Unity ScriptableObject のデザインパターン「イベントチャンネル」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「拡張可能な Enum」を確認する - Self-Taught CODE Tokushima Tech Blog

前回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「拡張可能 Enum」の内容を確認しました。全体として以下の 5 つがあり、今回は最後の「イベントチャンネル」です。

  • データコンテナ (DATA CONTAINERS)
  • デリゲートオブジェクト (DELEGATE OBJECTS)
  • イベントチャンネル (EVENT CHANNELS)
  • ランタイムセット (RUNTIME SETS)
  • 拡張可能な Enum (ENUMS)

イベントチャンネル

Use ScriptableObjects as Event Channels in Your Code | Unity

緩やかな結合と高い凝集度

  • Unity では、「移動・衝突判定」などを処理するコンポーネントの相互依存を、Inspector で簡単に連携できる
  • 一方で、他のオブジェクトへの依存関係が増えると一定のリスクが生じる
    • よって、依存関係は最小限に抑えることが望ましい
    • システム外とのやり取りのように、直接的ではない方法がよい
    • ⚠️ ここでいう「一定のリスク」とは、依存を持つコードの変更をすることで、依存している・されている側のコードに直接的に影響がでること(例えばビルドエラーとか)
    • 変更する以上は、依存関係を抑えていてもゲームとしての影響は出るが、いきなり壊れたりしないように影響を小さくしましょうねということ
  • 目標は、モジュール内部では緊密に結合しつつ、外部との依存関係は可能な限り分離する
    • 🤔 このモジュールという言い方は、割と突然使われたりして日本語で理解しにくい。例えば Paddle Ball ゲームでは「パドル」と「ボール」は違うモジュールとして扱うが、場合によっては同じモジュールとして扱うこともある。
    • あくまで管理する範囲をどう決めるかということなので、定義は考えない方がいい。
  • Paddle Ball プロジェクトの NullRefChecker クラスを使うと、Inspector で参照が欠落している場合に警告を表示できる
    • Awake などで Validate を呼び出す
    • ✅ これは多くの開発の場面で依存をチェックする方法として使われる
  • フィールドを未設定にしても問題ない場合は、カスタム属性 [Optional] を追加することでチェックを無視できる

NullRefChecker

NullRefChecker は以下のような実装になっており、利用方法としては NullRefChecker.Validate(this); として MonoBehaviour 自身を渡してしまえばよいです。

GetType, GetFields と普段は使わないメソッドが出ていますが、これはリフレクション系の API でクラス自体を調査するようなものです。つまり、ここでは渡された MonoBehaviour のフィールドのうち、「インスタンス変数、public, public 以外」を満たすものを集めています。

そのフィールド群を「[Serializable] 属性がなければスキップ」「[Optional] 属性があればスキップ」し、値が未設定のものがあればログに出しています。

public static class NullRefChecker
{
    public static void Validate(object instance)
    {
        FieldInfo[] fields = instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);

        foreach (FieldInfo field in fields)
        {
            // [Serializable] 属性がなければスキップ
            // [Optional] 属性があればスキップ
            if (!field.IsDefined(typeof(SerializeField), true) || field.IsDefined(typeof(OptionalAttribute), true))
            {
                continue;
            }

            // If null, log an error
            if (field.GetValue(instance) == null)
            {
                // Check if instance is a MonoBehaviour...
                if (instance is MonoBehaviour monoBehaviour)
                {
                    GameObject gameObject = monoBehaviour.gameObject;

                    Debug.LogError($"Missing assignment for field: {field.Name} in component: {instance.GetType().Name} on GameObject: " +
                        $"{monoBehaviour.gameObject}", monoBehaviour.gameObject);
                }
                // ... or a ScriptableObect
                else if (instance is ScriptableObject scriptableObject)
                {
                    Debug.LogError($"Missing assignment for field: {field.Name} on ScriptableObject:  " +
                        $"{scriptableObject.name} ({instance.GetType().Name})");
                }
                else
                {
                    Debug.LogError($"Missing assignment for field: {field.Name} in object: {instance.GetType().Name}");
                }

            }
        }
    }
}

カスタム属性 [Optional]

こちらは空の実装ですね。あくまで NullRefChecker での検査を回避するためのもののようです。

public class OptionalAttribute : PropertyAttribute
{
}

イベントの使用方法

  • では、アプリケーション内でモジュール群をどのように連携させるか
  • イベントを使用してオブジェクト間でメッセージを送受信する

集中型イベントシステム

  • 上記の Broadcaster -Listner では、Broadcaster (または Publisher or 送信者) は誰が受信するかについては関心をもたない
  • 逆に、Listner 側は、イベントの登録解除を行うため Broadcaster の存在についてある程度の知識が必要にある

以下のような実装を考えると、BroadcasterListner のことを知らないが、ListnerBoardcaster のことを知っている状態になります。

public class Broadcaster : MonoBehaviour {
    public UnityAction<Vector2> RaiseEvent;

    private void OnCollisionEnter2D(Collision2D collision) {
        RaiseEvent.Invoke(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    [SerializeField]
    private Broadcaster boardcaster;

    private void OnEnable() {
        boardcaster.RaiseEvent += ListenMothod
    }

    private void OnDisable() {
        boardcaster.RaiseEvent -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

ここでは、その対策として以下の GameEvents のような静的クラスの導入を提案しています。

public static class GameEvents
{
    public static Action ExitApplication;
    public static Action HomeScreenShown;
    public static Action<float> LoadProgressUpdated;
}

このパターンを使って書き直すと以下のように書き直せます。

public static class GameEvents {
    public static Action<Vector2> OnCollision;
}

public class Broadcaster : MonoBehaviour {
    private void OnCollisionEnter2D(Collision2D collision) {
        GameEvents.OnCollision(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    private void OnEnable() {
        GameEvents.OnCollision += ListenMothod
    }

    private void OnDisable() {
        GameEvents.OnCollision -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

確かに中間に位置する仲介役として機能する一方で、GameEvents は静的クラスであるため Inspector での表示ができず、エディタフレンドリーとは言えません。

イベントチャネルの設定

上記を解決するのが、ScriptableObject ベースのイベントです。ScriptableObjectシリアライズ可能なので Inspector に表示することができ、GameEvents よりも仲介役としてエディタフレンドリーです。

alt text

イベントチャンネルとして機能させるには以下を持つことが条件になります。

  • デリゲート (UnityAction or System.Action など)
    • Subscriber (Listner 受信側) に通知を行い、データをパラメータとして渡す
  • イベント発生メソッド
    • この public メソッドがデリゲートを実行する
public class Vector2EventChannelSO {
    public UnityAction<Vector2> OnEventRaised;

    public void RaiseEvent(Vector2 vec2) {
        OnEventRaised.Invoke(vec2);
    }
}

public class Broadcaster : MonoBehaviour {
    [SerializeField]
    private Vector2EventChannelSO onCollision;

    private void OnCollisionEnter2D(Collision2D collision) {
        onCollision.RaiseEvent(collision.GetContact(0).point);
    }
}

public class Listner : MonoBehaviour {
    [SerializeField]
    private Vector2EventChannelSO onCollision;

    private void OnEnable() {
        onCollision.OnEventRaised += ListenMothod
    }

    private void OnDisable() {
        onCollision.OnEventRaised -= ListenMothod
    }

    private void ListenMothod(Vector2 vec2) {}
}

上記のように書き直すことで、Vector2EventChannelSO のアセットを作成し、Inspector から Broadcaster/Listner のそれぞれに割り当てることができます。

イベントチャンネルアセットの作成

先ほど、「Vector2EventChannelSO のアセットを作成し」と書きましたが、このアセットも Unity エディタから作成できます。

また、命名に関してもサフィックスのガイドがでています。

  • イベントチャンネル用
    • _SO
    • BallCollided_SO
    • 🤔 そもそもScriptableObjectスクリプトVector2EventChannelSO のように SO が最後に付くので、アンダーバーだけというのはちょっと分かりにくい気もするが、ScriptableObject のアセットは見た目も違うので、データと識別できれば十分か
  • データコンテナ用
    • _Data
    • DefaultGame_Data

イベントチャネルがどのように役立つか

  • イベントチャンネルはプロジェクトレベルで存在するためグローバルにアクセス可能
    • シーン階層内の任意のオブジェクトを接続できる
    • シーンの読み込み後も状態を保持できる
  • 任意のイベントチャンネルオブジェクト (ScriptableObject のアセット) が Broadcaster または Listner として機能できる
    • ⚠️ Inspector では、どちらの用途で使われているかを HeaderAttribute で明示するべき
  • プロジェクトレベルでのイベント利用の利点は、シングルトンパターンの必要性を多くの場合置き換えられること
    • 先程の GameEvents もシングルトンパターンではなかったものの、実質グローバルに依存を及ぼしていた

イベントのデバッグ

alt text

上記のような Raise Event ボタンで任意のタイミングでイベントを発生させられるようにするとデバッグがしやすくなる。

これに関しては実際にコードを見てもらった方が早いのでリンクをしておきます。

https://github.com/UnityTechnologies/PaddleGameSO/blob/main/Assets/Core/EventChannels/Editor/GenericEventChannelSOEditor.cs

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「イベントチャンネル」を確認しました。

eBooks よりもイベントチャンネルがどうして有用なのかが段階的に説明されていて理解が深まりました。

カスタムのエディタスクリプトや、Inspector の表示をどのように整理すべきか、イベントチャンネルをどの程度作成するかなど、Paddle Ball プロジェクトの内容は実践的な実例なので、参考にしていきたいなと思います。