Pull to refresh

Еще одна простая стейт машина для Unity

Reading time 6 min
Views 22K


Хочу поделиться еще одним вариантом реализации стейт машины (конечного автомата) для 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 и другие. Правда, здесь имеются две тонкости:

  1. Стоит быть осторожным с событием Start: изначально состояния и переходы стейт-машины должны быть «выключены», а для выключенного компонента это событие произойдет не при старте сцены, а тогда, когда он будет в первый раз «включен». Таково стандартное поведение Unity.
  2. При наследовании от State придется переопределять (override) метод FixedUpdate (если он вам нужен, конечно же): он реализован в классе State для того, чтобы в Inspector'е всегда показывалась галочка «включить/выключить» для состояния. При наличии этой галочки можно наблюдать за переключением состояний в реальном времени, самый что ни на есть «визуальный дебаг».


Перейдем, наконец, к коду (с комментариями на русском):
Transition
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;
    }
}
State
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()
    {
    }
}
StateMachine
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 скрипта, это класс перехода по таймеру:
TimerTransition
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 :
TranslateState
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);
    }
}
и унаследованные от него классы конкретных состояний:
MoveRight
/// Состояние движения вправо. 
/// Этот класс нужен для того, чтобы 
/// состояние имело уникальное "имя".
public class MoveRight : TranslateState 
{
}
MoveLeft
/// Состояние движения влево.
/// Этот класс нужен для того, чтобы
/// состояние имело уникальное "имя".
public class MoveLeft : TranslateState 
{
}

Теперь, когда все необходимые классы готовы, нужно собрать стейт машину из компонентов. Для этого выделим в Hierarchy наш объект с именем StateMachine и навесим на него все компоненты как на картинке:
Картинка
Не забудем «выключить» компоненты состояний и переходов, но не саму стейт машину.

Заполним наши компоненты следующим образом:
Готовая стейт-машина
Поля, предназначенные для состояний и переходов можно заполнить перетаскиванием соответствующих компонентов. Не забудьте задать StartingState стейт машине и добавить переходы в списки Transitions состояний!

Теперь можно запускать сцену. Если все сделано верно, куб будет двигаться по экрану вправо-влево. Если выделить объект StateMachine в Hierarchy, то в инспекторе можно будет следить за сменой состояний в реальном времени.

Заключение


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

Конструктивная критика приветствуется.
Tags:
Hubs:
+19
Comments 7
Comments Comments 7

Articles