Визуальный редактор логики для 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;
}

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

С целью простоты работы с редактором логики, в нем реализован функционал добавления компонент через Draw&Drop файла скрипта в область рисования. Реализуется это через стандартный механизм Unity3d:

Много кода
private void RepaintHandler(Event ev)
{ var receivedData = DragAndDrop.GetGenericData("VLEDragData"); if (receivedData != null) { if (receivedData is VLECatalog.CatalogComponent) { SetDragAndDropVisualMode(DragAndDropVisualMode.Link); var catalogComponent = (VLECatalog.CatalogComponent)receivedData; var boxStyle = new GUIStyle(GUI.skin.box); boxStyle.normal.textColor = catalogComponent.ComponentColor; GUI.Box(new Rect(ev.mousePosition.x, ev.mousePosition.y, 150f, 25f), catalogComponent.Name, boxStyle); } else if (receivedData is LinkingPointData) { SetDragAndDropVisualMode(DragAndDropVisualMode.Move); var outputPoint = (LinkingPointData)receivedData; var sourcePoint = outputPoint.Center + logicAreaRect.position - logicScrollPosition; sourcePoint.x = Mathf.Clamp(sourcePoint.x, logicAreaRect.position.x, float.MaxValue); sourcePoint.y = Mathf.Clamp(sourcePoint.y, logicAreaRect.position.y, float.MaxValue); var targetIsInversion = false; var color = Color.white; for (var i = 0; i < _logicWidgets.Count; i++) { var component = _logicWidgets[i] as IComponentWidget; if (component != null) { var value = CheckAcceptedInputPoint(ev.mousePosition, component, outputPoint.DataType); if (value == -1) { DragAndDrop.visualMode = DragAndDropVisualMode.Rejected; targetIsInversion = component.IsInversion; color = Color.red; break; } else if (value == 1) { DragAndDrop.visualMode = DragAndDropVisualMode.Link; targetIsInversion = component.IsInversion; color = Color.green; break; } } } VLEditorUtils.DrawBezierLink(sourcePoint, ev.mousePosition, ((VLEComponent)outputPoint.Component).IsInversion, targetIsInversion, color); } } else if (DragAndDrop.objectReferences.Length > 0) { var componentScript = DragAndDrop.objectReferences[0] as UnityEditor.MonoScript; if (componentScript != null) { var componentType = componentScript.GetClass(); if (typeof(Core.LogicComponent).IsAssignableFrom(componentType)) { SetDragAndDropVisualMode(DragAndDropVisualMode.Link); var attributes = componentType.GetCustomAttributes(typeof(ComponentDefinitionAttribute), true); if (attributes.Length > 0) { var attributeData = (ComponentDefinitionAttribute)attributes[0]; var color = (Color)VLECommon.Setting.ColorComponent; if (!ColorUtility.TryParseHtmlString(attributeData.Color, out color)) { color = VLECommon.Setting.ColorComponent; } var boxStyle = new GUIStyle(GUI.skin.box); boxStyle.normal.textColor = color; GUI.Box(new Rect(ev.mousePosition.x, ev.mousePosition.y, 150f, 25f), attributeData.Name, boxStyle); } } else { SetDragAndDropVisualMode(DragAndDropVisualMode.Rejected); } } else { SetDragAndDropVisualMode(DragAndDropVisualMode.Rejected); } }
} private void DragPerformHandler(Event ev)
{ DragAndDrop.AcceptDrag(); if (DragAndDrop.visualMode == DragAndDropVisualMode.Rejected) return; var receivedData = DragAndDrop.GetGenericData("VLEDragData"); if (receivedData != null) { if (receivedData is VLECatalog.CatalogComponent) { OnAddComponent((VLECatalog.CatalogComponent)receivedData, GetLogicAreaPosition(ev.mousePosition)); } else if (receivedData is LinkingPointData) { var outputPoint = (LinkingPointData)receivedData; for (var i = 0; i < _logicWidgets.Count; i++) { var component = _logicWidgets[i] as IComponentWidget; if (component != null) { var value = CheckAcceptedInputPoint(ev.mousePosition, component, outputPoint.DataType); if (value == 1) { var inputPoint = component.GetLinkAcceptedPoint(); OnAddLink(outputPoint, inputPoint); break; } } } } DragAndDrop.SetGenericData("VLEDragData", null); } else if (DragAndDrop.objectReferences.Length > 0) { var componentScript = DragAndDrop.objectReferences[0] as UnityEditor.MonoScript; if (componentScript != null) { var componentType = componentScript.GetClass(); if (typeof(Core.LogicComponent).IsAssignableFrom(componentType)) { var attributes = componentType.GetCustomAttributes(typeof(ComponentDefinitionAttribute), true); if (attributes.Length > 0) { var attributeData = (ComponentDefinitionAttribute)attributes[0]; var color = (Color)VLECommon.Setting.ColorComponent; if (!ColorUtility.TryParseHtmlString(attributeData.Color, out color)) { color = VLECommon.Setting.ColorComponent; } var catalogComponent = new VLECatalog.CatalogComponent(attributeData.Name, attributeData.Tooltip, componentType, color); OnAddComponent(catalogComponent, GetLogicAreaPosition(ev.mousePosition)); } } } } ev.Use();
}

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

  1. Для того, чтобы узнать, что перетаскиваемый объект — это скрипт, нужно использовать не MonoBehaviour тип, а UnityEditor.MonoScript.

  2. Установка GenericData для DragAndDrop происходит там, где выполняется событие Drag.

  3. Помимо добавления компонента в логику, код также реализует механизм соединения входных и выходных точек. Это делается в блоке:

    else if (receivedData is LinkingPointData)
  4. Поскольку перетаскивать можно любой скрипт в проекте Unity, нужно отсеять те, которые не являются компонентами логики, это делается в следующей части кода:

var componentScript = DragAndDrop.objectReferences[0] as UnityEditor.MonoScript; if (componentScript != null)
{ var componentType = componentScript.GetClass(); if (typeof(Core.LogicComponent).IsAssignableFrom(componentType)) { /// }
}

Примечание: как видно, перетаскивание работает только с единичным экземпляром, это связано с тем, что я не нашел разумного способа распределения (позиции) компонентов в логике при добавлении их более, чем два сразу. Возможно, когда-нибудь я решу эту проблему😊.

Строка статуса

Строка статуса — это область редактора, в которой выводится различная информация, к которой относится:

  • Количество компонентов в логике.

  • Количество несохраненных изменений в логике.

  • Таймер автосохранения.

  • Количество элементов в буфере.

  • Текущее значения масштаба.

Много кода (Status Bar)
namespace uViLEd.Editor
{ public partial class VLELogicEditor { private class StatusBar : IEditorDrawer, IDisposable { public delegate void SaveLogicEvent(); public event SaveLogicEvent OnSaveLogic; private Rect _statusBarRect = new Rect(5f, 0f, 0f, 0f); private Rect _positionRect = new Rect(2f, 2f, 200f, 0f); private GUIStyle _positionStyle; private GUIStyle _labelStyle; private bool _guiStylePrepared; private float _autoSaveTimer = float.NegativeInfinity; private double _previousTime; private readonly IList<ILogicWidget> _currentVLEWidgets; public StatusBar(IList<ILogicWidget> currentVLEWidgets) { _currentVLEWidgets = currentVLEWidgets; _statusBarRect.height = _statusBarHeight; _positionRect.height = _statusBarHeight - 4f; } public void Dispose() { foreach (var eventHandler in OnSaveLogic.GetInvocationList()) { OnSaveLogic -= (SaveLogicEvent)eventHandler; } } public bool Draw(Rect container, bool lockInput) { UpdateGUIStyle(); _statusBarRect.width = container.width - 10f; _statusBarRect.y = container.height - _statusBarRect.height - 5f; _positionRect.x = _statusBarRect.width - _positionRect.width - 5f; DrawAsDarkSkinImitation(() => GUILayout.BeginArea(_statusBarRect, GUI.skin.box), VLESetting.DarkGrayImitation); { GUILayout.BeginHorizontal(); { if (VLECommon.Setting.MaximumComponents > 0) { GUILayout.Label($"<color=orange>Logic Components:</color> <color=white>{_currentVLEWidgets.Count} / {VLECommon.Setting.MaximumComponents}</color>", _labelStyle, GUILayout.ExpandHeight(true), GUILayout.MaxWidth(175f)); } if (VLECommon.UnsavedChanges > 0) { Separator(); GUILayout.Label($"<color=orange>Unsaved changes:</color> <color=white>{VLECommon.UnsavedChanges}</color>", _labelStyle, GUILayout.ExpandHeight(true), GUILayout.MaxWidth(150f)); if (VLECommon.Setting.AutoSave) { AutoSaveTimer(); Separator(); GUILayout.Label($"<color=orange>Save over:</color> <color=white>{GetTimeStr(_autoSaveTimer)}</color>", _labelStyle, GUILayout.ExpandHeight(true), GUILayout.MaxWidth(150f)); } } else { _autoSaveTimer = float.NegativeInfinity; } if (VLECommon.BufferCounter > 0) { Separator(); GUILayout.Label($"<color=orange>Сlipboard components:</color> <color=white>{VLECommon.BufferCounter}</color>", _labelStyle, GUILayout.ExpandHeight(true), GUILayout.MaxWidth(150f)); } } GUILayout.EndHorizontal(); var scaleValue = VLECommon.CurrentEditorScale * 100f; var reverseScale = 1f / VLECommon.CurrentEditorScale; if (VLECommon.SelectedWidget != null) { var x = Mathf.Round(VLECommon.SelectedWidget.Area.position.x * reverseScale); var y = Mathf.Round(VLECommon.SelectedWidget.Area.position.y * reverseScale); GUI.Label(_positionRect, $"[x: {x}; y: {y}] <color=orange>[{scaleValue}%]</color>", _positionStyle); } else { GUI.Label(_positionRect, $"<color=orange>[{scaleValue}%]</color>", _positionStyle); } } GUILayout.EndArea(); return lockInput; } private void UpdateGUIStyle() { if (_guiStylePrepared) return; _positionStyle = new GUIStyle(GUI.skin.label); _positionStyle.alignment = TextAnchor.MiddleRight; _positionStyle.richText = true; if (!EditorGUIUtility.isProSkin) { _positionStyle.normal.textColor = Color.white; } _labelStyle = new GUIStyle(GUI.skin.label); _labelStyle.alignment = TextAnchor.MiddleLeft; _labelStyle.richText = true; _guiStylePrepared = true; } private void Separator() => GUILayout.Box(string.Empty, GUILayout.Width(4f), GUILayout.ExpandHeight(true)); private string GetTimeStr(float seconds) { var timeSpan = TimeSpan.FromSeconds(seconds); var timeStr = ((timeSpan.Minutes < 10) ? $"0" + timeSpan.Minutes.ToString() : timeSpan.Minutes.ToString()) + ":" + ((timeSpan.Seconds < 10) ? "0" + timeSpan.Seconds.ToString() : timeSpan.Seconds.ToString()); return timeStr; } private void AutoSaveTimer() { if (_autoSaveTimer == float.NegativeInfinity) { _autoSaveTimer = VLECommon.Setting.AutoSavePeriod; _previousTime = EditorApplication.timeSinceStartup; } _autoSaveTimer = Mathf.Clamp(_autoSaveTimer - (float)(EditorApplication.timeSinceStartup - _previousTime), 0f, float.MaxValue); if (_autoSaveTimer == 0f) { _autoSaveTimer = float.NegativeInfinity; _previousTime = 0f; OnSaveLogic(); } _previousTime = EditorApplication.timeSinceStartup; } } }
}

Рисуем компонент

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

Рассмотрим общую блок-схему реализации:

Итак, компонент представляют собой конструкции, состоящие из набора виджетов (заголовок, тело параметры и т.п.). Сами компонент представлены интерфейсом ILogicWidget и абстракцией VLEWidget. Все виджеты, которые наполняются визуальную часть компонента, являются реализациями интерфейсов IChildWidget и IWidgetContainer. Вторая сущность по сути является контейнером для других виджетов, ее введение обусловлено необходимостью оперирования набором последних как единым целым.

Рассмотрим более подробно все составные части компонента.

Основа

Как было сказано выше в основе всех компонентов лежит интерфейс ILogicWidget.

Код
namespace uViLEd.Editor
{ public interface ILogicWidget { string Id { get; } string Name { get; set; } string Comment { get; set; } string Description { get; } string Group { get; set; } ScriptableObject Instance { get; } Type InstanceType { get; } Core.LogicStorage.ComponentsStorage.ComponentData ComponentStorage { get; } Color WidgetColor { get; set; } Rect Area { get; } bool IsSelected { get; } void Draw(bool lockMouse); void SetPosition(Vector2 newPosition); void Select(bool state, bool isMultiSelect = true); }
}

Здесь требуют пояснения следующие поля:

  • Instance – это экземпляр компонента

  • Type – непосредственный тип компонента

  • ComponentStorage – это сериализованные данные ScriptableObject компонента

Данный интерфейс имплементируется абстракцией VLEWidget.

Очень много кода
public abstract partial class VLEWidget : ILogicWidget, IDisposable
{ public string Name { get => _name; set => VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, _name = value); } public string Comment { get => _comment; set => VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, _comment = value, false); } public string Group { get => _group; set => VLECommon.CurrentLogic.Components.ModiftyComponentGroup(Id, _group = value); } public Color WidgetColor { get => _widgetColor; set => VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, _widgetColor = value); } public ScriptableObject Instance { get { if (!EditorApplication.isPlaying) { if (_instance == null) { _instance = ScriptableObject.CreateInstance(InstanceType); JsonUtility.FromJsonOverwrite(ComponentStorage.JsonData, _instance); } return _instance; } else { if (VLECommon.CurrentLogicController.RuntimeComponentInstances != null && VLECommon.CurrentLogicController.RuntimeComponentInstances.ContainsKey(Id)) { return VLECommon.CurrentLogicController.RuntimeComponentInstances[Id]; } else { return _instance; } } } } public string Id { get; } public Type InstanceType { get; } public string Description { get; } public Core.LogicStorage.ComponentsStorage.ComponentData ComponentStorage { get; } public bool IsSelected { get; private set; } = false; protected readonly IDictionary<Type, Type> widgetTypeDict = new Dictionary<Type, Type>(); private string _name; private string _comment; private string _group; private Color _widgetColor; private ScriptableObject _instance; public VLEWidget(Core.LogicStorage.ComponentsStorage.ComponentData componentStorageData) { ComponentStorage = componentStorageData; Id = ComponentStorage.Id; _group = componentStorageData.Group; InstanceType = AssemblyHelper.GetAssemblyType(ComponentStorage.Assembly, ComponentStorage.Type); _instance = ScriptableObject.CreateInstance(InstanceType); JsonUtility.FromJsonOverwrite(ComponentStorage.JsonData, _instance); var attrubutes = InstanceType.GetCustomAttributes(typeof(ComponentDefinitionAttribute), true); if (attrubutes.Length > 0) { var componentDefinition = (ComponentDefinitionAttribute)attrubutes[0]; if (ComponentStorage.InstanceName.Length > 0) { _name = ComponentStorage.InstanceName; } else { Name = componentDefinition.Name; } if (!ColorUtility.TryParseHtmlString(componentDefinition.Color, out _widgetColor)) { _widgetColor = VLECommon.Setting.ColorComponent; } if (ComponentStorage.Color.Length > 0) { Color inctanceColor; if (ColorUtility.TryParseHtmlString(ComponentStorage.Color, out inctanceColor)) { _widgetColor = inctanceColor; } } Description = componentDefinition.Tooltip; } else { if ((ComponentStorage.InstanceName.Length > 0)) { _name = ComponentStorage.InstanceName; } else { Name = InstanceType.Name; } _widgetColor = VLECommon.Setting.ColorComponent; if (ComponentStorage.Color.Length > 0) { Color inctanceColor; if (ColorUtility.TryParseHtmlString(ComponentStorage.Color, out inctanceColor)) { _widgetColor = inctanceColor; } } Description = InstanceType.Name; } _comment = ComponentStorage.Comment; PrepareChildWidget(); } protected virtual void PrepareChildWidget() { mainWidget = CreateWidget<MainWidget>((Vector2)ComponentStorage.Position); mainWidget.AddWidget(bodyWidget = CreateWidget<BodyWidget>(this, mainWidget)); mainWidget.AddWidget(headerWidget = CreateWidget<HeaderWidget>(this)); mainWidget.AddWidget(descriptionWidget = CreateWidget<DescriptionWidget>(this)); } protected void ReplaceWidget<Source, Target>() { var sourceType = typeof(Source); var targetType = typeof(Target); if (widgetTypeDict.ContainsKey(sourceType)) { widgetTypeDict[sourceType] = targetType; } else { widgetTypeDict.Add(sourceType, targetType); } } protected T CreateWidget<T>(params object[] args) { var sourceType = typeof(T); var targetType = sourceType; if (widgetTypeDict.ContainsKey(sourceType)) { targetType = widgetTypeDict[sourceType]; } return (T)Activator.CreateInstance(targetType, args); } public void Select(bool state, bool isMultiSelect = true) { IsSelected = state; if (isMultiSelect) { if (IsSelected) { if (!VLECommon.SelectedWidgets.Contains(this)) { VLECommon.SelectedWidgets.Add(this); } } else { if (VLECommon.SelectedWidgets.Contains(this)) { VLECommon.SelectedWidgets.Remove(this); } } } else { if (IsSelected) { if (VLECommon.SelectedWidget != this) { if (VLECommon.SelectedWidget != null) { VLECommon.SelectedWidget.Select(false, false); } VLECommon.SelectedWidget = this; } } else { if (VLECommon.SelectedWidget == this) { VLECommon.SelectedWidget = null; } } } } public virtual void Dispose() { }
}

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

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

