Предыдущий пост о процедурной генерации предоставляет нам прекрасный повод посмотреть на возможности кастомизации элементов в редакторе Unity.
Редактор Unity это очень мощный и гибкий инструмент, который позволяет создавать и довольно гибко настраивать элементы управления. Основное предназначение таких инструментов предоставить возможность настройки всего и вся без написания кода. В каком-то смысле создание таких инструментов превращяет Unity в язык программирования пятого поколения.
Пятое поколение
Языки программирования принято делить на пять поколений. Если перейти сразу к пятому, то коротко его можно охарактеризовать как системы создания прикладных программ с помощью визуальных средств разработки, без знания программирования. В корпоративной среде это очень популярная тема. Выдать любому аналитику или дизайнеру инструмент, чтобы он мог сам всё сделать без участия программиста - это светлая мечта любого эффективного менеджера.
На мой взгляд, именно геймдев подошёл к созданию таких систем наиболее близко. Самым ярким примером, конечно же, являются blueprints(блюпринты) в UnrealEngine. Unity имеет большой набор сторонних ассетов для тех же целей и уже несколько лет создаёт свои движок визуального программирования(Visual Scripting). Версия 2020 обещает его предварительную версию, хотя явно у этой задачи не самый большой приоритет.
Безусловно, основным плюсом подобного подхода является сильное снижение порога входа. Если брать тот же пример с блюпринтами, то с их помощью вполне можно сделать полноценную игру без использования кода.
В качестве расплаты вы получите специфический обмен знаниями, когда вместо копирования куска кода придётся повторять в своём блюпринте картинку со StackOverflow. А также довольно большой проблемой становится merge таких форматов при распределённой разработке, самым распространённым решением которой становится запрет на одновременную работу с блюпринтами нескольких человек.
Если же отбросить визуальное программирование и дополнительные ассеты в Unity, то базовые механизмы по изменению отображения параметров объектов или добавление кастомных редакторов тоже может сильно облегчить жизнь. Для примера воспользуемся проектом, который был реализован в предыдущем посте. Реализуем 2 улучшения, которые сразу бросаются в глаза:
Добавим возможность генерации карты высот из редактора, что позволит тестировать результат без перехода в Playmode
Создадим кастомный редактора привязки цвета к высоте, что сильно облегчит настройку параметров
CustomEditor
Это расширение редактора позволяет изменять отображение стандартных компонентов на нужное нам. В качестве примера добавим кнопку генерации карты прямо в редакторе, без запуска игры.
usingUnityEditor;usingUnityEngine;[CustomEditor(typeof(NoiseMap))]publicclassNoiseMapEditorGenerate:Editor{publicoverridevoidOnInspectorGUI(){// Получаем компонент
NoiseMapnoiseMap=(NoiseMap)target;// Рисуем стандартный компонент, а также при изменении пересоздаём карту
if(DrawDefaultInspector()){noiseMap.GenerateMap();}// Добавляем кнопку для генерации карты
if(GUILayout.Button("Generate")){noiseMap.GenerateMap();}}}
В результате компонент NoiseMap получит дополнительную кнопку, клик по которой создаст и выставит в Renderer карту без запуска сцены.
CustomPropertyDrawer и EditorWindow
Напомню, что в предыдущей статье мы генерировали карту высот на основании Шума Перлина.
Настройка цвета в зависимости от высоты происходила через объект типа List и выглядела таким образом:
В результате добавление цвета в середину диапазона требует ручного переноса всех остальных значений, что является не очень приятным занятием. В качестве альтернативы исходному способу можно создать кастомный редактор цветов при помощи EditorWindow.
usingSystem;usingSystem.Collections.Generic;usingUnityEngine;[Serializable]publicclassTerrainLevels{// Структура, отвечающая за цвет
[Serializable]publicstructColorKey{ [SerializeField]privatestringname; [SerializeField]privateColorcolor; [SerializeField]privatefloatheight;publicColorKey(stringname,Colorcolor,floatheight){this.name=name;this.color=color;this.height=height;}publicstringName=>name;publicColorColor=>color;publicfloatHeight=>height;} [SerializeField]privateList<ColorKey>levels=newList<ColorKey>();// Количество доступных цветов
publicintLevelsCount=>levels.Count;// Базовый конструктор
publicTerrainLevels(){levels.Add(newColorKey("White",Color.white,1));}// Соответствие цвета высоте
publicColorHeightToColor(floatheight){// Базовым является самый высокий цвет
ColorretColor=levels[levels.Count-1].Color;foreach(varlevelinlevels){// Если находим цвет ниже, то выбираем его
if(height<level.Height){retColor=level.Color;break;}}returnretColor;}// Текстура для вертикальной линейки
publicTexture2DGenerateTextureVertical(intheight){Texture2Dtexture=newTexture2D(1,height);returnFillTexture(texture,height);}// Текстура для горизонтальной линейки
publicTexture2DGenerateTextureHorizontal(intwidth){Texture2Dtexture=newTexture2D(width,1);returnFillTexture(texture,width);}// Заполнение текстуры цветом высот
privateTexture2DFillTexture(Texture2Dtexture,intsize){texture.wrapMode=TextureWrapMode.Clamp;texture.filterMode=FilterMode.Point;// Проще задать текстуре весь массив цветов разом
Color[]colors=newColor[size];for(inti=0;i<size;i++){// Заполняем линейку цветов доступными
colors[i]=HeightToColor((float)i/(size-1));}texture.SetPixels(colors);texture.Apply();returntexture;}// Получить данные о цвете
publicColorKeyGetKey(inti){returnlevels[i];}// Добавить цвет
publicintAddKey(Colorcolor,floatheight){ColorKeynewKey=newColorKey("New key",color,height);for(inti=0;i<levels.Count;i++){if(newKey.Height<levels[i].Height){// Сохраняем список отсортированным по высотам
levels.Insert(i,newKey);returni;}}levels.Add(newKey);returnlevels.Count-1;}// Удаляем цвет
publicvoidRemoveKey(intindex){if(levels.Count>1){levels.RemoveAt(index);}}// Обновляем цвет
publicvoidUpdateKeyColor(stringname,intindex,ColornewColor){levels[index]=newColorKey(name,newColor,levels[index].Height);}// Обновляем положение цвета на шкале
publicintUpdateKeyHeight(intkeyIndex,floatnewHeight){Colorcol=levels[keyIndex].Color;RemoveKey(keyIndex);returnAddKey(col,newHeight);}}
Кастомный отрисовщик свойства для нашего класса линейки цветов
usingUnityEditor;usingUnityEngine;[CustomPropertyDrawer(typeof(TerrainLevels))]publicclassTerrainLevelsPropertyDrawer:PropertyDrawer{publicoverridevoidOnGUI(Rectposition,SerializedPropertyproperty,GUIContentlabel){EventguiEvent=Event.current;// Получаем объект с линейкой цветов
TerrainLevelsterrainLevels=(TerrainLevels)fieldInfo.GetValue(property.serializedObject.targetObject);// Рассчитываем размер заголовка компонента и прямоугольник для отрисовки линейки
floatlabelWidth=GUI.skin.label.CalcSize(label).x+5;RecttextureRect=newRect(position.x+labelWidth,position.y,position.width-labelWidth,position.height);// Перерисовываем линейку
if(guiEvent.type==EventType.Repaint){GUI.Label(position,label);GUI.DrawTexture(textureRect,terrainLevels.GenerateTextureHorizontal((int)position.width));}// Открываем окно редактора
if(guiEvent.type==EventType.MouseDown&&guiEvent.button==0&&textureRect.Contains(guiEvent.mousePosition)){TerrainLevelsEditorwindow=EditorWindow.GetWindow<TerrainLevelsEditor>();window.SetTerrainLevels(terrainLevels);}}}
usingUnityEditor;usingUnityEngine;usingRandom=UnityEngine.Random;publicclassTerrainLevelsEditor:EditorWindow{// Линейка цветов
privateTerrainLevels_terrainLevels;// Прямоугольник для отрисовки линейки
privateRect_levelRulerRect;// Прямоугольники конечных цветов
privateRect[]_keyRects;// Ширина линейки
privateconstintLevelRullerWidth=25;// Ключевые размеры
privateconstintBorderSize=10;privateconstfloatKeyWidth=60;privateconstfloatKeyHeight=20;// Поддержка редактирования
privatebool_mouseDown=false;privateint_selectedKeyIndex=0;privatebool_repaint=false;privatevoidOnEnable(){// При открытии окна выставляем параметры заголовка и размеров
titleContent.text="Terrain level editor";position.Set(position.x,position.y,300,400);minSize=newVector2(300,400);maxSize=newVector2(300,1500);}// Помечаем сцену грязной при закрытии редактора
privatevoidOnDisable(){UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(UnityEngine.SceneManagement.SceneManager.GetActiveScene());}// Передаём в редактор линейку цветов
publicvoidSetTerrainLevels(TerrainLevelslevels){_terrainLevels=levels;}privatevoidOnGUI(){// Базовая отрисовка редактора
Draw();Input();if(_repaint){_repaint=false;Repaint();}}privatevoidDraw(){// Отрисовываем линейку цветов
_levelRulerRect=newRect(BorderSize,BorderSize,LevelRullerWidth,position.height-BorderSize*2);GUI.DrawTexture(_levelRulerRect,_terrainLevels.GenerateTextureVertical((int)_levelRulerRect.height));// Отрисовываем конкретные цвета
_keyRects=newRect[_terrainLevels.LevelsCount];for(inti=0;i<_terrainLevels.LevelsCount;i++){TerrainLevels.ColorKeykey=_terrainLevels.GetKey(i);RectkeyRect=newRect(_levelRulerRect.xMax+BorderSize,_levelRulerRect.height-_levelRulerRect.height*key.Height,KeyWidth,KeyHeight);// Для текущего выбранного цвета рисуем рамку
if(i==_selectedKeyIndex){EditorGUI.DrawRect(newRect(keyRect.x-2,keyRect.y-2,keyRect.width+4,keyRect.height+4),Color.black);}// Рисуем цвет
EditorGUI.DrawRect(keyRect,key.Color);// В зависимости от яркости конкретного цвета выбираем цвет шрифта, для удобства чтения
floatbrightness=key.Color.r*0.3f+key.Color.g*0.59f+key.Color.b*0.11f;GUIStylestyle=newGUIStyle();style.normal.textColor=brightness<0.4?Color.white:Color.black;// На прямоугольник с цветом накладываем значение высоты
EditorGUI.LabelField(keyRect,key.Height.ToString("F"),style);_keyRects[i]=keyRect;}// Прямоугольник для отображения полей настройки цвета
RectsettingsRect=newRect(BorderSize*3+LevelRullerWidth+KeyWidth,BorderSize,position.width-(BorderSize*4+LevelRullerWidth+KeyWidth),position.height-BorderSize*2);GUILayout.BeginArea(settingsRect);// Слушаем изменение параметров
EditorGUI.BeginChangeCheck();// Поле ввода имени для цвета
GUILayout.BeginHorizontal();GUILayout.Label("Name");stringnameText=EditorGUILayout.TextField(_terrainLevels.GetKey(_selectedKeyIndex).Name);GUILayout.EndHorizontal();// Выбор самого цвета
ColornewColor=EditorGUILayout.ColorField(_terrainLevels.GetKey(_selectedKeyIndex).Color);// При изменении параметров обновляем цвет в линейке
if(EditorGUI.EndChangeCheck()){_terrainLevels.UpdateKeyColor(nameText,_selectedKeyIndex,newColor);}// Кнопка удаления цвета
if(GUILayout.Button("Remove")){_terrainLevels.RemoveKey(_selectedKeyIndex);if(_selectedKeyIndex>=_terrainLevels.LevelsCount){_selectedKeyIndex--;_repaint=true;}}GUILayout.EndArea();}privatevoidInput(){EventguiEvent=Event.current;// Добавляем цвет по клику мышки на редакторе
if(guiEvent.type==EventType.MouseDown&&guiEvent.button==0){for(inti=0;i<_keyRects.Length;i++){if(_keyRects[i].Contains(guiEvent.mousePosition)){_mouseDown=true;_selectedKeyIndex=i;_repaint=true;break;}}if(!_mouseDown){// Инициализируем случайным цветом
ColorrandomColor=newColor(Random.value,Random.value,Random.value);// Определяем высоту для цвета в диапазоне [0,1], по координатам относительно линейки
floatkeyHeight=Mathf.InverseLerp(_levelRulerRect.y,_levelRulerRect.yMax,guiEvent.mousePosition.y);// Шкала цвета обратно направлена координатам редактора
_selectedKeyIndex=_terrainLevels.AddKey(randomColor,1-keyHeight);_mouseDown=true;_repaint=true;}}// Сбрасываем отслеживание клика
if(guiEvent.type==EventType.MouseUp&&guiEvent.button==0){_mouseDown=false;}// Перетаскивание цвета при движении мышки
if(_mouseDown&&guiEvent.type==EventType.MouseDrag&&guiEvent.button==0){// Определяем высоту для цвета в диапазоне [0,1], по координатам относительно линейки
floatkeyHeight=Mathf.InverseLerp(_levelRulerRect.y,_levelRulerRect.yMax,guiEvent.mousePosition.y);// Шкала цвета обратно направлена координатам редактора
_selectedKeyIndex=_terrainLevels.UpdateKeyHeight(_selectedKeyIndex,1-keyHeight);_repaint=true;}}}
В результате наше кастомное свойство в компоненте будет выглядеть как горизонтальная линейка
А редактор позволит легко её редактировать
Заключение
Unity имеет практически бесконечные возможности для кастомизации интерфейса в целях упрощения жизни, однако, не стоит забывать о стоимости разработки. В нашем примере мы заменили 10 строк изначальной версии с List, на 300 строк кода кастомного редактора. Так что принимая решение о создании кастомных редакторов не забывайте, что любая работа занимает время, причём не только время на создание, но и на поддержку, так как наверняка новая версия Unity, если и не привнесёт новых багов, то вполне может поменять Api. Пока! =)