うら干物書き

ゲームを作っています。

【Unity】エディタ拡張をして、他クラスのパラメータを取得できないかな

 パラメータをUnityエディタ上で設定していくうえで、設定しやすいことに越したことはありませんよね。
 まったく同じパラメータを何度も何度も入力するのはとても疲れます。
 特に文字列の場合では、打ち間違えたりする可能性も高く、重大なバグを引き起こしてしまうかもしれません。

 それを解消すべく、今回エディタ拡張に乗り出してみたのですが……。
 なんかいまいちな結果になってしまった、そんなお話です。


この記事にはUnity5.6.1f1を使用しています。

とあるクラスのパラメータを利用して、このパラメータを設定したいんだ!

 さてさて現在、こんな状況におかれていますよ。

 のデータと、武器のデータがあります。
 人のデータには、名前所持している武器の名前のパラメータがあります。
 武器のデータには、名前攻撃力のパラメータがあります。
 各データはたくさんの数がリストで管理されています。

 人のデータの所持している武器の名前は、武器データの名前と一致する必要があります。

 この条件を満たすべく、データのテーブルを作ってみましょう。
 まずは、のデータと武器のデータのクラスを作成してみます。

[System.Serializable]
public class PersonData
{
    [SerializeField]
    private string  m_name  = string.Empty;
    public string   Name { get{ return m_name; } }
    [SerializeField]
    private string  m_weapon    = null;
    public string   Weapon { get{ return m_weapon; } }
}
[System.Serializable]
public class WeaponData
{
    [SerializeField]
    private string  m_name      = "";
    public string   Name { get{ return m_name; } }
    [SerializeField, Range( 0, 100 )]
    private int     m_attack    = 0;
    public int      Attack { get{ return m_attack; } }
}

 そしてこののデータと武器のデータを持つテーブルクラスを用意しましょう。
 ScriptableObjectを使ってちゃちゃっと作ります、

GameTable.cs

[CreateAssetMenu( menuName = "Create GameTable", fileName = "GameTable" )]
public class GameTable : ScriptableObject
{
    [SerializeField]
    private PersonData[]    m_persons   = null;
    [SerializeField]
    private WeaponData[]    m_weapons   = null;

    private static readonly string RESOURCE_PATH    = "GameTable";
    public static GameTable LoadAsset()
    {
        return Resources.Load<GameTable>( RESOURCE_PATH );
    }

} // class GameTable

 このテーブルクラスをUnityエディタ上で見てみるとこんな感じになります。

f:id:urahimono:20170521184257p:plain

 情報としては問題ないのですが、パラメータを設定していくうえで厄介そうな点が見えます。
 人データのWeaponの情報の設定には、文字を直接入力していく必要があります。
 そしてここに指定する文字列は、Weaponsリストのデータにある名前と同じである必要があります。
 一字一句間違えてはいけないのです。
 特にWeaponsリストのデータ量が増えた日には、とても苦労してしまいそうです。

 こんな場合は、文字入力で設定するよりも、武器データリストからNameのリストを取得して、ポップアップなどで設定できるようにしたほうが便利で簡単、ミスもなくなりそうです。

f:id:urahimono:20170521184310p:plain

 そんな願いをかなえるために、エディタ拡張していきましょう。

テーブルクラスを拡張しちゃうぞ!

 今回のすべての出来事は、GameTableクラスで起きていることです。
 それならば、GameTableのエディタを拡張してしまうのが一番簡単そうですね。

 ではGameTableのエディタ拡張用のクラスを作成してきましょう。

GameTableEditor.cs

using UnityEngine;
using UnityEditor;

[CustomEditor( typeof( GameTable ) )]
public class GameTableEditor : Editor
{
    private SerializedProperty  m_persons   = null;
    private SerializedProperty  m_weapons   = null;

