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:
- В Unity нажмите « Окно» > «Диспетчер пакетов », чтобы открыть окно «Диспетчер пакетов».
- Нажмите + (знак плюса) в верхнем левом углу окна и выберите Добавить пакет из URL-адреса git , чтобы отобразить текстовое поле.
- Введите или вставьте
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
принимает форму узла. Входные и/или выходные соединения позволяют узлу связываться с любым или всеми другими узлами в нашем графе.
Представим узел с тремя входными значениями: двумя произвольными и одним логическим. Узел будет выводить одно из двух входных значений произвольного типа, в зависимости от того, является ли логическое значение истинным или ложным.
Чтобы преобразовать наш существующий 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.
Чтобы создать состояния «
Chase
и «Patrol
»:Щелкните правой кнопкой мыши и выберите Состояние , чтобы создать новый узел.
Назовите узел
Chase
.Вернитесь в раскрывающееся меню, снова выберите Состояние , чтобы создать второй узел.
Назовите узел
Patrol
.Перетащите существующие действия «
Chase
» и «Patrol
» в их вновь созданные соответствующие состояния.
Чтобы создать переход:
Щелкните правой кнопкой мыши и выберите « Переход », чтобы создать новый узел.
Назначьте объект
LineOfSightDecision
полюDecision
перехода.
Чтобы создать узел
RemainInState
:- Щелкните правой кнопкой мыши и выберите RemainInState , чтобы создать новый узел.
Для подключения графа:
Соедините выход
Transitions
узлаPatrol
с входомEntry
узлаTransition
.Соедините выход
True State
узлаTransition
с входомEntry
узлаChase
.Соедините выход
False State
узлаTransition
с входомEntry
узлаRemain In State
.
График должен выглядеть так:
Ничто на графике не указывает, какой узел — состояние « 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:
- Щелкните правой кнопкой мыши и выберите Initial Node , чтобы создать новый узел.
- Присоедините выход FSM Node к входу
Entry
узлаPatrol
.
Теперь график должен выглядеть так:
Чтобы облегчить себе жизнь, добавим в 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:
Откройте ресурс SampleScene.
Найдите наш игровой объект
Enemy
в окне иерархии Unity.Замените компонент
BaseStateMachine
компонентомBaseStateMachineGraph
:Щелкните Добавить компонент и выберите правильный сценарий
BaseStateMachineGraph
.Назначьте наш FSM-граф
EnemyGraph
в полеGraph
компонентаBaseStateMachineGraph
.Удалите компонент
BaseStateMachine
(так как он больше не нужен), щелкнув правой кнопкой мыши и выбрав Remove Component .
Игровой объект Enemy
должен выглядеть так:
Вот и все! Теперь у нас есть модульный автомат с графическим редактором. Нажатие кнопки « Воспроизвести» показывает, что графически созданный ИИ противника работает точно так же, как наш ранее созданный враг ScriptableObject
.
Движение вперед: оптимизация нашего FSM
Предостережение: по мере того, как вы разрабатываете более сложный ИИ для своей игры, количество состояний и переходов увеличивается, а FSM становится запутанным и трудным для чтения. Графический редактор становится похожим на паутину линий, которые начинаются в нескольких состояниях и заканчиваются несколькими переходами, и наоборот, что затрудняет отладку FSM.
Как и в предыдущем уроке, мы предлагаем вам создать собственный код, оптимизировать стелс-игру и решить эти проблемы. Представьте, насколько полезным было бы покрасить узлы состояния, чтобы указать, является ли узел активным или неактивным, или изменить размер узлов RemainInState
и Initial
, чтобы ограничить их пространство на экране.
Такие улучшения носят не только косметический характер. Ссылки на цвет и размер помогут определить, где и когда проводить отладку. График, который удобен для глаз, также легче оценивать, анализировать и понимать. Любые дальнейшие шаги зависят от вас — с основой нашего графического редактора нет предела возможностям для разработчиков.
Дальнейшее чтение в блоге Toptal Engineering:
- 10 самых распространенных ошибок, которые совершают разработчики Unity
- Unity с MVC: как повысить уровень разработки игр
- Освоение 2D-камер в Unity: руководство для разработчиков игр
- Лучшие практики и советы по Unity от разработчиков Toptal
Блог Toptal Engineering выражает благодарность Горану Лаличу за его опыт и техническую рецензию на эту статью.