[EXP] Unity 3D кастомный Inspector для MonoBehaviour. UnityEditor Scripting P.1

[EXP] Unity 3D кастомный Inspector для MonoBehaviour. UnityEditor Scripting P.1

Вступление

В проекте, над которым я сейчас работаю встала задачка - нужно создавать, хранить и балансировать карточки, дающие игроку бафы/дебафы. К тому же, есть мысль создавать отдельные наборы карточек (по случаю праздников, например) и заменять их, когда душе угодно. Предвещая неудобства и технические запутанности при пользовании или масштабировании, я принял решение "очеловечить" процесс, создав кастомный инструмент с использованием ScriptableObject, работа с которым ведется в инспекторе.

Собственно, это и сподвигло меня на написание данной статьи, в которой я затрону создание кастомного инспектора с классом MonoBehaviour, отображение/сохранение публичных и сериализованных переменных, а также некоторые классы и методы, связанные с визуальным представлением компонентов инспектора.

Хочется поделиться опытом, а может кто-то еще что-нибудь интересного подскажет. Тема весьма запутанная, но результат иногда - отвал башки, так что запаситесь терпением и решите нужно ли это вам вообще)

Зачем кастомный инспектор?

Окно инспектора является одной из наиболее базированных вещей в Unity, ведь благодаря ему мы можем лицезреть и настраивать присущие конкретному объекту компоненты или же другие составляющие движка: модели, звуки, текстуры, материалы и т.д.

В общем, инспектором мы пользуемся часто. А по сути своей это то, как движок технически и визуально представляет нам классы типа MonoBehaviour и ScriptableObject.

Как правило, Unity разработчику достаточно обычного инспектора. Но иногда его все же стоит сделать кастомным в виду некоторых обстоятельств:
• Скрипт требует реализации дополнительного функционала;
• Скрипт планируется ре-использовать и уже есть основание полагать, что он станет полноценным тулом студии;
• Скрипт уже стал очень многофункциональным и запутанным, а в команде есть люди, которых нельзя пугать кодом и неоднозначными переменными;
• Просто есть желание слегка по-Марксистки насладиться результатами своего труда. Пользоваться скриптом, который красив и снаружи, и внутри;
• И еще миллиард причин, почему инспектор стоит сделать кастомным!..

Но инспектор - лишь верхушка айсберга. Ко всему прочему, движок юнити предоставляет возможность свободно использовать API (UnityEngine, UnityEditor), тем самым открывая доступ к широкому функционалу разработки собственных полноценных инструментов для управления работы движка, ресурсов и способов визуального представления. Например, можно создать тул, который будет искать текстуры в папке проекта и сортировать их на "POT"/"NPOT", при этом будет открываться в отдельном окне через вкладку Tools на панели управления. Надо - открыл, надо - закрыл.

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

1. Как я уже говорил, инспектор взаимодействует с классами типа MonoBehaviour и ScriptableObject. Поэтому нам нужен класс, который наследуется от них. (В данной статье речь будет идти о MonoBehaviour)

Вот тестовый класс Player, над которым будем ставить опыты:

using UnityEngine; public class Player : MonoBehaviour { public int a; public string b; public float c; public float _moveSpeed; [SerializeField]private float _health; private void Start() { // Init Player } private void Update() { // Do some Player Stuff } }
Стандартный инспектор класса
Стандартный инспектор класса

2. Для того, чтобы сделать инспектор кастомным, нам нужно создать дополнительный класс PlayerEditor, который:
• Наследуется от Editor;
• Использует библиотеку UnityEditor;
• Имеет атрибут [CustomEditor(typeof(class Name))].

Все что связанно с PlayerEditor следует заключить в

#if UNITY_EDITOR //Код внутри будет выполняться только в редакторе Unity //После компиляции всё это вырежется и в билд не попадет #endif

Кто-то создает класс кастомного Editor в отдельном файле и кладет в папку "Editor", но для маленьких скриптов я предпочитаю использовать оба класса в одном файле.

using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif [+]public class Player [...] //типо скрыл содержимое #if UNITY_EDITOR [CustomEditor(typeof(Player)), CanEditMultipleObjects] public class PlayerEditor : Editor { } #endif

Все атрибуты есть на офф. сайте с документацией.

В данном случае используются два (оба относятся к UnityEditor API):
CustomEditor (обязательный) - сообщает редактору юнити, что это кастомный Editor и для какого типа он используется;
CanEditMultipleObjects - позволяет редактировать сразу несколько объектов на сцене.

Без атрибута и с атрибутом CanEditMultipleObjects.
Без атрибута и с атрибутом CanEditMultipleObjects.

3. Теперь в классе Editor объявим переменные типа Player, SerializedObject и SerializedProperty.

Player - будет экземпляр нашего класса.

SerializedObject - класс UnityEditor, который используется для редактирования сериализованных переменных. (Сериализует экземпляр нашего класса?)

SerializedProperty - класс UnityEditor, который используется для редактирования сериализованных переменных. (Создает ссылку на сериализованную переменную сериализованного экземпляра нашего класса?)

Грубо говоря, чтобы менять приватные переменные с атрибутом [SerializeField], иметь возможность использовать отмену действия "ctrl+z" и сохранять изменения физически на диск, нам будут нужны SerializedObject и SerializedProperty.

[CustomEditor(typeof(Player)), CanEditMultipleObjects] public class PlayerEditor : Editor { Player _playerScript; SerializedObject _serializedPlayerScript; SerializedProperty _serializedHealth; private void OnEnable() //Метод, вызываемый при включении окна инспектора, например, когда мы выбираем объект { _playerScript = target as Player; _serializedPlayerScript = new SerializedObject(_playerScript); _serializedHealth = _serializedPlayerScript.FindProperty("_health"); //_health - сериализованное поле класса Player } }

4. Далее нужно переопределить метод OnInspectorGUI(), в котором мы будем отрисовывать визуальный стиль и реализуем сохранение значений переменных.

Тут есть важный нюанс - необходимо самостоятельно оповещать Unity, когда произошли изменения как в публичных, так и в приватных (сериализованных) переменных.

В MonoBehaviour это делается при помощи:
• EditorUtility.SetDirty(_playerScript) - для публичных;
• _serializedPlayerScript.Update() - для сериализованных;
• _serializedPlayerScript.ApplyModifiedProperties() - для сериализованных.

public override void OnInspectorGUI() { //base.OnInspectorGUI(); //будет отрисовывать оригинальный инспектор _serializedPlayerScript.Update(); //Обновляет репрезентацию сериализованного объекта (скрипта Player). Позволяет корректно читать и записывать значения //DrawCustomInspector(); //Позже в этом методе будем отрисовывать кастомный инспектор // Отрабатывает, когда GUI изменился (поменяли значение в инспекторе, например) if (GUI.changed) { EditorUtility.SetDirty(_playerScript); //Помечает экземпляр грязным, тем самым дает Unity понять, что произошли изменения и экземпляр возможно понадобится сохранить на диск _serializedPlayerScript.ApplyModifiedProperties(); //Применяет изменения сериализованных переменных, дает понять, что экземпляр возможно понадобится сохранить на диск } }

5. Наконец-то можно перейти к отрисовке инспектора...

Для отрисовки мы можем воспользоваться следующими классами:
EditorGUILayout - автоматически компонуемый класс UnityEditor;
EditorGUI - класс UnityEditor, настраиваемый вручную;
GUILayout - автоматически компануемый класс UnityEngine;

GUI - класс UnityEngine для Unity GUI;
GUIContent - класс UnityEngine, определяет что отрисовывать в качестве контента;
GUIStyle - необязательный класс UnityEditor для настройки кастомного стиля конкретного элемента (позиция, шрифт, размер шрифта и т.д.)

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

Для отрисовки пишем методы последовательно друг за другом. Начнем с отображения текста:

private void DrawCustomInspector() { EditorGUILayout.Space(); // Отступ EditorGUILayout.LabelField("Custom Player Script Editor", new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold, fontSize = 16 }); // Заголовок, позиция текста - центр, шрифт жирный, размер 16 EditorGUILayout.LabelField("", GUI.skin.horizontalSlider); // Разделитель EditorGUILayout.HelpBox("Это кастомный инспектор для скрипта!", MessageType.None); // Сообщение по типу подсказки нейтральное EditorGUILayout.Space(); // Снова отступ }
[EXP] Unity 3D кастомный Inspector для MonoBehaviour. UnityEditor Scripting P.1

EditorGUILayout.Space() или EditorGUILayout.Space(float width) - создает отступ;

EditorGUILayout.LabelField("text", new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Bold, fontSize = 16 }) - создает заголовок и определяет для него новый стиль с позиционированием текста по центру, жирным шрифтом и размером 16;

EditorGUILayout.LabelField("", GUI.skin.horizontalSlider) - располагает заголовок с пустым текстом и стилем фона от слайдера (получается разделительная полоска).

EditorGUILayout.HelpBox("Напоминание, Подсказка, Ошибка", MessageType.None) - еще один способ представления текста пользователю. MessageType.Info - появится желтый знак. Также есть MessageType.Warning и MessageType.Error.

Теперь добавим отображение переменных:

_playerScript.a = EditorGUILayout.IntField(new GUIContent("Переменная А", "Переменная типа int"), _playerScript.a); // Поле для ввода и хранения int значений _playerScript.b = EditorGUILayout.TextField(new GUIContent("Переменная B:", "Переменная типа string"), _playerScript.b); // Поле для ввода и хранения string значений _playerScript.c = EditorGUILayout.FloatField(new GUIContent("Переменная C", "Переменная типа float"), _playerScript.c); // Поле для ввода и хранения float значений _playerScript._moveSpeed = EditorGUILayout.Slider(new GUIContent("Скорость игрока", "Настраиваемая скорость игрока"), _playerScript._moveSpeed, 1, 10); // Слайдер от 1 до 10 _serializedHealth.floatValue = EditorGUILayout.FloatField(new GUIContent("Здоровье", "Уровень здоровья игрока"), _serializedHealth.floatValue); // Поле для ввода и хранения сериализованных значений типа float
Отображение переменных, подсказка при наведении
Отображение переменных, подсказка при наведении

_playerScript.a = EditorGUILayout.IntField(new GUIContent("Переменная А", "Переменная типа int"), _playerScript.a) - указывает, что это поле для записи и отображения int переменных (IntField), в качестве контента будет текст с названием и подсказкой при наведении мыши. В качестве значения переменная a экземпляра _playerScript. Обратите внимание, что поле должно соответствовать типу переменной (IntField, TextField, FloatField).

_playerScript._moveSpeed = EditorGUILayout.Slider(new GUIContent("Скорость игрока", "Настраиваемая скорость игрока"), _playerScript._moveSpeed, 1, 10) - тут вместо обычного поля ввода будет слайдер с значениями от 1 до 10. Также используется название и подсказка.

_serializedHealth.floatValue = EditorGUILayout.FloatField(new GUIContent("Здоровье", "Уровень здоровья игрока"), _serializedHealth.floatValue) - теперь подошли к сериализованному параметру. Обратите внимание, что обращаясь нужно указать тип переменной после точки _serializedHealth.floatValue.

Добавим еще пару приколов (картинку и выпадающий список):

Texture2D cover = (Texture2D)AssetDatabase.LoadAssetAtPath("Assets/CustomEditor/customEditor.png", typeof(Texture2D)); // Подгружаем картинку float imageWidth = EditorGUIUtility.currentViewWidth; // Берем ширину картинки float imageHeight = imageWidth * cover.height / cover.width; // Высчитываем высоту картинки, сохраняя её пропорции Rect rect = GUILayoutUtility.GetRect(imageWidth, imageHeight); // Создаем область равную размерам, которые высчитали до этого GUI.DrawTexture(rect, cover, ScaleMode.ScaleToFit); // Помещаем картинку cover в области rect, полностью растягивая ScaleToFit showVariables = EditorGUILayout.Toggle(new GUIContent("Показать что-то", "Нажмите, чтобы узреть какие-то подкапотные переменные."), showVariables); // Отображение bool переменной showVariables в кастомном инспекторе. if (showVariables) // Если стоит галка, то будут показываться переменные { _playerScript.a = EditorGUILayout.IntField(new GUIContent("Переменная А", "Переменная типа int"), _playerScript.a); _playerScript.b = EditorGUILayout.TextField(new GUIContent("Переменная B:", "Переменная типа string"), _playerScript.b); _playerScript.c = EditorGUILayout.FloatField(new GUIContent("Переменная C", "Переменная типа float"), _playerScript.c); } GUILayout.Label("By Cosyplaid", new GUIStyle(GUI.skin.box) { alignment = TextAnchor.MiddleCenter, fontStyle = FontStyle.Normal, fontSize = 12 }); // Небольшой понт
Обложка и галка
Обложка и галка

showVariables = EditorGUILayout.Toggle(new GUIContent("Показать что-то", "Нажмите, чтобы узреть какие-то подкапотные переменные."), showVariables) - эта строка, которая отображает Toggle для showVariables (переменная showVariables объявлена вне метода, она принадлежит классу PlayerEditor).

Теперь в инспекторе появилась галочка. И если значение true, то переменные a, b и c будут отрисовываться, иначе - нет.

GUI.DrawTexture(rect, cover, ScaleMode.ScaleToFit) - отобразит текстуру/картинку, размеров rect, растянув до краев.

Также, можно добавить кнопки:

GUILayout.BeginHorizontal(); // Начало горизонтальной группы if (GUILayout.Button("Восстановить здоровье", new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleCenter, fixedHeight = 30 })) { _serializedHealth.floatValue = 100; ShowHealthMessage(); } if (GUILayout.Button("Ударить игрока", new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleCenter, fixedHeight = 30 })) { _playerScript.ApplyDamage(20); ShowHealthMessage(); } GUILayout.EndHorizontal(); // Конец горизонтальной группы private void ShowHealthMessage() { if (_serializedHealth.floatValue > 0) Debug.Log(string.Format("Player's health is {0} now!", _serializedHealth.floatValue)); }
Взаимодействие с кастомным инспектором

if (GUILayout.Button("Восстановить здоровье", new GUIStyle(GUI.skin.button) { alignment = TextAnchor.MiddleCenter, fixedHeight = 30 })) - строка отображает кнопку с кастомным стилем и проверяет была ли она нажата.

GUILayout.BeginHorizontal() и GUILayout.EndHorizontal() - весь контент, располагающийся между этими двумя методами, будет являться одной горизонтальной группой и компоноваться на одной линии друг за другом

Заключение

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

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

В будущем планирую написать статью про свой тул на ScriptableObjects для создания и хранения карточек бафа/дебафа и про кастомный редактор малой кровью. Так что, если статья была вам полезна, дайте знать и подписывайтесь. Может кто-то поделится своим опытом разработки кастомных инструментов или редакторов, буду рад.

На этом все, спасибо) Берегите себя.

1212
11 комментариев

Круто! С таким контентом лучше в подсайт @Gamedev сразу постить.

1
Ответить

Спасибо! Ого, на DTF такой раздел есть, спасибо за наводку)

1
Ответить

Насколько оно себя окупает? Ну затраты времени на то, чтобы сделать такой едитор и по сравнению с тем, сколько ты в нем копаешься обычно.
Ну и еще вопрос, как сильно он нагружать будет инспектор юньки, а то у меня в недавнем проекте инспектор ложился из-за огромного количества данных в нем

1
Ответить

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

Другой пример это OdinInspector, который упращает жизнь разработчику. Хотя я им не пользовался)

Еще без кастомных редакторов не добавить кнопки.

как сильно он нагружать будет инспектор юнькиНагружает не сильно, но это смотря как написано. Если выводить список или таблицу из 1000+ элеметов, то редактор будет заметно подвисать. (Если это не делать через специальный класс таблицы, которые специально под это заточен. А вот простые списки под это не заточены.)

Я бы еще добавил, к тому насколько он себя окупает, то делать его все-таки затратно и пример в статье не показетль, там можно обойтись и стандартными средствами. Поэтому, если не нужен, то можно не делать. Или есть свободное время и желание, то можно пробовать.

1
Ответить

Имхо, окупает от случая к случаю - иногда сполна, а иногда выходит дорогой, но зато какой стильный пшик. Надо смотреть по ситуации более конкретно... Сам лично бы принимал решение, отвечая на следующие вопросы:
1. Какой объем функционала и надстроек предоставляет отдельный скрипт, требуется ли для этого более систематизированный инспектор?
2. Кто потенциальный пользователь скрипта (кодер, гейм-дизайнер, саунд-дизайнер и т.д)?
3. Какой дополнительный функционал требуется реализовать в скрипте, и нужны ли для этого дополнительные средства юнити?
4. Как часто скрипт планируется ре-использовать в будущем?
5. Насколько скрипт нравится лично мне о_О
Если хотя бы 3/5 вопросов располагают к положительному решению, то почему бы и нет.

По времени, честно затрудняюсь ответить - в живом проекте мы вероятно копаемся в скрипте периодически все время разработки. Вопрос лишь в частоте копания, и у каждого будет свой ответ...
На написание среднего едитора в лучшем случае уйдет час-два, в худшем - до дня.

По поводу нагрузки: действительно, в общем случае с представлением переменных и заголовков, кастомный инспектор совсем чуть-чуть может быть тяжелее обычного, хотя бы потому что он переопределяет метод базового класса. Но это настолько мелкий чих, что я бы даже париться об этом не стал. Более сложные конструкции и отображение графики тоже могут малым образом влиять на производительность - все еще не думаю, что это повод для беспокойства.
А вот если мы пишем тул с кнопками, рассчитывающими мега сложный рекурсивный трипл А алгоритм для генерации миров с предпросмотром в окошечке, то тут конечно же будут ощутимые, а главное закономерные просадки. При желании, по нажатии кнопки можно и переполнение вызвать, крашнув движок.

Ответить

Если интересно разрабатывать тулинг, рекомендую посмотреть в сторону UI Toolkit'а.

Как правило, там легче и прикольнее делаются инструменты для Editor'а.

https://docs.unity3d.com/Manual/UIE-HowTo-CreateEditorWindow.html

1
Ответить

Занятная тема. Как я понял, это поддерживается на версиях 2022.3 и 2021.3? Все равно круто, что разрабы Юнити развиваются и в этом направлении.

Ответить