    private void OnEnable()
    {
        m_persons   = serializedObject.FindProperty( "m_persons" );
        m_weapons   = serializedObject.FindProperty( "m_weapons" );
    }
    
    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        EditorGUILayout.PropertyField( m_persons, new GUIContent( m_persons.displayName ), true );
        EditorGUILayout.PropertyField( m_weapons, new GUIContent( m_weapons.displayName ), true );

        serializedObject.ApplyModifiedProperties();
    }

} // class GameTableEditor

f:id:urahimono:20170521184257p:plain

 この状態では、エディタは何も変わっていません。
 そりゃ、まだ何も特別なことはしていませんからね。
 このスクリプトを基本として、拡張していきます。

 人のデータリストは拡張するので、とりあえずデータ一つ一つを自分で描画する形に変えましょう。

GameTableEditor.cs

public override void OnInspectorGUI()
{
    serializedObject.Update();


    // 人データのエディタ拡張。
    // リスト内のデータをを一つずつPropertyField()を使って描画しているだけなので、通常時と変わらないはず……。
    foreach( SerializedProperty person in m_persons )
    {
        var name    = person.FindPropertyRelative( "m_name" );
        EditorGUILayout.PropertyField( name, new GUIContent( name.displayName ), true );

        var weapon  = person.FindPropertyRelative( "m_weapon" );
        EditorGUILayout.PropertyField( weapon, new GUIContent( weapon.displayName ), true );
    }

    // 武器データに拡張はいらないので、標準のものを。
    EditorGUILayout.PropertyField( m_weapons, new GUIContent( m_weapons.displayName ), true );

    serializedObject.ApplyModifiedProperties();
}

f:id:urahimono:20170521184403p:plain

 な、なんか変だな……。
 通常状態のものと見比べてみましょう。

f:id:urahimono:20170521184413p:plain

 ああ、リストサイズの変更場所がなくなっている。

 な、なるほど、中のデータだけを描画するのではダメなようですな。
 リストという情報も描画しなくちゃいけないんだね。
 もう一度チャレンジです。

GameTableEditor.cs

public class GameTableEditor : Editor
{
    private SerializedProperty  m_persons   = null;
    private SerializedProperty  m_weapons   = null;

    private void OnEnable()
    {
        m_persons   = serializedObject.FindProperty( "m_persons" );
        m_weapons   = serializedObject.FindProperty( "m_weapons" );
    }
    
    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        DrawPersonProperties();
        
        EditorGUILayout.PropertyField( m_weapons, new GUIContent( m_weapons.displayName ), true );

        serializedObject.ApplyModifiedProperties();
    }

    /// <summary>
    /// 人プロパティリストの描画
    /// </summary>
    private void DrawPersonProperties()
    {
        // リストそのものの折りたたみ処理。
        m_persons.isExpanded    = EditorGUILayout.Foldout( m_persons.isExpanded, m_persons.displayName );
        if( !m_persons.isExpanded )
        {
            return;
        }

        EditorGUI.indentLevel++;
        {
            // リストのサイズ調整用処理。
            int newArraySize    = EditorGUILayout.IntField( new GUIContent( "Size" ), m_persons.arraySize );
            newArraySize = Mathf.Max( 0, newArraySize );
            AdjustPersonListSize( newArraySize );
            
            foreach( SerializedProperty person in m_persons )
            {
                person.isExpanded   = EditorGUILayout.Foldout( person.isExpanded, person.displayName );
                if( !person.isExpanded )
                {
                    continue;
                }

                EditorGUI.indentLevel++;
                {
                    DrawPersonProperty( person );
                }
                EditorGUI.indentLevel--;
            }
        }
        EditorGUI.indentLevel--;

    }

    /// <summary>
    /// 人プロパティの描画
    /// </summary>
    private void DrawPersonProperty( SerializedProperty i_personProperty )
    {
        var name    = i_personProperty.FindPropertyRelative( "m_name" );
        EditorGUILayout.PropertyField( name, new GUIContent( name.displayName ), true );

        var weapon  = i_personProperty.FindPropertyRelative( "m_weapon" );
        EditorGUILayout.PropertyField( weapon, new GUIContent( weapon.displayName ), true );
    }

    /// <summary>
    /// 人プロパティリストのサイズ調整
    /// </summary>
    private void AdjustPersonListSize( int i_newArraySize )
    {
        if( m_persons.arraySize > i_newArraySize )
        {
            int decreasedCount  = m_persons.arraySize - i_newArraySize;
            for( int i = 0; i < decreasedCount; ++i )
            {
                m_persons.DeleteArrayElementAtIndex( m_persons.arraySize - 1 );
            }
        }
        else if( m_persons.arraySize < i_newArraySize )
        {
            int increasedCount  = i_newArraySize - m_persons.arraySize;
            for( int i = 0; i < increasedCount; ++i )
            {
                m_persons.InsertArrayElementAtIndex( m_persons.arraySize );
            }
        }
    }

} // class GameTableEditor

