【Unity】enumの途中にシンボルを追加しても値が変わらないようにする

Unity で enum をインスペクターで使用しているときに、enum の途中にメンバーを追加すると、追加したメンバーより後ろの値を設定した場合値がずれる問題が発生します。

例えば以下のように Fruit 列挙型があったとします。

// Fruit.cs

// もともとの定義
public enum Fruit
{
    Apple,
    Banana,
    Orange,
    Grape
}

そして、インスペクターで Grape を設定している状態とし、そこに以下のように中ほどに Strawberry を追加します。

public enum Fruit
{
    Apple,
    Banana,
    Orange,
    Strawberry, // ★追加
    Grape
}

この変更を行った場合、インスペクターで先ほどまで Grape を選択していた箇所が Strawberry のように変わってしまいます。中身が数字で enum を保存しているのためこのような現象が起きます。

この問題は以下のルールを守れば回避できます。

  • 値を途中に追加せず末尾に追加する
  • 一度定義したら値の順序を変更しない

もしくは

  • enum 個々に値をあらかじめ振っておく
    • 各シンボルランダムな値をそれぞれに振って数値に規則性を持たせない

ただし毎回上記ルールを守る訳にも行かない場合があるため今回はこの回避策を考えてみたいと思います。

確認環境

  • Unity 2022.3.5f1

Editor上のみで動作確認しています。

仕様の説明

今回の実装の要件は以下の通りです

  • (1) enumのメンバーを途中に追加しても値が変わらない
  • (2) enumのメンバーの順序を変えても値が変わらない
  • (3) enumのメンバーが持つ番号を手動で変えても値が変わらない
  • (4) enumのメンバー名が変わっても順序が変わらなければ値を引き継げる

制限事項は以下の通りです。

  • (1) enumのメンバーの順序の変更と同時にメンバー名変更が同時に発生した場合は対応できない
  • (2) 途中で(以降で紹介する)属性を外したときにenumへの変換は手作業が必要
  • (3) 途中で(以降で紹介する)属性を外してstring → enumに型を変更すると値が消滅する
  • (4) 処理の効率性(紹介する実装によるゲーム処理速度の低下)は考慮しない

内部仕様は以下の通りです。

  • enum の値は string として保存し "0:Apple" のように、${enumが持つ数値}:${シンボル名} として unity へシリアライズ・デシリアライズを行う。

使用する側のスクリプトに何度も同じ処理を書かないように、enum を使用している箇所が増えても実装を修正不要としてメタプログラミングと PropertyDrawer を使って実装していたいと思います。

また、申し訳ありませんがこの実装例が実際のプロジェクトで実用に耐えられるか十全に検討できていないためあらかじめご了承ください。

使い方

まずは使い方の紹介です。

enum を使用したいスクリプトに以下のように string のフィールドを定義して CustomEnum(enumの型情報) というように属性を追加します。そしてその string から任意の enum を取得するためのプロパティを作成します。

// MyScript1.cs
using UnityEngine;

// ★使用例(1)
// 毎回値を変換する
//  → シンプルに使えるが処理効率がかなり悪い
public class MyScript1 : MonoBehaviour
{
    [SerializeField] [CustomEnum(typeof(Fruit))] string _type;
    public Fruit Value => SerializeUtil.Restore<Fruit>(_type);
}

表示は以下の通り Fruit のメンバーが表示されます。

使用例その 2です。こっちはパフォーマンス向上のため ISerializationCallbackReceiver でオブジェクトがデシリアライズされたときに enum の値を復元します。頻繁にプロパティにアクセスする場合はこっちのほうがいいかもしれません。

// MyScript2.cs
using UnityEngine;

// ★使用例(2)
// シリアライズされたときに変換する
//  → 処理効率はまぁまぁ良いがこの属性を使用するスクリプトに毎回処理を書かないといけない
public class MyScript2 : MonoBehaviour, ISerializationCallbackReceiver
{
    [SerializeField] [CustomEnum(typeof(Fruit))] string _type;
    public Fruit Value { get; private set; }

    // 何もしない
    public void OnBeforeSerialize() { }
    // stringのフィールドの値をenumに復元する
    public void OnAfterDeserialize()
    {
        Value = (Fruit)SerializeUtil.Restore(typeof(Fruit), _type);
    }
}

実装コード

CustomEnumAttributeクラス

enum を使いたいフィールドに付与する目印の属性の定義です。型情報をコンストラクタの引数に取る属性として実装しています。

using System;
using UnityEngine;

// カスタムEnum型を扱うためのクラス
public class CustomEnumAttribute : PropertyAttribute
{
    public readonly Type Type;
    
    public CustomEnumAttribute(Type enumType) => Type = enumType;

    // 属性に指定されている型がenumかどうかを取得する
    // true: enumである / false: enumでない
    public bool IsEnum => Type != null && Type.IsEnum;
}

EnumEditorクラス

Editor拡張のスクリプトです。

CustomEnumAttribute を持つフィールドに対し PropertyDrawer で表示のカスタマイズと、Unity へのシリアライズ・デシリアライズ方法を定義します。

#if UNITY_EDITOR