Рассмотрим теперь непосредственно класс VLEComponent.

Много кода
namespace uViLEd.Editor
{ public partial class VLEComponent : VLEWidget, IComponentWidget, IParameterContainer { public class ParameterValueData { public string Name; public object Value; } public bool IsActive { get; private set; } = true; public bool IsInversion => _usedInternalInversionValue ? _inversionInputOutputPoints : VLECommon.Setting.InverstionIOPoint; public bool IsContainsParameters => parametersContainer != null; public bool IsMinimized { get; private set; } = false; public bool IsHideParameter { get; private set; } = false; public bool IsCanMinimize { get; private set; } = false; public bool TracertState { get; private set; } = false; public bool TracertInProgress { get; private set; } = false; private bool _inversionInputOutputPoints; private bool _usedInternalInversionValue; public VLEComponent(LogicStorage.ComponentsStorage.ComponentData componentStorageData) : base(componentStorageData) { IsActive = ComponentStorage.IsActive; IsMinimized = ComponentStorage.CollapseIOPoints; IsHideParameter = ComponentStorage.CollapseParameter; _inversionInputOutputPoints = ComponentStorage.InversionPoint; _usedInternalInversionValue = ComponentStorage.InternalInversionState; bodyWidget.AddWidget(CreateWidget<MenuWidget>(this)); bodyWidget.AddWidget(CreateWidget<StateWidget>(this)); PrepareInputOutputWidgets(); PrepareParameterWidgets(); } public void MakeTracert(bool state, bool inProgress) { TracertState = state; TracertInProgress = inProgress; } public void SetActiveComponent(bool state) { IsActive = state; VLECommon.CurrentLogic.Components.ModifyComponentActive(Id, IsActive); VLECommon.ComponentChangedActive(this); } public void SetCollapseComponent(bool state) { if ((!IsCanMinimize && IsMinimized == true && state == false) || (IsCanMinimize && (IsMinimized != state))) { IsMinimized = state; if (IsMinimized) { bodyWidget.RemoveWidget(pointsContainer); bodyWidget.RemoveWidget(parametersContainer); bodyWidget.AddWidget(pointsCollapsedContainer); } else { bodyWidget.RemoveWidget(pointsCollapsedContainer); bodyWidget.AddWidget(pointsContainer); if (parametersContainer != null) { bodyWidget.AddWidget(parametersContainer); } } VLECommon.UnsavedChanges++; VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, IsMinimized, true); } } public void SetCollapseParameter(bool state) { if (parametersContainer == null || IsHideParameter == state) return; IsHideParameter = state; if (IsHideParameter) { parametersContainer.RemoveWidget(parameterValuesContainter); } else { parametersContainer.AddWidget(parameterValuesContainter); } VLECommon.UnsavedChanges++; VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, IsHideParameter, false); } public void SetInversionPoints(bool state) { _usedInternalInversionValue = true; _inversionInputOutputPoints = state; VLECommon.UnsavedChanges++; VLECommon.CurrentLogic.Components.ModiftyComponentByLogic(Id, _inversionInputOutputPoints); } protected override void HeaderDoubleClick() { if (!IsCanMinimize) return; SetCollapseComponent(!IsMinimized); } protected override void MouseDrag(Event ev) { if (!isMoved && !eventHandleForbidden) { if (Area.Contains(ev.mousePosition)) { var draggedPoint = GetDraggedOutputPoint(ev.mousePosition); if (draggedPoint != null) { DragAndDrop.PrepareStartDrag(); DragAndDrop.objectReferences = new UnityEngine.Object[0]; DragAndDrop.paths = new string[0]; DragAndDrop.SetGenericData("VLEDragData", draggedPoint); DragAndDrop.StartDrag("VLEDragData"); ev.Use(); } } } base.MouseDrag(ev); } public override void Draw(bool lockInput) { if (!lockInput) { HandleEvent(Event.current); } var color = GUI.color; if (!IsActive) { GUI.color = Color.gray; } mainWidget.Draw(); GUI.color = color; } public override void Dispose() { var fields = InstanceType.GetFields(BindingFlags.Public | BindingFlags.Instance); foreach (var fieldInfo in fields) { if (fieldInfo.FieldType == typeof(VLObject)) { var VLEObject = (VLObject)fieldInfo.GetValue(Instance); VLECommon.CurrentLogicController.SetObject(VLEObject.Id, null); } else if (fieldInfo.FieldType == typeof(VLObject[])) { var vsObjects = (VLObject[])fieldInfo.GetValue(Instance); foreach (var VLEObject in vsObjects) { VLECommon.CurrentLogicController.SetObject(VLEObject.Id, null); } } else if (fieldInfo.FieldType == typeof(List<VLObject>)) { var vsObjects = (List<VLObject>)fieldInfo.GetValue(Instance); foreach (var VLEObject in vsObjects) { VLECommon.CurrentLogicController.SetObject(VLEObject.Id, null); } } } } }
}

Примечание: инициализация булевых свойств значением false, это для наглядности и понимания, вдруг кто не знает, что дефолтное значение bool это false, как 0f у float.

Как видно, помимо базовой абстракции VLEWidget, класс имплементирует еще два интерфейса IComponentWidget и IParamatersContainer. Первый служит для спецификации конкретики по компоненту (поскольку есть еще связи, переменные и группы), второй необходим для работы с параметрами, которые требуется обновлять в визуальной части и в рантайме (запуск в редакторе) и во время работы редактора логики. Также стоит пояснить, что происходит в методе Dispose: если компонент удаляется из логики, а в нем содержались экземпляры класса VLObject (ссылки на объекты Unity), то их надо удалить из контейнера и декрементировать холдеры, дабы не наплодилось лишнего.

Интерфейс IComponentWidget
namespace uViLEd.Editor
{ public interface IComponentWidget { bool IsActive { get; } bool IsMinimized { get; } bool IsHideParameter { get; } bool IsInversion { get; } bool IsCanMinimize { get; } bool IsContainsParameters { get; } Rect GetInputPointRect(string pointName); Rect GetOutputPointRect(string pointName); int InputPointCanBeAccepted(Vector2 position, Type type); LinkingPointData GetLinkAcceptedPoint(); void SetActiveComponent(bool state); void SetCollapseComponent(bool state); void SetCollapseParameter(bool state); void SetInversionPoints(bool state); bool TracertState { get; } bool TracertInProgress { get; } void MakeTracert(bool state, bool inProgress); }
}

Здесь хочу обратить внимания на два метода:

  • InputPointCanBeAccepted – метод для проверки возможности соединения выходной и входной точки по типу передаваемых данных.

  • GetLinkAcceptedPoint – метод, который возвращает данные о входной точке, в которую в данный момент пользователь желает соединить со входной точкой.

Примечание: последние два поля (TracertState, TracertInProgress) и метод MakeTracert относятся к режиму отладки логики. Этот аспект редактора будет рассмотрен в отдельном пункте.

Интерфейс IParameterContainer
namespace uViLEd.Editor
{ public interface IParameterContainer { void UpdateParameterValues(); }
}

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

Виджеты

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

  • MainWidget – основной контейнер компонента, содержащий все остальные виджеты.

  • BodyWidget – контенейр для виджетов, которые составляют тело компонента.

  • PointsContainer – контейнер для виджетов входных и выходных точек.

  • ParametersContainer – контейнер для виджетов параметров компонента.

  • ParameterValuesContainer – контейнер для виджетов значений параметров компонента.

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

Рассмотрим теперь остальные виджеты:

  • HeaderWidget – виджет заголовка компонента.

  • DescriptionWidget  — виджет описания компонента.

  • MenuWidget – виджет для отображения выпадающего меню компонента.

  • StateWidget – виджет отображения текущего состояния компонента.

  • PointWidget – виджет для отображения входной или выходной точки.

  • ParameterValueWidget – виджет для отображения значения параметра компонента.

  • VariableLinkWidget – виджет для отображения ссылки на переменную логики.

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