f:id:urahimono:20170521184257p:plain

 つ、疲れた。
 ようやく元の形に戻った。
 本題とは全く関係ないところにずいぶんと時間をかけてしまった。

 ではここからようやく本題です。
 武器プロパティから武器の名前リスト作成する関数を作っていきます。

GameTableEditor.cs

private string[] GetWeaponNameList()
{
    // 武器リストから武器の名前を取得しリスト化する。
    var weaponNames = new List<string>( m_weapons.arraySize );

    foreach( SerializedProperty weapon in m_weapons )
    {
        string name = weapon.FindPropertyRelative( "m_name" ).stringValue;
        weaponNames.Add( name );
    }

    return weaponNames.ToArray();
}

 この関数から習得できる文字列のリストを使ってポップアップが出るようにしましょう。

GameTableEditor.cs

private void DrawPersonProperty( SerializedProperty i_personProperty )
{
    var name    = i_personProperty.FindPropertyRelative( "m_name" );
    EditorGUILayout.PropertyField( name, new GUIContent( name.displayName ), true );


    string[] weaponNames    = GetWeaponNameList();

    var weapon  = i_personProperty.FindPropertyRelative( "m_weapon" );
    int selectedIndex   = System.Array.FindIndex( weaponNames, ( value ) => value == weapon.stringValue );
    selectedIndex       = Mathf.Clamp( selectedIndex, 0, weaponNames.Length );

    selectedIndex       = EditorGUILayout.Popup( selectedIndex, weaponNames );
    weapon.stringValue  = weaponNames[ selectedIndex ];
}

f:id:urahimono:20170521184511p:plain
f:id:urahimono:20170521184520p:plain

 出来ました。
 望んでいたとおり、人データの所持している武器の名前は、武器データリストにあるデータから引っ張ってきてますから、打ち間違えはないですし、武器が増えればポップアップに表示されるデータも自動で追加されます。

 これにて一件落着……、と言いたいところですが、やけに余計なコードを長々と書いた気がします。
 デフォルトのリストを表示するのに、あそこまでいろいろとやらねばならないとは……。

 うーん、他にもっといい方法はないのでしょうか。
 もう少し検討してみましょう。

データクラスを拡張しちゃうぞ!

 今度は人データのクラスである、PersonDataを拡張していきましょう。
 この場合でしたら、PersonDataをリスト化しているのはGameTableクラスなので、わざわざリストの処理を書かなくてすむかもしれません。
 そのため先ほど作ったGameTableEditor.csは破棄します。

 それでは、PersonDataのエディタ拡張クラスを作成しています。

PersonDataEditor.cs

using UnityEngine;
using UnityEditor;

[CustomPropertyDrawer( typeof( PersonData ) )]
public class PersonDataEditor : PropertyDrawer
{
    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        EditorGUI.PropertyField( i_position, i_property, i_label, true );
    }

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

} // class PersonDataEditor

f:id:urahimono:20170521184257p:plain

 このままならもちろんエディタに変化はなしです。
 では再び、パラメータ一つ一つを自分で描画する形に変えていきます。

