Unity ScriptableObject のデザインパターン「拡張可能な Enum」を確認する

alt text

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

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

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

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

拡張可能な Enum

  • enum はコード内で固定された名前付きの値を管理する方法
    • シリアライズされるとシンボル名ではなく整数として保存される
    • 明示的に数値を指定しないと、値を削除したり順序変更すると予期しない結果がでる
[System.Serializable]
public enum HandGestures
{
    Rock,
    Paper,
    Scissors
}

System.Serializable 属性をつけて列挙型をシリアライズすると Inspector に表示されるようになる。

ただ、上記は暗黙的に Rock = 0, Paper = 1, Scissors = 2 の番号がついており、これに依存すると、Paper を消した時に、Scissors の値が 1 になってしまう。

🤔 個人的には、数値に依存することはほぼ無いと思うので、この問題は気にしなくていいと思うが Unity 開発では問題になるのでしょうか。

ScriptableObjectベースの列挙型

ScriptableObjectベースの列挙型は、従来の列挙型と同様の機能を持ちながら、個別のアセットとして保存されます。

[CreateAssetMenu(fileName = "PlayerID")]
public class PlayerIDSO: ScriptableObject
{
}

上記は Paddle Ball ゲームの PlayerIDSO ですが、見た目は空の ScriptableObject です。

ですが、先程の C#enum と同様に、PlayerIDSO から作られたアセットはデータが格納されていなくとも「異なる値」として比較可能になります。

等価性 の確認に使えるだけで、この空の ScriptableObject は価値があります。

PaddleBallSO におけるプレイヤーID

Paddle Ball ゲームでは前述の通りプレイヤーを PlayerIDSOP1, P2 としてそれぞれアセットを作成し識別していました。

パターンデモ

alt text

このデモでは、TeamID という仕組みが入っています。チームといってもこのデモにおいては「ブロックの種類」がチームになっています。

上図のように赤で囲まれた「枠だけのブロック」と青で囲まれた「塗りつぶされたブロック」がそれぞれチームに分かれています。

P1 青チーム P2 赤チーム
alt text alt text

それぞれの Team ID コンポーネントP1_SOP2_SO がついているのが分かります。

加えて、Destroy On CollisionUse Team ID というチェックがついているのが分かります。

ではそれぞれを見ていきます。

TeamID

public class TeamID : MonoBehaviour
{
    [Tooltip("ScriptableObject for comparison")]
    [SerializeField] private PlayerIDSO m_ID;

    public PlayerIDSO ID { get => m_ID; set => m_ID = value; }

    public bool IsEqual(PlayerIDSO id)
    {
        return id == m_ID;
    }

    public static PlayerIDSO GetTeam(GameObject gameObject)
    {
        return gameObject.GetComponent<TeamID>().ID;
    }

    public static bool AreEqual(GameObject a, GameObject b)
    {
        TeamID teamA = a.GetComponent<TeamID>();
        TeamID teamB = b.GetComponent<TeamID>();

        if (teamA != null && teamB != null)
            return teamA.ID == teamB.ID;

        return false;
    }

    public static bool AreBothNull(GameObject a, GameObject b)
    {
        TeamID teamA = a.GetComponent<TeamID>();
        TeamID teamB = b.GetComponent<TeamID>();

        if (teamA == null && teamB == null)
            return true;

        return false;
    }
}

IsEqual のように PlayerIDSO を直接比較するか、AreEqual のように、それぞれから TeamID コンポーネントを取り出して、そこから PlayerIDSOID を比較する違いはあるものの、いずれも PlayerIDSO の等価性を使って比較しています。

DestroyOnCollision

続いて、DestroyOnCollision です。

public class DestroyOnCollision : MonoBehaviour
{
    [Tooltip("Only destroy when colliding with objects from this PlayerID")]
    [SerializeField] private bool m_UseTeamID;

    [Header("Broadcast to Event Channels")]
    [SerializeField] private GameObjectEventChannelSO m_DestroyGameObject;

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.collider.GetComponent<Ball>() == null)
            return;

        if (m_UseTeamID)
        {
            PlayerIDSO id = TeamID.GetTeam(this.gameObject);

            if (!TeamID.AreEqual(collision.collider.gameObject, this.gameObject))
            { 
                return;
            }

            if (TeamID.AreBothNull(collision.collider.gameObject, this.gameObject))
            {
                return;
            }
        }

        m_DestroyGameObject.RaiseEvent(gameObject);
        Destroy(gameObject);

    }
}

m_UseTeamID にチェックが入っていると、自身(つまりブロック)のチームID を読み出し、衝突したもの(つまりボール)のチームID と比較して、等価であれば削除する という処理になります。

もちろん、以下のようにボール (DemoBall) にも TeamID がついています。つまり、ボールのチームIDと一致するブロックしか破壊できないということですね。

alt text

このデモでは、ボタンによって、ボールのチームIDを切り替えられるようになっていて、それだけでボールの振る舞いが変わるというのも面白いですね。

動作の拡張

  • ScriptableObject は、enum とは異なり、追加のデータの保持や、フィールド、メソッド定義も可能
    • 専用の比較ロジックを実装すれば、特殊なダメージ効果を定義することもできる
  • インベントリシステムのためのアイテム種別や武器スロットの表現にも適している
    • 特定のキャラクターのみ装備可能
    • アイテムに魔法効果や特殊能力の付与
  • Weakness(弱点) のフィールドを持たせて、専用の判定メソッド(例えば IsWinner のような) をつければ、ジャンケンのような複雑な相互作用も表現できる

ScriptableObjectベースの列挙型を使用するメリット

  • ScriptableObject ベースの列挙型を使うと、特にチーム開発での作業効率が上がる
    • アセットとして扱われるため、更新時のマージ競合が発生しにくく、データ損失のリスクも低減
  • エディタでのドラッグ&ドロップUIを使うことで、デザイナーでもゲームデータの拡張を行える
    • フィールドの初期設定は調整が必要だが、使い始めればデザイナー自身がデータの入力作業を行える

最後に

今回は Paddle Ball プロジェクトに含まれているデザインパターンの説明から「拡張可能な Enum」を確認しました。

C#enum を使っていても拡張メソッドや属性で、追加のパラメータやメソッドを追加することがあります。

ScriptableObject は class なので、enum よりは重いですが、それでも Inspector からも使えることを考えるととても有効な方法のように思えます。う