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

Инди-команды не имея опыта в программировании собирают целые игры исключительно на нодах, а большие игры-сервисы удерживают исходники проекта в текстовом варианте? В нескольких проектах, с которыми я работал, были инструменты и системы визуального программирования: от древнего, тогда еще самостоятельного Bolt до монстров FlowCanvas/NodeCanvas и BluePrints в Unreal Engine.

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


Мотивация

Визуальное программирование

Paradox Notion разработала два решения для визуального программирования в Unity: FlowCanvas (далее FC) и NodeCanvas. В отличии от купленного Unity Bolt-a они имеют расширенный функционал, активно поддерживаются и чаще востребованы в больших и коммерческих проектах. NodeCanvas оперирует древовидными структурами и применяется для стейт-машин (state machine), диалоговых (dialogue tree) и поведенческих деревьев (behaviour tree, AI). Основа FlowCanvas – поток [данных / управления] (flow), позволяющий реализовывать более комплексные и даже системные вещи, аналогично BluePrints в UE.

Bolt и продукты Paradox Notion позиционируются как no-code решения для любых проектов. Используются часто соло- и инди-разработчиками, не имеющими навыков программирования или как инструменты [геймдизайнеров] для отдельных подсистем в технически сложных проектах.

Технический геймдизайн

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

Технический геймдизайнер (далее TGD) работает в движке, но он не должен погружаться в исходный код проекта. Задача разработчиков – предоставить ему компоненты для сборки персонажей, уровней, механик и инструменты работы с ними.

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

в геймдеве ты должен знать обо всем понемногу

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

Именно в точке пересечения программистов и TGD появляются и применяются визуальное программирование, кастомные редакторы, прочие инструменты, и расширения движка. Считаю, что FlowCanvas – не замена C#-коду, а инструмент, имеющий ареал своего применения. Далее покажу показания к его применению и способы встраивания сценариев визуального программирования в архитектуру проекта.

Сценарий, логика уровня и связи сцены

Обозначим две важные предпосылки:

  1. Любой код, компонент, инструмент разрабатываются от потребителя. Кому это нужно? Задачи TGD – сборка уровней и механик, выстраивание и перестраивание их логики, сценариев.

  2. Потребность TGD – «выстраивание верхнеуровневой логики, сценария и связей уровня». Фиксируем эту потребность и формулировку.

Значит, в FC мы выносим высокоуровневую логику (сценарий) уровня, но не логику (алгоритмы) отдельных игровых объектов далее (GO) и компонентов. Такая логика часто меняется в процессе разработки и должна быть доступна для TGD напрямую в движке, без погружения в код. Берем только те связи, которые участвуют в сценарии и прогрессе уровня, игры: локальные интеракции (выключатель - лампочка), не меняющие прогресс уровня и/или истории, связываются напрямую в Инспекторе. Такое разделение позволит избежать перегрузки FC ответственностями и предотвратит появление нечитаемых спагетти-связей, показанных на заглавном рисунке.

В сценарий уровня выносятся:

  • диалоги,

  • катсцены,

  • события, влияющие на прогресс уровня.

Не стоит реализовывать в визуальном программировании:

  • отдельные локальные и контекстные интеракции

  • поведение персонажей и игрока

  • управление.

Все еще, как и в MonoBeh-ах, дурным тоном считается привязка ко времени и хардкод задержек.

Было бы хорошо инкапсулировать сценарий FC от кода и компонентов, предоставить API для управления им извне и организации потоков информации в/из сценария от/к GO сцены.


2. Архитектурные условия

Проект построен по сервисной архитектуре, с использованием Zenject, поделен на три основные секции:

  • Infrastructure – провайдеры и сервисы, общее и вспомогательное;

  • Meta – мета-геймплей и меню, MV*-паттерны, связанное с UI;

  • (Core) Gameplay3C, компоненты игровых объектов.

Все сервисы и провайдеры регистрируются в нужном DI-контейнере по интерфейсу и попадают в конструкторы классов компонентов также по интерфейсу.

