0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Unity] バリデーション失敗した配列要素を赤くするエディタ拡張

Posted at

概要

配列・リストの要素を特定のルールに基づいてチェックして、エラーになったものを赤く表示するエディタ拡張(CustomPropertyDrawer)を作りました。
要素数が多くて、問題のある要素がどこにあるか探すのが大変な時に役立つはず。

image.png

↓ こんな風に、カスタム属性にバリエーションメソッドを指定するだけで汎用的に使えます

    [ValidateWith(nameof(OnValidateSprite))]
    [SerializeField] private Sprite[] sprites;

    private bool OnValidateSprite(Sprite sprite)
    {
        if (sprite == null) return true;
        return sprite.name.StartsWith("iconStatus");
    }

経緯

Unityの自作コンポーネントやScriptableObjectで、配列やリストをSerializeFieldにしたとき、特定のルールに基づいたものだけを追加したいというのはよくあると思います。

自分は普段 NaughtyAttributes を使っていて、あまりカスタムエディタを書かない(代わりにカスタムプロパティドロワーを書く)ので、NaughtyAttributes の流儀を真似て、カスタム属性とそのプロパティードロワーにより、既存のエディタ拡張と共存できる汎用的な仕組みを考えました。

実装

まずはカスタム属性。特にひねりはありません。バリデーションメソッド名を引数に受け取るようにしています。

ValidateWithAttribute.cs
using UnityEngine;

/// <summary>
/// 指定したメソッドで条件チェックしてfalseなら背景を赤く表示する
/// </summary>
public class ValidateWithAttribute : PropertyAttribute
{
    public string MethodName { get; }
    public ValidateWithAttribute(string methodName) => MethodName = methodName;
}

その属性に対するCustomPropertyDrawer。
ValidateWithAttributeの引数で与えられたメソッド名をリフレクションで呼び出し、結果がNGであれば描画エリアを赤く塗りつぶします。
対象プロパティーが配列またはリストの場合は、その要素を指定してバリデーションメソッドを呼びます。
(配列でもリストでもない場合は、普通に対象プロパティーを指定してバリデーションメソッドを呼びます。)

VaridateWithDrawer.cs
using System.Collections;
using System.Reflection;
using UnityEditor;
using UnityEngine;

namespace Crea
{
    /// <summary>
    /// バリデーションメソッドでNGになったプロパティを赤く表示する
    /// </summary>
    [CustomPropertyDrawer(typeof(ValidateWithAttribute))]
    public class ValidateWithDrawer : PropertyDrawer
    {
        public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
        {
            var attr = (ValidateWithAttribute)attribute;
            var target = property.serializedObject.targetObject;
            bool isError = false; // バリデーション結果変数

            var method = target.GetType().GetMethod(attr.MethodName,
                BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
            if (method != null)
            {
                var field = fieldInfo;
                // 配列・リストの場合、要素のインデックス(それ以外は -1)
                var index = GetIndexFromPath(property.propertyPath);
                if (field != null)
                {
                    var value = field.GetValue(target);
                    object element = null;

                    if (index >= 0 && value is IList list)
                    {
                        element = list[index];
                    }
                    else
                    {
                        // 単独フィールド(配列じゃない場合)
                        element = value;
                    }

                    if (element != null)
                    {
                        var result = method.Invoke(target, new object[] { element });
                        if (result is bool b) isError = !b;
                    }
                }
            }

            // バリデーション失敗なら背景を赤く塗りつぶす
            if (isError)
            {
                var border = new Rect(position.x - 1, position.y - 1, position.width + 2, position.height + 2);
                EditorGUI.DrawRect(border, Color.red);
            }

            EditorGUI.PropertyField(position, property, label, true);
        }
        
        private int GetIndexFromPath(string path)
        {
            // 例: "elements.Array.data[2]" → 2 を取り出す
            var match = System.Text.RegularExpressions.Regex.Match(path, @"Array\.data\[(\d+)\]");
            if (match.Success && int.TryParse(match.Groups[1].Value, out int index))
                return index;
            return -1;
        }
        
        public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
        {
            float baseHeight = EditorGUI.GetPropertyHeight(property, label, true);
            return baseHeight + 2f; 
        }
    }
}

用例/サンプル

サンプルコード

使い方を示したサンプルコードです。任意の SerializeField に ValiudationWith カスタム属性をつけて、引数にバリデーションメソッドの名前を指定するだけ。

