Unity ScriptableObject のデザインパターン「ランタイムセット」を確認する

alt text

前回: Unity ScriptableObject のデザインパターン「デリゲート」を確認する - Self-Taught CODE Tokushima Tech Blog

前回、前々回の記事で ScriptableObject の公式のサンプルである Paddle Ball プロジェクトのデザインパターンの紹介部分における「デリゲート」の内容を確認しました。全体として以下の 5 つがあり、今回は 3 つ目のランタイムセットです。

イベントチャンネルはかなり長いので、後回しにします。

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

ランタイムセット (RUNTIME SETS)

このランタイムセットは eBooks では紹介がありましたが、Paddle Ball のゲーム部分には使われておらず、最初の記事で紹介していません。

  • ScriptableObject は静的データを格納できるほか、動的データを保持するようにカスタマイズもできる
  • これは、「シングルトン (Singleton)」の利点の一つである グローバルなアクセスの容易さ を再現している
  • しかし、シングルトンは不要な依存関係を生じてしまう
  • その代替手段として、「ランタイムセット」を検討できる
    • ゲーム要素の追跡に活用できる
  • ScriptableObject はプロジェクトレベルのアセットであり、シーン内のすべてのオブジェクトからグローバルにアクセス可能
    • シングルトン使用時にありがちな欠点をほとんど伴わずに情報を共有できる

この部分だけだと、シングルトンの欠点を伴わない理由がいまいち理解できませんが、まずは「シングルトンを検討する時にランタイムセットを思い出す」「特にゲーム要素の追跡に使いたい場合」を頭に入れたいと思います。

基本的な実装方法

まず、以下のコード例が示されていました。かなりシンプルな GameObject のリストの管理クラスです。
ドキュメントでも「データコンテナのようなもの」とありますが、動的に要素を変更できるデータコンテナのように見えます。

確かにシングルトンでも実装しそうなものです。

using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "GameObject Runtime Set", fileName
= "GORuntimeSet")]
public class GameObjectRuntimeSetSO: ScriptableObject
{
   private List<GameObject> items = new List<GameObject>();
   public List<GameObject> Items => items;

   public void Add(GameObject thingToAdd)
   {
       if (!items.Contains(thingToAdd))
            items.Add(thingToAdd);
   }

   public void Remove(GameObject thingToRemove)
   {
       if (items.Contains(thingToRemove))
            items.Remove(thingToRemove);
   } 
}

Generic バージョン

続いて Generic を用いて実装されたバージョンです。そのままですね。(Items が公開フィールドになっているのは記事のボリューム都合の簡略化でしょうか。)

public abstract class RuntimeSetSO<T>: ScriptableObject
{
   public List<T> Items = new List<T>();

   public void Add(T thing)
   {
       if (!Items.Contains(thing))
            Items.Add(thing);
   }

   public void Remove(T thing)
   {
       if (Items.Contains(thing))
            Items.Remove(thing);
   } 
}

[CreateAssetMenu(menuName = "Enemy Runtime Set", fileName =
"EnemyRuntimeSet")]
public class EnemyRuntimeSetSO: RuntimeSet<Enemy>
{

}

使用方法と制限事項

  • ランタイムセットの使用方法は開発者の裁量次第
  • 多くの場合は、ゲームプレイ中に設定や更新をするために、別の MonoBehaviour を使用する
    • 例えば、自動的にセットしたいなら、OnEnable/OnDisable でランタイムセットの Add/Remove を呼ぶなど
    • Prefab として以下の Enemy を利用するようなケースでは特に便利
public class Enemy: MonoBehaviour
{
     [SerializeField] private EnemyRuntimeSetSO m_RuntimeSet;

     private void OnEnable()
     {
         m_RuntimeSet.Add(this);
     }

     private void OnDisable()
     {
         m_RuntimeSet.Remove(this);
     }
}
  • 制約として、実行時に ScriptableObject をインスペクターで確認すると Items リスト内の内容が表示されず、エラーが表示される
    • 実際にはリストは正常に機能している
    • 混乱を避けるなら [HideInInspector] をつけておく
    • また、カスタムエディタスクリプトとインスペクターを使用することで解決できる
      • 次のサンプルで紹介

パターンデモ

youtu.be

上記の動画にあるように

  • 「Spawn Block」ボタンでブロックの壁が生成され、ボールが衝突すると消える
  • Inspector に表示されている BlockRuntimeSet_Data アセットの Runtime Set Items に「Block0, Block」と生成・消滅するブロックが追跡されていることが分かる

GameObjectRuntimeSetSO

まずは、BlockRuntimeSet_Data アセットのベースになっている ScriptableObject である GameObjectRuntimeSetSO を見てみます。(一部省略しています)