Много кода (абстракции)
namespace uViLEd.Editor
{ public abstract partial class VLEWidget { public abstract class WidgetComponentsContainer : IChildWidget, IWidgetContainer { public virtual Rect Area => widgetVirtualRect.GetRect(VLECommon.CurrentEditorScale); public virtual Vector2 Position { get => widgetVirtualRect.GetRect(VLECommon.CurrentEditorScale).position; set => widgetVirtualRect.SetPosition(value, VLECommon.CurrentEditorScale); } public virtual Vector2 WorldPosition { get => _worldPosition.GetPosition(VLECommon.CurrentEditorScale); set { var delta = value - WorldPosition; _worldPosition.SetPosition(value, VLECommon.CurrentEditorScale); UpdateChildWidgetWorldPosition(delta); } } protected VLEExtension.VLEVirtualRect widgetVirtualRect; protected readonly List<IChildWidget> widgets = new List<IChildWidget>(); protected readonly IWidgetContainer rootContainer; private VLEExtension.VLEVirtualPosition _worldPosition = new VLEExtension.VLEVirtualPosition(0f, 0f); public WidgetComponentsContainer(IWidgetContainer root) => rootContainer = root; public virtual void RegisterStyle() { } public virtual void Draw() { for (var i = 0; i < widgets.Count; i++) { var widget = widgets[i]; widget.RegisterStyle(); widget.Draw(); } } public virtual void AddWidget(IChildWidget widget) { widget.WorldPosition = widget.Position + WorldPosition; widgets.Add(widget); ResizeWidgetContainer(); } public virtual void RemoveWidget(IChildWidget widget) { if (widgets.Contains(widget)) { widgets.Remove(widget); ResizeWidgetContainer(); } } public virtual void ClearWidget() => widgets.Clear(); public virtual void ResizeWidgetContainer() { var yMax = 0f; foreach (var widget in widgets) { if (widget.Area.yMax > yMax) { yMax = widget.Area.yMax; } } widgetVirtualRect.SetHeight(yMax, VLECommon.CurrentEditorScale); UpdateRootContainer(); } protected void UpdateRootContainer() => rootContainer?.ResizeWidgetContainer(); protected void UpdateChildWidgetWorldPosition(Vector2 delta) => widgets.SafeForEach(widget => widget.WorldPosition += delta); } public abstract class ChildWidget : IChildWidget { public virtual Rect Area => widgetVirtualRect.GetRect(VLECommon.CurrentEditorScale); public virtual Vector2 Position { get => widgetVirtualRect.GetRect(VLECommon.CurrentEditorScale).position; set => widgetVirtualRect.SetPosition(value, VLECommon.CurrentEditorScale); } public virtual Vector2 WorldPosition { get => _worldVirtualPosition.GetPosition(VLECommon.CurrentEditorScale); set => _worldVirtualPosition.SetPosition(value, VLECommon.CurrentEditorScale); } protected VLEExtension.VLEVirtualRect widgetVirtualRect; private readonly VLEExtension.VLEVirtualPosition _worldVirtualPosition = new VLEExtension.VLEVirtualPosition(0f, 0f); public virtual void RegisterStyle() { } public abstract void Draw(); } }
} 
Очень много кода (виджеты)
namespace uViLEd.Editor
{ public partial class VLEComponent { public class PointsContainer : WidgetComponentsContainer { public PointsContainer(IWidgetContainer rootContinaer) : base(rootContinaer) { widgetVirtualRect = new VLEExtension.VLEVirtualRect(0f, 50f, 250f, 0f); } public override void Draw() { GUI.BeginGroup(Area); { base.Draw(); } GUI.EndGroup(); } public Rect GetPointRect(string pointName, PointTypeEnum pointType) { var point = widgets.Find((widget) => { var pointWidget = widget as PointWidget; return pointWidget.Point.PointType == pointType && pointWidget.Point.Name == pointName; }); if (point != null) { var worldRect = point.Area; worldRect.position = point.Area.position + WorldPosition; return worldRect; } else { return Rect.zero; } } public IChildWidget GetPointContainsPosition(Vector2 position, PointTypeEnum pointType) { return widgets.Find((widget) => { var pointWidget = (PointWidget)widget; var worldRect = widget.Area; worldRect.position = pointWidget.Area.position + WorldPosition; return pointWidget.Point.PointType == pointType && worldRect.Contains(position); }); } public override void ResizeWidgetContainer() { var yMax = 0f; foreach (var widget in widgets) { if (widget.Area.yMax > yMax) { yMax = widget.Area.yMax; } } widgetVirtualRect.SetHeight(yMax + 10f * VLECommon.CurrentEditorScale, VLECommon.CurrentEditorScale); UpdateRootContainer(); } } public class PointWidget : ChildWidget { public InputOutputPointData Point { get; } protected Rect inputConnectorRect => inputConnectorVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect outputConnectorRect => outputConnectorVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect inputConnectorVisualRect => inputConnectorDirectionVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect outputConnectorVisualRect => outputConnectorDirectionVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect inputPointNameRect => inputPointNameVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect outputPointNameRect => outputPointNameVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect inputConnectorPointRect => inputConnectorPointVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected Rect outputConnectorPointRect => outputConnectorPointVirtualRect.GetRect(VLECommon.CurrentEditorScale); protected GUIStyle pointNameStyle; protected GUIStyle connectorDirectionStyle; protected GUIStyle connectorPointStyle; protected VLEExtension.VLEVirtualRect inputConnectorVirtualRect = new VLEExtension.VLEVirtualRect(5f, 0f, 20f, 20f); protected VLEExtension.VLEVirtualRect outputConnectorVirtualRect = new VLEExtension.VLEVirtualRect(228f, 0f, 20f, 20f); protected VLEExtension.VLEVirtualRect inputConnectorDirectionVirtualRect = new VLEExtension.VLEVirtualRect(6f, 0f, 20f, 20f); protected VLEExtension.VLEVirtualRect outputConnectorDirectionVirtualRect = new VLEExtension.VLEVirtualRect(225f, 0f, 20f, 20f); protected VLEExtension.VLEVirtualRect inputConnectorPointVirtualRect = new VLEExtension.VLEVirtualRect(0f, 0f, 12f, 20f); protected VLEExtension.VLEVirtualRect outputConnectorPointVirtualRect = new VLEExtension.VLEVirtualRect(237f, 0f, 12f, 20f); protected VLEExtension.VLEVirtualRect inputPointNameVirtualRect = new VLEExtension.VLEVirtualRect(20f, 0f, 100f, 20f); protected VLEExtension.VLEVirtualRect outputPointNameVirtualRect = new VLEExtension.VLEVirtualRect(130f, 0f, 100f, 20f); protected VLEExtension.VLEVirualFontSize virtualFontSize = new VLEExtension.VLEVirualFontSize(11); protected VLEExtension.VLEVirtualRect inputConnectorForExternalVirtualRect = new VLEExtension.VLEVirtualRect(2f, 0f, 10f, 20f); protected VLEExtension.VLEVirtualRect outputConnectorForExternalVirtualRect = new VLEExtension.VLEVirtualRect(235f, 0f, 10f, 20f); protected GUIContent pointContent; protected GUIContent connectorDirectionContent; protected GUIContent inversionConnectorDirectionContent; protected GUIContent connectorPointContent; protected Color connectorPointColor = new Color(0.75f, 0.75f, 0.75f, 1f); protected Action currentDraw; protected readonly IComponentWidget component; public PointWidget(IComponentWidget baseComponent, InputOutputPointData pointData, int indexNumber) { component = baseComponent; Point = pointData; connectorDirectionContent = EditorGUIUtility.IconContent("profiler.nextframe"); inversionConnectorDirectionContent = EditorGUIUtility.IconContent("profiler.prevframe"); connectorPointContent = new GUIContent(EditorGUIUtility.IconContent("blendsampler")); connectorPointContent.tooltip = (Point.DataType != null) ? Point.DataType.Name : string.Empty; var yPosByIndexNumber = inputConnectorRect.y + (inputConnectorRect.height + Mathf.Round(5f * VLECommon.CurrentEditorScale)) * indexNumber; inputConnectorVirtualRect.SetY(yPosByIndexNumber, VLECommon.CurrentEditorScale); inputPointNameVirtualRect.SetY(yPosByIndexNumber, VLECommon.CurrentEditorScale); outputConnectorVirtualRect.SetY(yPosByIndexNumber, VLECommon.CurrentEditorScale); outputPointNameVirtualRect.SetY(yPosByIndexNumber, VLECommon.CurrentEditorScale); inputConnectorDirectionVirtualRect.SetY(inputConnectorRect.y + inputConnectorRect.height / 2f - inputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); outputConnectorDirectionVirtualRect.SetY(outputConnectorRect.y + outputConnectorRect.height / 2f - outputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); inputConnectorPointVirtualRect.SetY(inputConnectorRect.y + inputConnectorRect.height / 2f - inputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); outputConnectorPointVirtualRect.SetY(outputConnectorRect.y + outputConnectorRect.height / 2f - outputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); inputConnectorForExternalVirtualRect.SetY(inputConnectorRect.y + inputConnectorRect.height / 2f - inputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); outputConnectorForExternalVirtualRect.SetY(outputConnectorRect.y + outputConnectorRect.height / 2f - outputConnectorVisualRect.height / 2f - 1f, VLECommon.CurrentEditorScale); switch (pointData.PointType) { case PointTypeEnum.Input: widgetVirtualRect = inputConnectorForExternalVirtualRect; currentDraw = DrawAsInputPoint; break; case PointTypeEnum.Output: widgetVirtualRect = outputConnectorForExternalVirtualRect; currentDraw = DrawAsOutputPoint; break; } } public override void RegisterStyle() { if (pointNameStyle == null) { pointNameStyle = new GUIStyle(EditorGUIUtility.isProSkin ? GUI.skin.box : GUI.skin.label); pointNameStyle.clipping = TextClipping.Clip; pointNameStyle.fontSize = virtualFontSize.GetFontSize(VLECommon.CurrentEditorScale); connectorDirectionStyle = new GUIStyle(GUI.skin.label); connectorDirectionStyle.alignment = TextAnchor.MiddleLeft; connectorPointStyle = new GUIStyle(GUI.skin.label); connectorPointStyle.alignment = TextAnchor.MiddleLeft; } } public override void Draw() { pointNameStyle.fontSize = virtualFontSize.GetFontSize(VLECommon.CurrentEditorScale); connectorDirectionStyle.fontSize = virtualFontSize.GetFontSize(VLECommon.CurrentEditorScale); currentDraw(); } protected virtual void DrawConnector(Rect rectDirection, Rect rectPoint, Color color, GUIContent content) { var guiColor = GUI.color; GUI.color = color; GUI.Label(rectDirection, content, connectorDirectionStyle); GUI.color = connectorPointColor; GUI.Label(rectPoint, connectorPointContent, connectorPointStyle); GUI.color = guiColor; } protected virtual void DrawInputPointName(Rect rect) { pointNameStyle.normal.textColor = VLECommon.Setting.ColorInputPoint; if (!EditorGUIUtility.isProSkin) { pointNameStyle.alignment = component.IsInversion ? TextAnchor.MiddleRight : TextAnchor.MiddleLeft; VLELogicEditor.DrawAsDarkSkinImitation(() => GUI.Box(rect, string.Empty), VLESetting.DarkLightGrayImitation); } else { pointNameStyle.alignment = component.IsInversion ? TextAnchor.UpperRight : TextAnchor.UpperLeft; } VLEditorUtils.DrawLabelWithAutoTooltip(rect, Point.Name, Point.Tooltip, pointNameStyle); } protected virtual void DrawOutputPointName(Rect rect) { pointNameStyle.normal.textColor = VLECommon.Setting.ColorOutputPoint; if (!EditorGUIUtility.isProSkin) { pointNameStyle.alignment = component.IsInversion ? TextAnchor.MiddleLeft : TextAnchor.MiddleRight; VLELogicEditor.DrawAsDarkSkinImitation(() => GUI.Box(rect, string.Empty), VLESetting.DarkLightGrayImitation); } else { pointNameStyle.alignment = component.IsInversion ? TextAnchor.UpperLeft : TextAnchor.UpperRight; } VLEditorUtils.DrawLabelWithAutoTooltip(rect, Point.Name, Point.Tooltip, pointNameStyle); } protected virtual void DrawAsInputPoint() { var inputNameRect = component.IsInversion ? outputPointNameRect : inputPointNameRect; var connectorVisualRect = component.IsInversion ? outputConnectorVisualRect : inputConnectorVisualRect; var connectorContent = component.IsInversion ? inversionConnectorDirectionContent : connectorDirectionContent; var connectorPointRect = component.IsInversion ? outputConnectorPointRect : inputConnectorPointRect; widgetVirtualRect = component.IsInversion ? outputConnectorForExternalVirtualRect : inputConnectorForExternalVirtualRect; DrawConnector(connectorVisualRect, connectorPointRect, VLECommon.Setting.ColorInputPoint, connectorContent); DrawInputPointName(inputNameRect); } protected virtual void DrawAsOutputPoint() { var outputNameRect = component.IsInversion ? inputPointNameRect : outputPointNameRect; var connectorVisualRect = component.IsInversion ? inputConnectorVisualRect : outputConnectorVisualRect; var connectorContent = component.IsInversion ? inversionConnectorDirectionContent : connectorDirectionContent; var connectorPointRect = component.IsInversion ? inputConnectorPointRect : outputConnectorPointRect; widgetVirtualRect = component.IsInversion ? inputConnectorForExternalVirtualRect : outputConnectorForExternalVirtualRect; DrawConnector(connectorVisualRect, connectorPointRect, VLECommon.Setting.ColorOutputPoint, connectorContent); DrawOutputPointName(outputNameRect); } } }
}

Рисуем связи

В основе кода отрисовки связей между компонентами лежит интерфейс ILink:

Код
namespace uViLEd.Editor
{ public interface ILink { string Id { get; } ILogicWidget SourceComponent { get; } ILogicWidget TargetComponent { get; } string OutputPoint { get; } string InputPoint { get; } bool IsSelected { get; } bool IsCorrupted { get; } int CallOrder { get; set; } Color LinkColor { get; set; } bool IsActive { get; set; } void Draw(bool lockMouse); void DrawTracertLinkData(); void Select(bool state); bool Contains(Vector2 position); void MakeTracert(bool state, string data); }
}

