- PVSM.RU - https://www.pvsm.ru -
В процессе программирования внутриигровых сущностей возникают ситуации, когда они должны действовать в различных условиях по-разному, что наводит на мысль об использовании состояний.
Но если вы решите применить способ грубого перебора, то код быстро превратится в запутанный хаос со множеством вложенных операторов if-else.
Для изящного решения этой задачи можно воспользоваться шаблоном проектирования «Состояние» (State design pattern). Ему-то мы и посвятим этот туториал!
Из туториала вы:
Примечание: этот туториал предназначен для опытных пользователей; предполагается, что вы уже умеете работать в Unity и обладаете средним уровнем знаний C#. Кроме того, в этом туториале используется Unity 2019.2 и C# 7.
Скачайте материалы проекта [1]. Распакуйте файл 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
: он перемещает персонажа, получая значения типа float speed
в качестве скорости перемещения и rotationSpeed
в качестве угловой скорости.ResetMoveParams
: этот метод сбрасывает параметры, используемые для анимации движения, и угловую скорость персонажа. Он используется просто для очистки.SetAnimationBool
: он присваивает параметру анимации param
типа Bool значение.CheckCollisionOverlap
: он получает точку
типа Vector3
и возвращает bool
, определяющий, есть ли в пределах заданного радиуса от точки
коллайдеры.TriggerAnimation
: переключает входной параметр анимации param
.ApplyImpulse
: прикладывает к Character импульс, равный входному параметру force
типа Vector3
.Ниже вы увидите эти методы. В нашем туториале их содержимое и внутренняя работа не важны.
Машина состояний (State machine) — это концепция, при которой контейнер хранит в себе состояние чего-то в текущий момент времени. На основании входящих данных он может обеспечивать вывод, зависящий от текущего состояния, переходя в этом процессе в новое состояние. Машины состояний можно представить в виде диаграммы состояний [2]. Подготовка диаграммы состояний позволяет продумать все возможные состояния системы и переходы между ними.
Конечные автоматы или FSM (Finite state machine) — это одно из четырёх основных семейств автоматов. Автоматы — это абстрактные модели простых машин. Они изучаются в рамках теории автоматов [3] — теоретической отрасли computer science.
В двух словах:
Чтобы лучше понять это, рассмотрим персонажа игры-платформера, который стоит на земле. Персонаж находится в состоянии Standing. Это будет его активным состоянием, пока игрок не нажмёт кнопку, чтобы персонаж подпрыгнул.
Состояние Standing идентифицирует нажатие кнопки как значимые входящие данные и в качестве выходного результата выполняет переход в состояние Jumping.
Допустим, существует определённое количество таких состояний движения и персонаж может за раз находиться только в одном из состояний. Это и есть пример FSM.
Рассмотрим платформер, использующий FSM, в котором несколько состояний имеют общую логику физики. Например, можно двигаться и прыгать в состояниях Crouching и Standing. В таком случае несколько входящих переменных приводят к одинаковому поведению и выводу информации для двух разных состояний.
В подобной ситуации логично будет делегировать общее поведение какому-то другому состоянию. К счастью, этого можно добиться при помощи иерархических машин состояний (автоматов) (hierarchical state machines).
В иерархическом FSM существуют подсостояния, делегирующие необработанную входящую информацию своим надсостояниям. Это в свою очередь позволяет изящно уменьшать размер и сложность FSM, сохраняя при этом его логику.
В своей книге Design Patterns: Elements of Reusable Object-Oriented Software Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидис («Банда четырёх») определили задачу шаблона «Состояние» следующим образом:
«Он должен позволить объекту изменять своё поведение при изменении его внутреннего состояния. При этом будет казаться, что объект изменил свой класс».
Чтобы лучше понять это, рассмотрим следующий пример:
Следовательно, из-за того, что в разное время переменная текущего состояния ссылается на разные состояния, будет казаться, что один и тот же класс скрипта ведёт себя по-разному. В этом и есть суть шаблона «Состояние».
В нашем проекте в зависимости от разных состояний будет вести себя по-разному упомянутый выше класс Character. Но нам нужно, чтобы он вёл себя хорошо!
В общем случае существует три ключевых пункта для каждого класса состояния, позволяющих поведение состояния в целом:
Перейдите в 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 [4], а затем сохранитесь
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, чтобы персонаж прыгал.
В материалах проекта [1] есть заготовка проекта и готовый проект.
Несмотря на свою полезность, машины состояний имеют ограничения. С некоторыми из этих ограничений позволяют справиться Concurrent State Machines и автоматы с магазинной памятью (Pushdown Automaton). Прочитать о них можно в книге Роберта Нистрома Game Programming Patterns [5].
Кроме того, тему можно изучить глубже, исследовав деревья поведений [6], используемые для создания более сложных внутриигровых сущностей.
Автор: PatientZero
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/shablony-proektirovaniya/343584
Ссылки в тексте:
[1] материалы проекта: https://koenig-media.raywenderlich.com/uploads/2019/11/State-Machine-for-Unity.zip
[2] диаграммы состояний: https://www.geeksforgeeks.org/unified-modeling-language-uml-state-diagrams/
[3] теории автоматов: https://cs.stanford.edu/people/eroberts/courses/soco/projects/2004-05/automata-theory/basics.html
[4] MonoBehaviour: https://docs.unity3d.com/ScriptReference/MonoBehaviour.html
[5] Game Programming Patterns: https://gameprogrammingpatterns.com/state.html
[6] деревья поведений: https://en.wikipedia.org/wiki/Behavior_tree_(artificial_intelligence,_robotics_and_control)
[7] Источник: https://habr.com/ru/post/484176/?utm_campaign=484176&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.