うら干物書き

ゲームを作りたい!

【Unity】列挙型(enum)を文字列で保存すれば、エディタの設定情報を守れるだろうか

 列挙型(enum)は便利で助かりますね。
 スクリプト上に1やら2やらの謎の数値で記述されているよりも、列挙型で適切な名前になっていれば何のことだがよくわかります。
 エディタ上にも簡単に表示できるから、パラメーターを設定するときにも活躍していますね。

 ……ただ、この列挙型に新しくメンバを追加する際に、面倒なことになることもあるのですよ。
 というわけで今回はエディタ拡張で列挙型と戯れる話です。


この記事にはUnity2018.1.0f2を使用しています。
.Netのバージョン設定には.Net4.x (4.6相当)を使用しています。

列挙型(enum)を持つデータを作ろう

 さて、エディタ拡張をする以上、元となるデータは必要ですよね。
 というわけでデータ用のクラスを作っていきましょうか。
 今回はデータ用のクラスにScriptableObjectを使います。

 これは、後ほどデータの中身であるYAMLを見る予定なためです。
 ScriptableObjectはシーンやPrefab情報と違って余計なデータが含まれないので、データが見やすいですからね。

DataTable.cs

using UnityEngine;

public enum EGenre
{
    Action,
    Adventure,
    RPG,
    Shooooooting,
    Simulation,
}

[CreateAssetMenu( menuName = "Create Data Table", fileName = "DataTable" )]
public class DataTable : ScriptableObject
{
    [SerializeField]
    private GenreData[] m_genreList  = null;
    public  GenreData[] GenreList { get{ return m_genreList; } }

} // class DataTable

[System.Serializable]
public class GenreData
{
    [SerializeField]
    private EGenre m_genre = default( EGenre );
    public  EGenre Genre { get{ return m_genre; } }
    [SerializeField]
    private string m_value = string.Empty;
    public  string Value { get{ return m_value; } }
}

f:id:urahimono:20180521080128p:plain

 はい、出来ました。

 では先ほど作成したEGenre列挙型に新しいメンバを追加してみましょう。

DataTable.cs

public enum EGenre
{
    Action,
    Adventure,

    Puzzle, // ← 追加してみたよ!

    RPG,
    Shooooooting,
    Simulation,
}

 さて、データの方はどうなっているでしょうか。

f:id:urahimono:20180521080143p:plain

 あーあ、データが変わっちゃった。
 でも、データの中身がほんとに変わっちゃうのでしょうか。
 このDataTableの中身をテキストファイルで見てみましょう。
 データはYAML形式で保存されているはずです。

DataTable.asset

%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
  m_ObjectHideFlags: 0
  m_PrefabParentObject: {fileID: 0}
  m_PrefabInternal: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: e9fe71798e86dc549ba185bab18a5012, type: 3}
  m_Name: DataTable
  m_EditorClassIdentifier: 
  m_genreList:
  - m_genre: 0
    m_value: "\u3059\u3054\u3044"
  - m_genre: 2
    m_value: "\u305F\u306E\u3057\u3044"
  - m_genre: 3
    m_value: "\u308F\u30FC\u3044"

 m_genreの項目を見てもらうとわかる通り、数値で保存されています。
 まあ、列挙型って数値ですしね。そりゃそうでしょうよ。

