ゲーム化!tomo_manaのブログ

ゲーム化!tomo-manaのブログ

Unityでゲームを作る方法について紹介しています

Unity学習#21-1 (Unity 2019.4.1f1) 独自のイベントを定義する(ExecuteEvents.Execute と IEventSystemHandler)

第21回で、これまでEventSystemで使用していた SetSelectedGameObject() ではどうしても解消し切れなかった要求のために、自作イベントを作ることにしました。Unityでは、独自のイベントを定義するためのインターフェースとして ExecuteEvents.Execute() 関数が用意されています。

ExecuteEvents.Execute() は、直接他のスクリプトの関数を呼び出すのに比べて、以下の点でメリットがあります。

<特徴>
スクリプトへの直接参照が不要(ゲームオブジェクトにメッセージを送る)スクリプト(MonoBehaviour継承クラス)への参照を持たなくても、ゲームオブジェクトへの参照だけで関数をコールできます。(ゲームオブジェクトの中にメッセージを送れる対象がいなくてもエラーになりません)
自由度が高い:この関数自体がメッセージを飛ばしてくれるのではなく、この関数はコールバックを呼ぶだけ。メッセージの型は自由に定義できます。

しかし反面、自由度が高すぎるせいで、実装する上で少し困惑する部分があります。とっつきにくく感じるのは、この関数の使い方が Unity のマニュアルだけだと分かりにくい点です。また、この関数の動作についての説明が少ないことも、とっつきにくい原因になっていると思います。

今回は、混乱しにくい実装方法と、この関数の動作イメージ、また、混乱の元になっている様々な拡張ポイントについてまとめます。


先に実装方法をまとめ、その後に動作イメージをまとめます。

実装方法

作成したいイベントと、イベントを発行するためのコールバックを定義します。

以下に、送信側と受信側のコードをまとめます。図の赤文字が形として決まっているもの、黒文字が自分で定義するもの、青文字が自分で定義した名前(黒字)に合わせる必要があるものです。

f:id:tomo_mana:20201123223649p:plain
クラス関係

以下の順に実装すると、あまり混乱せずに実装できる印象です。
(1) 送信側:基本となる形を定義(名前を先に決めてしまう)
(2) インターフェースの作成
(3) 送信側:コールバックの実装
(4) 受信側:インターフェースの実装
(5) 送信側:1で作ったものをコールする

実装手順

送信側:基本形の定義

ExecuteEvents.Execute() の基本形は少し複雑です。先に、用意すべきものを明確にする目的で、ExecuteEvents.Execute() の基本形を書き、ここでインターフェース名とコールバック名を決めてしまいます。この実装を入れた時点ではインターフェースとコールバックの実物が存在しないのでコンパイルエラーになります。

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: コールバック名
    );

eventData は BaseEventData型 になりますが、少し複雑なのでここでは仮にnullを入れます(BaseEventData型の活用については後述します)。
ポイントは target ですが、これはMonoBehaviourではなく、GameObjectを指定します。これもメカニズムが少し分かりづらいですが、受信側のMonoBehaiourが属するゲームオブジェクトを指定します。

インターフェースの作成

次に、先ほど定義したインターフェース名に合うように、新しいインターフェースのソースコードをProjectフォルダに作ります。飛ばしたいメッセージを合わせて定義します。
インターフェース名.cs

using UnityEngine.EventSystems;
public interface インターフェース名 : IEventSystemHandler
{
    // 飛ばしたいメッセージをここに定義
    void メッセージA();
    :
}

送信側:コールバックの実装

ExecuteEvents.Executeは、実際はfunctorに指定したコールバックを呼ぶだけの処理になります(キューイングなども一切せず、同一スタック上でコールバックを実行します)。コールバックのインターフェースは以下の形式になります。

    void コールバック名 (インターフェース名 receiver, BaseEventData eventData){
        // メッセージを飛ばす処理(自作する)
        receiver.メッセージA();
    }