Жизненный цикл игры поделен на состояния. В состоянии LoadLevelState подготавливается уровень: загружается сцена, инстанциируются и/или инициализируются игровые объекты и их компоненты. По завершению этого этапа стейт-машина игры (GameStateMachine, GSM) переходит в состояние GameLoopState, в методе Enter которого сцене и объектам на ней передаются управляющие сигналы для начала работы. Таким образом и проект, и каждый уровень в отдельности имеют явно определенную точку входа (entry point) и полностью управляемый и предсказуемый жизненный цикл.

От сценария уровня, реализованного во FC, ожидаем такого же детерминированного поведения и наличия entry point. Сценарий обязан запускаться в том же методе Enter состояния GameLoopState.


3. FlowCanvas и его API

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

FlowScriptController и Blackboard

Каждый сценарий (FlowScript) управляется компонентом FlowScriptController, наследуемым от MonoBehavior (через Grpah). Скрипт может храниться либо в связке с контроллером (bounded graph), либо отдельным ассетом, экземпляром FlowScript, наследуемым через от ScriptableObject (через Graph). Внутри, очевидно, содержится сценарий в графовом представлении: логические узлы (ноды, Node, FlowNode, FlowControlNode) и связи между ними.

Данные, с которыми работают отдельные ноды сценария хранятся в специальном компоненте Blackboard (наследнике MonoBehavior). Таких контроллеров со сценариями и досками переменных может быть несколько на сцене, и каждый из них будет работать независимо, со своим MonoBeh-похожим жизненным циклом.

Данные, Flow и Nodes в сценариях

Внутри сценария управление и данные передаются между нодами с помощью потока (Flow). Для этого у нод есть входные (InputPorts) и выходные (OutputPorts) порты. Порты делятся на два типа – для потока (FlowPort) и для данных (ValuePort).

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

К ValuePort присоединяются ссылки на переменные из Blackboard. Типы данных, разумеется, должны соответствовать. Также [временные] данные можно записывать (WriteParameter) и читать (ReadParameter) из потока. Поток передается через выходной порт, когда для него (потока) вызывается метод Call, c параметром выходного порта (int).

