Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Создание кастомного инспектора или как упростить себе работу в редакторе Unity.

Марк Левковский
Android-разработчик

В данной статье мы рассмотрим пару способов, как облегчить себе работу в Unity, модифицировав окно инспектора под собственные нужды и визуализировав на сцене те процессы, которые помогут составить картину происходящего в нашем скрипте.

Вот для наглядности что у нас получится в итоге

В качестве примера я взял нашу игру — Zodiac. А точнее, мою работу над созданием новых свойств объектов в 2D пространстве (в последующем — баррикад). И чтоб все эти свойства можно было просто, быстро и наглядно настраивать, мне и понадобился кастомный инспектор для баррикад. Я не буду углубляться в подробности этих свойств (по названиям и так думаю понятно), так как для нас это не важно, а важно только какого типа эти свойства.

Кастомный инспектор

Для начала разберемся с инспектором. Рассмотрим лишь часть создания моего скрипта, так как другая делается по аналогии.

И так, приступим.

В нашем скрипте Barricade укажем нужные нам свойства


public bool IsRotation = true;
public bool IsDependOnTap = true;
public bool IsClockwise;
public int RotationSpeed = 35;

Те свойства, которые мы собираемся изменять в инспекторе, делаем публичными и, если надо, выставляем значения по умолчанию. Дальше нам потребуется создать отдельный скрипт, который будет отвечать за инспектор конкретного скрипта (в нашем случае — Barricade). Назовем его BarricadeEditor, наследуем его от класса UnityEditor.Editor и укажем атрибут CustomEditor c указанием на наш класс Barricade. Этот скрипт должен обязательно размещаться в папке Editor, иначе ничего не заработает. В итоге он должен иметь такой вид


[CustomEditor(typeof(Barricade))]
public class BarricadeEditor : UnityEditor.Editor
{
}

BarricadeEdiror знает о Barricade (из атрибута) , но ничего не знает о его свойствах, и мы должны ему в этом помочь. На каждое поле класса Barricade, которое мы хотим видеть и изменять в инспекторе, заводим по SerializedProperty в BarricadeEditor. И поле с самим Barricade, который понадобится позже.


private SerializedProperty _isRotationProp;
private SerializedProperty _isClockwiseProp;
private SerializedProperty _rotationSpeedProp;
private SerializedProperty _isDependOnTapProp;

private Barricade _barricade;

Далее, в методе OnEnable() родительского класса UnityEditor.Editor вытаскиваем поля из Barricade и назначаем в свойства.


public void OnEnable()
{
   _isRotationProp = serializedObject.FindProperty("IsRotation");
   _isClockwiseProp = serializedObject.FindProperty("IsClockwise");
   _rotationSpeedProp = serializedObject.FindProperty("RotationSpeed");
   _isDependOnTapProp = serializedObject.FindProperty("IsDependOnTap");
}

Если мы сейчас заглянем в инспектор, то увидим наши поля такими же, как если бы ничего и не делали

Но нам нужно сделать так, что если верхнее свойство true , то и другие активны, а если иначе, то скрывать их. Ну и сделать приятный слайдер для настройки Rotation Speed.

Для этого нам нужен еще один метод — OnInspectorGUI(). Сначала присваиваем Barricade к нашему полю _barricade строкой


barricade = (Barricade) target;

Теперь мы подошли к теме статьи. Создаем переключатель EditorGUILayout.Toggle() для основного свойства _isRotationProp, которое отвечает за наши остальные свойства в этом блоке.


isRotationProp.boolValue = EditorGUILayout.Toggle("Rotation", _isRotationProp.boolValue);

Переключатель принимает два параметра — название свойства (то что будем видеть в инспекторе) и булево значение (берем из нашего свойства). И назначаем его же на наше свойство. Так же обязательно нужно всегда последней строкой в методе OnInspectorGUI() указывать


serializedObject.ApplyModifiedProperties();

Получаем в итоге

Осталось добавить остальные свойства для блока Rotation. Ставим условие, что будем выполнять метод ShowRotationInterface() (в котором и будем отображать остальные свойства), только если isRotationProp.boolValue положительное.


if (_isRotationProp.boolValue)
{
   ShowRotationInterface();
}

И по аналогии с isRotationProp распишем остальные три свойства в методе ShowRotationInterface()


private void ShowRotationInterface()
{
   _isClockwiseProp.boolValue = EditorGUILayout.Toggle("Clockwise", _isClockwiseProp.boolValue);
   _rotationSpeedProp.intValue = EditorGUILayout.IntSlider("Rotation Speed", _rotationSpeedProp.intValue, 0, 300);
   _isDependOnTapProp.boolValue = EditorGUILayout.Toggle("Depend on tap", _isDependOnTapProp.boolValue);
}

