Unity AI Development: Учебное пособие по графическому FSM на основе xNode

Опубликовано: 2022-08-12

В «Разработке искусственного интеллекта в Unity: учебное пособие по конечной машине» мы создали простую стелс-игру — модульный искусственный интеллект на основе FSM. В игре вражеский агент патрулирует игровое пространство. Когда он замечает игрока, враг меняет свое состояние и следует за игроком вместо патрулирования.

На этом втором этапе нашего пути к Unity мы создадим графический пользовательский интерфейс (GUI) для более быстрого создания основных компонентов нашего конечного автомата (FSM) с улучшенным интерфейсом разработчиков Unity.

Быстрое обновление

Конечный автомат, подробно описанный в предыдущем руководстве, был построен из архитектурных блоков в виде сценариев C#. Мы добавили пользовательские действия и решения ScriptableObject в виде классов. Подход ScriptableObject позволил легко поддерживать и настраивать FSM. В этом руководстве мы заменяем перетаскиваемые ScriptableObject FSM на графическую опцию.

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

Начало работы с xNode

Мы создадим наш графический редактор, используя xNode, фреймворк для деревьев поведения на основе узлов, который будет визуально отображать поток нашего FSM. Хотя GraphView от Unity может выполнить эту работу, его API экспериментальный и скудно документированный. Пользовательский интерфейс xNode обеспечивает превосходный опыт разработчика, облегчая создание прототипов и быстрое расширение нашего FSM.

Давайте добавим xNode в наш проект как зависимость Git с помощью диспетчера пакетов Unity:

  1. В Unity нажмите « Окно» > «Диспетчер пакетов », чтобы открыть окно «Диспетчер пакетов».
  2. Нажмите + (знак плюса) в верхнем левом углу окна и выберите Добавить пакет из URL-адреса git , чтобы отобразить текстовое поле.
  3. Введите или вставьте https://github.com/siccity/xNode.git в текстовое поле без метки и нажмите кнопку « Добавить ».

Теперь мы готовы углубиться и понять ключевые компоненты xNode:

Класс Node Представляет узел, самую фундаментальную единицу графа. В этом руководстве по xNode мы получаем из класса Node новые классы, которые объявляют узлы, оснащенные пользовательскими функциями и ролями.
класс NodeGraph Представляет набор узлов (экземпляры класса Node ) и ребер, которые их соединяют. В этом руководстве по xNode мы получаем из NodeGraph новый класс, который управляет узлами и оценивает их.
Класс NodePort Представляет коммуникационный шлюз, порт ввода или вывода типа, расположенный между экземплярами Node в NodeGraph . Класс NodePort уникален для xNode.
[Input] атрибут Добавление атрибута [Input] к порту обозначает его как вход, что позволяет порту передавать значения узлу, частью которого он является. Думайте об атрибуте [Input] как о параметре функции.
[Output] атрибут Добавление атрибута [Output] к порту определяет его как выход, что позволяет порту передавать значения от узла, частью которого он является. Думайте об атрибуте [Output] как о возвращаемом значении функции.

Визуализация среды построения xNode

В xNode мы работаем с графами, где каждое State и Transition принимает форму узла. Входные и/или выходные соединения позволяют узлу связываться с любым или всеми другими узлами в нашем графе.

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

Узел Branch, представленный большим прямоугольником в центре, содержит псевдокод «If C == True A Else B». Слева находятся три прямоугольника, каждый из которых имеет стрелку, указывающую на узел Branch: «A (произвольный)», «B (произвольный)» и «C (логический)». Наконец, узел Branch имеет стрелку, указывающую на прямоугольник «Выход».
Пример узла Branch

Чтобы преобразовать наш существующий FSM в граф, мы модифицируем классы State и Transition , чтобы они наследовали класс Node вместо класса ScriptableObject . Мы создаем объект графа типа NodeGraph , который будет содержать все наши объекты State и Transition .

Изменение BaseStateMachine для использования в качестве базового типа

Начните создавать графический интерфейс, добавив два новых виртуальных метода в наш существующий класс BaseStateMachine :

Init Присваивает начальное состояние свойству CurrentState .
Execute Выполняет текущее состояние