Ноды делятся на несколько типов:

  1. Events – для реакции на подсистемы (Input/Colliders/Animator…) Unity и обработки коллбеков из кода (UnityEvent/C#-event/delegate).

  2. Flow controllers – встроенные управляющие [потоком] ноды.

  3. Functions – встроенные функции (логические операции, действия с Unity-объектами, таймеры).

  4. Variables – для обработки (get/set) данных графа и Blackboard.

  5. Macros – макросы, например. Для группировки подграфов из нод и использования их как шаблонов одной нодой.

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

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

Custom Nodes

В документации FC рассматриваются примеры реализации простых нод

  • CallableActionNode<T> (с перегрузкой void Invoke(T arg)),

  • CallableFunctionNode<TResult> (TResult Invoke()),

  • LatentActionNode (IEnumerator Invoke(T arg)),

  • PureFunctionNode<TResult> (TResult Invoke()).

Количество передаваемых T параметров – от 0 до 10. Различаются они способом вызова внутренней логики Invoke (метод / корутина) и наличием возвращаемого значения.

Отдельной группой рассматриваются событийные ноды, наследуемые от EventNode, а для полного доступа к потоку и графу сценария предлагается расширять класс FlowControlNode.

Разрабатываемые далее ноды будут устроены по принципу черного ящика:

  1. они должны получить входной поток,

  2. обработать его согласно внутренней логике и связям со сценой

  3. и передать управление далее, в выходные порты.

Некоторые ноды должны так же контролировать свое внутреннее состояние, согласно состоянию всего графа сценария – реагировать на его

  1. запуск и

  2. остановку.

Поэтому из всех методов FlowControlNode нас будут интересовать для перегрузки

  1. void RegisterPorts(),

  2. void OnGraphStarted(),

  3. void OnGraphStoped() (орфография Paradox Notion сохранена).

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

[GatherPortsCallback]
[SerializeField, ExposeField, DelayedField] 
private bool _hasWhen = false;

private bool _open = false;

public override string name =>
  $"{base.name} " + (_hasWhen ? _open ? "[ON]" : "[OFF]" : ""); 

А также указать имя и цвет через атрибуты:

[Name("Run dialogue")]
[Color("fce99f")]

4. Способы связывания

События в C#, Unity и FlowCanvas

В случае, если системы слишком разнородны, или возникает необходимость менять связи между ними во время выполнения, прямой вызов методов одной системы из другой – не подходит. Альтернатива этому – механизм событий, реализующий слабосвязанный, динамически связанный код. В C# это связано с делегатами (delegate, ссылка на метод), ключевым словом event и предопределёнными делегатами Action, Predicate и Func.

В Unity добавлены специальные [сериализуемые] UnityEvent и UnityAction, позволяющий добавлять и удалять подписчиков прямо в Инспекторе.

Самый простой способ, которым предлагается связать FC с Unity-миром – EventNode. Такое же событие, но со стороны сценария, которое настраивается в окне графа.

Резюмируя, можно сказать, что событийно-ориентированное программирование – способ реализации слабо связанных систем, в Unity имеющий возможность редактирования без погружения в код. Из такого определения СОП очевидны и его недостатки: злоупотребление событиями приводит к плохо связанному коду, в котором сложно идентифицировать связи и зависимости между объектами, информационные потоки. Отладка такого кода крайне затруднительна. Если же механизм подписок вынесен в редактор, то дебаг средствами IDE становится невозможен. Требуется осмотр всех потенциальных участников в инспекторе.

Терминальная стадия злоупотребления событиями – шина событий (signal / event bus). К слабосвязанному коду добавляется механизм глобального доступа: кто угодно может отправить событие или подписаться на него.

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

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

Наблюдатели и Активаторы

Рассмотрим пример, когда в одном префабе необходимо иметь несколько триггерных коллайдеров, и различать их между собой. Здесь, у врага определены зона обнаружения (AggroZone) и зона поражения (AttackZone), которые проще моделировать и настраивать через коллайдеры, а не аналитически:

Триггерные зоны префаба Transistor: на каждой - отдельный компонент TriggerObserver
Триггерные зоны префаба Transistor: на каждой - отдельный компонент TriggerObserver

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

Связи компонентов TriggerObserver с компонентами игровой логики префаба Transistor
Связи компонентов TriggerObserver с компонентами игровой логики префаба Transistor
[RequireComponent(typeof(Collider))]
public class TriggerObserver : MonoBehaviour 
{
  public event Action<Collider> TriggerEnter;
  public event Action<Collider> TriggerExit;

  private void OnTriggerEnter(Collider other) =>
    TriggerEnter?.Invoke(other);

  private void OnTriggerExit(Collider other) =>
    TriggerExit?.Invoke(other);
}

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

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

public class AttackRange : MonoBehaviour
{
  [SerializeField] private TriggerObserver attackTrigger;
  [SerializeField] private EnemyAttack attackComponent;
        
  private void Start()
  {
    attackTrigger.TriggerEnter += TriggerEnter;
    attackTrigger.TriggerExit += TriggerExit;
  }

  private void OnDestroy()
  {
    attackTrigger.TriggerEnter -= TriggerEnter;
    attackTrigger.TriggerExit -= TriggerExit;
  }

  private void TriggerEnter(Collider other) => 
    attackComponent.Activate();

  private void TriggerExit(Collider other) => 
    attackComponent.Deactivate();
}

В контексте обсуждаемой темы пример интересен тем, что доступ к конкретному событию Наблюдателя имеется ровно в одном, зависимом от Наблюдателя компоненте. Жизненный цикл их связи привязан к жизненному циклу (Start, OnDestroy) компонента. Ссылки attackTrigger и aggroTrigger, заполняемые в инспекторе ограничены контекстом префаба.

public class AggroRange : MonoBehaviour
{
  [SerializeField] private TriggerObserver aggroTrigger;
  [SerializeField] private List<EnemyFollowBase> followComponents;

  private void Start() 
  {
    aggroTrigger.TriggerEnter += TriggerEnter;
    aggroTrigger.TriggerExit += TriggerExit;
  }

  private void OnDestroy()
  {
    aggroTrigger.TriggerEnter -= TriggerEnter;
    aggroTrigger.TriggerExit -= TriggerExit;
  }

  private void TriggerEnter(Collider other) =>
    followComponents
      .ForEach(fc => fc
        .FollowTo());

  private void TriggerExit(Collider other) => 
    followComponents
      .ForEach(fc => fc
        .Stop());
}

Для связывания FC-сценария и GO сцены будет использоваться похожий механизм, построенный на компонентах-Активаторах.


5. Реализация

Схема управления потоком

Объекты сцены и сценарий должны быть связаны двунаправленно:

  1. Если поток доходит до определенной ноды – необходимо сообщить об этом объекту на сцене, активировать объект или действие с ним. MonoBeh-компонент, включаемый на сцене – GameObjectActivator.

  2. Поток в сценарии может прерываться до достижения определенного прогресса на сцене. Специальный объект на сцене должен активировать движение потока с определенной ноды далее по графу сценария. Компонент – FlowActivator.

Активация потока происходит следующим образом:

  1. Нода сценария FlowActivatorNode подписывается на событие OnActivate связанного Активатора (поле, Blackboard-переменная activator).

  2. GameObject на сцене вызывает метод Activate() компонента FlowActivator другого объекта.

  3. Внутри этого компонента происходит вызов события OnActivate.

  4. Опционально, нода отписывается от события OnActivate после активации, если выставлен флаг bool activateOnce.

Активация игровых объектов на сцене, инициируется потоком в ноде GameObjectActivatorNode и производится в похожем порядке:

  1. В редакторе на событие UnityAction OnActivate подписываются обработчики игровых объектов.

  2. В ноде GameObjectActivatorNode, при регистрации портов связывается появление входного потока с вызовом метода Activate() Активатора _target.

  3. В методе Activate() Активатора вызывается событие OnActivate.

Активаторы и их настройка

FlowActivator содержит в себе событие и вспомогательный метод:

public class FlowActivator : MonoBehaviour 
{
  /// Subscribe by flow nodes only
  public Action OnActivate { get; set; }

  /// Invoke by level game objects only
  public void Activate() => 
    OnActivate?.Invoke();
}

Используется не сериализуемое свойство Action, недоступное в инспекторе. Метод Activate() должен вызываться только в компонентах объектов сцены.

public class GameObjectActivator : MonoBehaviour 
{
  /// Subscribe by level game objects only
  [field: SerializeField] public UnityEvent OnActivate { get; set; }

  /// Subscribe by flow nodes only
  public event Action OnFeedback;
      
  /// Invoke by flow nodes only
  public void Activate() =>
    OnActivate?.Invoke();
        
  /// Invoke by level game objects only
  public void FeedbackToFlow() =>
    OnFeedback?.Invoke();
}

Для членов GameObjectActivator доступ перевернут: свойство OnActivate объявлено с типом UnityEvent и сериализуется через поле (атрибут [field: SerializeField]), а метод Activate() должен вызываться только из нод FC.

Кастомные ноды

В классе FlowActivatorNode определены следующие члены:

  • Булевы поля _activateOnce, _hasWhen и _closeOnActivate настраиваемые через инспекцию ноды в графе, и отвечающие за то, должен ли Активатор срабатывать однократно, иметь дополнительное условие активации и закрываться после срабатывания соответственно.

  • Закрытые поля для хранения ссылок _activatorPort и _whenPort на входные (основной и дополнительного условия) и выходной _outputPort порты.

  • Переопределено свойство name – зависит от состояния Активатора.

Переопределены методы

  • OnGraphStarted, в котором происходит подписка методов Активатора на событие связанного FlowActivator-компонента

  • и OnGraphStoped, сбрасывающий внутреннее состояние активатора в момент остановки графа.

В закрытых членах класса регистрируются порты ноды и происходит подписка методов Активатора на возникновение потока в ноде.

[Name("Flow Activator")][Category("NDA")]
[ContextDefinedInputs(typeof(bool))]
public class FlowActivatorNode : FlowControlNode 
{
  [GatherPortsCallback]
  [SerializeField, ExposeField, DelayedField]
  private bool _activateOnce = true;
        
  [GatherPortsCallback]
  [SerializeField, ExposeField, DelayedField] 
  private bool _hasWhen = false;

  [GatherPortsCallback][ShowIf("_hasWhen", 1)]
  [SerializeField, ExposeField, DelayedField]
  private bool _closeOnActivate = true;

  private ValueInput<FlowActivator> _activatorPort;
  private ValueInput<FlowActivator> _whenPort;
  private FlowOutput _outputPort;

  private bool _open = false;
  private bool _original;

  public override string name =>
    $"{base.name} " + (_hasWhen ? _open ? "[ON]" : "[OFF]" : "");

  protected override void RegisterPorts() 
  {
    RegisterInputs();
    RegisterOutputs();
  }

  public override void OnGraphStarted() 
  {
    _original = _open;
    _activatorPort.value.OnActivate += OnActivate;
    if (_hasWhen) 
      _whenPort.value.OnActivate += Open;
  }

  public override void OnGraphStoped()
  {
    _open = _original;
  }

  private void RegisterInputs() 
  {
    _activatorPort = AddValueInput<FlowActivator>("Activator");

    if (_hasWhen) 
      _whenPort = AddValueInput<FlowActivator>("When");
  }

  private void RegisterOutputs() 
  {
    _outputPort = AddFlowOutput("Out");
  }
  
  private void OnActivate()
  {
    if (!_hasWhen || (_hasWhen && _open))
    {
      _outputPort.Call(new Flow());
      Debug.Log(
        $"node activated by {_activatorPort.value.gameObject.name}" +
        $" [When={_hasWhen}, open={_open}, trigger={_closeOnActivate}");
                  
      if (_hasWhen && _closeOnActivate)
        Close();
      else if (_activateOnce)
        _activatorPort.value.OnActivate -= OnActivate;
    }
  }
  
  private void Open() => 
    _open = true;
  
  private void Close() => 
    _open = false;
}

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

  • GatherPortsCallback – при изменении значения поля пересобираются все порты ноды.

  • ExposeField – форсирует отображение поля в инспекторе, даже закрытого.

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

  • ShowIf – отображает поле в инспекторе только при указанном условии, значении другого поля. Аналогичен одноименному в OdinInspector.

Класс GameObjectActivatorNode:

[Name("Game Object Activator")][Category("NDA")]
[ContextDefinedInputs(typeof(bool))]
public class GameObjectActivatorNode : FlowControlNode
{
  [GatherPortsCallback]
  [SerializeField, ExposeField, DelayedField] 
  private bool _hasFeedback = false;
        
  private ValueInput<GameObjectActivator> _targetGameObject;
  private FlowInput _inputPort;
  private FlowOutput _outputPort;
        
  private bool _waitForFlow = true;
  private Flow _flow;
        
  protected override void RegisterPorts()
  {
    RegisterInputs();
    RegisterOutputs();
  }

  private void RegisterInputs()
  {
    _inputPort = AddFlowInput("In", (inputFlow) =>
    {
      _flow = inputFlow;
      if (_waitForFlow) 
      { 
        OnActivate(); 
        _waitForFlow = false; 
      }
    });

    _targetGameObject = AddValueInput<GameObjectActivator>("Target");
  }

  private void RegisterOutputs()
  {
    if (_hasFeedback) 
      _outputPort = AddFlowOutput("Out");
  }

  private void OnActivate()
  {
    _targetGameObject.value.Activate();
    Debug.Log(
      $"GameObject {_targetGameObject.value.gameObject.name} "+
      $"activated by Flow");
            
    if (_hasFeedback)
      _targetGameObject.value.OnFeedback += OnFeedback;
  }

  private void OnFeedback()
  {
    _outputPort.Call(_flow);
    _targetGameObject.value.OnFeedback -= OnFeedback;
    Debug.Log(
      $"Flow activated by feedback from {_targetGameObject.value.gameObject.name}");
  }
}

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


6. Использование

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

В этом примере взаимодействие с игровым объектом сцены terminal запускает активирует поток в ноде FlowActivator и запускает диалог с ключом d0060 в ноде RunDialogue. По окончанию всех реплик диалога поток выходит из этой ноды и попадает в ноду GameObjectActivator, запускающую реакцию в компоненте GameObjectActivator terminalExitReaction.

Инспекторы Активаторов в редакторе сценария:

FlowActivator
FlowActivator
GameObjectActivator
GameObjectActivator
Инспекция FlowActivator с условием when
Инспекция FlowActivator с условием when

Точка входа для уровня и активация сценария реализована, очевидно, простым отдельным Активатором:

Entry point сценария
Entry point сценария

Список всех переменных Blackboard для небольшого уровня (ничего лишнего, только Активаторы):


Референсы и контакты

Комментарии (0)