Визуальный редактор логики для Unity3d. Часть 3

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Введение

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

Предыдущие статьи в серии:

Визуальный редактор логики для Unity3d. Часть 1

Визуальный редактор логики для Unity3d. Часть 2

Архитектура

Ниже представлена обобщенная схема архитектуры редактора.

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

  1. Однопоточный рендеринг.

  2. Очень кривая и странная система событий.

  3. Отсутствие поддержки асинхронных вызовов (Coroutine).

    Примечание: теперь она есть, но, когда писался редактор, её не было.

Исходя из вышеописанного было решено разделить всю логику и код между двумя основными сущностями:

  1. Окно редактора визуальной логики.

  2. Редактор параметров компонента логики на основе инспектора Unity3d.

Все остальные подсистемы являются вложенными в эти две и реализуются на основе приватных классов.

Рассмотрим подробнее элементы архитектуры визуального редактора логики.

  • VLELogicEditor. Основной класс окна визуального редактора логики

    • MainMenu. Класс, реализующий меню в редакторе.

    • LogicArea. Класс, отвечающий за визуализацию области отрисовки компонентов логики.

    • Status. Класс, реализующий область состояния.

  • VLECatalog. Класс, реализующий логику сбора и отображения списка всех компонентов логики, находящихся в проекте.

  • VLEInspector. Клаcc, реализующий функционал настройки параметров компонентов на основе инспектора Unity3D

    • VLELogicComponentCustomEditor. Класс, реализующий общую логику визуализации параметров компонента логики в инспекторе.

    • VLEObjectEditor. Класс, реализующий логику настройки ссылок на объекты Unity3d. Фактически — это кастомный редактор для обертки VLObject.

  • VLESettingWindow. Класс, реализующий логику окна общих настроек редактора.

  • VLESearchWindow. Класс, реализующий функционал поиска компонентов и групп в текущем открытом файле логики.

  • VLEWidget. Базовый класс, для визуализации элементов логики.

    • VLEComponent. Класс, реализующий визуализации компонента.

    • VLEGroup. Класс, реализующий визуализации групп компонентов.

    • VLELink. Класс, реализующий визуализацию связей между компонентами.

    • VLEVariable. Класс, реализующий визуализацию переменных логики.

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

Подробный код каждого элемента архитектуры рассмотрим в нижеследующих пунктах.

Необходимая поддержка в ядре

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

Список файлов логики, используемых в сцене

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

С этой целью для режима редактора в класс LogicController было добавлено свойство:

#if UNITY_EDITOR
	public string[] LogicVisualNames => SceneLogicList.ConvertAll(item => item.Name).ToArray();      
#endif

Ниже представлено изображение, где показано как этот список используется.

Доступ к экземплярам компонентов

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

#if UNITY_EDITOR 
	public IDictionary<string, LogicComponent> RuntimeComponentInstances = new Dictionary<string, LogicComponent>();
#endif

Соответственно при загрузке логики и создании компонентов мы сохраняем ссылки на экземпляры в этом хранилище:

#if UNITY_EDITOR
	foreach (var componentInstance in instances)
	{
		RuntimeComponentInstances.Add(componentInstance.Key, componentInstance.Value);
	}
#endif

Работа со ссылками на объекты Unity

Для работы кастомного редактора обертки над объектами Unity, который реализован в классе VLObject, необходимо обеспечить ряд механизмов:

  1. Счетчик держателей ссылки, с помощью которого можно будет отслеживать, когда на объект Unity в компонентах больше нет ни одной ссылки и его можно удалить из кэша. Для этого в классе ObjectLinkData заведем поле:

    #if UNITY_EDITOR
    	public int Holders;
    #endif
  2. Поскольку в билде ссылки на объекты Unity по сути свой статичны, то там не требуется их установка или удаление извне. Однако для редактора самой собой этот механизм нужен, реализуется он с помощью специальных методов:

Код
#if UNITY_EDITOR
	public string SetObject(string oldObjId, UnityEngine.Object newObj)
	{                
  	if (newObj == null)
  	{
    	RemoveObjectLink(oldObjId);

    	return string.Empty;
  	}
  	else
  	{
    	var linkData = _objectLinks.Find(link => link.Obj == newObj);
                
    	if (linkData == null)                    
    	{
        linkData = new ObjectLinkData(Guid.NewGuid().ToString(), newObj);
                    
      	_objectLinks.Add(linkData);
    	}else
    	{
      	linkData.Holders++;

      	if (linkData.Id != oldObjId)
      	{
          RemoveObjectLink(oldObjId);
      	}
    	}                                                        
    
    	return linkData.Id;
  	}
	}
        
	private void RemoveObjectLink(string objectId)
	{
    var linkData = _objectLinks.Find(link => string.Compare(link.Id, objectId, StringComparison.Ordinal) == 0);
            
  	if (linkData != null)
  	{
    	linkData.Holders--;

      if (linkData.Holders <= 0)
      {
      	_objectLinks.Remove(linkData);
      }
    }
  }
#endif

Как видно по коду, именно здесь и используется счетчик держателей ссылки.

Рисуем главное окно редактора

Конвейер рендеринга

С целью унифицировать отрисовку каждого элемента редактора был создан специальный интерфейс:

public interface IEditorDrawer
{
	bool Draw(Rect container, bool lockInput);
}

Каждый класс, который реализует визуализацию определенной части редактора, имплементирует этот интерфейс. Таким образом основной цикл отрисовки (OnGUI метод) выглядит следующим образом:

Код
if (!EditorApplication.isCompiling)
{
    for (var i = 0; i < _currentEditorDraw.Count; i++)
    {
        var editorDraw = _currentEditorDraw[i];

        _inputLocked = editorDraw.Draw(position, _inputLocked);
    }

    RemoveInactiveEditorDraws();

		_inputLocked = false;
}
else
{
    GUI.Label(new Rect(5f, 5f, position.width - 10f, 25f), "wait for compiling...");
}

Здесь:

  1. Отрисовка происходит только, если редактор не находится в режиме компиляции.

  2. Все экземпляры IEditorDraw отрисовываются в том порядке, в котором они были зарегистрированы.

  3. В качестве параметров передается ссылка на Rect окна редактора и значение флага блокировки ввода, который может выставить в true любой из элементов.

  4. Для плавающих окон (диалоговые, информационные и т.п.) существует механизм добавления и удаления из конвейера, это делается с помощью метода DrawEditor:

    Код
    private void DrawEditor(IEditorDrawer drawEditor)
    {
    	if (!_currentEditorDraw.Contains(drawEditor))
      {
      	_currentEditorDraw.Add(drawEditor);
      }else
      {
      	_currentEditorDrawForDelete.Add(drawEditor);
      }            
    }
  5. Если окно убирается из рендеринга, то он помещается в соответствующий список, из которого уже удаляется в методе RemoveInactiveEditorDraws:

Код
private void RemoveInactiveEditorDraws()
{
	if (_currentEditorDrawForDelete.Count > 0)
	{
		for (var i = 0; i < _currentEditorDrawForDelete.Count; i++)
		{
			var editorDraw = _currentEditorDrawForDelete[i];

			(editorDraw as IDisposable)?.Dispose();

			_currentEditorDraw.Remove(editorDraw);
		}
                
	_currentEditorDrawForDelete.Clear();
	}
}

События

События в редакторе реализованы на классическом варианте из C# - event/delegate. Данный вариант был выбран по причине простоты и надежности. События хранятся в глобальном статическом классе, который доступен всем элементам редактора. Рассмотрим эти события подробно:

  • SelectedComponentChanged – событие изменения выбранного компонента.

  • SelectedLogicChanged – событие изменения выбранной логики.

  • SelectedLogicUpdated – событие обновления данных текущей логики.

  • ComponentActiveChanged – событие изменения активности компонента.

  • OpenLogicsStorage – событие открытия логики из внешних источников.

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