На что стоит обратить внимание:

  • SourceComponent, TargetComponent – виджеты компонентов между которыми рисуется связь.

  • OutputPoint, InputPoint – строковые идентификаторы выходной и входной точек компонентов.

  • IsCorrupted – флаг, говорящий о том, что связь нельзя установить (требуется в момент установки связи).

  • CallOrder – порядковый номер вызова связи, если связей выходящих из одной точки несколько (номера может не быть, тогда его значение равно -1).

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

  • Contains – метод определения, что позиция находится на линии связи.

  • MakeTracert – метод выполнения пошаговой отладки связи (см. раздел отладка).

Рассмотри теперь класс имплементирующий описанный выше интерфейс:

Очень много кода
namespace uViLEd.Editor
{ public class VLELink : ILink { public string Id { get; } public ILogicWidget SourceComponent => _sourceComponent as ILogicWidget; public ILogicWidget TargetComponent => _targetComponent as ILogicWidget; public int CallOrder { get => _callOrder; set { _callOrder = value; VLECommon.CurrentLogic.Links.ModifyLinkByLogic(Id, _callOrder); VLECommon.UnsavedChanges++; } } public Color LinkColor { get => _linkColor; set { _linkColor = value; VLECommon.CurrentLogic.Links.ModifyLinkByLogic(Id, _linkColor); VLECommon.UnsavedChanges++; } } public bool IsActive { get => _isActive; set { _isActive = value; VLECommon.CurrentLogic.Links.ModifyLinkByLogic(Id, _isActive); VLECommon.UnsavedChanges++; } } public string OutputPoint { get; } public string InputPoint { get; } public bool IsSelected { get; private set; } = false; public bool IsCorrupted { get; private set; } = false; private bool _isActive = true; private bool _tracert; private readonly List<string> _tracertDataList = new List<string>(); private Vector2 _tracertDataScrollPosition = Vector2.zero; private Rect _linkDataRect = new Rect(0f, 0f, 200f, 0f); private Rect _linkDataViewRect = new Rect(0f, 0f, 200f, 100f); private bool _pulsation; private float _pulsationTime; private IComponentWidget _sourceComponent; private IComponentWidget _targetComponent; private int _callOrder = -1; private Color _linkColor; private Vector2 _source; private Vector2 _target; private Vector2 _sourceTan; private Vector2 _targetTan; private GUIStyle _tracertGUIStyle; private GUIStyle _orderSetGUIStyle; private GUIStyle _orderViewGUIStyle; private LogicStorage.LinksStorage.LinkData _linkStorage; private const string _DATA_FMT_STR = "<color=yellow><b>Data</b></color>: <color=white>[{0}]</color>"; public VLELink(LogicStorage.LinksStorage.LinkData linkStorage, IComponentWidget source, IComponentWidget target) { _linkStorage = linkStorage; _sourceComponent = source; _targetComponent = target; OutputPoint = linkStorage.OutputPoint; InputPoint = linkStorage.InputPoint; Id = linkStorage.Id; _callOrder = linkStorage.CallOrder; _isActive = _linkStorage.IsActive; Color inctanceColor; if (!string.IsNullOrEmpty(linkStorage.Color) && ColorUtility.TryParseHtmlString(linkStorage.Color, out inctanceColor)) { _linkColor = inctanceColor; } else { _linkColor = VLECommon.Setting.ColorLink; } } public void Draw(bool lockMouse) { RegisterStyle(); var sourceRect = _sourceComponent.GetOutputPointRect(OutputPoint); var targetRect = _targetComponent.GetInputPointRect(InputPoint); if (sourceRect != Rect.zero && targetRect != Rect.zero) { _source = new Vector2(sourceRect.xMax - 2f, sourceRect.center.y - 1f); _target = new Vector2(targetRect.xMin + 2f, targetRect.center.y - 1f); if (string.IsNullOrEmpty(_linkStorage.Color)) { _linkColor = VLECommon.Setting.ColorLink; } _linkColor.a = _isActive ? 1f : 0.5f; VLEditorUtils.DrawBezierLink(_source, _target, ref _sourceTan, ref _targetTan, _sourceComponent.IsInversion, _targetComponent.IsInversion, _isActive ? (_tracert ? GetTracertColor() : _linkColor) : Color.gray, (IsSelected || _tracert) ? 5f : 2f); DrawLinkOrderData(); HandleEvent(lockMouse); } else { IsCorrupted = true; } } private void HandleEvent(bool lockMouse) { var ev = Event.current; if (VLECommon.IsMultipleLinks || ev.control || lockMouse) return; if (ev.type == EventType.MouseDown && ev.button == 0) { if (Contains(ev.mousePosition)) { IsSelected = true; if (VLECommon.SelectedLink != this) { if (VLECommon.SelectedLink != null) { VLECommon.SelectedLink.Select(false); } VLECommon.SelectedLink = this; } } else { IsSelected = false; if (VLECommon.SelectedLink == this) { VLECommon.SelectedLink = null; } } } } public bool Contains(Vector2 position) => HandleUtility.DistancePointBezier(position, _source, _target, _sourceTan, _targetTan) < 5f; public void MakeTracert(bool state, string data) { if (_tracert && state) { if (!_pulsation) { _pulsation = true; _pulsationTime = VLETime.Time; } } _tracert = state; _sourceComponent.MakeTracert(_tracert, false); _targetComponent.MakeTracert(_tracert, true); if (_tracert) { if (data.Length != 0) { _tracertDataList.Add(_DATA_FMT_STR.Fmt(data)); } } else { _tracertDataList.Clear(); _tracertDataScrollPosition = Vector2.zero; } } public void Select(bool state) { IsSelected = state; if (VLECommon.SelectedLinks.Contains(this)) { if (!IsSelected) { VLECommon.SelectedLinks.Remove(this); } } else { if (IsSelected) { VLECommon.SelectedLinks.Add(this); } } } public void DrawTracertLinkData() { if (_tracert && IsSelected && (_tracertDataList.Count > 0)) { var target = GetMiddlePoint(); _linkDataRect.width = 250f; _linkDataViewRect.width = _linkDataRect.width; target.x = Mathf.Floor(target.x - _linkDataRect.width / 2f); target.y = Mathf.Floor(target.y); _linkDataRect.position = target; _linkDataViewRect.position = target; var tracertData = PrepareTracertData().ToString(); if (tracertData.Length > 15000) { _tracertDataList.RemoveAt(0); tracertData = PrepareTracertData().ToString(); } _linkDataRect.height = Mathf.Floor(_tracertGUIStyle.CalcHeight(new GUIContent(tracertData), _linkDataRect.width)); _tracertDataScrollPosition = GUI.BeginScrollView(_linkDataViewRect, _tracertDataScrollPosition, _linkDataRect, GUIStyle.none, GUI.skin.verticalScrollbar); var handleRect = new Rect(_linkDataRect); handleRect.width -= 2f; handleRect.height -= 1f; handleRect.x += 1f; handleRect.y += 1f; Handles.DrawSolidRectangleWithOutline(handleRect, Color.gray, Color.black); GUI.Label(_linkDataRect, tracertData, _tracertGUIStyle); GUI.EndScrollView(); } } private void DrawLinkOrderData() { void prepareLinkDataRect(float width, float height) { _linkDataRect.width = width; _linkDataRect.height = height; var linkDataRectPosition = _source; linkDataRectPosition.y = _source.y + (_target.y - _source.y) / 2f - _linkDataRect.height; if ((!_sourceComponent.IsInversion && !_targetComponent.IsInversion) || (_sourceComponent.IsInversion && _targetComponent.IsInversion)) { linkDataRectPosition.x += (_target.x - _source.x) / 2f; } else if (_sourceComponent.IsInversion) { if (_target.x <= _source.x) { linkDataRectPosition.x = _target.x - width / 2f; } else { linkDataRectPosition.x = _source.x - width / 2f; } } else if (_targetComponent.IsInversion) { if (_target.x > _source.x) { linkDataRectPosition.x += (_target.x - _source.x) / 2f; } } _linkDataRect.position = linkDataRectPosition; }; if (!_tracert && IsSelected && !EditorApplication.isPlayingOrWillChangePlaymode && !VLECommon.IsMultipleLinks) { var ordersValue = new List<string>(); var ordersCount = VLECommon.CurrentLogic.Links.GetLinks(SourceComponent.Id, OutputPoint).Count; if (ordersCount > 1) { prepareLinkDataRect(50f, 25f); for (var i = 0; i < ordersCount; i++) { ordersValue.Add((i + 1).ToString()); } var newOrder = EditorGUI.Popup(_linkDataRect, _callOrder, ordersValue.ToArray()); if (newOrder != _callOrder) { _callOrder = newOrder; VLECommon.CurrentLogic.Links.ModifyLinkByLogic(Id, _callOrder); VLECommon.UnsavedChanges++; } } } else { var ordersCount = VLECommon.CurrentLogic.Links.GetLinks(SourceComponent.Id, OutputPoint).Count; if (ordersCount <= 1) { if (_callOrder >= 0) { _callOrder = -1; VLECommon.CurrentLogic.Links.ModifyLinkByLogic(Id, _callOrder); VLECommon.UnsavedChanges++; } } if (_callOrder >= 0) { prepareLinkDataRect(75f, 25f); EditorGUI.LabelField(_linkDataRect, (_callOrder + 1).ToString(), _orderViewGUIStyle); } } } private void RegisterStyle() { if (_tracertGUIStyle == null) { _tracertGUIStyle = new GUIStyle(GUI.skin.label); _tracertGUIStyle.fontSize = 12; _tracertGUIStyle.alignment = TextAnchor.UpperLeft; _tracertGUIStyle.wordWrap = true; _tracertGUIStyle.richText = true; _orderSetGUIStyle = new GUIStyle(EditorStyles.popup); _orderSetGUIStyle.alignment = TextAnchor.MiddleLeft; _orderViewGUIStyle = new GUIStyle(GUI.skin.label); _orderViewGUIStyle.alignment = TextAnchor.MiddleLeft; _orderViewGUIStyle.normal.textColor = Color.white; } } private Vector2 GetMiddlePoint() => _source + (_target - _source).normalized * Vector2.Distance(_source, _target) / 2f; private Color GetTracertColor() { var tracertColor = VLECommon.Setting.ColorTracertComplete; var linkColor = (Color)VLECommon.Setting.ColorLink; if (_pulsation) { var time = 5f * Mathf.Clamp01(VLETime.Time - _pulsationTime); tracertColor = Color.Lerp(linkColor, tracertColor, time); if (time >= 1f) { _pulsation = false; } } return tracertColor; } private StringBuilder PrepareTracertData() { var stringBuilder = new StringBuilder(); for (var i = 0; i < _tracertDataList.Count; i++) { var data = _tracertDataList[i]; if (stringBuilder.Length != 0) { stringBuilder.Append("\n"); } stringBuilder.Append(data); } return stringBuilder; } }
}

Как видно, символов много, но сам код простой как дрова, единственный момент, на котором хотелось бы, остановится это отрисовка самой линии связи. Для этого используется метод Handles.DrawBezier обернутый в специальный метод.

Код
public static void DrawBezierLink(Vector2 sourcePoint, Vector2 targetPoint, ref Vector2 sourceTan, ref Vector2 targetTan, bool sourceInversion, bool targetIsInversion, Color color, float width = 2f)
{ var bendPower = Vector2.up * (targetPoint.x > sourcePoint.x ? 0f : (targetPoint.y - sourcePoint.y) * 0.5f); var dirTan = Vector2.right * Mathf.Clamp(Mathf.Abs(targetPoint.x - sourcePoint.x), 50f, 200f) * 0.5f; if (!sourceInversion) { sourceTan = sourcePoint + dirTan + bendPower; } else { sourceTan = sourcePoint - dirTan; } if (!targetIsInversion) { targetTan = targetPoint - dirTan - bendPower; } else { targetTan = targetPoint + dirTan; } Handles.DrawBezier(sourcePoint, targetPoint, sourceTan, targetTan, color, null, width);
}

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

Инспектор

Как было указано реализация настроек параметров компонентов основана на стандартном инспекторе Unity путем переопределения редактора через CustomEditor. В целом данный аспект визуального редактора логики представляет собой три основные сущности:

  • Редактор ссылок на объекты Unity.

  • Редактор ссылок на переменные логики.

  • Редактор параметров компонентов

Рассмотрим подробнее каждый элемент.

Редактор ссылок на объекты Unity

Ниже представлено изображение данного редактора:

Рассмотри теперь код класса, определяющего CustomEditor для VLObject:

Код
namespace uViLEd.Editor
{ [CustomPropertyDrawer(typeof(VLObject), true)] public class VLEObjectEditor : PropertyDrawer { private const string _ID_STR = "_id"; private const string _GAMEOBJECT_STR = "GameObject"; public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { var labelRect = new Rect(position.x, position.y + 2f, position.width, 20f); var objectLinkRect = new Rect(position.x + 4f, position.y + 25f, position.width - 4f, 17f); var componentsPopupRect = new Rect(position.x + 2f, position.y + 45f, position.width - 4f, 20f); var obectReferenseType = typeof(UnityEngine.Object); var attributes = fieldInfo.GetCustomAttributes(typeof(TypeConstraintAttribute), true); var height = 64f; if (attributes.Length > 0) { var attribute = attributes[0] as TypeConstraintAttribute; obectReferenseType = attribute.Constraint; height = 46f; } var propertyRect = new Rect(position.x, position.y, position.width, height); var areaRect = new Rect(position.x, position.y, position.width, height); EditorGUI.BeginProperty(propertyRect, label, property); var guiStyle = new GUIStyle(EditorStyles.label); guiStyle.fontSize = 12; guiStyle.fontStyle = FontStyle.Bold; guiStyle.alignment = TextAnchor.MiddleCenter; guiStyle.normal.textColor = Color.gray; GUI.Box(areaRect, GUIContent.none); EditorGUI.LabelField(labelRect, property.displayName, guiStyle); var VLEController = VLECommon.CurrentLogicController; var idField = property.FindPropertyRelative(_ID_STR); var referenceObj = VLEController.GetObject(idField.stringValue); var newReferenceObj = EditorGUI.ObjectField(objectLinkRect, string.Empty, referenceObj, obectReferenseType, true); if (referenceObj != newReferenceObj) { SaveObjectLink(idField, newReferenceObj); } if (attributes.Length == 0 && newReferenceObj != null && (newReferenceObj is GameObject || newReferenceObj is Component)) { var gameObject = (newReferenceObj is GameObject) ? (GameObject)newReferenceObj : ((Component)newReferenceObj).gameObject; var components = gameObject.GetComponents(typeof(Component)); var componentNames = new List<string>(); componentNames.Add(_GAMEOBJECT_STR); for (var i = 0; i < components.Length; i++) { componentNames.Add(components[i].GetType().Name); } var index = EditorGUI.Popup(componentsPopupRect, string.Empty, -1, componentNames.ToArray()); if (index > 0) { var component = components[index - 1]; SaveObjectLink(idField, component); } else if (index == 0) { SaveObjectLink(idField, gameObject); } } EditorGUI.EndProperty(); } private void SaveObjectLink(SerializedProperty idProperty, UnityEngine.Object obj) { var vleController = VLECommon.CurrentLogicController; var oldValue = idProperty.stringValue; idProperty.stringValue = vleController.SetObject(oldValue, obj); VLECommon.UnsavedChanges++; } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) => base.GetPropertyHeight(property, label) + 50f; }
}

На что стоит обратить внимание здесь:

  1. После выбора объекта Unity имеется возможно выбрать любой компонент для установки в ссылку, который находится на данном объекте (через выпадающий список). Получение компонентов делается через GetComponents(typeof(Component)). Если посмотреть код, то видно, что данный механизм включается только, если выбранный объект является GameObject и не имеет атрибута ограничения типа.

  2. Добавлена обработка атрибута ограничения типа TypeConstraint. Данный атрибут позволяет ограничить установку в VLObject ссылок по определенному типу. Получение атрибута осуществляется через рефлексию fieldInfo.GetCustomAttributes(typeof(TypeConstraintAttribute), true)

Редактор ссылок на переменные логики

Ниже представлено изображение данного редактора:

Прежде чем вдаваться в подробности кода, напомню, что данный редактор работает с сущностью VARIABLE_LINK, которая является generic-классом и позволяет устанавливать ссылки на переменные логики, представленные generic-классом Variable (см. вторую часть статью про визуальный редактор логики). Поскольку оба указанных класса это generic’и, то в редакторе необходимо учесть, чтобы типы их аргументов совпадали, т. е. при установке ссылки, мы должны показывать только переменные, соответствующие типу, указанному в аргументе класса VARIABLE_LINK. Рассмотрим теперь код и первым делом остановимся на поддержке функционирования редактора.

Много кода
namespace uViLEd.Editor
{ public partial class VLELogicComponentCustomEditor { private class VariableData { public string Id; public string Name; public Type CoreType; public VariableData(string id, string name, Type coreType) { Id = id; Name = name; CoreType = coreType; } } private readonly List<VariableData> _variableList = new List<VariableData>(); private readonly IDictionary<string, List<string>> _variableNamesByLinkName = new Dictionary<string, List<string>>(); private readonly IDictionary<string, int> _variableNameSelected = new Dictionary<string, int>(); private readonly IDictionary<string, string> _variableLinksStorage = new Dictionary<string, string>(); private void LoadVariablesList() { _variableList.Clear(); var variables = VLECommon.CurrentLogic.Components.Items.FindAll(data => typeof(Core.Variable).IsAssignableFrom(AssemblyHelper.GetAssemblyType(data.Assembly, data.Type))); variables.SafeForEach(variable => { var variableType = AssemblyHelper.GetAssemblyType(variable.Assembly, variable.Type); var variableDefinition = (ComponentDefinitionAttribute)variableType.GetCustomAttributes(typeof(ComponentDefinitionAttribute), true)[0]; _variableList.Add(new VariableData(variable.Id, (variable.InstanceName == string.Empty) ? variableDefinition.Name : variable.InstanceName, variableType.BaseType.GetGenericArguments()[0])); }); } private void UpdateVariableLinks() { GetVariableLinks(); RestoreVariableLinks(); } private void GetVariableLinks() { _variableNamesByLinkName.Clear(); var variableLinkFields = VLECommon.SelectedWidget.InstanceType.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy); for (var i = 0; i < variableLinkFields.Length; i++) { var fieldInfo = variableLinkFields[i]; if (fieldInfo.FieldType.IsGenericType && fieldInfo.FieldType.GetGenericTypeDefinition() == typeof(VARIABLE_LINK<>)) { var variableDatas = _variableList.FindAll(data => data.CoreType == fieldInfo.FieldType.GetGenericArguments()[0]); var allowedVariableNames = new List<string>() { "None" }; for (var j = 0; j < variableDatas.Count; j++) { var variableData = variableDatas[j]; allowedVariableNames.Add(variableData.Name); } _variableNamesByLinkName.Add(fieldInfo.Name, allowedVariableNames); } } } private void RestoreVariableLinks() { _variableNameSelected.Clear(); _variableLinksStorage.Clear(); foreach (var variableName in _variableNamesByLinkName.Keys) { _variableNameSelected.Add(variableName, 0); } var componentVariableLinks = VLECommon.CurrentLogic.Links.GetVariableLinks(VLECommon.SelectedWidget.Id); if (componentVariableLinks.Count > 0) { for (var i = 0; i < componentVariableLinks.Count; i++) { var linkData = componentVariableLinks[i]; if (_variableNameSelected.ContainsKey(linkData.variableName)) { var targetVariable = _variableList.Find((data) => data.Id == linkData.TargetComponent); if (targetVariable != null) { _variableNameSelected[linkData.variableName] = _variableNamesByLinkName[linkData.variableName].IndexOf(targetVariable.Name); _variableLinksStorage.Add(linkData.variableName, linkData.Id); } else { Debug.LogWarning($"[uViLEd]: not found variable component [TargetComponent = {linkData.TargetComponent}; VariableName = {linkData.variableName}]"); } } } } } }
}

Здесь:

  • LoadVariablesList – метод, который загружает информацию обо всех переменных в текущей логике.

  • GetVariableLinks – метод, который загружает информацию обо всех ссылках на переменные в выбранном компоненте.

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

Что касается отображения редактора для ссылок на переменные, то оно основано на EditorGUILayout.Popup (подробнее смотри в следующем разделе).

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

Как было сказано в предыдущих разделах, редактор параметров компонента основан на стандартном инспекторе Unity, это связано с тем, что в их основе лежит класс ScriptableObject, который сериализуется автоматически. Помимо этого, стоит задача сделать кастомные заголовки и добавить редакторы ссылок на переменные и обеспечить возможно использовать CustomPropertyDrawer для разработчиков компонентов.

Много кода
namespace uViLEd.Editor
{ [CustomEditor(typeof(LogicComponent), true)] public partial class VLELogicComponentCustomEditor : UnityEditor.Editor { private GUIStyle _headerStyle, _descriptionStyle, _variableLinkHeaderStyle, _variableLinkDeprecetedStyle; private bool _prepared, _unsavedChangesCounted, _variableInitialized; protected override void OnHeaderGUI() { if (VLECommon.SelectedWidget == null) return; UpdateGUIStyle(); EditorGUILayout.BeginHorizontal(); { _headerStyle.normal.textColor = VLECommon.SelectedWidget.WidgetColor; EditorGUILayout.LabelField(VLECommon.SelectedWidget.Name, _headerStyle, GUILayout.ExpandWidth(true)); EditorGUIWithChangeCheck(() => { VLECommon.SelectedWidget.WidgetColor = EditorGUILayout.ColorField(VLECommon.SelectedWidget.WidgetColor, GUILayout.MaxWidth(50f)); }); } EditorGUILayout.EndHorizontal(); EditorGUILayout.LabelField(VLECommon.SelectedWidget.Description, _descriptionStyle, GUILayout.ExpandWidth(true)); } public override void OnInspectorGUI() { if (VLECommon.SelectedWidget == null) return; if (!_variableInitialized) { LoadVariablesList(); UpdateVariableLinks(); _variableInitialized = true; } if (!EditorApplication.isCompiling && !EditorApplication.isPlayingOrWillChangePlaymode) { ComponentInspector(); } else { if (EditorApplication.isCompiling) { GUILayout.Label("wait for compiling..."); } else if (EditorApplication.isPlayingOrWillChangePlaymode) { GUILayout.Label("you can not change settings in the Play mode..."); } } } private void ComponentInspector() { EditorGUILayout.BeginHorizontal(); { EditorGUILayout.LabelField("Name", GUILayout.MaxWidth(146f)); EditorGUIWithChangeCheck(() => VLECommon.SelectedWidget.Name = EditorGUILayout.TextField(VLECommon.SelectedWidget.Name)); } EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); { EditorGUILayout.LabelField("Comment", GUILayout.MaxWidth(146f)); EditorGUIWithChangeCheck(() => VLECommon.SelectedWidget.Comment = EditorGUILayout.TextArea(VLECommon.SelectedWidget.Comment)); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); EditorGUILayout.LabelField("Component Parameters", _variableLinkHeaderStyle, GUILayout.ExpandWidth(true)); EditorGUILayout.Space(); serializedObject.Update(); var property = serializedObject.GetIterator(); property.NextVisible(true); while (property.NextVisible(false)) { EditorGUIWithChangeCheck(() => { EditorGUILayout.PropertyField(property, true); }); } if (_variableNamesByLinkName.Count > 0) { EditorGUILayout.Space(); EditorGUILayout.LabelField("Variable Links", _variableLinkHeaderStyle, GUILayout.ExpandWidth(true)); EditorGUILayout.Space(); } foreach (var variableLinkName in _variableNamesByLinkName.Keys) { var displayedNames = _variableNamesByLinkName[variableLinkName].ToArray(); if (displayedNames.Length > 0) { EditorGUIWithChangeCheck(() => _variableNameSelected[variableLinkName] = EditorGUILayout.Popup(variableLinkName, _variableNameSelected[variableLinkName], displayedNames)); } else { EditorGUILayout.LabelField(variableLinkName + " [impossible setup link, no variables in logic]", _variableLinkDeprecetedStyle); } } SaveChanges(); } private void SaveChanges() { serializedObject.ApplyModifiedProperties(); VLECommon.CurrentLogic.Components.ModiftyComponent(VLECommon.SelectedWidget.Id, JsonUtility.ToJson(VLECommon.SelectedWidget.Instance)); if (!(VLECommon.SelectedWidget is IComponentWidget)) { var variableData = _variableList.Find(data => data.Id == VLECommon.SelectedWidget.Id); variableData.Name = VLECommon.SelectedWidget.Name; } foreach (var variableLinkName in _variableNameSelected.Keys) { var variableIndex = _variableNameSelected[variableLinkName]; if (variableIndex > 0) { var variableName = _variableNamesByLinkName[variableLinkName][variableIndex]; var variableData = _variableList.Find(data => data.Name == variableName); if (_variableLinksStorage.ContainsKey(variableLinkName)) { VLECommon.CurrentLogic.Links.ModifyLink(_variableLinksStorage[variableLinkName], VLECommon.SelectedWidget.Id, variableData.Id, variableLinkName); } else { var newLink = VLECommon.CurrentLogic.Links.AddLink(VLECommon.SelectedWidget.Id, variableData.Id, variableLinkName); _variableLinksStorage.Add(variableLinkName, newLink.Id); } } else { if (_variableLinksStorage.ContainsKey(variableLinkName)) { VLECommon.CurrentLogic.Links.ModifyLink(_variableLinksStorage[variableLinkName], string.Empty, string.Empty, string.Empty); _variableLinksStorage.Remove(variableLinkName); } } } (VLECommon.SelectedWidget as IParameterContainer)?.UpdateParameterValues(); } private void EditorGUIWithChangeCheck(Action guiDraw) { EditorGUI.BeginChangeCheck(); guiDraw(); if (EditorGUI.EndChangeCheck()) { if (!_unsavedChangesCounted || VLECommon.UnsavedChanges == 0) { VLECommon.UnsavedChanges++; _unsavedChangesCounted = true; } } } private void UpdateGUIStyle() { if (_prepared) return; _headerStyle = new GUIStyle(GUI.skin.box); _headerStyle.fontStyle = FontStyle.Bold; _headerStyle.fontSize = 18; _descriptionStyle = new GUIStyle(GUI.skin.box); _descriptionStyle.normal.textColor = Color.gray; _variableLinkHeaderStyle = new GUIStyle(GUI.skin.box); _variableLinkHeaderStyle.normal.textColor = Color.gray; _variableLinkHeaderStyle.fontStyle = FontStyle.Bold; _variableLinkDeprecetedStyle = new GUIStyle(GUI.skin.label); _variableLinkDeprecetedStyle.normal.textColor = Color.red; _prepared = true; } }
}

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

  1. Отслеживание изменений параметров, для отображения в статистике логике. Данный механизм реализуется с помощью методов EditorGUI.BeginChangeCheck() и EditorGUI.EndChangeCheck(), который обернуты в метод EditorGUIWithChangeCheck.

  2. Вывод всех пропертей компонента “как есть” – с помощью стандартных средств Unity. Для этого используется механизм перебора свойств:

    serializedObject.Update(); var property = serializedObject.GetIterator(); property.NextVisible(true); while (property.NextVisible(false))
    { EditorGUIWithChangeCheck(() => { EditorGUILayout.PropertyField(property, true); });
    }