ここは少し混乱する点ですが、とりあえずインターフェースを継承したオブジェクトをreceiverという変数名にしておいて、飛ばしたいメッセージをコールする処理を実装します。

C#には、コールバックに関数名と型定義を付けない記法(ラムダ式)もあるようです。

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: (receiver, eventData) => receiver.メッセージA();
    );

(分岐がある or 複数行にわたる場合)

    ExecuteEvents.Execute<インターフェース名>(
        target: 受信側ゲームオブジェクト
        eventData: null
        functor: (receiver, eventData) => {
                receiver.メッセージA();
                // さらに何か処理・・・
        }
    );

受信側:インターフェースの実装

ここまで実装したところで、送信側の実装は中断して、今度は受信側にインターフェースを実装します。受信側にインターフェースを実装した後で、メッセージを受信した時の処理を記述します。最初は動作を確認するために Debug.Log() だけでも良いかもしれません。

using UnityEngine.EventSystems;
public class 受信クラス名 : MonoBehaviour, インターフェース名
{
    public void メッセージA()
    {
        // 受信時の処理;
    }
}

送信側:基本形をコールする部分の実装

最後に、送信側の処理に、一通り揃った状態の ExecuteEvents.Execute() を組み込みます。

using UnityEngine.EventSystems;
public class 送信側コンポーネント名 : MonoBehaviour
{
    // 先ほど作ったコールバック
    void コールバック名 (インターフェース名 receiver, BaseEventData eventData){
        receiver.メッセージA();
    }

    // 処理に組み込む
    void xxx ()
    {
        if( xx ){
            ExecuteEvents.Execute<インターフェース名>(
                target: 受信側ゲームオブジェクト
                eventData: null
                functor: コールバック名
            );
        }
    }
}

コンパイルエラーが解消されたことを確認して、動作テストします。

動作イメージ

ExecuteEvents.Execute() に対して、メッセージとかイベントとかといった言葉を使うと、少し違和感があるかもしれません。メッセージというと、異なるシステムスタックで動作する異なるタスクまたはスレッドに対して、(特に受信側が)非同期に実行するために送信側が実行予約を行う目的で使うケースも多いためです。ExecuteEvents.Execute() は、イベントを送ると書かれていますが、していることは呼び出し元のスタック上でコールバックを実行しているだけです(ソースコードの抜粋を巻末に掲載)。Unityでは、このイベント送信を非同期に行うところまではサポートしていません。しかし、非同期なメッセージ送信に活用することはできるのではないかと思います。新規スレッドの立ち上げについてはそのうちに調べたいですが、メッセージ処理の中で、受信側のフラグを変更する、または受信キューに登録するだけの処理をして、受信側の Update() または自分のスレッド上でフラグまたは受信キューを確認して処理を行うようにすれば、非同期な処理も実現できると思います。

以下に、簡単に ExecuteEvents.Execute() の動作イメージについてまとめます。

メッセージ

一般的にメッセージの送信は、送信側から受信側に何らかの情報を送ることが目的です。

f:id:tomo_mana:20201123223719p:plain
メッセージ(イメージ)

実装

ExecuteEvents での実装では、以下のようなイメージになると思います。一般的なメッセージ送信と比べ、実装上は送信側はExecuteEvents に処理を委任するような形に見えます。実際は ExecuteEvents.Execute() が受信側のゲームオブジェクトを勝手に探してくれるなどのマッチング処理をしてくれるわけではありません。

ポイントは、送信側が受信側のスクリプトを直接参照しなくても、一般的に使用されているGameObject型と、IEventHandlerを実装したインターフェースだけでメッセージを送信できることで、これは保守性・拡張性が高い実装です。それは、複数のゲームオブジェクトがメッセージの送信先だったとして、それぞれが異なるMonoBehaviourスクリプトだったとしても、同じインターフェースとして扱えることです。イベントの送信先をコレクション的に扱えることになります。

なお、1回のコールで1つのゲームオブジェクトにしかメッセージを送ることはできません(複数のゲームオブジェクトにブロードキャストとかはしてくれません)。

