Разработка ИИ в Unity: Учебное пособие по конечному автомату

Опубликовано: 2022-03-11

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

Тенденции ИИ сместились на поведенческие деревья, но FSM остаются актуальными. Они включены — в том или ином качестве — практически в каждую электронную игру.

Анатомия конечного автомата

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

Компонент Описание
Состояние Один из конечного набора параметров, указывающих текущее общее состояние конечного автомата; любое данное состояние включает связанный набор действий
Действие Что делает состояние, когда FSM запрашивает его
Решение Логика, устанавливающая, когда происходит переход
Переход Процесс смены состояний

Хотя мы сосредоточимся на конечных автоматах с точки зрения реализации ИИ, такие понятия, как конечные автоматы анимации и общие игровые состояния, также попадают под зонтик конечных автоматов.

Визуализация конечного автомата

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

Призрак Pac-Man всегда находится в одном из двух состояний: преследует или уклоняется. Естественно, мы должны предусмотреть два перехода — один от погони к уклонению, другой от уклонения к погоне:

Диаграмма: Слева — состояние погони. Стрелка (указывающая, что игрок съел силовую таблетку) ведет к состоянию уклонения справа. Вторая стрелка (указывающая на то, что срок действия силовой таблетки истек) ведет обратно к состоянию погони слева.
Переходы между призрачными состояниями Pac-Man

Конечный автомат по своей конструкции запрашивает текущее состояние, которое запрашивает решение(я) и действие(я) этого состояния. Следующая диаграмма представляет наш пример Pac-Man и показывает решение, которое проверяет статус включения игрока. Если началось усиление, NPC переходят от преследования к уклонению. Если усиление закончилось, NPC переходят от уклонения к преследованию. Наконец, если нет изменения при включении питания, переход не происходит.

Ромбовидная диаграмма, представляющая цикл: слева начинается состояние погони, подразумевающее соответствующее действие. Затем состояние погони указывает на вершину, где находится решение: если игрок съел силовую таблетку, мы переходим к состоянию уклонения и уклоняемся от действия справа. Состояние уклонения указывает на решение внизу: если истекло время действия силовой таблетки, мы продолжаем возвращаться к исходной точке.
Компоненты Pac-Man Ghost FSM

Масштабируемость

Конечные автоматы позволяют нам создавать модульный ИИ. Например, всего одним новым действием мы можем создать NPC с новым поведением. Таким образом, мы можем приписать новое действие — поедание силовой гранулы — одному из наших призраков Pac-Man, дав ему возможность поедать силовые гранулы, уклоняясь от игрока. Мы можем повторно использовать существующие действия, решения и переходы для поддержки этого поведения.

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

Реализация FSM в Unity

Чтобы продемонстрировать, как реализовать автомат с конечным числом состояний в Unity, давайте создадим простую игру-невидимку. Наша архитектура будет включать ScriptableObject s, которые являются контейнерами данных, которые могут хранить информацию и обмениваться ею в приложении, поэтому нам не нужно ее воспроизводить. ScriptableObject могут выполнять ограниченную обработку, например, вызывать действия и запрашивать решения. В дополнение к официальной документации Unity, старый доклад Game Architecture with Scriptable Objects остается отличным ресурсом, если вы хотите погрузиться глубже.

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

Диаграмма: Семь блоков, которые соединяются друг с другом, описаны в порядке появления слева/сверху: Блок с меткой BaseStateMachine включает в себя + CurrentState: BaseState. BaseStateMachine соединяется с BaseState двунаправленной стрелкой. Поле с надписью BaseState включает + Execute(BaseStateMachine): void. BaseState соединяется с BaseStateMachine двунаправленной стрелкой. Однонаправленные стрелки из State и RemainInState соединяются с BaseState. Поле, помеченное как State, включает + Execute(BaseStateMachine): void, + Actions: List<Action> и + Transition: List<Transition>. Состояние соединяется с BaseState однонаправленной стрелкой, с Action однонаправленной стрелкой, помеченной «1», и с Transition однонаправленной стрелкой, помеченной «1». Поле с надписью RemainInState включает + Execute(BaseStateMachine): void. RemainInState соединяется с BaseState однонаправленной стрелкой. Поле с надписью Action включает + Execute(BaseStateMachine): void. Однонаправленная стрелка с надписью «1» от состояния соединяется с действием. Поле с надписью Transition включает + Decide(BaseStateMachine): void, + TransitionDecision: Decision, + TrueState: BaseState и + FalseState: BaseState. Переход соединяется с решением однонаправленной стрелкой. Однонаправленная стрелка с надписью «1» от состояния соединяется с переходом. Поле с надписью Решение включает + Decide(BaseStateMachine): bool.
Предлагаемая архитектура FSM

В нашем образце игры враг (NPC, представленный синей капсулой) патрулирует. Когда враг видит игрока (обозначен серой капсулой), он начинает преследовать игрока:

Диаграмма: Пять полей, которые соединяются друг с другом, описаны в порядке появления, слева/сверху: поле с надписью Patrol соединяется с полем с надписью IF игрок находится в прямой видимости однонаправленной стрелкой, а с полем с надписью Patrol Action — с однонаправленная стрелка с надписью «состояние». Поле с надписью IF player находится в пределах прямой видимости с дополнительной надписью «решение» сразу под полем. Поле с надписью ЕСЛИ игрок находится в пределах прямой видимости и соединяется с полем с надписью Погоня однонаправленной стрелкой. Однонаправленная стрелка из поля с надписью «Патруль» соединяется с полем с надписью «ЕСЛИ игрок находится в пределах прямой видимости». Поле с надписью Chase соединяется с полем с надписью Chase Action однонаправленной стрелкой, помеченной как «состояние». Однонаправленная стрелка из поля с надписью ЕСЛИ игрок находится в пределах прямой видимости соединяется с полем с надписью Погоня. Однонаправленная стрелка из поля с надписью Patrol соединяется с полем с надписью Patrol Action. Однонаправленная стрелка из поля с надписью Chase соединяется с полем с надписью Chase Action.
Основные компоненты нашего примера стелс-игры FSM

В отличие от Pac-Man, враг в нашей игре не вернется в состояние по умолчанию («патрулирование») после того, как будет следовать за игроком.

Создание классов

Начнем с создания наших классов. В новую папку scripts мы добавим все предлагаемые архитектурные стандартные блоки в виде сценариев C#.

Реализация класса BaseStateMachine

Класс BaseStateMachine — единственный MonoBehavior , который мы добавим для доступа к нашим NPC с поддержкой ИИ. Для простоты наша BaseStateMachine будет простой. Однако при желании мы могли бы добавить унаследованный пользовательский FSM, в котором хранятся дополнительные параметры и ссылки на дополнительные компоненты. Обратите внимание, что код не будет скомпилирован должным образом, пока мы не добавим наш класс BaseState , что мы сделаем позже в нашем руководстве.

Код для BaseStateMachine обращается к текущему состоянию и выполняет его для выполнения действий и проверки того, оправдан ли переход:

 using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } }

Реализация класса BaseState

Наше состояние имеет тип BaseState , который мы получаем от ScriptableObject . BaseState включает единственный метод Execute , принимающий BaseStateMachine в качестве аргумента и передающий ему действия и переходы. Вот как выглядит BaseState :

 using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } }

Реализация классов State и RemainInState

Теперь мы получаем два класса из BaseState . Во-первых, у нас есть класс State , который хранит ссылки на действия и переходы, включает в себя два списка (один для действий, другой для переходов), а также переопределяет и вызывает базовый Execute для действий и переходов:

 using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } }

Во-вторых, у нас есть класс RemainInState , который сообщает FSM, когда не выполнять переход:

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } }

Обратите внимание, что эти классы не будут компилироваться, пока мы не FSMAction , Decision и Transition .

Реализация класса FSMAction

На диаграмме предлагаемой архитектуры FSM базовый класс FSMAction помечен как «Действие». Однако мы создадим базовый класс FSMAction и будем использовать имя FSMAction (поскольку Action уже используется пространством имен .NET System ).

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

Абстрактный класс FSMAction выглядит следующим образом:

 using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } }

Реализация классов Decision и Transition

Чтобы закончить наш FSM, мы определим еще два класса. Во-первых, у нас есть Decision , абстрактный класс, из которого все остальные решения будут определять свое собственное поведение:

 using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } }

Второй класс, Transition , содержит объект Decision и два состояния:

  • Состояние, в которое следует перейти, если Decision возвращает true.
  • Другое состояние, в которое нужно перейти, если Decision дает false.

Это выглядит так:

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }

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

Создание пользовательских действий и решений

Теперь, когда тяжелая работа сделана, мы готовы реализовать пользовательские действия и решения в новой папке scripts .

Реализация классов Patrol и Chase

Когда мы анализируем диаграмму FSM «Основные компоненты нашего примера стелс-игры», мы видим, что наш NPC может находиться в одном из двух состояний:

  1. Состояние патрулирования . С состоянием связаны:
    • Одно действие: NPC посещает случайные точки патрулирования по всему миру.
    • Один переход: NPC проверяет, находится ли игрок в поле зрения и, если да, переходит в состояние погони.
    • Одно решение: NPC проверяет, находится ли игрок в поле зрения.
  2. Состояние погони — с состоянием связано:
    • Одно действие: NPC преследует игрока.

Мы можем повторно использовать нашу существующую реализацию перехода через графический интерфейс Unity, как мы обсудим позже. Это оставляет нам два действия ( PatrolAction и ChaseAction ) и решение для кода.

Действие состояния патрулирования (производное от базового FSMAction ) переопределяет метод Execute для получения двух компонентов:

  1. PatrolPoints , который отслеживает точки патрулирования.
  2. NavMeshAgent — реализация Unity для навигации в 3D-пространстве.

Затем переопределение проверяет, достиг ли агент ИИ своего пункта назначения, и, если да, переходит к следующему пункту назначения. Это выглядит так:

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } }

Мы можем рассмотреть возможность кэширования компонентов PatrolPoints и NavMeshAgent . Кэширование позволило бы нам совместно использовать ScriptableObject для действий между агентами без влияния на производительность запуска GetComponent для каждого запроса конечного автомата.

Чтобы было ясно, мы не можем кэшировать экземпляры компонентов в методе Execute . Поэтому вместо этого мы добавим пользовательский метод GetComponent в BaseStateMachine . Наш пользовательский GetComponent будет кэшировать экземпляр при первом вызове, возвращая кэшированный экземпляр при последовательных вызовах. Для справки, это реализация 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() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } 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; } } }

Как и его аналог PatrolAction , класс ChaseAction переопределяет метод Execute для получения компонентов PatrolPoints и NavMeshAgent . Однако, напротив, после проверки того, достиг ли агент ИИ своего пункта назначения, действие класса ChaseAction устанавливает пункт назначения Player.position :

 using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } }

Реализация класса InLineOfSightDecision

Последняя часть — это класс InLineOfSightDecision , который наследует базовый Decision и получает компонент EnemySightSensor для проверки того, находится ли игрок в зоне прямой видимости NPC:

 using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }

Присоединение поведения к состояниям

Наконец-то мы готовы прикрепить поведение к Enemy агенту. Они создаются в окне проекта редактора Unity.

Добавление состояний Patrol и Chase

Создадим два состояния и назовем их «Патруль» и «Погоня»:

  • Щелкните правой кнопкой мыши> Создать> FSM> Состояние

А пока давайте также создадим объект RemainInState :

  • Щелкните правой кнопкой мыши> Создать> FSM> Остаться в состоянии

Теперь пришло время создать действия, которые мы только что закодировали:

  • Щелкните правой кнопкой мыши> Создать> FSM> Действие> Патрулировать
  • Щелкните правой кнопкой мыши> Создать> FSM> Действие> Преследование

Чтобы закодировать Decision :

  • Щелкните правой кнопкой мыши> Создать> FSM> Решения> В пределах прямой видимости

Чтобы включить переход от PatrolState к ChaseState , давайте сначала создадим объект перехода, доступный для сценариев:

  • Щелкните правой кнопкой мыши> Создать> FSM> Переход
  • Выберите имя, которое вам нравится. Я назвал своего Пятнистого врага.

Мы заполним полученное окно инспектора следующим образом:

Экран Spotted Enemy (Transition) состоит из четырех строк: значение сценария установлено на «Transition» и неактивно. Значение решения установлено на «LineOfSightDecision (в пределах прямой видимости)». Значение True State установлено на «ChaseState (State)». Значение False State установлено на «RemainInState (остаться в состоянии)».
Заполнение окна инспектора Spotted Enemy (Transition)

Затем мы завершим диалоговое окно инспектора Chase State следующим образом:

Экран Chase State (Состояние) начинается с метки «Открыто». Рядом с меткой «Сценарий» выбрано «Состояние». Рядом с меткой «Действие» выбрано «1». В раскрывающемся списке «Действие» выбирается «Элемент 0 Действие по преследованию (Действие по преследованию)». Далее следует знак плюс и минус. Рядом с меткой «Переходы» выбрано «0». В раскрывающемся списке «Переходы» отображается «Список пуст». Далее следует знак плюс и минус.
Заполнение окна Chase State Inspector

Далее мы завершим диалог состояния патрулирования:

Экран состояния патрулирования (состояние) начинается с метки «Открыто». Рядом с меткой «Сценарий» выбрано «Состояние». Рядом с меткой «Действие» выбрано «1». В раскрывающемся списке «Действие» выбирается «Элемент 0 Патрульное действие (Патрульное действие)». Далее следует знак плюс и минус. Рядом с меткой «Переходы» выбрано «1». В раскрывающемся списке «Переходы» отображается «Элемент 0 SpottedEnemy (Переход)». Далее следует знак плюс и минус.
Заполнение окна государственного инспектора патрулирования

Наконец, мы добавим компонент BaseStateMachine к вражескому объекту: В окне Project редактора Unity откройте ресурс SampleScene, выберите объект Enemy на панели Hierarchy и в окне Inspector выберите Add Component > Base State Machine :

Экран Base State Machine (Script): рядом с выделенной серым цветом меткой «Script» выбрано и затенено «BaseStateMachine». Рядом с меткой «Исходное состояние» выбрано «PatrolState (State)».
Добавление компонента базового конечного автомата (сценария)

Если возникнут какие-либо проблемы, еще раз проверьте, правильно ли настроены ваши игровые объекты. Например, подтвердите, что объект Enemy включает компонент скрипта PatrolPoints и объекты Point1 , Point2 и т. д. Эта информация может быть потеряна при неправильной версии редактора.

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

Использование FSM для создания увлекательного интерактивного взаимодействия с пользователем

В этом руководстве по конечной машине мы создали высокомодульный ИИ на основе FSM (и соответствующий репозиторий GitHub), который мы можем повторно использовать в будущих проектах. Благодаря этой модульности мы всегда можем добавить мощности нашему ИИ, вводя новые компоненты.

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