Каталог

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

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

Много кода
namespace uViLEd.Editor
{ public sealed partial class VLECatalog { public abstract class CatalogBase { public string Name { get; private set; } public CatalogBase(string name) => Name = name; } public sealed class CatalogComponent : CatalogBase { public string Tooltip { get; private set; } public Type ComponentType { get; private set; } public Color ComponentColor { get; private set; } public CatalogComponent(string name, string tooltip, Type type, Color color) : base(name) { Tooltip = tooltip; ComponentType = type; ComponentColor = color; } } public sealed class CatalogFolder : CatalogBase { public CatalogFolder(string name) : base(name) { } public readonly List<CatalogBase> SubFolders = new List<CatalogBase>(); public readonly List<CatalogBase> Components = new List<CatalogBase>(); } public CatalogBase Catalog { get; } = new CatalogFolder("Root"); private readonly IList<Type> _components = new List<Type>(); private readonly IList<Type> _variables = new List<Type>(); public VLECatalog() { LoadAssembly(); FillCatalog(_components); FillCatalog(_variables); } private void LoadAssembly() { var allAssembliesDomain = AppDomain.CurrentDomain.GetAssemblies(); foreach (var assembly in allAssembliesDomain) { if (assembly.FullName.Contains("System") || assembly.FullName.Contains("UnityEditor") || assembly.FullName.Contains("UnityEngine") || assembly.FullName.Contains("uViLEdEditor")) { continue; } else { foreach (var checkedType in assembly.GetTypes()) { if (checkedType.IsAbstract) continue; if (checkedType != typeof(Core.Variable) && checkedType != typeof(Core.Variable<>) && checkedType != typeof(Core.LogicComponent)) { if (typeof(Core.Variable).IsAssignableFrom(checkedType)) { if (!_variables.Contains(checkedType)) { _variables.Add(checkedType); } } else if (typeof(Core.LogicComponent).IsAssignableFrom(checkedType)) { if (!_components.Contains(checkedType)) { _components.Add(checkedType); } } } } } } } private void FillCatalog(IList<Type> components) { void addAndSort(List<CatalogBase> folders, CatalogBase subFolder) { folders.Add(subFolder); SortCatalogByName(folders); } foreach (var component in components) { var componentData = CreateCatalogComponent(component); var subFolderNames = componentData.Path.Split('/'); if (subFolderNames.Length > 0) { var folders = (Catalog as CatalogFolder).SubFolders; for (var i = 0; i < subFolderNames.Length; i++) { var subFolderName = subFolderNames[i]; var subFolder = (CatalogFolder)GetCatalog(subFolderNames[i], folders); if (subFolder == null) { subFolder = new CatalogFolder(subFolderName); addAndSort(folders, subFolder); } if (i == subFolderNames.Length - 1) { if (GetCatalog(componentData.Component.Name, subFolder.Components) == null) { addAndSort(subFolder.Components, componentData.Component); } else { Debug.LogWarning($"[uViLEd]: catalog duplicate logic component in folder [name = {componentData.Component.Name}, type = {componentData.Component.ComponentType}]"); } } else { folders = subFolder.SubFolders; } } } else { (Catalog as CatalogFolder)?.Components.Add(componentData.Component); } } } private (string Path, CatalogComponent Component) CreateCatalogComponent(Type componentType) { var path = string.Empty; var catalogName = componentType.Name; var tooltip = componentType.Name; var color = (Color)VLECommon.Setting.ColorComponent; var attributes = componentType.GetCustomAttributes(typeof(ComponentDefinitionAttribute), true); if (attributes.Length > 0) { var attributeData = (ComponentDefinitionAttribute)attributes[0]; path = attributeData.Path; catalogName = attributeData.Name; tooltip = attributeData.Tooltip; if (!ColorUtility.TryParseHtmlString(attributeData.Color, out color)) { color = VLECommon.Setting.ColorComponent; } } var catalogComponent = new CatalogComponent(catalogName, tooltip, componentType, color); return (path, catalogComponent); } } public IList<CatalogBase> SearchComponentByContext(string context) { var returnedValue = new List<CatalogBase>(); var subFolders = (Catalog as CatalogFolder).SubFolders; subFolders.ForEach(folder => SearchComponentInFolder(context, folder, returnedValue)); return returnedValue; } private void SearchComponentInFolder(string context, CatalogBase rootFolder, List<CatalogBase> collection) { var catalogFolder = (CatalogFolder)rootFolder; var lowerContext = context.ToLower(); var findedComponent = from component in catalogFolder.Components where component.Name.Substring(0, Mathf.Clamp(context.Length, 0, component.Name.Length)).ToLower() == lowerContext select component; collection.AddRange(findedComponent); catalogFolder.SubFolders.ForEach(subfolder => SearchComponentInFolder(context, subfolder, collection)); } private CatalogBase GetCatalog(string name, List<CatalogBase> components) => components.Find((data) => data.Name == name); private void SortCatalogByName(List<CatalogBase> folders) => folders.Sort((one, two) => one.Name.CompareTo(two.Name));
}

Итак, что происходит в данном классе:

  1. Происходит загрузка сборок проекта и вытаскивание из них всех классов наследников LogicComponent и Variable (в том числе generic).

  2. Заполняются данные по каталогам и компонентам с автоматической сортировкой по имени.

  3. Имя компонента, его размещение, а также цвет и т. п. данные берутся из данных атрибута ComponentDefinition.

Рассмотрим теперь код окна, которая выводит каталог, для работы с ним со стороны пользователя:

Много кода
namespace uViLEd.Editor
{ public partial class VLECatalogWindow : VLEWindowEditor { private VLECatalog _vleCatalog; private const string _COMPONENTS_CATALOG_STR = "Components Catalog"; private const string _CAN_NOT_ADD_STR = "you can not add components in the Play mode..."; private const string _WAIT_COMPILINIG_STR = "wait for compiling..."; public static void Open() => CreateWindow<VLECatalogWindow>(_COMPONENTS_CATALOG_STR); void OnEnable() { _vleCatalog = new VLECatalog(); LoadCatalogState(); } void OnDisable() { _prepared = false; VLECommon.ClearSetting(); } void OnGUI() { if (EditorApplication.isPlayingOrWillChangePlaymode) { GUILayout.Label(_CAN_NOT_ADD_STR); } else { GUILayout.BeginVertical(); { GUILayout.Space(5f); if (!Context()) { if (!EditorApplication.isCompiling) { GUILayout.Space(5f); Tree(); } else { if (EditorApplication.isCompiling) { GUILayout.Label(_WAIT_COMPILINIG_STR); } } } } GUILayout.EndVertical(); } } }
}

Базовый код достаточно примитивный, основные методы тут — это Context, который выводит список компонентов, в зависимости от строки, введённой в поиске и Tree, которые выводит полную структуру каталога компонентов.

Много кода (методы Context и Tree)
private string _context = string.Empty;
private bool Context()
{ GUILayout.BeginHorizontal(GUI.skin.box); GUILayout.Label("Context search", GUILayout.Width(100f)); _context = GUILayout.TextField(_context); GUILayout.EndHorizontal(); if (_context.Length > 0) { var components = _vleCatalog.SearchComponentByContext(_context); _scrollPosition = GUILayout.BeginScrollView(_scrollPosition); { TreeComponent(components, false); } GUILayout.EndScrollView(); } return _context.Length > 0;
} private Vector2 _scrollPosition = Vector2.zero; private GUIStyle _boxComponentStyle;
private GUIStyle _tooltipStyle; private bool _prepared;
private const string _COMPONENT_TOOLTIP = "Please double click for open script"; private void UpdateGUIStyle()
{ if (_prepared) return; _boxComponentStyle = new GUIStyle(EditorGUIUtility.isProSkin ? GUI.skin.box : GUI.skin.label); _boxComponentStyle.normal.textColor = Color.white; _boxComponentStyle.onNormal.textColor = Color.white; _tooltipStyle = new GUIStyle(GUI.skin.label); _tooltipStyle.wordWrap = true; if (!EditorGUIUtility.isProSkin) { _tooltipStyle.normal.textColor = Color.white; } _prepared = true;
} private void Tree()
{ UpdateGUIStyle(); _scrollPosition = GUILayout.BeginScrollView(_scrollPosition); TreeFolder(((VLECatalog.CatalogFolder)_vleCatalog.Catalog).SubFolders, string.Empty, false); SaveCatalogState(); GUILayout.EndScrollView();
} private void TreeFolder(List<VLECatalog.CatalogBase> folders, string path = "", bool spaced = true)
{ foreach (var catalogComponent in folders) { var catalogFolder = (VLECatalog.CatalogFolder)catalogComponent; var folderPath = path + catalogFolder.Name; if (!_catalogState.ContainsKey(folderPath)) { _catalogState.Add(folderPath, false); } GUILayout.BeginHorizontal(); { if (spaced) GUILayout.Space(15f); GUILayout.BeginVertical(); { var guiStyle = EditorStyles.foldout; var name = catalogComponent.Name; var tooltip = string.Empty; if (catalogComponent.Name.Length > 15) { var stringBuilder = new StringBuilder(name.Substring(0, 12)); stringBuilder.Append("..."); name = stringBuilder.ToString(); tooltip = catalogComponent.Name; } var content = new GUIContent(EditorGUIUtility.IconContent("Folder Icon")); content.text = name; content.tooltip = tooltip; if (_catalogState[folderPath] = EditorGUILayout.Foldout(_catalogState[folderPath], content, true, guiStyle)) { TreeFolder(catalogFolder.SubFolders, folderPath); TreeComponent(catalogFolder.Components); } } GUILayout.EndVertical(); } GUILayout.EndHorizontal(); }
} private void TreeComponent(IList<VLECatalog.CatalogBase> components, bool spaced = true)
{ foreach (var catalogComponent in components) { var component = catalogComponent as VLECatalog.CatalogComponent; var color = GUI.color; var componentStyle = new GUIStyle(GUI.skin.box); GUILayout.BeginHorizontal(); { if (spaced) { GUILayout.Space(30f); } VLELogicEditor.DrawAsDarkSkinImitation(() => GUILayout.BeginVertical(GUI.skin.box), Color.gray); { _boxComponentStyle.normal.textColor = component.ComponentColor; _boxComponentStyle.onNormal.textColor = component.ComponentColor; var content = new GUIContent(EditorGUIUtility.IconContent("blendkey")); content.text = component.Name; content.tooltip = _COMPONENT_TOOLTIP; Rect labelRect; var calcSize = _boxComponentStyle.CalcSize(content); if (EditorGUIUtility.isProSkin) { GUILayout.Box(content, _boxComponentStyle, GUILayout.Width(calcSize.x + 10f)); labelRect = GUILayoutUtility.GetLastRect(); } else { VLELogicEditor.DrawAsDarkSkinImitation(() => GUILayout.Box(string.Empty, GUILayout.Width(calcSize.x + 10f)), VLESetting.DarkLightGrayImitation); GUI.Label(labelRect = GUILayoutUtility.GetLastRect(), content, _boxComponentStyle); } var ev = Event.current; if (ev.button == 0 && ev.clickCount == 2 && labelRect.Contains(ev.mousePosition)) { VLEditorUtils.OpenScriptByType(component.ComponentType); } GUILayout.Label(component.Tooltip, _tooltipStyle); } GUILayout.EndVertical(); CheckDragComponent(component); } GUILayout.EndHorizontal(); GUI.color = color; }
} private void CheckDragComponent(VLECatalog.CatalogComponent component)
{ var ev = Event.current; if (ev.type == EventType.MouseDrag && GUILayoutUtility.GetLastRect().Contains(ev.mousePosition)) { DragAndDrop.PrepareStartDrag(); DragAndDrop.objectReferences = new UnityEngine.Object[0]; DragAndDrop.paths = new string[0]; DragAndDrop.SetGenericData("VLEDragData", component); DragAndDrop.StartDrag("VLEDragData"); ev.Use(); }
}

Как видно по коду, здесь работает рекурсия метода TreeFolder для вывода все компонентов в каталогах и подкаталогах. Также добавлена обработка Drag&Drop для добавления компонента в логику путем перетаскивания.

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