Тут мы создаем слайдер EditorGUILayout.IntSlider() с целочисленными значениями для _rotationSpeedProp. Он так же помимо параметров названия и значения свойства, которое будет изменять, принимает два параметра — минимальное значение и максимальное.

Первый блок Rotation мы создали, и вы уже имеете небольшое представление, что будем делать дальше. А дальше нам нужен блок свойств Move, и в нем мы добавляем динамический список и кнопки в инспектор.

Добавляем новые поля в класс Barricade


public bool IsMovable;
public float MoveSpeed = 0.8f;
public List PointsList;
public bool IsLoopMove;
public bool IsReverseLoop;

Тут мы добавляем список PointsList из элементов BarricadePoint. Этот класс содержит нужные мне данные точки перемещения для обьекта —


[System.Serializable]
public class BarricadePoint
{
   public Vector3 Position;

   public float Speed;

   public bool IsDefaultSpeed ;

   public BarricadePoint(Vector3 position, float speed, bool isDefaultSpeed)
   {
       Position = position;
       Speed = speed;
       IsDefaultSpeed = isDefaultSpeed;
   }
}

Обычный класс с полями и конструктором, но для использования в инспекторе нужно пометить атрибутом [System.Serializable]

Возвращаемся в BarricadeEditor, добавляем новые свойства и назначаем их в OnEnable()


private SerializedProperty IsMovableProp;
private SerializedProperty PositionsListProp;
private SerializedProperty MoveSpeedProp;
private SerializedProperty IsLoopMoveProp;
private SerializedProperty IsReverseLoopProp;


public void OnEnable()
{
   IsRotationProp = serializedObject.FindProperty("IsRotation");
   IsClockwiseProp = serializedObject.FindProperty("IsClockwise");
   RotationSpeedProp = serializedObject.FindProperty("RotationSpeed");
   IsDependOnTapProp = serializedObject.FindProperty("IsDependOnTap");
   IsMovableProp = serializedObject.FindProperty("IsMovable");
   PositionsListProp = serializedObject.FindProperty("PointsList");
   MoveSpeedProp = serializedObject.FindProperty("MoveSpeed");
   IsLoopMoveProp = serializedObject.FindProperty("IsLoopMove");
   IsReverseLoopProp = serializedObject.FindProperty("IsReverseLoop");
}

В OnInspectorGUI() добавляем переключатель с проверкой для свойства IsMovableProp по аналогии с предыдущим.


public override void OnInspectorGUI()
{
   _barricade = (Barricade) target;

   IsRotationProp.boolValue = EditorGUILayout.Toggle("Rotation", IsRotationProp.boolValue);
   if (IsRotationProp.boolValue)
   {
       ShowRotationInterface();
   }

   EditorGUILayout.LabelField("________________________________________________________________________________");

   IsMovableProp.boolValue = EditorGUILayout.Toggle("Move", IsMovableProp.boolValue);
   if (IsMovableProp.boolValue)
   {
       ShowMovingInterface();
   }

   serializedObject.ApplyModifiedProperties();
}

private void ShowMovingInterface()
{
   MoveSpeedProp.floatValue = EditorGUILayout.Slider("Move Speed", MoveSpeedProp.floatValue, 0, 15);

   EditorGUILayout.BeginHorizontal();
   IsLoopMoveProp.boolValue = EditorGUILayout.Toggle("Loop Move", IsLoopMoveProp.boolValue);
   if (IsLoopMoveProp.boolValue)
   {
       IsReverseLoopProp.boolValue = EditorGUILayout.Toggle("Reverse", IsReverseLoopProp.boolValue);
   }
   EditorGUILayout.EndHorizontal();

   if (!IsLoopMoveProp.boolValue)
   {
       EditorGUILayout.Space();
       EditorGUILayout.Space();

       var buttonStyle = new GUIStyle(GUI.skin.button) {fixedWidth = 100};

       for (var i = 0; i < _barricade.PointsList.Count; i++)
       {
           
           var point = PositionsListProp.GetArrayElementAtIndex(i);
           var position = point.FindPropertyRelative("Position");
           var speed = point.FindPropertyRelative("Speed");
           var isDefaultSpeed = point.FindPropertyRelative("IsDefaultSpeed");

EditorGUILayout.BeginHorizontal();


           position.vector3Value = EditorGUILayout.Vector2Field(i + 1 + " Position", position.vector3Value);
           if (GUILayout.Button("Get current", buttonStyle))
           {
               position.vector3Value = _barricade.transform.position;
           }

           EditorGUILayout.EndHorizontal();
           EditorGUILayout.BeginHorizontal();

           GUI.enabled = !isDefaultSpeed.boolValue;
           EditorGUIUtility.labelWidth = 125;
           speed.floatValue = EditorGUILayout.Slider("Speed To Next Point", speed.floatValue, 0, 15);
           GUI.enabled = true;

           EditorGUIUtility.labelWidth = 1;
           EditorGUILayout.LabelField("-||-");
           EditorGUIUtility.labelWidth = 0;

           EditorGUIUtility.labelWidth = 125;
           isDefaultSpeed.boolValue = EditorGUILayout.Toggle("Use Default Speed", isDefaultSpeed.boolValue);

           EditorGUILayout.EndHorizontal();
           EditorGUILayout.LabelField(
               "............................................................................................");
           EditorGUILayout.Space();
       }

       EditorGUILayout.BeginHorizontal();
       if (GUILayout.Button("Add point", buttonStyle))
       {
           AddPoint();
       }
       if (GUILayout.Button("Delete point", buttonStyle))
       {
           DeletePoint();
       }
       EditorGUILayout.EndHorizontal();
   }
}