f:id:tomo_mana:20201123223743p:plain
メッセージ(実装イメージ)

実際の動き

ExecuteEvents.Execute() を実際にコールした時の動きは、以下のようになります。ExecuteEvents.Execute() はゲームオブジェクトからインターフェース型を実装したコンポーネント毎に、コールバックを呼んでくれるだけのことをしています。(ポイントは、GameObject.GetComponentsで受け取るコンポーネントは1つとは限らないことです。これも後述します。)

f:id:tomo_mana:20201123223804p:plain
メッセージ(実際の動き)

ソースコードとのマッピング

ソースコードマッピングすると、以下のようになります(これは最初に示した図と同じものになります)

f:id:tomo_mana:20201125214324p:plain
クラス関係

拡張性

以下は、ExecuteEvents.Execute() 関数の拡張性についてまとめます。拡張性が高いといっても、使いやすい点と、かえって使いづらい点とがあり、後者は注意が必要です。

メッセージに変数を渡す

メッセージに変数を渡す時は、メッセージに引数を定義して、コールバック処理の中でメッセージに引数を渡す処理を実装します。

void Callback(インターフェース名 receiver, BaseEventData eventData){
  int val = 2;
  receiver.メッセージA( val );
}

コールバックに指定する引数を、関数の外から持ってくることも可能です(同一クラス内であれば)。

同一のIEventHandler型に対して複数のコールバックを使い分ける

ExecuteEvents.Execute インターフェースを単純に眺めると、同一のIEventHandler型に対して、コールバックを複数用意することで、状況に応じて動作を入れ替えられることが分かります。例えば、メッセージA、B の2つをインターフェースに登録したとして、ある時はメッセージAだけ、ある時はメッセージA と B の両方をコールしたい場合、状況に応じて2つのコールバックを呼び分ければ良いことになります。(以下の例くらいなら、コールバックの中で場合分けした方が良さそうですが・・・)

void Callback1(インターフェース名 receiver, BaseEventData eventData){
    receiver.メッセージA();
}
void Callback2(インターフェース名 receiver, BaseEventData eventData){
    receiver.メッセージA();
    receiver.メッセージB();
}
if( xx ){
    ExecuteEvents.Execute<インターフェース名>( gameObject, null, Callback1 );
} else {
    ExecuteEvents.Execute<インターフェース名>( gameObject, null, Callback2 );
}

同一のIEventHandler型を持つスクリプトを2つ以上、同じゲームオブジェクトに持たせる

用途があるかは別ですが、同一のIEventHandler型を持つスクリプトを2つ以上、同じゲームオブジェクトに持たせることもできます。この場合、同じIEventHandler型を持つスクリプトの数だけコールバックが呼ばれ、それぞれのスクリプトに順々にメッセージコールが行われることになります。

f:id:tomo_mana:20201125215801p:plain
1つのゲームオブジェクト内に同一イベントハンドラが2つ以上ある場合の動き

BaseEventData型 を使用する

前半では、BaseEventData型はnullで良いと書きましたが、このBaseEventData型は、マルチプレイの場合に、どのプレイヤーが行った操作かを通知する目的のインターフェースのように見えます。ただ、このインターフェースを使用しなくても、先述の通りIEventHandler継承インターフェースの中でメッセージの引数を自由に定義できるため、BaseEventData型をわざわざ使わなくても何とかなる気がします。
ExecuteEvents.Execute() は、このBaseEventData型の中身は気にせず、単純にコールバック関数に引き渡すだけです。つまりBaseEventData型の中身は、すべて自分で設定してあげる必要があります。

BaseEventData とは

f:id:tomo_mana:20201125223705p:plain
BaseEventDataクラスの中身はキー入力に関係するBaseInputModuleと、現在選択されているゲームオブジェクトへの参照です。送信側と受信側で、メッセージ受信時しか必要としないゲームオブジェクト(どれが選択されているか)があった場合は、ここで共有します。ただしその中身は全て、自分で設定しなければなりません。