Объявление этих методов виртуальными позволяет нам переопределить их, поэтому мы можем определить собственное поведение классов, наследующих класс BaseStateMachine , для инициализации и выполнения:

 using System; using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private Dictionary<Type, Component> _cachedComponents; private void Awake() { Init(); _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { Execute(); } public virtual void Init() { CurrentState = _initialState; } public virtual void Execute() { CurrentState.Execute(this); } // Allows us to execute consecutive calls of GetComponent in O(1) time public new T GetComponent<T>() where T : Component { if(_cachedComponents.ContainsKey(typeof(T))) return _cachedComponents[typeof(T)] as T; var component = base.GetComponent<T>(); if(component != null) { _cachedComponents.Add(typeof(T), component); } return component; } } }

Далее в нашей папке FSM создадим:

FSMGraph Папка
BaseStateMachineGraph Класс AC# в FSMGraph

На данный момент BaseStateMachineGraph будет наследовать только класс BaseStateMachine :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

Мы не можем добавить функциональность в BaseStateMachineGraph , пока не создадим наш базовый тип узла; давайте сделаем это дальше.

Реализация NodeGraph и создание базового типа узла

В нашей недавно созданной папке FSMGraph мы создадим:

FSMGraph Класс

На данный момент FSMGraph унаследует только класс NodeGraph (без дополнительных функций):

 using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }

Прежде чем мы создадим классы для наших узлов, добавим:

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

Класс FSMNodeBase будет содержать ввод с именем Entry типа FSMNodeBase , чтобы мы могли соединять узлы друг с другом.

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

GetFirst Извлекает первый узел, подключенный к запрошенному выходу
GetAllOnPort Извлекает все оставшиеся узлы, которые подключаются к запрошенному выходу
 using System.Collections.Generic; using XNode; namespace Demo.FSM.Graph { public abstract class FSMNodeBase : Node { [Input(backingValue = ShowBackingValue.Never)] public FSMNodeBase Entry; protected IEnumerable<T> GetAllOnPort<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); for (var portIndex = 0; portIndex < port.ConnectionCount; portIndex++) { yield return port.GetConnection(portIndex).node as T; } } protected T GetFirst<T>(string fieldName) where T : FSMNodeBase { NodePort port = GetOutputPort(fieldName); if (port.ConnectionCount > 0) return port.GetConnection(0).node as T; return null; } } }

В конечном итоге у нас будет два типа узлов состояния; давайте добавим класс для их поддержки:

BaseStateNode Базовый класс для поддержки как StateNode , так и RemainInStateNode
 namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }

Затем измените класс BaseStateMachineGraph :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }

Здесь мы скрыли свойство CurrentState , унаследованное от базового класса, и изменили его тип с BaseState на BaseStateNode .

Создание строительных блоков для нашего FSM-графа

Затем, чтобы сформировать основные строительные блоки нашего FSM, давайте добавим три новых класса в нашу папку FSMGraph :

StateNode Представляет состояние агента. При выполнении StateNode TransitionNode , подключенные к выходному порту StateNode (полученные вспомогательным методом). StateNode запрашивает каждый из них, следует ли перевести узел в другое состояние или оставить состояние узла как есть.
RemainInStateNode Указывает, что узел должен оставаться в текущем состоянии.
TransitionNode Принимает решение перейти в другое состояние или остаться в том же состоянии.

В предыдущем руководстве по Unity FSM класс State выполняет итерацию по списку переходов. Здесь, в xNode, StateNode служит эквивалентом State для перебора узлов, полученных с помощью нашего вспомогательного метода GetAllOnPort .

Теперь добавьте атрибут [Output] к исходящим соединениям (узлам перехода), чтобы указать, что они должны быть частью графического интерфейса. По замыслу xNode значение атрибута исходит из исходного узла: узла, содержащего поле, помеченное атрибутом [Output] . Поскольку мы используем атрибуты [Output] и [Input] для описания отношений и соединений, которые будут установлены графическим интерфейсом xNode, мы не можем обрабатывать эти значения, как обычно. Рассмотрим, как мы итерируем Actions по сравнению с Transitions :

 using System.Collections.Generic; namespace Demo.FSM.Graph { [CreateNodeMenu("State")] public sealed class StateNode : BaseStateNode { public List<FSMAction> Actions; [Output] public List<TransitionNode> Transitions; public void Execute(BaseStateMachineGraph baseStateMachine) { foreach (var action in Actions) action.Execute(baseStateMachine); foreach (var transition in GetAllOnPort<TransitionNode>(nameof(Transitions))) transition.Execute(baseStateMachine); } } }

В этом случае к выходу Transitions может быть присоединено несколько узлов; мы должны вызвать вспомогательный метод GetAllOnPort , чтобы получить список соединений [Output] .

RemainInStateNode — это, безусловно, наш самый простой класс. Не выполняя никакой логики, RemainInStateNode просто указывает нашему агенту — в случае нашей игры, противнику — оставаться в текущем состоянии:

 namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }

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

