В процессе программирования внутриигровых сущностей возникают ситуации, когда они должны действовать в различных условиях по-разному, что наводит на мысль об использовании состояний.
Но если вы решите применить способ грубого перебора, то код быстро превратится в запутанный хаос со множеством вложенных операторов if-else.
Для изящного решения этой задачи можно воспользоваться шаблоном проектирования «Состояние» (State design pattern). Ему-то мы и посвятим этот туториал!
Из туториала вы:
- Научитесь основам шаблона «Состояние» в Unity.
- Узнаете, что такое конечный автомат, и когда его использовать.
- Узнаете, как использовать эти концепции для управления движением персонажа.
Примечание: этот туториал предназначен для опытных пользователей; предполагается, что вы уже умеете работать в Unity и обладаете средним уровнем знаний C#. Кроме того, в этом туториале используется Unity 2019.2 и C# 7.
Приступаем к работе
Скачайте материалы проекта. Распакуйте файл zip и откройте в Unity проект starter.
В проекте есть несколько папок, которые помогут вам начать работу. В папке Assets/RW находятся папки Animations, Materials, Models, Prefabs, Resources, Scenes, Scripts и Sounds, названные в соответствии с содержащимися в них ресурсами.
Для выполнения туториала мы будем работать только со Scenes и Scripts.
Перейдите в RW/Scenes и откройте Main. В режиме Game вы увидите персонажа в капюшоне внутри средневекового замка.
Нажмите на Play и заметьте, как камера Camera перемещается, чтобы поместить в кадр Character. На данный момент в нашей маленькой игре отсутствуют взаимодействия, над ними мы и будем работать в туториале.
Исследуем персонажа
В иерархии выберите Character. Обратите внимание на Inspector. Вы увидите компонент с аналогичным названием, содержащий логику управления Character.
Откройте Character.cs, находящийся в RW/Scripts.
В скрипте выполняется много действий, но большинство из них нам не важно. Пока обратим внимание на следующие методы.
Move
: он перемещает персонажа, получая значения типа floatspeed
в качестве скорости перемещения иrotationSpeed
в качестве угловой скорости.ResetMoveParams
: этот метод сбрасывает параметры, используемые для анимации движения, и угловую скорость персонажа. Он используется просто для очистки.SetAnimationBool
: он присваивает параметру анимацииparam
типа Bool значение.CheckCollisionOverlap
: он получаетточку
типаVector3
и возвращаетbool
, определяющий, есть ли в пределах заданного радиуса отточки
коллайдеры.TriggerAnimation
: переключает входной параметр анимацииparam
.ApplyImpulse
: прикладывает к Character импульс, равный входному параметруforce
типаVector3
.
Ниже вы увидите эти методы. В нашем туториале их содержимое и внутренняя работа не важны.
Что такое машины состояний
Машина состояний (State machine) — это концепция, при которой контейнер хранит в себе состояние чего-то в текущий момент времени. На основании входящих данных он может обеспечивать вывод, зависящий от текущего состояния, переходя в этом процессе в новое состояние. Машины состояний можно представить в виде диаграммы состояний. Подготовка диаграммы состояний позволяет продумать все возможные состояния системы и переходы между ними.
Конечные автоматы
Конечные автоматы или FSM (Finite state machine) — это одно из четырёх основных семейств автоматов. Автоматы — это абстрактные модели простых машин. Они изучаются в рамках теории автоматов — теоретической отрасли computer science.
В двух словах:
- FSM состоит из конечного количества состоянии. В любой момент времени активно только одно из этих состояний.
- Каждое состояние определяет, в какое состояние оно перейдёт в качестве выходного результата на основании полученной последовательности входящей информации.
- Выходное состояние становится новым активным состоянием. Другими словами, происходит переход между состояниями.
Чтобы лучше понять это, рассмотрим персонажа игры-платформера, который стоит на земле. Персонаж находится в состоянии Standing. Это будет его активным состоянием, пока игрок не нажмёт кнопку, чтобы персонаж подпрыгнул.
Состояние Standing идентифицирует нажатие кнопки как значимые входящие данные и в качестве выходного результата выполняет переход в состояние Jumping.
Допустим, существует определённое количество таких состояний движения и персонаж может за раз находиться только в одном из состояний. Это и есть пример FSM.
Иерархические машины состояний
Рассмотрим платформер, использующий FSM, в котором несколько состояний имеют общую логику физики. Например, можно двигаться и прыгать в состояниях Crouching и Standing. В таком случае несколько входящих переменных приводят к одинаковому поведению и выводу информации для двух разных состояний.
В подобной ситуации логично будет делегировать общее поведение какому-то другому состоянию. К счастью, этого можно добиться при помощи иерархических машин состояний (автоматов) (hierarchical state machines).
В иерархическом FSM существуют подсостояния, делегирующие необработанную входящую информацию своим надсостояниям. Это в свою очередь позволяет изящно уменьшать размер и сложность FSM, сохраняя при этом его логику.
Шаблон «Состояние»
В своей книге Design Patterns: Elements of Reusable Object-Oriented Software Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидис («Банда четырёх») определили задачу шаблона «Состояние» следующим образом:
«Он должен позволить объекту изменять своё поведение при изменении его внутреннего состояния. При этом будет казаться, что объект изменил свой класс».
Чтобы лучше понять это, рассмотрим следующий пример:
- Скрипт, получающий входящую информацию для логики движения, прикреплён к внутриигровой сущности.
- Этот класс хранит переменную текущего состояния, которая просто ссылается на экземпляр класса состояния.
- Входящая информация делегируется этому текущему состоянию, которое обрабатывает его и создаёт поведение, определённое внутри себя. Также оно обрабатывает требуемые переходы между состояниями.
Следовательно, из-за того, что в разное время переменная текущего состояния ссылается на разные состояния, будет казаться, что один и тот же класс скрипта ведёт себя по-разному. В этом и есть суть шаблона «Состояние».
В нашем проекте в зависимости от разных состояний будет вести себя по-разному упомянутый выше класс Character. Но нам нужно, чтобы он вёл себя хорошо!
В общем случае существует три ключевых пункта для каждого класса состояния, позволяющих поведение состояния в целом:
- Вход (Entry): это момент, когда сущность входит в состояние и выполняет действия, которые нужно сделать только один раз, при входе в состояние.
- Выход (Exit): аналогично входу — здесь выполняются все операции сброса, которые нужно совершать только перед изменением состояния.
- Цикл обновления (Update Loop): здесь находится базовая логика обновления, которая выполняется в каждом кадре. Её можно разделить на несколько частей, например, на цикл для обновления физики и цикл для обработки ввода игрока.
Задание состояния и машины состояний
Перейдите в RW/Scripts и откройте StateMachine.cs.
State Machine, как можно догадаться, обеспечивает абстракцию для машины состояний. Заметьте, что CurrentState
правильно находится внутри этого класса. Оно будет хранить ссылку на текущее активное состояние машины состояний.
Теперь чтобы задать концепцию состояния, перейдём в RW/Scripts и откроем в IDE скрипт State.cs.
State — это абстрактный класс, который мы будем использовать как образец, из которого получаются все классы состояний проекта. Часть кода в материалах проекта уже готова.
DisplayOnUI
только отображает название текущего состояния в экранном UI. Вам не обязательно знать его внутреннее устройство, достаточно только понимать, что он получает в качестве входного параметра enumerator типа UIManager.Alignment
, который может иметь значение Left
или Right
. От него зависит отображение названия состояния в левой или правой нижней части экрана.
Кроме того, существуют две protected-переменные character
и stateMachine
. Переменная character
ссылается на экземпляр класса Character, а stateMachine
ссылается на экземпляр машины состояний, связанной с состоянием.
При создании экземпляра состояния конструктор связывает character
и stateMachine
.
Каждый из множества экземпляров Character
в сцене может иметь собственный набор состояний и машин состояний.
Теперь добавим в State.cs следующие методы и сохраним файл:
public virtual void Enter()
{
DisplayOnUI(UIManager.Alignment.Left);
}
public virtual void HandleInput()
{
}
public virtual void LogicUpdate()
{
}
public virtual void PhysicsUpdate()
{
}
public virtual void Exit()
{
}
Эти виртуальные методы задают описанные выше ключевые пункты состояния. Когда машина состояний выполняет переход между состояниями, мы вызываем Exit
для предыдущего состояния и Enter
нового активного состояния.
HandleInput
, LogicUpdate
и PhysicsUpdate
вместе задают цикл обновления. HandleInput
обрабатывает ввод игрока. LogicUpdate
обрабатывает базовую логику, а PhyiscsUpdate
обрабатывает логику и вычисления физики.
Теперь снова откроем StateMachine.cs, добавим следующие методы и сохраним файл:
public void Initialize(State startingState)
{
CurrentState = startingState;
startingState.Enter();
}
public void ChangeState(State newState)
{
CurrentState.Exit();
CurrentState = newState;
newState.Enter();
}
Initialize
конфигурирует машину состояний, присваивая CurrentState
значение startingState
и вызывая для него Enter
. Это инициализирует машину состояний, в первый раз задавая активное состояние.
ChangeState
обрабатывает переходы между состояниями. Он вызывает Exit
для старого CurrentState
перед заменой его ссылки на newState
. В конце он вызывает Enter
для newState
.
Таким образом мы задали состояние и машину состояний.
Создание состояний движения
Взгляните на следующую диаграмму состояний, на которой показаны различные состояния движения внутриигровой сущности игрока. В этом разделе мы реализуем шаблон «Состояние» для показанного на рисунке FSM движения:
Обратите внимание на состояния движения, а именно на Standing, Ducking и Jumping, а также на то, как входящие данные вызывают переходы между состояниями. Это иерархический FSM, в котором Grounded является надсостоянием для подсостояний Ducking и Standing.
Вернитесь в Unity и перейдите в RW/Scripts/States. Там вы найдёте несколько файлов C# с именами, заканчивающимися на State.
Каждый из этих файлов определяет один класс, каждый из которых наследуется из State
. Следовательно, эти классы определяют состояния, которые мы будем использовать в проекте.
Теперь откройте Character.cs из папки RW/Scripts.
Перейдите выше #region Variables
файла и добавьте следующий код:
public StateMachine movementSM;
public StandingState standing;
public DuckingState ducking;
public JumpingState jumping;
Этот movementSM
ссылается на машину состояний, обрабатывающую логику движения для экземпляра Character
. Также мы добавили ссылки на три состояния, которые мы реализуем для каждого типа движения.
Перейдите к #region MonoBehaviour Callbacks
в том же файле. Добавьте следующие методы MonoBehaviour, а затем сохранитесь
private void Start()
{
movementSM = new StateMachine();
standing = new StandingState(this, movementSM);
ducking = new DuckingState(this, movementSM);
jumping = new JumpingState(this, movementSM);
movementSM.Initialize(standing);
}
private void Update()
{
movementSM.CurrentState.HandleInput();
movementSM.CurrentState.LogicUpdate();
}
private void FixedUpdate()
{
movementSM.CurrentState.PhysicsUpdate();
}
- В
Start
код создаёт экземпляр State Machine и присваивает егоmovementSM
, а также создаёт экземпляры различных состояний движения. При создании каждого из состояний движения мы передаём ссылки на экземплярCharacter
при помощи ключевого словаthis
, а также экземпляраmovementSM
. В конце мы вызываемInitialize
дляmovementSM
и передаём в качестве начального состоянияStanding
. - В методе
Update
мы вызываемHandleInput
иLogicUpdate
дляCurrentState
машиныmovementSM
. Аналогично, вFixedUpdate
мы вызываемPhysicsUpdate
дляCurrentState
машиныmovementSM
. По сути это делегирует задачи активному состоянию; в этом и заключается смысл шаблона «Состояние».
Теперь нам нужно задать поведение внутри каждого из состояний движения. Крепитесь, кода будет много!
Твёрдо стоим на ногах
Вернитесь в RW/Scripts/States в окне Project.
Откройте Grounded.cs и заметьте, что этот класс имеет конструктор, соответствующий конструктору State
. Это логично, потому что этот класс наследует от него. То же самое вы увидите и во всех остальных классах состояний.
Добавьте следующий код:
public override void Enter()
{
base.Enter();
horizontalInput = verticalInput = 0.0f;
}
public override void Exit()
{
base.Exit();
character.ResetMoveParams();
}
public override void HandleInput()
{
base.HandleInput();
verticalInput = Input.GetAxis("Vertical");
horizontalInput = Input.GetAxis("Horizontal");
}
public override void PhysicsUpdate()
{
base.PhysicsUpdate();
character.Move(verticalInput * speed, horizontalInput * rotationSpeed);
}
Вот что здесь происходит:
- Мы переопределяем один из виртуальных методов, определённый в родительском классе. Чтобы сохранить всю функциональность, которая может существовать в родителе, мы вызываем метод
base
с тем же названием из каждого переопределённого метода. Это важный шаблон, который мы продолжим использовать. - В следующей строке
Enter
переменнымhorizontalInput
иverticalInput
задаются их значения по умолчанию. - Внутри
Exit
мы, как говорилось выше, вызываем методResetMoveParams
персонажа
для сброса при переходе в другое состояние. - В методе
HandleInput
переменныеhorizontalInput
иverticalInput
кешируют значения горизонтальной и вертикальной осей ввода. Благодаря этому игрок может управлять персонажем при помощи клавиш W, A, S и D. - В
PhysicsUpdate
мы выполняем вызовMove
, передавая переменныеhorizontalInput
иverticalInput
, умноженные на соответствующие скорости. В переменнойspeed
хранится скорость перемещения, а вrotationSpeed
— угловая скорость.
Теперь откроем Standing.cs и обратим внимание на то, что он наследуется от Grounded
. Так получилось потому, что, как мы говорили выше, Standing является подсостоянием для Grounded. Существуют разные способы для реализации этого взаимоотношения, но в этом туториале мы используем наследование.
Добавим следующие override
-методы и сохраним скрипт:
public override void Enter()
{
base.Enter();
speed = character.MovementSpeed;
rotationSpeed = character.RotationSpeed;
crouch = false;
jump = false;
}
public override void HandleInput()
{
base.HandleInput();
crouch = Input.GetButtonDown("Fire3");
jump = Input.GetButtonDown("Jump");
}
public override void LogicUpdate()
{
base.LogicUpdate();
if (crouch)
{
stateMachine.ChangeState(character.ducking);
}
else if (jump)
{
stateMachine.ChangeState(character.jumping);
}
}
- В
Enter
мы конфигурируем переменные, наследуемые отGrounded
. ПрименяемMovementSpeed
иRotationSpeed
персонажа кspeed
иrotationSpeed
. Затем они относятся, соответственно, к нормальной скорости перемещения и угловой скорости, предназначенной для сущности персонажа.Кроме того сбрасываются на false переменные для хранения ввода
crouch
иjump
. - Внутри
HandleInput
переменныеcrouch
иjump
хранят ввод игрока для приседания и прыжка. Если в сцене Main игрок нажимает на клавишу Shift приседанию присваивается true. Аналогично этому игрок может использовать клавишу Space дляjump
(прыжка). - В
LogicUpdate
мы проверяем переменныеcrouch
иjump
типаbool
. Еслиcrouch
равна true, тоmovementSM.CurrentState
меняется наcharacter.ducking
. Еслиjump
равно true, то состояние меняется наcharacter.jumping
.
Сохраните и соберите проект, после чего нажмите на Play. Вы сможете перемещаться по сцене при помощи клавиш W, A, S и D. Если вы попробуете нажать на Shift или Space, то возникнет unexpected behavior, потому что соответствующие состояния ещё не реализованы.
Попробуйте перемещаться под объектами-столами. Вы увидите, что из-за высоты коллайдера персонажа это невозможно. Чтобы персонаж мог это делать, нужно добавить поведение приседания.
Забираемся под стол
Откройте скрипт Ducking.cs. Обратите внимание, что Ducking
тоже наследуется от класса Grounded
по тем же причинам, что и у Standing
. Добавьте следующие override
-методы и сохраните скрипт:
public override void Enter()
{
base.Enter();
character.SetAnimationBool(character.crouchParam, true);
speed = character.CrouchSpeed;
rotationSpeed = character.CrouchRotationSpeed;
character.ColliderSize = character.CrouchColliderHeight;
belowCeiling = false;
}
public override void Exit()
{
base.Exit();
character.SetAnimationBool(character.crouchParam, false);
character.ColliderSize = character.NormalColliderHeight;
}
public override void HandleInput()
{
base.HandleInput();
crouchHeld = Input.GetButton("Fire3");
}
public override void LogicUpdate()
{
base.LogicUpdate();
if (!(crouchHeld || belowCeiling))
{
stateMachine.ChangeState(character.standing);
}
}
public override void PhysicsUpdate()
{
base.PhysicsUpdate();
belowCeiling = character.CheckCollisionOverlap(character.transform.position +
Vector3.up * character.NormalColliderHeight);
}
- Внутри
Enter
параметру, вызывающему переключение анимации приседания, присваивается значение crouch, что включает анимацию приседания. Свойствамcharacter.CrouchSpeed
иcharacter.CrouchRotationSpeed
присваиваются значенияspeed
иrotation
, которые возвращают перемещение и угловую скорость персонажа при движении в приседе.Далее
character.CrouchColliderHeight
задаёт размер коллайдера персонажа, который возвращает нужную высоту коллайдера при приседании. В концеbelowCeiling
сбрасывается на false. - Внутри
Exit
параметру анимации приседания присваивается false. Это отключает анимацию приседания. Затем задаётся обычная высота коллайдера, возвращаемаяcharacter.NormalColliderHeight
. - Внутри
HandleInput
переменнойcrouchHeld
задаётся значение ввода игрока. В сцене Main удерживание Shift присваиваетcrouchHeld
значение true. - Внутри
PhysicsUpdate
переменнойbelowCeiling
присваивается значение при помощи передачи точки в форматеVector3
с головой игрового объекта персонажа методуCheckCollisionOverlap
. Если рядом с этой точкой есть коллизия, то это означает, что персонаж находится под каким-то потолком. - Внутри
LogicUpdate
проверяется, имеют лиcrouchHeld
илиbelowCeiling
значение true. Если ни одна из них не равна true, тоmovementSM.CurrentState
меняется наcharacter.standing
.
Соберите проект и нажмите на Play. Теперь вы сможете перемещаться по сцене. Если вы нажмёте Shift, персонаж присядет и вы сможете перемещаться в приседе.
Также вы сможете забираться под платформы. Если отпустить Shift, находясь под платформами, то персонаж всё равно будет в приседе, пока не покинет своё укрытие.
Взмываем вверх!
Откройте Jumping.cs. Вы увидите метод под названием Jump
. Не беспокойтесь о том, как он работает; достаточно понять, что он используется для того, чтобы персонаж мог прыгать с учётом физики и анимации.
Теперь добавьте обычные override
-методы и сохраните скрипт
public override void Enter()
{
base.Enter();
SoundManager.Instance.PlaySound(SoundManager.Instance.jumpSounds);
grounded = false;
Jump();
}
public override void LogicUpdate()
{
base.LogicUpdate();
if (grounded)
{
character.TriggerAnimation(landParam);
SoundManager.Instance.PlaySound(SoundManager.Instance.landing);
stateMachine.ChangeState(character.standing);
}
}
public override void PhysicsUpdate()
{
base.PhysicsUpdate();
grounded = character.CheckCollisionOverlap(character.transform.position);
}
- Внутри
Enter
синглтонSoundManager
воспроизводит звук прыжка. Затемgrounded
сбрасывается на значение по умолчанию. В конце вызываетсяJump
. - Внутри
PhysicsUpdate
точкаVector3
рядом с ногами персонажа отправляется вCheckCollisionOverlap
, и это значит, что когда персонаж находится на земле,grounded
будет присвоено значение true. - В
LogicUpdate
, еслиgrounded
равно true, мы вызываемTriggerAnimation
для включения анимации приземления, воспроизводится звук приземления, аmovementSM.CurrentState
изменяется наcharacter.standing
.
Итак, на этом мы завершили полную реализацию FSM перемещения при помощи шаблона «Состояние». Соберите проект и запустите его. Нажимайте Space, чтобы персонаж прыгал.
Куда двигаться дальше?
В материалах проекта есть заготовка проекта и готовый проект.
Несмотря на свою полезность, машины состояний имеют ограничения. С некоторыми из этих ограничений позволяют справиться Concurrent State Machines и автоматы с магазинной памятью (Pushdown Automaton). Прочитать о них можно в книге Роберта Нистрома Game Programming Patterns.
Кроме того, тему можно изучить глубже, исследовав деревья поведений, используемые для создания более сложных внутриигровых сущностей.
Автор: PatientZero