  • [ValuidateWith("OnValidateData")] のように文字列を入れても動きますが、typo を防ぐため nameof式 を使うことをお勧めします
  • バリエーションメソッドの引数は期待する要素型で大丈夫
  • バリエーションメソッド内では最初にnullチェックなどしてください(インスペクタ上でnull要素を追加したときエラーにならないように)
SampleComponent.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

[Serializable]
public class Data
{
    public string Text;
}

public class SampleComponent : MonoBehaviour
{
    [ValidateWith(nameof(OnValidateData)),Header("Textが重複するデータは登録できません")]
    [SerializeField] private List<Data> Data1;

    [ValidateWith(nameof(OnValidateSprite)),Header("iconStatusで始まる画像を選択")]
    [SerializeField] private Sprite[] sprites;

    [ValidateWith(nameof(OnValidateSprite)), Header("iconStatusで始まる画像を選択")]
    [SerializeField] private Sprite singleSprite;
    private bool OnValidateData(Data data)
    {
        if (data == null) return true;
        // Textと一致するものは一個しかないこと
        return Data1.Count(d => d.Text == data.Text) == 1;
    }

    private bool OnValidateSprite(Sprite sprite)
    {
        if (sprite == null) return true;
        return sprite.name.StartsWith("iconStatus");
    }

}

今回、わかりやすさ優先で見た目は適当に済ませましたが、真っ赤で目がチカチカする!って人は、例えばこんな感じで塗りつぶす範囲を最小限にしてみるといいかもしれません。

VaridateWithDrawer.cs
var border = new Rect(position.x - 1, position.y - 1, 2, position.height + 2);

結果

正常 NGあり
image.png image.png

ちなみに配列でもリストでもない単独プロパティーでも機能します
image.png

ただ、単独であれば冒頭でご紹介した NaughtyAttribute の ValidateInput 属性があるので、そっちを使った方がいいかもしれません。

    [ValidateInput(nameof(OnValidateSprite),"フォーマットが違います")]
    [SerializeField] private Sprite singleSprite;

image.png

おまけ

データ設定時のミスを減らすために作りましたが、ちょっと工夫すれば再生中にインスペクタ上で「フィールド内のユニット一覧で、HP0になったユニットを赤くする」みたいな、特定の条件になった要素の検出にも使えそうですね。

下記は、跳ね回るボールのうち、水色の円(Colider2D)の中に入ったものを赤く表示するサンプルです。
balls_inside_circle.gif

SampleComponent.cs
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class SampleComponent : MonoBehaviour
{
    [SerializeField] private GameObject prefab;

    [ValidateWith(nameof(OnValidateObj))]
    [SerializeField] private List<GameObject> balls = new ();
    private Collider2D _myColider;

    private bool OnValidateObj(GameObject go)
    {
        if (go == null) return true;
        var rigid = go.GetComponent<Rigidbody2D>();
        if (rigid == null) return true;
        // ボールが指定のコライダーと接触してるかどうか
        return !rigid.IsTouching(_myColider);
    }

    private void Start()
    {
        _myColider = GetComponent<Collider2D>();
    }

    void Update()
    {
#if UNITY_EDITOR
        if (UnityEditor.Selection.activeObject == this.gameObject)
        {
            // インスペクタを強制的に更新する
            UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
            UnityEditor.EditorUtility.SetDirty(this);
        }
#endif
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?