Помимо кастомных событий в редакторе обрабатываются также и UI события (нажатия, drag&drop и т. п.). В виду того, что рендер редакторов однопоточный, то обработка таких событий сопряжена с трудностями:

  1. Обработка событий происходит там же, где и рендеринг.

  2. События полностью синхронные.

  3. Если в редакторе несколько виджетов обрабатывают событие и сработали условия по всем, то они выполнятся одновременно (нет блокировки события между виджетами, как это есть в рантаймовом Unity UI).

    Примечание: для таких целей можно использовать метод Event.Use() и тип события EventType.Used, однако это по разным причинам бывает недостаточно.

Для того, чтобы нивелировать описанные выше особенности в интерфейс IEditorDraw добавлена передача флага о том, что ввод блокирован предыдущим виджетом/окном.

Элементы

Главное меню

Класс для отрисовки меню:

private class MainMenuToolbar : IEditorDrawer, IDisposable {}

Иконки для кнопок берутся стандартные (из Unity) для этого используются метод EditorGUIUtility.IconContent:

_catalogIconContent = new GUIContent(EditorGUIUtility.IconContent("project"));

Сами кнопки рисуются с помощью стандартного GUILayout.Button, который обернут в специальный метод:

Код
private bool LayoutButton(GUIContent content, string tooltip = "", bool state = true)
{
	EditorGUI.BeginDisabledGroup(!state);
  
	content.tooltip = tooltip;

	var result = GUILayout.Button(content, _buttonStyle, GUILayout.MaxWidth(_mainMenuHeightBar - 5f), GUILayout.MaxHeight(_mainMenuHeightBar - 5f));
  
	EditorGUI.EndDisabledGroup();

	if (!_lockInput)
	{
		return result;
	}else
	{
		return false;
	}
}

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

Код
public event SaveLogicEvent OnSaveLogic;
public event AddLogicToSceneEvent OnAddLogicToScene;
public event CreateLogicEvent OnCreateLogic;
public event DeleteLogicEvent OnDeleteLogic;
public event RestoreSceneLogicEvent OnRestoreSceneLogic;
public event LoadLogicEvent OnLoadLogic;
public event SearchComponentEvent OnSearchComponent;
public event CollapseComponentEvent OnCollapseComponent;
public event CollapseParameterEvent OnCollapseParameter;
public event RollbackLogicEvent OnRollbackLogic;

Примечание: как правило, подписка и отписка на события выносится туда, где это происходит. Однако я посчитал это несколько неудобным в данном случае. Поэтому отписка от всех событий происходит там, где они объявляются (как пример в методe Dispose). Делается это следующим образом:

if (OnSaveLogic != null)
{
	foreach (var eventHandler in OnSaveLogic.GetInvocationList())
	{
		OnSaveLogic -= (SaveLogicEvent)eventHandler;
	}
}

Область логики

Данный элемент редактора отвечает за отрисовку компонентов логики и связей между ними.

Основной класс виджета области рисования логики:

private class LogicArea : LogicBaseDrawer, ILogic, IDisposable{}

LogicBaseDrawer это простая абстракция для удобства, которая имплементирует интерфейс IEditorDrawer. ILogic интерфейс для доступа к визуализации логики:

Код
public interface ILogic
{
	Rect RealArea { get; }
	Rect ViewArea { get; }
	Vector2 ScrollPosition { get; }            

	void CenterToComponent(ILogicWidget component);
	void Reset();
}

Здесь:

  • RealArea – это общий Rect области рисования логики

  • ViewArea – это видимая часть редактора

  • CenterToComponent – метод, позволяющий центрироваться на выбранном компоненте (используется в окне поиска)

  • Reset – метод, который сбрасывает позицию скроллирования логики.

Основные элементы, которые рисует окно логики это:

  • Компоненты

  • Связи

  • Группы

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

private readonly IList<IWidgetGroup> _logicGroups;
private readonly IList<ILogicWidget> _logicWidgets;
private readonly IList<ILink> _logicLinks;

Общий конвейер рендеринга выглядит следующим образом:

protected override void LogicDraw(bool lockInput)
{
	DrawLinks(lockInput);
	DrawWidgets(lockInput);
	DrawGroups(lockInput);
	DrawSelectedWidgets(lockInput);
	DrawSelectedLinkData();
	DrawSelectionRect();
}

Порядок такой:

  1. Рисуются связи

  2. Рисуются виджеты компонентов

  3. Рисуются группы

  4. Рисуются выделенные виджеты

  5. Рисуются данные трассировки выделенной связи

  6. Рисуется Rect области выделения виджетов/связей

Примечание: класс области рисования логики не занимается непосредственной отрисовкой, а лишь вызывает соответствующее методы интерфейсов.

Для связи с другими подсистемами редактора предусмотрен набор событий:

Код
public event AddComponentEvent OnAddComponent;
public event RemoveComponentEvent OnRemoveComponent;
public event CopyComponentEvent OnCopyComponent;
public event AddLinkEvent OnAddLink;
public event PasteComponentEvent OnPasteComponent;
public event SelectAllEvent OnSelectAllEvent;
public event AddGroupEvent OnAddGroupEvent;
public event UngroupEvent OnUngroupEvent;
public event FindEvent OnFindEvent;
public event SetStateActiveLinkEvent OnSetStateLinkEvent;
public event SetStateActiveComponentEvent OnSetStateActiveComponentEvent;
public event SetLinkColorEvent OnSetLinkColorEvent;

Выше был описан метод, который вызывает методы Draw для различных элементов логики. Однако это не основной метод отрисовки, как и все остальные подсистемы редактора класс LogicArea имплементирует интерфейс IEditorDrawer:

Код
public bool Draw(Rect container, bool lockInput)
{
	PrepareGUIStyle();

	logicAreaRect.width = container.width - 10f;
	logicAreaRect.height = container.height - _mainMenuHeightBar - _statusBarHeight - 20f;

	if (VLECommon.Setting.ShowScrollBar)
	{
		logicScrollPosition = GUI.BeginScrollView(logicAreaRect, logicScrollPosition, logicRealRect, true, true);
	}
	else
	{
		logicScrollPosition = GUI.BeginScrollView(logicAreaRect, logicScrollPosition, logicRealRect, true, true, GUIStyle.none, GUIStyle.none);
	}
	{
		DrawAsDarkSkinImitation(() => GUI.BeginGroup(logicRealRect, GUI.skin.box), VLESetting.DarkGrayImitation);
		{
			VLEditorUtils.DrawGrid(logicRealRect, logicScrollPosition, gridColor, gridAccentColor);

			LogicDraw(lockInput);
			HeaderDraw();
		}
	  GUI.EndGroup();
	}
	GUI.EndScrollView(false);

	if (!lockInput)
	{
		HandleEvent();
	}

	return lockInput;
}

Здесь особым моментом является механизм имитации темного скина для редактора, о нём будет подробнее описано в разделе про махинации

Источник: https://habr.com/ru/post/595009/


Интересные статьи

Интересные статьи

В прошлый раз я рассуждал на тему расширений и шаблонов, что их можно создавать не только для общего пользования, но и для нужд конкретного проекта. А так же показал, как легко можно создать шаблон эл...
Предлагаю ознакомиться с ранее размещенными материалами по проекту Starlink (SL): ‣ Часть 20. Внутреннее устройство терминала SL ‣ Часть 21. SL и проблемы поляризаци ‣ Часть 22. Пробл...
Всем доброго времени суток! Хочу поделиться своим опытом из разряда «Войти в IT». Тем более, что он происходил в полностью невизуальном режиме. О незрячих людях в нашем обществе знают...
Всем привет! Не так давно на работе в рамках тестирования нового бизнес-процесса мне понадобилась возможность авторизации под разными пользователями. Переход в соответствующий р...
Дисклеймер: эта статья носит исключительно образовательный характер. Мы не поддерживаем и осуждаем любые киберпреступления. Надеемся, что эта статья поможет вам лучше организовать...