enum (C# リファレンス) | Microsoft Docs

 なので、データそのものは変わってないのです。
 各メンバに対応する数値が変わっただけで。

 ただ、スクリプト上では列挙型のメンバによって振る舞いなどを変えるでしょうから、この変更は大惨事になりそうですよ。
 今回の場合では、「RPG」が「Puzzle」のデータになってしまい、「Shooting」が「RPG」のデータになってしまいました。
 項目量次第では、この列挙型を追加した人は断罪されるでしょうね。

 さて、この問題はどうすれば発生しなくなるのでしょうか。
 列挙型にメンバを追加する場合は下から追加するようにしていくルールを設けるか、きっちりとメンバの値を設定するようにすれば、こんなことにはならなかったはずです。

DataTable.cs

public enum EGenre
{
    // 値をしっかり指定しちゃうぞ!
    Action          = 5,
    Adventure       = 3,
    Puzzle          = 6,
    RPG             = 1,
    Shooooooting    = 2,
    Simulation      = 9,
}

 ですが、他に方法はないだろうか。
 そもそも列挙型の名前でデータが保存されていれば、こんな悲劇は起こらなかったのではないか。
 よし、エディタ改良でなんとかしてみようじゃないか。

string型がエディタ上では列挙型に見えるようにしよう

 とりあえず数値でシリアライズしては駄目だ。
 文字列でシリアライズするようにせねば。
 だがエディタで設定したデータが、どのようにしてYAMLのデータにシリアライズされるのかの流れを僕は知らない。
 あの日見た花の名前だって僕はまだ知らない。

 今、分かっている情報だけで戦うしかなさそうだ。
 設定するプロパティの型をstring型にしてしまえば、とりあえず文字列でシリアライズされるはずだ。
 ただ文字列のままだとスクリプト上で使いにくいので、スクリプト上でデータを取得するときには、列挙型で取得できるようにしておこう。

DataTable.cs

[System.Serializable]
public class GenreData : ISerializationCallbackReceiver
{
    // 型をstringに変えたよ!
    [SerializeField]
    private string m_genre = string.Empty;
//    public  EGenre Genre { get{ return m_genre; } }
    public EGenre Genre
    {
        get;
        private set;
    }

    [SerializeField]
    private string m_value = string.Empty;
    public  string Value { get{ return m_value; } }

    public void OnBeforeSerialize()
    {

    }

    // デシリアライズ後にstringを列挙型に変換だ!
    public void OnAfterDeserialize()
    {
        EGenre genre = default( EGenre );
        // System.Enum.TryParse()は.Net3.xでは使用できないので注意が必要だ!
        if( !System.Enum.TryParse( m_genre, out genre ) )
        {
            Debug.LogWarningFormat( "[GenreDataEditor] EGenreのパースに失敗しちゃった... 元のデータ:{0}", m_genre );
            return;
        }
        Genre = genre;
    }
}

 でもこのままでは、エディタ上で列挙型のメンバと同じ名前の文字列を設定しなくてはいけない。
 エディタを拡張する時が来たようだね!
 もうエディタの拡張の仕方は、昔何かしら記事を書いた気がする。
 あの日の記憶を頼りに拡張していきますよ。
www.urablog.xyz

DataTableEditor.cs

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer( typeof( GenreData ) )]
public class GenreDataEditor : PropertyDrawer
{
    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        Rect position   = i_position;
        position.height = EditorGUIUtility.singleLineHeight;

        i_property.isExpanded = EditorGUI.Foldout( position, i_property.isExpanded, i_label );
        if( !i_property.isExpanded )
        {
            return;
        }

        position.y  += EditorGUIUtility.singleLineHeight;

        EditorGUI.indentLevel++;
        {
            // m_genreを描画
            DrawGenre( ref position, i_property, "m_genre" );
            // m_valueを描画
            DrawValue( ref position, i_property, "m_value" );
        }
        EditorGUI.indentLevel--;  
    }

    public override float GetPropertyHeight( SerializedProperty i_property, GUIContent i_label )
    {
        return EditorGUI.GetPropertyHeight( i_property, i_label, true );
    }

    private void DrawGenre( ref Rect io_position, SerializedProperty i_property, string i_name )
    {
        var property    = i_property.FindPropertyRelative( i_name );
        var label       = new GUIContent( property.displayName );

        EGenre genreType    = default( EGenre );
        if( !string.IsNullOrEmpty( property.stringValue ) )
        {
            if( !System.Enum.TryParse( property.stringValue, out genreType ) )
            {
                Debug.LogWarningFormat( "EGenreのパースに失敗しちゃった... 元のデータ:{0}", property.stringValue );
            }
        }
                
        genreType = (EGenre)EditorGUI.EnumPopup( io_position, label, genreType );
        property.stringValue = genreType.ToString();

        io_position.y   += EditorGUI.GetPropertyHeight( property, label, true );
    }

    private void DrawValue( ref Rect io_position, SerializedProperty i_property, string i_name )
    {
        var property    = i_property.FindPropertyRelative( i_name );
        var label       = new GUIContent( property.displayName );
        EditorGUI.PropertyField( io_position, property, label, true );

        io_position.y  += EditorGUI.GetPropertyHeight( i_property, label, true );
    }

} // class GenreDataEditor

 さて、データはどうなっているだろうか。