using System;
using UnityEditor;
using UnityEngine;

// stringのフィールドをenumリスト表示にするEditor拡張
[CustomPropertyDrawer(typeof(CustomEnumAttribute))]
public class EnumEditor : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent content)
    {
        var att = attribute as CustomEnumAttribute;
        if (property.propertyType != SerializedPropertyType.String)
        {
            EditorGUI.PropertyField(position, property);
            Debug.LogWarning($"stringメンバー以外に{nameof(CustomEnumAttribute)}" +
                $"属性を指定しないでください。" +
                $"オブジェクト={property.serializedObject.targetObject}, 変数名={property.name}");
            return;
        }
        if (!att.IsEnum)
        {
            EditorGUI.PropertyField(position, property);
            Debug.LogWarning($"型情報にenumが指定されていません。" +
                $"オブジェクト={property.serializedObject.targetObject}, 変数名={property.name}");
            return;
        }

        // 文字列で保存されている値をenumに復元する
        Enum value = SerializeUtil.Restore(att.Type, property.stringValue);
        if (value == null)
        {
            value = (Enum)Enum.GetValues(att.Type).GetValue(0);
        }

        var label = new GUIContent(property.displayName);
        Enum selected = null; // 選択結果

#if ODIN_INSPECTOR
        // OdinInspector を使用しているときは拡張検索ボックスを表示する処理
        string typeName =
            $"Sirenix.OdinInspector.Editor.EnumSelector`1" +
            $"[[{att.Type.AssemblyQualifiedName}]], " +
            $"Sirenix.OdinInspector.Editor, Culture=neutral, PublicKeyToken=null";
        Type t = Type.GetType(typeName);

        var m = t.GetMethod("DrawEnumField", new[] { 
            typeof(Rect), typeof(GUIContent), 
            att.Type, typeof(GUIStyle) });
        selected = m.Invoke(null, new object[] { position, label, value, null }) as Enum;
#else
        selected = EditorGUI.EnumPopup(position, property.displayName, value);
#endif
        property.stringValue = SerializeUtil.Convert(selected);
    }
}
#endif

EditorGUI.EnumPopup で enum のメンバーをリストアップして property.stringValue に設定することで string フィールドを enum のような選択式のドロップダウンに変換しています。

SerializeUtilクラス

文字列とenumを相互変換するためのクラスです。

using System;
using UnityEngine;

// 保存した文字列 ⇔ enum の相互変換するためのユーティリティ
public static class SerializeUtil
{
    // enum → 「値:シンボル名」形式に変換する
    public static string Convert(Enum value)
    {
        return string.Format("{0}:{1}", (int)(object)value, value.ToString());
    }

    // 「値:シンボル名」形式 → enumに変換する
    public static T Restore<T>(string value) => (T)(object)Restore(typeof(T), value);

    // 「値:シンボル名」形式 → enumに変換する
    public static Enum Restore(Type enumType, string value)
    {
        if (string.IsNullOrEmpty(value))
        {
            return default; // 読み取れない場合規定値を返す
        }

        // 列挙型の値と名前を取得して値を復元する処理
        string valueText = value;

        // 途中からこの方式に切り替えた時に数値しか入ってない時の対応
        string[] splitTexts = valueText.Split(':');
        if (splitTexts.Length == 1 && TryParseAll(enumType, splitTexts[0], out Enum result1))
        {
            return result1;
        }

        // 2つに分割できる時はシンボル名を優先で復元する
        if (Enum.TryParse(enumType, splitTexts[1], out object result2))
        {
            return (Enum)result2;
        }

        // 後ろのシンボルからenumが復元できなかったら前の数字が一致するものを選択する
        if (TryParseInt(enumType, splitTexts[0], out Enum result3))
        {
            return result3;
        }

        // シンボル名と数値を同時に変更した時、シンボルを削除して戻せない時などにくる
        Debug.LogWarningFormat($"{enumType.Name} の復元に失敗しました。 Source={value}");
        return default;
    }

    // 整数文字列 or enum文字列 → enum の変換
    private static bool TryParseAll(Type enumType, string value, out Enum result)
    {
        if (Enum.TryParse(enumType, value, out object tmpValue1))
        {
            result = (Enum)tmpValue1;
            return true;
        }
        else if (TryParseInt(enumType, value, out Enum tmpValue2))
        {
            result = tmpValue2;
            return true;
        }
        result = default; // どうやっても変換できない
        return false;
    }

    // 整数文字列("0") → enum の変換
    private static bool TryParseInt(Type enumType, string value, out Enum result)
    {
        if (int.TryParse(value, out int tmpInt) && Enum.IsDefined(enumType, tmpInt))
        {
            result = (Enum)Enum.Parse(enumType, tmpInt.ToString());
            return true;
        }
        result = default; // 定義されてない数値の指定は変化できない扱い
        return false;
    }
}

全部コピペして使用方法の通りコピペすると値を途中に追加しても問題が起きない enum が使用できるようになります。

参考サイト

値の保存方法と復元方法は以下を参考にしました。

https://www.urablog.xyz/entry/2018/05/21/081702

関連リンク

takap-tech.com

takap-tech.com

takap-tech.com