Чтобы построить TransitionNode , нам нужно обойти требование xNode, чтобы значение вывода исходило из исходного узла — как мы сделали, когда создавали StateNode . Основное различие между StateNode и TransitionNode заключается в том, что выходные данные TransitionNode могут присоединяться только к одному узлу. В нашем случае GetFirst извлечет один узел, подключенный к каждому из наших портов (один узел состояния для перехода в истинном случае и другой для перехода в ложном случае):

 namespace Demo.FSM.Graph { [CreateNodeMenu("Transition")] public sealed class TransitionNode : FSMNodeBase { public Decision Decision; [Output] public BaseStateNode TrueState; [Output] public BaseStateNode FalseState; public void Execute(BaseStateMachineGraph stateMachine) { var trueState = GetFirst<BaseStateNode>(nameof(TrueState)); var falseState = GetFirst<BaseStateNode>(nameof(FalseState)); var decision = Decision.Decide(stateMachine); if (decision && !(trueState is RemainInStateNode)) { stateMachine.CurrentState = trueState; } else if(!decision && !(falseState is RemainInStateNode)) stateMachine.CurrentState = falseState; } } }

Давайте посмотрим на графические результаты нашего кода.

Создание визуального графика

Разобравшись со всеми классами FSM, мы можем приступить к созданию графа FSM для вражеского агента игры. В окне проекта Unity щелкните правой кнопкой мыши папку EnemyAI и выберите: Create > FSM > FSM Graph . Чтобы наш график было легче идентифицировать, давайте переименуем его в EnemyGraph .

В окне редактора xNode Graph щелкните правой кнопкой мыши, чтобы открыть раскрывающееся меню со списком State , Transition и RemainInState . Если окно не отображается, дважды щелкните файл EnemyGraph , чтобы открыть окно редактора xNode Graph.

  1. Чтобы создать состояния « Chase и « Patrol »:

    1. Щелкните правой кнопкой мыши и выберите Состояние , чтобы создать новый узел.

    2. Назовите узел Chase .

    3. Вернитесь в раскрывающееся меню, снова выберите Состояние , чтобы создать второй узел.

    4. Назовите узел Patrol .

    5. Перетащите существующие действия « Chase » и « Patrol » в их вновь созданные соответствующие состояния.

  2. Чтобы создать переход:

    1. Щелкните правой кнопкой мыши и выберите « Переход », чтобы создать новый узел.

    2. Назначьте объект LineOfSightDecision полю Decision перехода.

  3. Чтобы создать узел RemainInState :

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

    1. Соедините выход Transitions узла Patrol с входом Entry узла Transition .

    2. Соедините выход True State узла Transition с входом Entry узла Chase .

    3. Соедините выход False State узла Transition с входом Entry узла Remain In State .

График должен выглядеть так:

Четыре узла, представленные в виде четырех прямоугольников, каждый из которых имеет входные круги ввода в верхней левой части. Слева направо в узле состояния патрулирования отображается одно действие: действие патрулирования. Узел состояния «Патруль» также включает в себя выходной круг «Переходы» в нижней правой части, который соединяется с кругом «Вход» узла «Переход». Узел Transition отображает одно решение: LineOfSight. Он имеет два выходных круга в нижней правой части: истинное состояние и ложное состояние. Истинное состояние соединяется с кругом входа нашей третьей структуры, узлом состояния погони. Узел состояния Chase отображает одно действие: Chase Action. Узел состояния Chase имеет выходной круг Transitions. Второй из двух выходных кругов Transition, False State, соединяется с кругом Entry нашей четвертой и последней структуры, узла RemainInState (который появляется под узлом состояния Chase).
Начальный взгляд на наш график FSM

Ничто на графике не указывает, какой узел — состояние « Patrol » или « Chase » — является нашим начальным узлом. Класс BaseStateMachineGraph обнаруживает четыре узла, но при отсутствии индикаторов не может выбрать начальное состояние.

Чтобы решить эту проблему, давайте создадим:

FSMInitialNode Класс, единственный вывод типа StateNode которого называется InitialNode

Наш вывод InitialNode обозначает начальное состояние. Далее в FSMInitialNode создайте:

NextNode Свойство, позволяющее нам получить узел, подключенный к выходу InitialNode
 using XNode; namespace Demo.FSM.Graph { [CreateNodeMenu("Initial Node"), NodeTint("#00ff52")] public class FSMInitialNode : Node { [Output] public StateNode InitialNode; public StateNode NextNode { get { var port = GetOutputPort("InitialNode"); if (port == null || port.ConnectionCount == 0) return null; return port.GetConnection(0).node as StateNode; } } } }

Теперь, когда мы создали класс FSMInitialNode , мы можем подключить его к входу Entry начального состояния и вернуть начальное состояние через свойство NextNode .

Вернемся к нашему графу и добавим начальный узел. В окне редактора xNode:

  1. Щелкните правой кнопкой мыши и выберите Initial Node , чтобы создать новый узел.
  2. Присоедините выход FSM Node к входу Entry узла Patrol .

Теперь график должен выглядеть так:

Тот же график, что и на предыдущем изображении, с одним добавленным зеленым прямоугольником FSM Node слева от четырех других прямоугольников. У него есть выход начального узла (обозначен синим кругом), который соединяется с входом «Вход» узла патрулирования (обозначен темно-красным кружком).
Наш FSM-граф с начальным узлом, присоединенным к состоянию патрулирования

Чтобы облегчить себе жизнь, добавим в FSMGraph :

InitialState Недвижимость

Когда мы в первый раз пытаемся получить значение свойства InitialState , метод получения свойства будет проходить через все узлы в нашем графе, пытаясь найти FSMInitialNode . Как только FSMInitialNode находится, мы используем свойство NextNode , чтобы найти наш узел начального состояния:

 using System.Linq; using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public sealed class FSMGraph : NodeGraph { private StateNode _initialState; public StateNode InitialState { get { if (_initialState == null) _initialState = FindInitialStateNode(); return _initialState; } } private StateNode FindInitialStateNode() { var initialNode = nodes.FirstOrDefault(x => x is FSMInitialNode); if (initialNode != null) { return (initialNode as FSMInitialNode).NextNode; } return null; } } }

Далее, в нашем BaseStateMachineGraph давайте сошлемся на FSMGraph и переопределим методы Init и Execute нашего BaseStateMachine . Переопределение Init устанавливает CurrentState в качестве начального состояния графа, а переопределение Execute вызывает Execute на CurrentState :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { [SerializeField] private FSMGraph _graph; public new BaseStateNode CurrentState { get; set; } public override void Init() { CurrentState = _graph.InitialState; } public override void Execute() { ((StateNode)CurrentState).Execute(this); } } }

Теперь давайте применим график к объекту Enemy и посмотрим на него в действии.

Тестирование FSM-графа

При подготовке к тестированию в окне проекта редактора Unity:

  1. Откройте ресурс SampleScene.

  2. Найдите наш игровой объект Enemy в окне иерархии Unity.

  3. Замените компонент BaseStateMachine компонентом BaseStateMachineGraph :

    1. Щелкните Добавить компонент и выберите правильный сценарий BaseStateMachineGraph .

    2. Назначьте наш FSM-граф EnemyGraph в поле Graph компонента BaseStateMachineGraph .

    3. Удалите компонент BaseStateMachine (так как он больше не нужен), щелкнув правой кнопкой мыши и выбрав Remove Component .

Игровой объект Enemy должен выглядеть так:

Сверху вниз на экране Инспектора рядом с Врагом есть галочка. «Игрок» выбран в раскрывающемся списке «Тег», «Враг» выбран в раскрывающемся списке «Слой». Раскрывающийся список «Преобразование» показывает положение, поворот и масштаб. Раскрывающееся меню Capsule сжато, а раскрывающиеся списки Mesh Renderer, Capsule Collider и Nav Mesh Agent выглядят сжатыми с флажком слева от них. В раскрывающемся списке «Датчик прицела противника» отображаются «Сценарий» и «Маска игнорирования». В раскрывающемся списке PatrolPoints отображается сценарий и четыре точки патрулирования. Рядом с раскрывающимся списком Base State Machine Graph (Script) есть галочка. Сценарий показывает «BaseStateMachineGraph», «Исходное состояние» показывает «Нет (базовое состояние)», а «График» показывает «EnemyGraph (FSM Graph)». Это.
Игровой объект «Наш Enemy »

Вот и все! Теперь у нас есть модульный автомат с графическим редактором. Нажатие кнопки « Воспроизвести» показывает, что графически созданный ИИ противника работает точно так же, как наш ранее созданный враг ScriptableObject .

Движение вперед: оптимизация нашего FSM

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

Как и в предыдущем уроке, мы предлагаем вам создать собственный код, оптимизировать стелс-игру и решить эти проблемы. Представьте, насколько полезным было бы покрасить узлы состояния, чтобы указать, является ли узел активным или неактивным, или изменить размер узлов RemainInState и Initial , чтобы ограничить их пространство на экране.

Такие улучшения носят не только косметический характер. Ссылки на цвет и размер помогут определить, где и когда проводить отладку. График, который удобен для глаз, также легче оценивать, анализировать и понимать. Любые дальнейшие шаги зависят от вас — с основой нашего графического редактора нет предела возможностям для разработчиков.

Дальнейшее чтение в блоге Toptal Engineering:

  • 10 самых распространенных ошибок, которые совершают разработчики Unity
  • Unity с MVC: как повысить уровень разработки игр
  • Освоение 2D-камер в Unity: руководство для разработчиков игр
  • Лучшие практики и советы по Unity от разработчиков Toptal

Блог Toptal Engineering выражает благодарность Горану Лаличу за его опыт и техническую рецензию на эту статью.