f:id:urahimono:20180521080158p:plain DataTable.asset

%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
  m_ObjectHideFlags: 0
  m_PrefabParentObject: {fileID: 0}
  m_PrefabInternal: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: e9fe71798e86dc549ba185bab18a5012, type: 3}
  m_Name: DataTable
  m_EditorClassIdentifier: 
  m_genreList:
  - m_genre: Action
    m_value: "\u3059\u3054\u3044"
  - m_genre: RPG
    m_value: "\u305F\u306E\u3057\u3044"
  - m_genre: Shooooooting
    m_value: "\u308F\u30FC\u3044"

 成功だ!
 エディタ上では今まで通り列挙型の設定になっているけど、データの中身は文字列になっている。
 これならば列挙型のメンバ名が変わっても対応できちゃうぞ!

列挙型のメンバ名が変わったときの対応をするぞ

 さて、皆さんはお気づきだろうか。
 今回作成した列挙型のメンバ名に打ち間違えがあることを。
 そう、Shootingだ。
 oが多すぎだ!

 直してみよう。

DataTable.cs

public enum EGenre
{
    Action,
    Adventure,
    Puzzle,
    RPG,
    Shooting, // ← 直した
    // Shooooooting,
    Simulation,
}

 はい、typoを直しました。
 おっと、そういえばデータの方はどうなってしまうのだろう。

f:id:urahimono:20180521080214p:plain

[GenreDataEditor] EGenreのパースに失敗しちゃった... 元のデータ:Shooooooting
UnityEngine.Debug:LogWarningFormat(String, Object[])
GenreDataEditor:OnGUI(Rect, SerializedProperty, GUIContent) (at Assets/Editor/DataTableEditor.cs:37)
UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr)

 しまった、データが壊れてしまった。
 列挙型の名前で保存するようにしたので、列挙型の名前が変わるとデータが正しく読み取れなくなってしまうのだ。

 むむむ。
 前の数値を保存していた場合は大丈夫だったことが、今の文字列版ではダメになっている。
 劣化してしまうのはまずい。
 対応せねば。

 列挙型の数値情報も保存するようにしてみようか。

DataTableEditor.cs

private void DrawGenre( ref Rect io_position, SerializedProperty i_property, string i_name )
{
    var property    = i_property.FindPropertyRelative( i_name );
    var label       = new GUIContent( property.displayName );

    EGenre genreType = DeserializeGenre( property.stringValue );

    genreType = (EGenre)EditorGUI.EnumPopup( io_position, label, genreType );
    
    // 値と名前を区切り文字で区切って保存するよ
    property.stringValue = string.Format( "{0}:{1}", (int)genreType, genreType.ToString() );

    io_position.y   += EditorGUI.GetPropertyHeight( property, label, true );
}

private EGenre DeserializeGenre( string i_text )
{
    if( string.IsNullOrEmpty( i_text ) )
    {
        return default( EGenre );
    }

    // 列挙型の値と名前を取得する。
    // 僕に正規表現を扱える力があれば、もう少し綺麗なコードになりそうなんだけど...
    string valueText = i_text;

    int? enumIndex = null;
    // 列挙型の値と名前を区切る文字には、列挙型で使用できない文字を使用してみたぞ!
    string[] splitTexts = valueText.Split( ':' );
    if( splitTexts.Length > 1 )
    {
        // 区切り文字があったら、列挙型の値があるかもしれないぞ!
        int index = 0;
        if( int.TryParse( splitTexts[ 0 ], out index ) )
        {
            enumIndex = index;
        }

        valueText = splitTexts[ 1 ];
    }

    EGenre genreType = default( EGenre );
    if( !System.Enum.TryParse( valueText, out genreType ) )
    {
        // 列挙型のメンバ名が存在しない場合、値から復元できるかもしれない!
        if( enumIndex.HasValue )
        {
            genreType = (EGenre)enumIndex.Value;
        }
        else
        {
            Debug.LogWarningFormat( "EGenreのパースに失敗しちゃった... 元のデータ:{0}", i_text );
        }
    }

    return genreType;
}

 保存する情報が文字列であることは変わらないが、「列挙型のメンバの数値:列挙型のメンバ名」のフォーマットで保存するようにしてみた。
 これならば列挙型のメンバ名が変わった場合には、数値の情報からデータの復旧が出来るようになったはずだ。
 データを見てみよう。

