Хочу поделиться еще одним вариантом реализации стейт машины (конечного автомата) для Unity. Статьи про конечные автоматы в привязке к Unity и/или C# на Хабре уже были, например, вот и вот, но я хочу продемонстрировать несколько иной подход, основанный на использовании компонентов Unity.
Ссылка на unitypackage с кодом из статьи.
Конечный автомат (на русском)
Finite-state machine (на английском)
Сам же попробую описать простым языком на игровом примере.
Стейт машина — это набор состояний, например, состояния персонажа:
- Idle (Отдых)
- Run (Бег)
- Jump (Прыжок)
- Fight (Драка)
- Dead (Мертв)
из которых в текущий момент времени активно может быть только одно; то есть, согласно означенному списку, персонаж может:
- либо отдыхать
- либо бежать
- либо прыгать
- либо драться
- либо быть мертвым
и переход между которыми осуществляется по удовлетворении заранее определенных условий, например:
- Отдых->Бег, если нажата клавиша движения
- Отдых->Прыжок, если нажата клавиша прыжка
- Бег->Драка, если произошло столкновение с противником
- Драка->Мертв, если закончилось здоровье
- ...
Для наглядности представим вышеописанный конечный автомат в виде графа, где зеленым обозначено начальное состояние стейт машины, красным — конечное.
Реализация
Реализация стейт-машины у нас будет состоять из трех классов: StateMachine, State и Transition, расположенных в одноименных файлах. Все три класса унаследованы от MonoBehaviour. Класс StateMachine используется напрямую, а вот от абстрактных State и Transition предлагается наследовать конкретные состояния и переходы. В результате, сама стейт машина, а также все ее состояния и переходы, являются компонентами и должны быть назначены какому-либо объекту в сцене. Ну, а для переключения состояний тогда можно воспользоваться уже имеющимся механизмом включения/выключения компонентов (свойство enabled). Это избавляет нас от необходимости создавать специализированные каллбэки для стейт машины, проверки на «включен/выключен» и тому подобное. Вместо этого используются привычные функции событий Unity: OnEnable, OnDisable, Update, Awake и другие. Правда, здесь имеются две тонкости:
- Стоит быть осторожным с событием Start: изначально состояния и переходы стейт-машины должны быть «выключены», а для выключенного компонента это событие произойдет не при старте сцены, а тогда, когда он будет в первый раз «включен». Таково стандартное поведение Unity.
- При наследовании от State придется переопределять (override) метод FixedUpdate (если он вам нужен, конечно же): он реализован в классе State для того, чтобы в Inspector'е всегда показывалась галочка «включить/выключить» для состояния. При наличии этой галочки можно наблюдать за переключением состояний в реальном времени, самый что ни на есть «визуальный дебаг».
Перейдем, наконец, к коду (с комментариями на русском):
using UnityEngine;
/// Базовый класс для переходов.
/// Наследуемые компоненты должны быть выключены (disabled) в Inspector'е.
public abstract class Transition : MonoBehaviour
{
/// Целевое состояние (куда переходим).
/// Задается в Inspector'е.
[SerializeField]
State targetState;
/// Проперти для получения целевого состояния.
/// Используется в State при необходимости перехода.
public State TargetState
{
get { return targetState; }
}
/// Когда переход должен произойти, необходимо в
/// наследнике установить это проперти в true.
/// Проверяется оно в State.
public bool NeedTransit
{
get;
protected set;
}
}
using UnityEngine;
using System.Collections.Generic;
/// Базовый класс для состояний.
/// Наследуемые компоненты должны быть выключены (disabled) в Inspector'е.
public abstract class State : MonoBehaviour
{
/// Список исходящих переходов.
/// Задается в Inspector'е.
[SerializeField, Tooltip("List of transitions from this state.")]
List<Transition> transitions = new List<Transition> ();
/// Возвращает следующее состояние, если должен быть
/// совершен переход, иначе возвращает null.
/// Вызывается из StateMachine.
public virtual State GetNext()
{
foreach (var transition in transitions)
{
if (transition.NeedTransit )
return transition.TargetState;
}
return null;
}
/// Выключает состояние и переходы из него.
/// Будет вызван OnDisable, если его реализовать в потомке.
public virtual void Exit()
{
if(enabled)
{
foreach(var transition in transitions)
{
transition.enabled = false;
}
enabled = false;
}
}
/// Включает состояние и переходы из него.
/// Будет вызван OnEnable, если его реализовать в потомке.
public virtual void Enter()
{
if(!enabled)
{
enabled = true;
foreach(var transition in transitions)
{
transition.enabled = true;
}
}
}
/// Этот метод реализован для того, чтобы в Inspector'е всегда
/// отображался чекбокс enabled/disabled для состояний.
/// В потомке его придется переопределять при необходимости.
protected virtual void FixedUpdate()
{
}
}
using UnityEngine;
/// Класс стейт машины.
public class StateMachine : MonoBehaviour
{
/// Начальное состояние.
/// Задается в Inspector'е.
[SerializeField]
State startingState;
/// Текущее состояние.
State current;
/// Доступ к текущему состоянию.
public State Current
{
get { return current; }
}
/// Инициализация (переход в начальное состояние).
void Start()
{
Reset();
}
/// Переводит стейт машину в начальное состояние.
public void Reset()
{
Transit(startingState);
}
/// На каждом кадре проверяет, не нужно ли совершить
/// переход. Если нужно - совершает.
void Update ()
{
if(current == null)
return;
var next = current.GetNext();
if(next != null)
Transit(next);
}
/// Собственно, переход.
/// Выходит из текущего состояния,
/// делает следующее текущим и
/// входит в него.
void Transit(State next)
{
if(current != null)
current.Exit();
current = next;
if(current != null)
current.Enter();
}
}
Использование получившейся стейт машины
Создадим небольшой тестовый проект, в котором будем двигать куб вправо-влево по экрану. Проект можно создать как в 2D, так и в 3D, отличия должны быть только визуальные. Создадим сцену или воспользуемся дефолтной. В ней уже будет камера, а теперь добавим еще и куб при помощи меню GameObject->Create Other->Cube. Кубу нужно задать позицию по оси X равную -4, так как далее он будет двигаться на 8 юнитов в каждую сторону. Кроме куба создадим дочерний ему пустой объект для нашей стейт машины. Для этого выделим куб в Hierarchy и используем меню GameObject->Create Empty Child. Нагляднее будет переименовать его в StateMachine.
Получится
Следующим шагом созданим скрипты. Нам понадобится 4 скрипта, это класс перехода по таймеру:
using UnityEngine;
using System.Collections;
/// Переход по таймеру.
public class TimerTransition : Transition
{
/// Время в секундах. Задается в Inspector'е.
[SerializeField, Tooltip("Time in seconds.")]
float time;
/// Событие "включения".
/// Запускает таймер и обнуляет свойство NeedTransit.
void OnEnable()
{
NeedTransit = false;
StartCoroutine("Timer");
}
/// Таймер, реализованный при помощи корутины.
/// По истечении времени устанавливает свойство NeedTransit в true.
IEnumerator Timer()
{
yield return new WaitForSeconds(time);
NeedTransit = true;
}
/// Событие "выключения".
/// Останавливает таймер.
void OnDisable()
{
StopCoroutine("Timer");
}
}
и еще 3 класса для состояний. Базовый класс для состояний движения, передвигающий объект при помощи метода Translate компонента Transform :
using UnityEngine;
/// Этот класс двигает заданный Transform при помощи метода Translate.
public class TranslateState : State
{
/// Transform, задается в Inspector'е.
[SerializeField]
Transform transformToMove;
/// Скорость в юнитах в секунду. Задается в Inspector'е.
[SerializeField, Tooltip("Speed in units per second.")]
Vector3 speed;
/// Двигаем заданный Transform.
void Update ()
{
var step = speed * Time.deltaTime;
transformToMove.Translate(step.x, step.y, step.z);
}
}
и унаследованные от него классы конкретных состояний:
/// Состояние движения вправо.
/// Этот класс нужен для того, чтобы
/// состояние имело уникальное "имя".
public class MoveRight : TranslateState
{
}
/// Состояние движения влево.
/// Этот класс нужен для того, чтобы
/// состояние имело уникальное "имя".
public class MoveLeft : TranslateState
{
}
Теперь, когда все необходимые классы готовы, нужно собрать стейт машину из компонентов. Для этого выделим в Hierarchy наш объект с именем StateMachine и навесим на него все компоненты как на картинке:
Не забудем «выключить» компоненты состояний и переходов, но не саму стейт машину.
Заполним наши компоненты следующим образом:
Поля, предназначенные для состояний и переходов можно заполнить перетаскиванием соответствующих компонентов. Не забудьте задать StartingState стейт машине и добавить переходы в списки Transitions состояний!
Теперь можно запускать сцену. Если все сделано верно, куб будет двигаться по экрану вправо-влево. Если выделить объект StateMachine в Hierarchy, то в инспекторе можно будет следить за сменой состояний в реальном времени.
Заключение
В заключение хочу заметить, что, хотя данная реализация стейт машины и не лишена недостатков, она вполне подходит для использования в небольших проектах. Для проектов покрупнее, на мой взгляд, само перетаскивание компонентов в инспекторе может оказаться довольно неприятной работой.
Конструктивная критика приветствуется.
Автор: marked-one