Код
namespace uViLEd.Editor
{ public partial class VLECatalogWindow { [Serializable] private class CatalogFolderState { [Serializable] public class Data { public string Path { get; private set; } public bool State { get; private set; } public Data(string path, bool state) { Path = path; State = state; } } public readonly List<Data> Info = new List<Data>(); } private IDictionary<string, bool> _catalogState; private void LoadCatalogState() { _catalogState = new Dictionary<string, bool>(); var catalogFolderState = Serialization.Deserialize(Path.Combine(Directory.GetParent(Application.dataPath).FullName, "_uViLEd Setting"), "CatalogFolderState.setting") as CatalogFolderState; if (catalogFolderState == null) return; catalogFolderState.Info.ForEach(folder => _catalogState.Add(folder.Path, folder.State)); } private void SaveCatalogState() { var catalogFolderState = new CatalogFolderState(); foreach (var info in _catalogState) { catalogFolderState.Info.Add(new CatalogFolderState.Data(info.Key, info.Value)); } Serialization.Serialize(catalogFolderState, Path.Combine(Directory.GetParent(Application.dataPath).FullName, "_uViLEd Setting"), "CatalogFolderState.setting"); } }
}

Примечание: сохранение данных основано на бинарной сериализации.

Поиск

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

Код окна поиска
namespace uViLEd.Editor
{ public class SearchComponentWindow : VLEWindowEditor { private string _searchingName = string.Empty; private Vector2 _scrollSearchResultPosition = Vector2.zero; private IList<ILogicWidget> _currentVLEWidgets; private IList<IWidgetGroup> _currentGroups; private IList<IWidgetGroup> _logicGroups; private IList<ILogicWidget> _vleWidgets; private VLELogicEditor.ILogic _currentLogicDrawer; private string[] _tabs = new string[] { "Components", "Groups" }; private int _currentTabs = 0; public void Initialization(IList<ILogicWidget> currentVLEWidgets, IList<IWidgetGroup> currentGroups, VLELogicEditor.ILogic currentLogicDrawer) { _searchingName = string.Empty; _currentVLEWidgets = currentVLEWidgets; _vleWidgets = new List<ILogicWidget>(currentVLEWidgets); _currentGroups = currentGroups; _logicGroups = new List<IWidgetGroup>(currentGroups); _currentLogicDrawer = currentLogicDrawer; } void OnGUI() { GUILayout.Space(5f); GUILayout.BeginHorizontal(GUI.skin.box); { GUILayout.Label("Context search", GUILayout.Width(100f)); _searchingName = GUILayout.TextField(_searchingName); } GUILayout.EndHorizontal(); _scrollSearchResultPosition = GUILayout.BeginScrollView(_scrollSearchResultPosition, GUIStyle.none, GUI.skin.verticalScrollbar); switch (_currentTabs = GUILayout.Toolbar(_currentTabs, _tabs)) { case 0: { if (_currentVLEWidgets.Count != _vleWidgets.Count) { _vleWidgets.Clear(); (_vleWidgets as List<ILogicWidget>)?.AddRange(_currentVLEWidgets); } for (var i = 0; i < _vleWidgets.Count; i++) { var component = _vleWidgets[i]; if (component != null) { var subString = component.Name.Substring(0, Mathf.Clamp(_searchingName.Length, 0, component.Name.Length)).ToLower(); if (subString == _searchingName.ToLower() || _searchingName == String.Empty) { var guiColor = GUI.color; GUI.color = component.WidgetColor; if (GUILayout.Button(component.Name)) { component.Select(true, false); _currentLogicDrawer.CenterToComponent(component); } GUI.color = guiColor; } } } } break; case 1: { if (_currentGroups.Count != _logicGroups.Count) { _logicGroups.Clear(); (_vleWidgets as List<IWidgetGroup>)?.AddRange(_currentGroups); } for (var i = 0; i < _logicGroups.Count; i++) { var group = _logicGroups[i]; if (group != null) { var subString = group.Name.Substring(0, Mathf.Clamp(_searchingName.Length, 0, group.Name.Length)).ToLower(); if (subString == _searchingName.ToLower() || _searchingName == String.Empty) { var guiColor = GUI.color; GUI.color = group.GroupColor; if (GUILayout.Button(group.Name)) { var component = group.Widgets[0]; _currentLogicDrawer.CenterToComponent(component); } GUI.color = guiColor; } } } } break; } GUILayout.EndScrollView(); } private void OnDisable() { _logicGroups.Clear(); _logicGroups = null; _vleWidgets.Clear(); _vleWidgets = null; _currentVLEWidgets = null; } }
}

Как видно, здесь так же, как и в каталоге поддерживается контекстный поиск по имени. При клике в элемент происходит центрирование области отображения на нём.

Отладка

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

  1. Отследить значения всех переменных (в том числе в реальном времени).

  2. Отследить цепочку вызовов связей между компонентами.

  3. Просмотреть все передаваемые данные между компонентами.

  4. Осуществлять пошаговый вызов цепочки исполнения логики (вызов связей).

Ниже представлено изображение редактора логики в режиме отладки:

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

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

Код
#if UNITY_EDITOR namespace uViLEd.Editor
{ public class VLEDebug { public delegate void TracertStackChanged(TracertData tracertData); public delegate void NextStep(); public static event TracertStackChanged OnTracertStackChanged; public static event NextStep OnNextStep; public class TracertData { public string LogicId { get; private set; } public string Link { get; private set; } public string Data { get; private set; } public TracertData(string logicId, string link, string data) { LogicId = logicId; Link = link; Data = data; } } public static bool IsBreak { get; private set; } = false; public static void TRACERT(string logicId, string link) => OnTracertStackChanged?.Invoke(new TracertData(logicId, link, string.Empty)); public static void TRACERT(string logicId, string link, string data) => OnTracertStackChanged?.Invoke(new TracertData(logicId, link, data)); public static void Break() => IsBreak = true; public static void Next() => OnNextStep?.Invoke(); public static void Play() { IsBreak = false; OnNextStep?.Invoke(); } public static void Clear() { if (OnNextStep != null) { foreach (var eventHandler in OnNextStep.GetInvocationList()) { OnNextStep -= (NextStep)eventHandler; } } } } }
#endif

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

  1. Изменение стека данных трассировки связей, вызывается данное событие, в момент передачи данных по связи между компонентами (в том числе если данные отсутствуют).

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

Рассмотрим методы данного класса:

  1. TRACERT – метод, который вызывает событие для проброса данных по связи (передает данные в виде экземпляра класса, где задается идентификатор логики, идентификатор связи и данные конвертированные в строку).

  2. Break – метод, вызывающий прерывание выполнения логики (постановка на паузу).

  3. Next – метод, используемый для вызова следующей по цепочке связи между компонентами.

  4. Play – метод, снимающий паузу с логики.

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

Код
namespace uViLEd.Core
{ public class OUTPUT_POINT<T> { private List<Action<T>> _linkedInputPoints = new List<Action<T>>(); private Action<T> ParsingActionEmpty(Action action, string logicId, string linkId) { Action<T> parsedAction = (value) => { Editor.VLEDebug.TRACERT(logicId, linkId, value.ToString()); action(); }; return parsedAction; } private Action<T> ParsingActionObject(Action<object> action, string logicId, string linkId) { Action<T> parsedAction = (value) => { Editor.VLEDebug.TRACERT(logicId, linkId, value.ToString()); action(value); }; return parsedAction; } private Action<T> ParsingActionT(Action<T> action, string logicId, string linkId) { Action<T> parsedAction = (value) => { Editor.VLEDebug.TRACERT(logicId, linkId, value.ToString()); action(value); }; return parsedAction; } public void Execute(T param) { if (Editor.VLEDebug.IsBreak) { if (_storedParam.Count == 0) { Editor.VLEDebug.OnNextStep += WaitNextStep; } _storedParam.Enqueue(param); } else { foreach (var handler in _linkedInputPoints) { handler(param); } } } private Queue<T> _storedParam = new Queue<T>(); private void WaitNextStep() { if (Editor.VLEDebug.IsBreak) { var param = _storedParam.Dequeue(); if (_storedParam.Count == 0) { Editor.VLEDebug.OnNextStep -= WaitNextStep; } foreach (var handler in _linkedInputPoints) { handler(param); } } else { while (_storedParam.Count > 0) { var param = _storedParam.Dequeue(); foreach (var handler in _linkedInputPoints) { handler(param); } } Editor.VLEDebug.OnNextStep -= WaitNextStep; } } } public class OUTPUT_POINT { private List<Action> _linkedInputPoints = new List<Action>(); public void Execute() { if (Editor.VLEDebug.IsBreak) { if (_callCount == 0) { Editor.VLEDebug.OnNextStep += WaitNextStep; } _callCount++; } else { foreach (var handler in _linkedInputPoints) { handler(); } } } private int _callCount = 0; private void WaitNextStep() { if (Editor.VLEDebug.IsBreak) { _callCount--; if (_callCount == 0) { Editor.VLEDebug.OnNextStep -= WaitNextStep; } foreach (var handler in _linkedInputPoints) { handler(); } } else { _callCount = 0; Editor.VLEDebug.OnNextStep -= WaitNextStep; foreach (var handler in _linkedInputPoints) { handler(); } } } }
}

На что стоит тут обратить внимание:

  1. Наличие функций с префиксом Parsing, которые оборачивают делегат в другой метод с добавлением к нему вызова функции Editor.VLEDebug.TRACERT

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

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

Для визуализации режима отладки в классе VLELogicEditor добавлен обработчик события OnTracertStackChanged:

Код
private void OnTracertDataChanged(VLEDebug.TracertData tracertData)
{ if (tracertData.LogicId != VLECommon.CurrentLogic.Id) { if (VLEDebug.IsBreak) { var logic = VLECommon.CurrentLogicController.SceneLogicList.Find(item => item.Id == tracertData.LogicId); if (logic == null) { return; } LoadLogic(VLECommon.CurrentLogicController.SceneLogicList.IndexOf(logic)); } else { return; } } var link = _logicLinks.Find((value) => { if (value.Id == tracertData.Link) { return true; } else { return false; } }); if (link != null) { link.MakeTracert(true, tracertData.Data); } else { Debug.LogWarning($"[uViLEd]: failed tracert link [{tracertData.Link}], link not exist"); } Repaint();
}

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

  1. Все значения переменных логики отображаются в реальном времени (в любой момент можно посмотреть их актуальное значение).

  2. Для отображения значения приватного поля на компоненте можно использовать атрибут ViewInEditorAttribute.

  3. Для отображения значения приватных полей в режиме запуска проекта в редакторе можно использовать атрибут ViewInDebugModeAttribute.

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

Код
public static IDictionary<FieldInfo, bool> GetParameters(Type instanceType)
{ var returnedValue = new Dictionary<FieldInfo, bool>(); var publicFieldsInfo = instanceType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); for (var i = 0; i < publicFieldsInfo.Length; i++) { var fieldInfo = publicFieldsInfo[i]; if (fieldInfo.FieldType.IsGenericType && fieldInfo.FieldType.GetGenericTypeDefinition() == typeof(VARIABLE_LINK<>)) { var hideInEditor = fieldInfo.GetCustomAttributes(typeof(HideInEditorAttribute), true); if (hideInEditor.Length == 0) { returnedValue.Add(fieldInfo, false); } } else { if ((fieldInfo.FieldType.IsGenericType && (fieldInfo.FieldType.GetGenericTypeDefinition() != typeof(OUTPUT_POINT<>)) && (fieldInfo.FieldType.GetGenericTypeDefinition() != typeof(INPUT_POINT<>))) || (!fieldInfo.FieldType.IsGenericType && fieldInfo.FieldType != typeof(OUTPUT_POINT) && fieldInfo.FieldType != typeof(INPUT_POINT))) { var viewInEditor = fieldInfo.GetCustomAttributes(typeof(ViewInEditorAttribute), true); if (viewInEditor.Length > 0) { returnedValue.Add(fieldInfo, false); } } } } var allFieldsInfo = instanceType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); for (var i = 0; i < allFieldsInfo.Length; i++) { var fieldInfo = allFieldsInfo[i]; if ((fieldInfo.FieldType.IsGenericType && (fieldInfo.FieldType.GetGenericTypeDefinition() != typeof(OUTPUT_POINT<>)) && (fieldInfo.FieldType.GetGenericTypeDefinition() != typeof(INPUT_POINT<>))) || (!fieldInfo.FieldType.IsGenericType && fieldInfo.FieldType != typeof(OUTPUT_POINT) && fieldInfo.FieldType != typeof(INPUT_POINT))) { var viewInDebugMode = fieldInfo.GetCustomAttributes(typeof(ViewInDebugModeAttribute), true); if (viewInDebugMode.Length > 0) { returnedValue.Add(fieldInfo, true); } } } return returnedValue;
}

Кастомизация визуальной части

Кастомизация визуальной части компонентов в редакторе логике работает по принципу схожему с таковым в Unity3D: создается класс для визуализации компонента и указывается атрибут VLECustomComponentDrawer с параметром типа исходного компонента.