f:id:urahimono:20180521080230p:plain DataTable.asset

%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
  m_ObjectHideFlags: 0
  m_PrefabParentObject: {fileID: 0}
  m_PrefabInternal: {fileID: 0}
  m_GameObject: {fileID: 0}
  m_Enabled: 1
  m_EditorHideFlags: 0
  m_Script: {fileID: 11500000, guid: e9fe71798e86dc549ba185bab18a5012, type: 3}
  m_Name: DataTable
  m_EditorClassIdentifier: 
  m_genreList:
  - m_genre: 0:Action
    m_value: "\u3059\u3054\u3044"
  - m_genre: 3:RPG
    m_value: "\u305F\u306E\u3057\u3044"
  - m_genre: 4:Shooting
    m_value: "\u308F\u30FC\u3044"

 うまくいったみたいだ。
 これならば、列挙型の名前が変わっても、数値が変わっても対応できるぞ!
 同時に起きると対応できないけどね!

 保存するデータのフォーマットが変わったので、データクラス側のデシリアライズ後の処理も変更する必要がある。
 GenreDataEditor.DeserializeGenre()と同じ処理を作る必要がありそうだ。
 エディタ側にもデータクラス側にも同じ処理がひつようなため、GenreDataクラスにstatic関数として処理を記述していまい、両方で使うようにしよう。
 ちなみにエディタ側のクラスに書いちゃいけないよ。
 ランタイム用(Editorフォルダ外)のスクリプトではエディタ用のスクリプトは見えないからね。

DataTable.cs

[System.Serializable]
public class GenreData : ISerializationCallbackReceiver
{
    // 型をstringに変えたよ!
    [SerializeField]
    private string m_genre = string.Empty;
//    public  EGenre Genre { get{ return m_genre; } }
    public EGenre Genre
    {
        get;
        private set;
    }

    [SerializeField]
    private string m_value = string.Empty;
    public  string Value { get{ return m_value; } }

    public void OnBeforeSerialize()
    {

    }

    // デシリアライズ後にstringを列挙型に変換だ!
    public void OnAfterDeserialize()
    {
        EGenre genre = default( EGenre );
        // System.Enum.TryParse()は.Net3.xでは使用できないので注意が必要だ!
        if( !System.Enum.TryParse( m_genre, out genre ) )
        {
            Debug.LogWarningFormat( "[GenreDataEditor] EGenreのパースに失敗しちゃった... 元のデータ:{0}", m_genre );
            return;
        }
        Genre = genre;
    }

    public static EGenre DeserializeGenre( string i_text )
    {
        if( string.IsNullOrEmpty( i_text ) )
        {
            return default( EGenre );
        }

        // 列挙型の値と名前を取得する。
        // 僕に正規表現を扱える力があれば、もう少し綺麗なコードになりそうなんだけど...
        string valueText = i_text;

        int? enumIndex = null;
        string[] splitTexts = valueText.Split( ':' );
        if( splitTexts.Length > 1 )
        {
            // 区切り文字があったら、列挙型の値があるかもしれないぞ!
            int index = 0;
            if( int.TryParse( splitTexts[ 0 ], out index ) )
            {
                enumIndex = index;
            }

            valueText = splitTexts[ 1 ];
        }

        EGenre genreType = default( EGenre );
        if( !System.Enum.TryParse( valueText, out genreType ) )
        {
            // 列挙型のメンバ名が存在しない場合、値から復元できるかもしれない!
            if( enumIndex.HasValue )
            {
                genreType = (EGenre)enumIndex.Value;
            }
            else
            {
                Debug.LogWarningFormat( "EGenreのパースに失敗しちゃった... 元のデータ:{0}", i_text );
            }
        }

        return genreType;
    }
}

 これでOKだ。
 たがしかし、新しい列挙型が増えるたびに、このエディタ拡張をせねばならないのか……。
 アトリビュートを作ることで共通化したかったけど、うまく列挙型をまとめる方法が思いつかなかった。

 まだまだ研究の余地はありそうだねぇ……。