PersonDataEditor.cs

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++;
    {
        {
            var property    = i_property.FindPropertyRelative( "m_name" );
            var label       = new GUIContent( property.displayName );
            EditorGUI.PropertyField( position, property, label, true );
            float height    = EditorGUI.GetPropertyHeight( property, label, true );

            position.y   += height;
        }

        {
            float height    = DrawWeaponProperty( position, i_property );

            position.y   += height;
        }
    }
    EditorGUI.indentLevel--;
}

/// <summary>
/// 武器プロパティの描画
/// </summary>
private float DrawWeaponProperty( Rect i_position, SerializedProperty i_personProperty )
{
    var property    = i_personProperty.FindPropertyRelative( "m_weapon" );
    var label       = new GUIContent( property.displayName );
    EditorGUI.PropertyField( i_position, property, label, true );
    float height    = EditorGUI.GetPropertyHeight( property, label, true );

    return height;
}

 あとはどうにかして、武器の名前リストをとってくる必要があります。
 ど、どうしよう。
 さっきは全てのパラメータが拡張しているGameTableクラスにあったから特に苦労はなかったけど、今回拡張しているPersonDataWeaponDataと直接関係ないからなぁ。

 ……えーいとりあえず、こうだ!

GameTable.cs

[CreateAssetMenu( menuName = "Create GameTable", fileName = "GameTable" )]
public class GameTable : ScriptableObject
{
    [SerializeField]
    private PersonData[]    m_persons   = null;
    [SerializeField]
    private WeaponData[]    m_weapons   = null;

    private static readonly string RESOURCE_PATH    = "GameTable";
    public static GameTable LoadAsset()
    {
        return Resources.Load<GameTable>( RESOURCE_PATH );
    }

    public static string[] GetWeaponNames()
    {
        return LoadAsset().m_weapons.Select( value => value.Name ).ToArray();
    }

} // class GameTable

 う、ちょーっといまいちなやり方だけど、武器の名前リストは手に入った。
 あとはこの関数を使って、

PersonDataEditor.cs

private float DrawWeaponProperty( Rect i_position, SerializedProperty i_personProperty )
{
    string[] weaponNames    = GameTable.GetWeaponNames();

    var property        = i_personProperty.FindPropertyRelative( "m_weapon" );
    var label           = new GUIContent( property.displayName );
    int selectedIndex   = System.Array.FindIndex( weaponNames, ( value ) => value == property.stringValue );
    selectedIndex       = Mathf.Clamp( selectedIndex, 0, weaponNames.Length );

    selectedIndex           = EditorGUI.Popup( i_position, selectedIndex, weaponNames );
    property.stringValue    = weaponNames[ selectedIndex ];

    return EditorGUIUtility.singleLineHeight;
}

f:id:urahimono:20170521184628p:plain
f:id:urahimono:20170521184635p:plain

 GameTableクラスの拡張より簡単なコードで済んだかな。
 武器の名前リストの取得の仕方はいまいちだけど。

 ただこの場合でも、EditorGUI.indentLevelを変更したり、引数で渡されるi_positionの値をいい感じにするために調整したりと結構大変だったなあ。
 もっといい方法はないものだろうか。

専用のアトリビュートクラスを作成しちゃうぞ!

 では今度はアトリビュートクラスを作成することで、何とかならないかを試してみましょう。
 というわけで、PersonDataEditor.csを破棄します。お疲れ!

 ではアトリビュートクラスを作成します。

WeaponNameAttribute.cs

using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif // UNITY_EDITOR

public class WeaponNameAttribute : PropertyAttribute
{

} // class WeaponNameAttribute


#if UNITY_EDITOR

[CustomPropertyDrawer( typeof( WeaponNameAttribute ) )]
public class WeaponNameAttributeDrawer : PropertyDrawer
{
    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        string[] weaponNames = GameTable.GetWeaponNames();