BaseInputModule とは

f:id:tomo_mana:20201125222815p:plain
BaseInputModule関連クラス図

BaseInputModuleは、InputManagerのキー入力を管理するStandaloneInputModuleや、同じくInputSystem用のInputSystemUIInputModuleの親クラスになります。

StandaloneInputModule (InputManager)

InputManager を使用している場合は、BaseInputModule には StandaloneInputModule を入れます。StandaloneInputModule は EventSystemゲームオブジェクトのコンポーネントです。

BaseInputModule baseInputModule = gameObject.GetComponent<StandaloneInputModule>();
InputSystemUIInputModule (InputSystem)

InputSystem を使用している場合は、BaseInputModule には InputSystemUIInputModule を入れます。
InputSystemUIInputModule は EventSystemゲームオブジェクトのコンポーネントです。

BaseInputModule baseInputModule = gameObject.GetComponent<InputSystemUIInputModule>();

2つのGameObject

ExecuteEvents.Execute() のインターフェースをよく眺めると、2つのGameObjectが指定できてしまうことが分かります。

void コールバック名( インターフェース名 receiver, BaseEventData eventData );

GameObject 意味
receiver <必須>メッセージを飛ばす対象となるゲームオブジェクト。ExecuteEventsのtargetに含まれる全てのIEventHandler実装インターフェース型を満たすMonoBehaviourにメッセージを飛ばします。
eventData.selectedObject <任意>今選択されているゲームオブジェクトを入れる箱。ただの箱なので何を入れても構わない。(何度も書きますが、このゲームオブジェクトはExecuteEvents.Execute関数の外で、自分で設定します)

eventData.selectedObject は、BaseInputModule を指定する必要がある場合に、必要に応じて設定するものと思われます。

ExecuteEventsクラス のソースコード(抜粋)

最後に、ExecuteEvents のコードの抜粋を載せておきます。

ExecuteEvents

using System;
using System.Collections.Generic;
using UnityEngine.UI;

namespace UnityEngine.EventSystems
{
    public static class ExecuteEvents
    {
        private static readonly ObjectPool<List<IEventSystemHandler>> s_HandlerListPool = new ObjectPool<List<IEventSystemHandler>>(null, l => l.Clear());
        
        public static bool Execute<T>(
            GameObject target, 
            BaseEventData eventData, 
            EventFunction<T> functor
        ) where T : IEventSystemHandler
        {
            // internalHandlers: GameObject に含まれるすべての IEventHandler 実装 Component
            var internalHandlers = s_HandlerListPool.Get();
            GetEventList<T>(target, internalHandlers);

            for (var i = 0; i < internalHandlers.Count; i++)
            {
                functor( (T)internalHandlers[i], eventData);
                // 説明のためtry/catch省略
            }
            // 以降省略:送信可能なイベントが一つでもあれば true を返す
        }

        // GameObject に含まれる IEventHandler を含む全ての Component を取得(Behaviourであるかは問わない)
        private static void GetEventList<T>(GameObject go, IList<IEventSystemHandler> results) where T : IEventSystemHandler
        {
            // 中略(nullチェック)
            
            // GameObjectのすべてのComponentを取得 (ListPoolを使用)
            var components = ListPool<Component>.Get();
            go.GetComponents(components);
            
            // Component が IEventHandler か?(MonoBehaviour の場合は、Active/Enable の場合のみ Yes)
            for (var i = 0; i < components.Count; i++)
            {
                if (ShouldSendToComponent<T>(components[i])) {
                    results.Add(components[i] as IEventSystemHandler);
                }
            }
            ListPool<Component>.Release(components);
        }

        private static bool ShouldSendToComponent<T>(Component component) where T : IEventSystemHandler
        {
            // IEventHandler か?
            var valid = component is T;
            if (!valid)
                return false;
            
            // Active/Enable か?
            var behaviour = component as Behaviour;
            if (behaviour != null)
                return behaviour.isActiveAndEnabled;
            return true;
        }
    }
}

以上です。