image

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

Но если вы решите применить способ грубого перебора, то код быстро превратится в запутанный хаос со множеством вложенных операторов 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: он перемещает персонажа, получая значения типа float speed в качестве скорости перемещения и 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.

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