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

Прежде чем я расскажу, что же здесь что и за что отвечает, остановимся на одном важном моменте связанным с Unity. Я думаю, многим известно об особенностях разработки расширений для Unity3D, но я все же опишу их:
Однопоточный рендеринг.
Очень кривая и странная система событий.
Отсутствие поддержки асинхронных вызовов (Coroutine).
Примечание: теперь она есть, но, когда писался редактор, её не было.
Исходя из вышеописанного было решено разделить всю логику и код между двумя основными сущностями:
Окно редактора визуальной логики.
Редактор параметров компонента логики на основе инспектора 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, необходимо обеспечить ряд механизмов:
Счетчик держателей ссылки, с помощью которого можно будет отслеживать, когда на объект Unity в компонентах больше нет ни одной ссылки и его можно удалить из кэша. Для этого в классе ObjectLinkData заведем поле:
#if UNITY_EDITOR public int Holders; #endifПоскольку в билде ссылки на объекты 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...");
}Здесь:
Отрисовка происходит только, если редактор не находится в режиме компиляции.
Все экземпляры IEditorDraw отрисовываются в том порядке, в котором они были зарегистрированы.
В качестве параметров передается ссылка на Rect окна редактора и значение флага блокировки ввода, который может выставить в true любой из элементов.
Для плавающих окон (диалоговые, информационные и т.п.) существует механизм добавления и удаления из конвейера, это делается с помощью метода DrawEditor:
Код
private void DrawEditor(IEditorDrawer drawEditor) { if (!_currentEditorDraw.Contains(drawEditor)) { _currentEditorDraw.Add(drawEditor); }else { _currentEditorDrawForDelete.Add(drawEditor); } }Если окно убирается из рендеринга, то он помещается в соответствующий список, из которого уже удаляется в методе 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 и т. п.). В виду того, что рендер редакторов однопоточный, то обработка таких событий сопряжена с трудностями:
Обработка событий происходит там же, где и рендеринг.
События полностью синхронные.
Если в редакторе несколько виджетов обрабатывают событие и сработали условия по всем, то они выполнятся одновременно (нет блокировки события между виджетами, как это есть в рантаймовом 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();
}Порядок такой:
Рисуются связи
Рисуются виджеты компонентов
Рисуются группы
Рисуются выделенные виджеты
Рисуются данные трассировки выделенной связи
Рисуется 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;
}Здесь особым моментом является механизм имитации темного скина для редактора, о нём будет подробнее описано в разделе про махинации