[CreateAssetMenu(menuName = "GameSystems/GameObject Runtime Set", fileName = "GameObjectRuntimeSet")]
public class GameObjectRuntimeSetSO : DescriptionSO
{
    public System.Action ItemsChanged;

    [Header("Optional")]
    [Tooltip("Notify other objects this Runtime Set has changed")]
    [SerializeField, Optional] private VoidEventChannelSO m_RuntimeSetUpdated;

    private List<GameObject> m_Items = new List<GameObject>();
    public List<GameObject> Items {get => m_Items; set => m_Items = value;}

    public void Add(GameObject thingToAdd)
    {
        if (!m_Items.Contains(thingToAdd))
            m_Items.Add(thingToAdd);

        if (m_RuntimeSetUpdated != null)
            m_RuntimeSetUpdated.RaiseEvent();

        if (ItemsChanged != null)
            ItemsChanged();
    }

    public void Remove(GameObject thingToRemove)
    {
        if (m_Items.Contains(thingToRemove))
            m_Items.Remove(thingToRemove);

        if (m_RuntimeSetUpdated != null)
            m_RuntimeSetUpdated.RaiseEvent();

        if (ItemsChanged != null)
            ItemsChanged();
    }
}

まず、先程と同じように管理対象である List<GameObject> とそれを操作する Add/Remove メソッドがあるのが分かります。

それ以外に m_RuntimeSetUpdated.RaiseEvent()ItemsChanged()Add/Remove のそれぞれで呼び出されているのが分かります。

まず m_RuntimeSetUpdated なのですが、これは動画内に表示されていた以下の BlockRuntimeSet_DataRuntime Set Updated にセットされている BlockRuntimeSetUpdated_SO になります。

alt text

これは、動画内にあった「現在のブロックのカウント」を表示している部分で Listen されています。

続いて、ItemsChanged です。これはドキュメントにも記載がありますが、「Items リストに要素が追加または削除された際にエディタースクリプトに通知し、Items リストが変更された際にインスペクターを更新」という役割をもっています。

カスタムのエディタースクリプトScriptableObject でのイベントチャンネルを使えないため、普通に C# のイベントの仕組みを利用しているのでしょう。

GameObjectRuntimeSetSOEditor

カスタムのエディタースクリプトである GameObjectRuntimeSetSOEditor です。
必要な箇所だけに省略しています。

[CustomEditor(typeof(GameObjectRuntimeSetSO))]
public class GameObjectRuntimeSetSOEditor : Editor
{
    private GameObjectRuntimeSetSO m_RuntimeSet;

    private ListView m_ItemsListView;

    private Label m_ListLabel;

    private void OnEnable()
    {
        m_RuntimeSet = target as GameObjectRuntimeSetSO;

        m_RuntimeSet.ItemsChanged += OnItemsChanged;
    }

    private void OnDisable()
    {
        m_RuntimeSet.ItemsChanged -= OnItemsChanged;
    }

    // 省略箇所多数

    // Invokes every time the runtime set list changes
    private void OnItemsChanged()
    {
        // Format the label with an item counter
        if (m_ListLabel != null)
        {
            string message = "Runtime Set Items";
            message = (m_RuntimeSet == null) ? message + ":" : message + " (Count " + m_RuntimeSet.Items.Count + "):";
            m_ListLabel.text = message;
        }

        // Force refresh of the Inspector when the Items list changes
        if (m_ItemsListView != null)
            m_ItemsListView.Rebuild();
    }
}

OnEnable/OnDisable で先程の ItemsChangedOnItemsChanged メソッドを紐づけていることが分かります。

これによって、GameObjectRuntimeSetSO で管理しているアイテムの数の変更が通知され、Inspector の方も表示が更新されているという仕組みのようです。

カスタムのエディタースクリプトということで、難しそうに感じましたが、とても簡単に利用できることが分かります。

ランタイムセットを使用するメリット

  • GameObject やコンポーネントの集中管理リストが必要な際には欄ライムセットの利用を検討する
    • オブジェクトの検索 (Object.FindObjectOfTypeGameObject.FindWithTag) を利用するよりも低コスト
    • シングルトンよりも問題が発生しにくい
  • ScriptableObject であるため、シーン内の個別の GameObject から毒膣している
    • 監視対象ごとにラインタイムセットを作成しておくと便利

最後に

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

eBooks を読んでいる時には微妙にわからない箇所もあったのですが、実例も交えての説明で理解が深まりました。

特に「リストで追跡・管理したいものに適している」というのは覚えておくべきだと思いました。

シングルトンとの違いについても、シングルトンであれば HogeSingleton.Instance のようにコード内に直接記述しなければいけない一方で、ScriptableObject の場合は、Inspector 経由の依存で済むため強い依存にはなりません。

いろいろと使い道のありそうなパターンだと思いました。