Мы добавили между этими свойствами EditorGUILayout.LabelField(), который выводит указанную строку в инспекторе, но мы используем ее в виде разделителя, поэтому передаем просто большое количество подчеркиваний.

Теперь разберем по порядку метод ShowMovingInterface(), отвечающий за свойства блока Move.

Сначала указываем слайдер. Он схож с тем, что использовали раньше, но у него цена деления меньше чем целое число, для более точной настройки. Следовательно и свойство его принимающее должно быть дробным. MoveSpeedProp.floatValue = EditorGUILayout.Slider(«Move Speed», MoveSpeedProp.floatValue, 0, 15);

Тут у нас знакомые переключатели с проверкой условия, но заключенные между двух вызовов методов EditorGUILayout.BeginHorizontal() в начале и EditorGUILayout.EndHorizontal() в конце. Как вы могли заметить, все свойства, которые мы указывали для инспектора, изображаются в столбец друг за другом. Но если использовать способ описанный выше, то все свойства, написанные между этими методами, будут изображаться в строку друг за другом.


EditorGUILayout.BeginHorizontal();
   IsLoopMoveProp.boolValue = EditorGUILayout.Toggle("Loop Move", IsLoopMoveProp.boolValue);
   if (IsLoopMoveProp.boolValue)
   {
       IsReverseLoopProp.boolValue = EditorGUILayout.Toggle("Reverse", IsReverseLoopProp.boolValue);
   }
   EditorGUILayout.EndHorizontal();

Далее еще одна проверка, в которой уже остальные свойства. В начале я два раза вызываю EditorGUILayout.Space(). Этот метод делает пропуск одного элемента в отображении и переходит к следующему. Если отображение в столбец, то на следующую строку. Следовательно, я сделал пропуск в виде двух строк.

Далее мы создаем стиль для кнопки, который используем позже. В нем можно задать множество параметров, но мне нужно было указать фиксированную ширину для кнопок.


var buttonStyle = new GUIStyle(GUI.skin.button) {fixedWidth = 100}

Потом цикл for с логикой отображения элементов списка PointsList. Его разберем чуть ниже.

И завершают две кнопки расположенные горизонтально. Они принимают знакомый параметр строки названия и (если требуется) определенный стиль, который я создал выше. Обработка нажатий совершается через условие if. Если кнопка была нажата то выполняется метод в теле условия. Методы обращаются к списку PointsList через _barricade инициализированный ранее и либо добавляют новый элемент к списку либо удаляют последний добавленный.


EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("Add point", buttonStyle))
{
   AddPoint();
}
if (GUILayout.Button("Delete point", buttonStyle))
{
   DeletePoint();
}
EditorGUILayout.EndHorizontal();

private void DeletePoint()
{
   if (_barricade.PointsList.Count > 0)
   {
       _barricade.PointsList.RemoveAt(_barricade.PointsList.Count - 1);
   }
}

private void AddPoint()
{
   _barricade.PointsList.Add(new BarricadePoint(_barricade.transform.position, MoveSpeedProp.floatValue, true));
}

Вот теперь и вернемся к циклу, который начинает работать после добавления хотя бы одного элемента в лист.

В начале, в каждом элементе списка мы берем его свойства, которые указывали в BarricadePoint из PositionsListProp


var point = PositionsListProp.GetArrayElementAtIndex(i);
var position = point.FindPropertyRelative("Position");
var speed = point.FindPropertyRelative("Speed");
var isDefaultSpeed = point.FindPropertyRelative("IsDefaultSpeed");

Каждый элемент списка у нас представлен в инспекторе двумя строками

Первая строка — это свойство с параметрами строки с названием, которое инкриментируется каждый проход по циклу и значением Vector2. И правее от этого свойства в этой же строке кнопка, которая берет текущую позицию объекта и записывает в свойство.