        int selectedIndex   = System.Array.FindIndex( weaponNames, ( value ) => value == i_property.stringValue );
        selectedIndex       = Mathf.Clamp( selectedIndex, 0, weaponNames.Length );

        selectedIndex       = EditorGUI.Popup( i_position, selectedIndex, weaponNames );
        i_property.stringValue = weaponNames[ selectedIndex ];
    }
}

#endif // UNITY_EDITOR

 そして、この作成したアトリビュートをPersonDataに設定してっと。
 

[System.Serializable]
public class PersonData
{
    [SerializeField]
    private string  m_name  = string.Empty;
    public string   Name { get{ return m_name; } }
    [SerializeField, WeaponName]
    private string  m_weapon    = null;
    public string   Weapon { get{ return m_weapon; } }
}

 これで完成です。
 一番手っ取り早かったですね。
 相変わらず武器の名前リストの取得の仕方はいまいちだけど。

 うーん、ただこのアトリビュートクラス、汎用性が無さすぎるなぁ。
 この調子だと、防具のリストができたら防具専用アトリビュートクラスを、妖精のリストができたら妖精専用アトリビュートクラスとどんどん増えていってしまう気がする。

 もっと汎用的なアトリビュートクラスを作れないだろうか。

汎用性のあるアトリビュートクラスを作成しちゃうぞ!

 よし、もっと先ほどのアトリビュートクラスをもっと汎用的に使えるように改良しましょう。
 ポップアップで表示したいstring[]を、アトリビュートクラスがわかっていればいいのだろうよ。
 ならアトリビュートクラスのコンストラクタで渡してあげればいいじゃない!

NamesAttribute.cs

using UnityEngine;
#if UNITY_EDITOR
using UnityEditor;
#endif // UNITY_EDITOR

public class NamesAttribute : PropertyAttribute
{
    public string[] m_names = null;

    public NamesAttribute( string[] i_names )
    {
        m_names = i_names;
    }

} // class NamesAttribute

#if UNITY_EDITOR

[CustomPropertyDrawer( typeof( NamesAttribute ) )]
public class NamesAttributeeDrawer : PropertyDrawer
{
    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        string[] names  = attribute.m_names;
        if( names == null || names.Length == 0 )
        {
            EditorGUI.PropertyField( i_position, i_property, i_label );
            return;
        }

        int selectedIndex = System.Array.FindIndex( names, ( value ) => value == i_property.stringValue );
        selectedIndex = Mathf.Clamp( selectedIndex, 0, names.Length );

        selectedIndex = EditorGUI.Popup( i_position, selectedIndex, names );
        i_property.stringValue = names[ selectedIndex ];
    }

    private new NamesAttribute attribute
    {
        get{ return base.attribute as NamesAttribute; }
    }
}

#endif // UNITY_EDITOR

 よっしゃ。
 じゃあ、これを先ほどのWeaponNameAttributeと差し替えてっと、
 

[System.Serializable]
public class PersonData
{
    [SerializeField]
    private string  m_name  = string.Empty;
    public string   Name { get{ return m_name; } }
    [SerializeField, Names( GameTable.GetWeaponNames() )]
    private string  m_weapon    = null;
    public string   Weapon { get{ return m_weapon; } }
}

 これでどうだー!
 あ、あれ、エラーが出たぞ。

f:id:urahimono:20170521184747p:plain

属性引数は、定数式、typeof 式、または属性パラメーター型の配列の作成式でなければなりません。

 あっ、渡すのは定数じゃないとだめだったか……。

 ……無念。
 失敗です。

今回のことを振り返っちゃうぞ!

 うーん、エディタ拡張にチャレンジしてみましたが、リストを作るのに随分苦労させられました。
 こんなことなら、ReorderbleListを使った方が早かったかなぁ。

 ただ手っ取り早くは専用のアトリビュートを作ってしまうほうが早そうですね。
 専用すぎるので、クラスが山ほど増えてしまいそうですけど、

 汎用的なものを作る方法をいろいろ検討する必要がありそうですね。