Пример
namespace uViLEd.Components.Editor
{ [VLECustomComponentDrawer(typeof(VariableColor))] public class VariableColorCustomDrawer : VLEVariable { public class VariableColorValueWidget : VariableValueWidget { public VariableColorValueWidget(VariableValueData valueData, IWidgetContainer rootContainer) : base(valueData, rootContainer) { } public override void UpdateView() { } public override void Draw() => EditorGUI.DrawRect(valueAreaRect, (Color)valueData.Value); } public VariableColorCustomDrawer(Core.LogicStorage.ComponentsStorage.ComponentData componentStorage) : base(componentStorage) { } protected override void PrepareChildWidget() { ReplaceWidget<VariableValueWidget, VariableColorValueWidget>(); base.PrepareChildWidget(); } public override void UpdateParameterValues() { var propertyInfo = VLEditorUtils.GetVariable(InstanceType); valueData.Value = propertyInfo.GetValue(Instance, null); } }
}

Далее данный атрибут проверяется у каждого класса во всех сборках проектах (исключаются только системные и сборки движка Unity3d) и все найденные классы кэшируются в словаре, где ключом выступает тип исходного компонента.

Код
private void LoadEditorRuntimeAssembly()
{ var allAssembliesDomain = AppDomain.CurrentDomain.GetAssemblies(); _customDrawerTypes.Clear(); foreach (var assembly in allAssembliesDomain) { if (assembly.FullName.Contains("System") || assembly.FullName.Contains("UnityEditor") || assembly.FullName.Contains("UnityEngine")) { continue; } else { var vleBaseComponents = assembly.GetTypes(); foreach (var vseBaseComponent in vleBaseComponents) { var attributes = vseBaseComponent.GetCustomAttributes(typeof(VLECustomComponentDrawerAttribute), true); if (attributes.Length > 0) { foreach (var attribute in attributes) { var customDrawer = (VLECustomComponentDrawerAttribute)attribute; _customDrawerTypes.Add(customDrawer.ComponentType, vseBaseComponent); } } } } }
}

После этого, в момент загрузки логики и создания экземпляров классов визуализации компонентов, происходит проверка на наличие кастомного класса визуализации по типу исходного компонента.

Код
private Type GetCustomDrawerType(Type componentType)
{ if (_customDrawerTypes.ContainsKey(componentType)) { return _customDrawerTypes[componentType]; } else { foreach (var logicComponentType in _customDrawerTypes.Keys) { if (logicComponentType.IsAssignableFrom(componentType)) { return _customDrawerTypes[logicComponentType]; } } } return null;
} ILogicWidget logicWidget; var customDrawerType = GetCustomDrawerType(componentType); if (customDrawerType != null)
{ logicWidget = Activator.CreateInstance(customDrawerType, new object[] { componentStorage }) as ILogicWidget;
}
else
{ if (typeof(Core.Variable).IsAssignableFrom(componentType)) { logicWidget = new VLEVariable(componentStorage); } else { logicWidget = new VLEComponent(componentStorage); }
}

Махинации

Область выделения

Область выделения рисуется максимально просто с использованием механизма Handles из состава Unity3D.

Handles.DrawSolidRectangleWithOutline(_multiSelectRect, _faceSelectionColor, Color.black);

Расчет _multiSelectRect производится в событии движения курсора мыши. При нажатии левой кнопки мыши происходит инициализация режима выделения:

if (!_multiSelectMoveMode && logicAreaRect.Contains(ev.mousePosition))
{ _multiSelectMode = true; _multiSelectRect.position = GetLogicAreaPosition(ev.mousePosition); _multiSelectRect.width = 0f; _multiSelectRect.height = 0f; ev.Use();
}

Затем при движении мыши пересчитываются значения ширины и высоты области:

Код
if (_multiSelectMode)
{ _multiSelectRect.width = (ev.mousePosition.x + logicScrollPosition.x - logicRealRect.position.x) - _multiSelectRect.position.x; _multiSelectRect.height = (ev.mousePosition.y + logicScrollPosition.y - logicRealRect.position.y) - _multiSelectRect.position.y; for (var i = 0; i < _logicWidgets.Count; i++) { var component = _logicWidgets[i]; if (_multiSelectRect.Overlaps(component.Area, true)) { component.Select(true); } else { component.Select(false); } } if (!logicAreaRect.Contains(ev.mousePosition)) { if (ev.mousePosition.x >= logicAreaRect.xMax) { logicScrollPosition.x += (0.1f) * (ev.mousePosition.x - logicAreaRect.xMax); } else if (ev.mousePosition.x <= logicAreaRect.xMin) { logicScrollPosition.x += (0.1f) * (ev.mousePosition.x - logicAreaRect.xMin); } if (ev.mousePosition.y >= logicAreaRect.yMax) { logicScrollPosition.y += (0.1f) * (ev.mousePosition.y - logicAreaRect.yMax); } else if (ev.mousePosition.y <= logicAreaRect.yMin) { logicScrollPosition.y += (0.1f) * (ev.mousePosition.y - logicAreaRect.yMin); } } ev.Use();
}

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

Имитируем темный скин

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

public static void DrawAsDarkSkinImitation(Action draw, Color color)
{ if (EditorGUIUtility.isProSkin) { draw(); } else { var guiColor = GUI.color; GUI.color = color; draw(); GUI.color = guiColor; }
}

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

VLELogicEditor.DrawAsDarkSkinImitation(() => GUILayout.Box(string.Empty, GUILayout.Width(calcSize.x + 10f)), VLESetting.DarkLightGrayImitation);

Масштабирование

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

Код
namespace uViLEd.Editor
{ public static class VLEExtension { public abstract class VLEVirtualBase { protected float currentPixelSize = float.MinValue; } public class VLEVirtualRect : VLEVirtualBase { private Rect _sourceRect; private Rect _currentRect; public VLEVirtualRect(float x, float y, float width, float height) : base() { _sourceRect = new Rect(x, y, width, height); _currentRect = _sourceRect; } public Rect GetRect(float pixelSize = 1f) { if (pixelSize != currentPixelSize) { currentPixelSize = pixelSize; var x = Mathf.Round(_sourceRect.x * pixelSize); var y = Mathf.Round(_sourceRect.y * pixelSize); var width = Mathf.Round(_sourceRect.width * pixelSize); var height = Mathf.Round(_sourceRect.height * pixelSize); _currentRect = new Rect(x, y, width, height); } return _currentRect; } public void SetPosition(Vector2 position, float pixelSize = 1f) { _sourceRect.x = Mathf.Round(position.x * 1f / pixelSize); _sourceRect.y = Mathf.Round(position.y * 1f / pixelSize); currentPixelSize = 0f; } public void SetX(float x, float pixelSize = 1f) { _sourceRect.x = Mathf.Round(x * 1f / pixelSize); currentPixelSize = 0f; } public void SetY(float y, float pixelSize = 1f) { _sourceRect.y = Mathf.Round(y * 1f / pixelSize); currentPixelSize = 0f; } public void SetWidth(float width, float pixelSize = 1f) { _sourceRect.width = Mathf.Round(width * 1f / pixelSize); currentPixelSize = 0f; } public void SetHeight(float height, float pixelSize = 1f) { _sourceRect.height = Mathf.Round(height * 1f / pixelSize); currentPixelSize = 0f; } } public class VLEVirtualPosition : VLEVirtualBase { private Vector2 _sourcePosition; private Vector2 _currentPosition; public VLEVirtualPosition(float x, float y) : base() { _sourcePosition = new Vector2(x, y); _currentPosition = _sourcePosition; } public Vector2 GetPosition(float pixelSize) { if (pixelSize != currentPixelSize) { currentPixelSize = pixelSize; _currentPosition = new Vector2(Mathf.Round(_sourcePosition.x * pixelSize), Mathf.Round(_sourcePosition.y * pixelSize)); } return _currentPosition; } public void SetPosition(Vector2 position, float pixelSize) { _sourcePosition.x = Mathf.Round(position.x * 1f / pixelSize); _sourcePosition.y = Mathf.Round(position.y * 1f / pixelSize); if (currentPixelSize == pixelSize) { _currentPosition = position; } } } public class VLEVirualFontSize : VLEVirtualBase { private int _sourceFontSize; private int _currentFontSize; public VLEVirualFontSize(int fontSize) : base() => _sourceFontSize = _currentFontSize = fontSize; public int GetFontSize(float pixelSize) { if (pixelSize != currentPixelSize) { currentPixelSize = pixelSize; _currentFontSize = (int)Mathf.Floor((float)_sourceFontSize * pixelSize); } return _currentFontSize; } } }
}

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

Пример класса для отображения состояния компонента
public partial class VLEComponent
{ public class StateWidget : ChildWidget { protected GUIStyle stateStyle; protected IComponentWidget component; protected VLEExtension.VLEVirualFontSize virtualFontSize = new VLEExtension.VLEVirualFontSize(10); public StateWidget(IComponentWidget component) { this.component = component; widgetVirtualRect = new VLEExtension.VLEVirtualRect(210f, 20f, 30f, 20f); } public override void RegisterStyle() { if (stateStyle == null) { stateStyle = new GUIStyle(GUI.skin.label); stateStyle.fontStyle = FontStyle.Normal; stateStyle.clipping = TextClipping.Clip; stateStyle.normal.textColor = Color.gray; } } public override void Draw() { if (component.IsCanMinimize && component.IsMinimized) { stateStyle.fontSize = virtualFontSize.GetFontSize(VLECommon.CurrentEditorScale); var rect = Area; rect.y = Mathf.Clamp(rect.y, 15f, 20f); GUI.Label(rect, "min", stateStyle); } } }
}

Как итог, само масштабирование осуществляется путем изменения размера виртуального пикселя, которое хранится в VLECommon.CurrentEditorScale.

Таймер

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

Код
namespace uViLEd.Editor
{ public class VLETime { public static float Time => UnityEngine.Time.realtimeSinceStartup; public static float DeltaTime => _deltaTime; private static float _previousTime = 0f; private static float _deltaTime = 0f; public static void Start() => _previousTime = Time; public static void Update() { _deltaTime = Time - _previousTime; _previousTime = Time; } }
}

Сетка

Все наверное знают, что практически во всех визуальных редакторах (Bolt, Blueprint, Shader Grpaph) на заднике рисуется красивая сеточка, которая еще и масштабируется при скейлинге. Мне тоже хотелось такую у себя. Опять же я перепробовал несколько вариантов и в итоге решил, что лучший способ — это процедурное рисование.

Код
public static void DrawGrid(Rect viewArea, Vector3 offset, float gridSizeX, float gridSizeY, Color color, Color accentColor)
{ var storeColor = Handles.color; if (gridSizeX == 0 || gridSizeY == 0) return; var startXLineCount = ((int)(offset.x / gridSizeX)) + 1; var startYLineCount = ((int)(offset.y / gridSizeY)) + 1; var x = startXLineCount * gridSizeX; var y = startYLineCount * gridSizeY; var counter = startXLineCount; while (x <= offset.x + viewArea.width) { var from = new Vector2(x, offset.y); var to = new Vector2(x, offset.y + viewArea.height); x += gridSizeX; if ((counter > 0) && (counter % 10 == 0)) { Handles.color = accentColor; } else { Handles.color = color; } Handles.DrawLine(from, to); counter++; } counter = startYLineCount; while (y <= offset.y + viewArea.height) { var from = new Vector2(offset.x, y); var to = new Vector2(offset.x + viewArea.width, y); y += gridSizeY; if ((counter > 0) && (counter % 10 == 0)) { Handles.color = accentColor; } else { Handles.color = color; } Handles.DrawLine(from, to); counter++; } Handles.color = storeColor;
}

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

Заключение

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

PS: полный исходный код ядра и редактора доступен в Asset Store. Версия для ознакомления тоже находится в Asset Store.

PS2: закрывая один гештальт, открываешь новый, теперь я хочу окунуться в мир JobSystem и масштабных параллельных вычислений для реализации игровой логики.

Читайте так же:

  • Встречайте официальные плагины для быстрой установки электронной коммерции в Яндекс.МетрикеВстречайте официальные плагины для быстрой установки электронной коммерции в Яндекс.Метрике Каждому владельцу интернет-магазина важно отслеживать не только посещаемость сайта, но и то, как пользователи взаимодействуют с товарами и что покупают. Ответить на эти вопросы помогает Яндекс.Метрика и опция «Электронная коммерция».Для подключения […]
  • Брутфорс хэшей в Active DirectoryБрутфорс хэшей в Active Directory Слабые пароли пользователей — очень распространенная проблема, которая может позволить злоумышленнику повысить свои привилегии и закрепиться в сети компании.Чтобы этого не допустить, необходимо регулярно анализировать стойкость паролей пользователей.У системных администраторов нет […]
  • Сделали «ФИАС» на основе ГАР. Пока он открыт для всехСделали «ФИАС» на основе ГАР. Пока он открыт для всех Если у вас есть учетные системы, которые работают на ФИАСе и не понимают ГАР, вы можете бесплатно получать обновления у нас. Ссылка внутри поста.Зачем понадобился еще один ФИАСС 31 августа ФНС перестала обновлять ФИАС — Федеральную информационную адресную систему. Остался ГАР — […]
  • Представлен «фанатский» Samsung Galaxy Tab S7 FE 5GПредставлен «фанатский» Samsung Galaxy Tab S7 FE 5G Компания Samsung Electronics без особого шума и специальных анонсов начала продажи своего первого планшета «фанатской» линейки — им стал Galaxy Tab S7 FE (Fan Edition). Планшет уже предлагается в Германии по цене 649 евро за версию с 4 ГБ оперативной памяти и 64 ГБ встроенной […]