EditorGUILayout.BeginHorizontal();
position.vector3Value = EditorGUILayout.Vector2Field(i + 1 + " Position", position.vector3Value);
if (GUILayout.Button("Get current", buttonStyle))
{
   position.vector3Value = _barricade.transform.position;
}

EditorGUILayout.EndHorizontal();

Вторая строка у нас знакомые слайдер и переключатель. Но слайдер обернут между GUI.enabled. Если этому значению выставить false, то все, что будет дальше показываться в инспекторе, будет не активным и не изменяемым, пока ему не выставится далее значение true. И в этом случае активность слайдера зависит от последующего переключателя.

В этой строке у нас еще используется EditorGUIUtility.labelWidth, которое фиксирует ширину следующего элемента в инспекторе.


EditorGUILayout.BeginHorizontal();

GUI.enabled = !isDefaultSpeed.boolValue;
EditorGUIUtility.labelWidth = 125;
speed.floatValue = EditorGUILayout.Slider("Speed To Next Point", speed.floatValue, 0, 15);
GUI.enabled = true;

EditorGUIUtility.labelWidth = 1;
EditorGUILayout.LabelField("-||-");

EditorGUIUtility.labelWidth = 125;
isDefaultSpeed.boolValue = EditorGUILayout.Toggle("Use Default Speed", isDefaultSpeed.boolValue);

EditorGUILayout.EndHorizontal();

И завершаем каждый элемент списка такой конструкцией в виде визуального разделителя элементов друг от друга


EditorGUILayout.LabelField(   "............................................................................................");
EditorGUILayout.Space();

На этом с блоком Move закончили, получаем такую картину

И, следовательно, закончили с рассмотрением кастомного инспектора, переходим к…

Рисование на сцене

А для этого нам понадобится класс Gizmos и метод родительского класса MonoBehaviour нашего Barricade. При помощи Gizmos можно рисовать множество вещей, которые будут видны на Scene View . Можно рисовать лучи, кубы, иконки и прочее. Но мне были нужны линии Gizmos.DrawLine(), прямоугольник из линий Gizmos.DrawWireCube() и сферы Gizmos.DrawSphere().

Как я сказал, для этого нам нужен метод OnDrawGizmosSelected() или OnDrawGizmos() родительского класса MonoBehaviour. Я выбрал первый, так как Гизмы рисуются только тогда, когда этот объект выбран на сцене, и не хочется захламлять сцену. Во втором Гизмы рисуются всегда.

Перейдем к самому методу


void OnDrawGizmosSelected()
{
  
   if (GetComponent() != null)
   {
       Gizmos.DrawWireCube(transform.position, GetComponent().bounds.size * ResizeScale);
   }

   if (IsMovable && !IsLoopMove)
   {
       Gizmos.color = new Color(0.51f, 0.18f, 1f);
       for (int i = 0; i < PointsList.Count; i++)
       {
           if (i == PointsList.Count - 1)
           {
               Gizmos.DrawLine(PointsList[i].Position, PointsList[0].Position);
               Gizmos.DrawSphere(PointsList[i].Position, 0.2f);
           }
           else
           {
               Gizmos.DrawLine(PointsList[i].Position, PointsList[i + 1].Position);
               Gizmos.DrawSphere(PointsList[i].Position, 0.2f);
           }
       }
   }
}

Я установил в методе два условия.

Если первое выполняется, то в сцене рисуется прямоугольник из линий Gizmos.DrawWireCube(). Я передаю параметры центра прямоугольника (центр моего обьекта) и размеры. Размеры я беру те, которые соответствуют размерам моего объекта, получается, что прямоугольник визуализирует размеры объекта. Также использую модификатор ResizeScale , который указывает, во сколько раз размеры объекта изменятся после запуска сцены. Gizmos.DrawWireCube() помогает отследить этот размер наглядно без запуска каждый раз сцены для каждого обьекта.


ResizeScale = 1


ResizeScale = 2

Во втором условии цикл for перебирает знакомый PointsList и визуализирует эти самые точки. При помощи Gizmos.DrawSphere() рисуется сфера в позиции каждой точки и размерами 0.2, Gizmos.DrawLine() проводит линию от текущей точки к следующей. А если это последняя точка, то к первой. Перед циклом я изменил цвет всех последующих Гизм через


Gizmos.color = new Color(0.51f, 0.18f, 1f);

На этом всё. Надеюсь, я заинтересовал вас и внес хоть немного ясности, для чего и как нужно делать кастомный инспектор и рисовать в сцене. Можно, потратив немного времени на создание такой системы и настроив под свои нужды, сэкономить в дальнейшем много времени. Облегчайте себе работу.

Читать и комментировать

Краснодар

Коммунаров, 268,
3 эт, офисы 705, 707

+7 (861) 200 27 34

Хьюстон

3523 Brinton trails Ln Katy

+1 833 933 0204

Москва

+7 (495) 145-01-05