Баннер записи 'Паттерн State Machine и как он применяется в Восхождении за кофе'

Паттерн State Machine и как он применяется в Восхождении за кофе

Идея

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

Зачем?

Машина состояний позволяет разбить один большой скрипт на много маленьких.

State Machine + Locomotion

В разделении можно пойти ещё дальше. Если часть состояний отличаются только анимацией и условиями переходов, то есть физика у них общая (но не у всех!), это наверняка приведёт к дублированию кода в состояниях.

Чтобы этого избежать, переключением физики может заниматься отдельный механизм - так называемая локомоция. Логические состояния отделяются от физических. Например, логические состояния: стоит, бежит, прыгает; физические: в воздухе, на земле, в воде.

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


Как это реализовано в Восхождении за кофе

Для состояний создан абстрактный класс CatState. Геттер LocomotionMode обеспечивает связь каждого логического состояния с физическим. Открытые методы позволяют связать жизненный цикл состояния с жизненным циклом объекта, а также выполнять действия при переходе между состояниями. Сейчас публичные методы вызывают закрытые - это сделано для единообразия по аналогии с Enter() и OnEnter(). В будущем в открытых методах может появиться более сложная логика.

Код
public abstract class CatState
{
    protected abstract LocomotionMode LocomotionMode { get; }
    protected readonly Cat cat;

    public CatState(Cat cat)
    {
        this.cat = cat;
    }

    public void Enter()
    {
        cat.Locomotion.SetMode(LocomotionMode);
        OnEnter();
    }

    public void Update() => OnUpdate();
    public void FixedUpdate() => OnFixedUpdate();
    public void Exit() => OnExit();

    protected abstract void OnEnter();
    protected abstract void OnUpdate();
    protected abstract void OnFixedUpdate();
    protected abstract void OnExit();
}

Рассмотрим сразу реализацию конкретного состояния. При переходе в состояние Бег включается соответствующая анимация. Каждый кадр выполняются проверки условий для перехода в другие состояния.

Код
public sealed class CatRun : CatState
{
    protected override LocomotionMode LocomotionMode { get => LocomotionMode.Grounded; }
    public CatRun(Cat cat) : base(cat) { }

    protected override void OnEnter() => cat.animator.Play("Run");

    protected override void OnExit() { }

    protected override void OnUpdate()
    {
        if (!cat.Locomotion.IsGrounded)
            cat.StateMachine.ChangeState(cat.FallState);
        else if (cat.Impact == 0)
            cat.StateMachine.ChangeState(cat.IdleState);
    }

    protected override void OnFixedUpdate() { }
}

Теперь взглянем собственно на машину состояний. Она хранит текущее состояние, управляет переключением и жизненным циклом состояний. В каждом методе стоит проверка на факт инициализации. Если инициализации не было, значит текущее состояние пустое, и его обработка приведёт к ошибкам. Поэтому меняем ошибку на более понятную.

Код
using UnityEngine;
public class CatStateMachine
{
    public CatState CurrentState { get; private set; }
    private bool _isInitialized = false;

    public void Initialize(CatState initialState)
    {
        if (!_isInitialized)
        {
            if (initialState == null)
            {
                Debug.LogError("CatStateMachine must not be initialized with a null initial state.");
                return;
            }

            CurrentState = initialState;
            CurrentState.Enter();
            _isInitialized = true;
        }
        else Debug.LogWarning("CatStateMachine is already initialized.");
    }

    public void ChangeState(CatState state)
    {
        if (!_isInitialized)
        {
            Debug.LogError("CatStateMachine is not initialized");
            return;
        }
        else
        {
            CurrentState.Exit();
            CurrentState = state;
            CurrentState.Enter();
        }
    }

    public void Update()
    {
        if (_isInitialized) CurrentState.Update();
        else Debug.LogError("CatStateMachine is not initialized");
    }

    public void FixedUpdate()
    {
        if (_isInitialized) CurrentState.FixedUpdate();
        else Debug.LogError("CatStateMachine is not initialized");
    }
}

Заметим, что флаги всё равно присутствуют. Но теперь они влияют только на переход между состояниями + находятся в отдельном месте, а не в главном скрипте Cat.cs.

Метод Tick() обрабатывает пользовательский ввод (в данном случае - движение влево или вправо) в соответствии с установленным режимом и меняет параметры RigidBody2D или Transform. Нажатие кнопки прыжка не нужно проверять каждый кадр, поэтому прыжок вынесен в отдельный метод. 

Код
public enum LocomotionMode
{
    Grounded, Airborne, NoControl
}

public class CatLocomotion
{
    private LocomotionMode mode = LocomotionMode.Grounded;
    private readonly Rigidbody2D rb;
    private readonly BoxCollider2D body;
    private readonly Transform transform;
    // Могут быть нужны и другие компоненты, но эти - основные


    // =====================================
    // Флаги
    // =====================================

    public bool IsGrounded
    {
        get
        {
            //...
        }
    }

    // --- Прыжок с буферизацией ---
    public bool JumpPerformed
    {
        get
        {
            if (jumpPerformed)
            {
                jumpPerformed = false;
                return true;
            }
            return false;
        }
    }

    public CatLocomotion(
    Rigidbody2D rb,
    BoxCollider2D body,
    Transform transform
    )
    {
        this.rb = rb;
        this.body = body;
        this.transform = transform;
    }


    public void SetMode(LocomotionMode mode) => this.mode = mode;

    public void Tick(float input, float dt)
    {
      // Это FixedUpdate() для физики
    }

    /// 
    /// Попытка прыжка. Проверяет землю, поддерживает буферизацию.
    /// 
    public void Jump()
    {
        //...
    }

}

Наконец, посмотрим, как все эти навороты связаны с персонажем. Оставлен только код, относящийся к теме.

В Awake() создаётся машина и все её состояния. В Start() происходит инициализация. В Update() и FixedUpdate() вызываются одноимённые методы CatStateMachine.

Код
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UI;

[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(BoxCollider2D))]
public class Cat : MonoBehaviour
{
    public Rigidbody2D rb;
    private BoxCollider2D boxCollider;
    public Animator animator;

    // текущий ввод (горизонтальный)
    public float Impact { get; private set; } = 0F; 

    // --- Машина состояний ---
    public CatStateMachine StateMachine { get; private set; }
    public CatIdle IdleState { get; private set; }
    public CatRun RunState { get; private set; }
    public CatJump JumpState { get; private set; }
    public CatFall FallState { get; private set; }
    public CatInteracting InteractingState { get; private set; }
    
    public CatLocomotion Locomotion { get; private set; }

    // ============================================================
    //  Жизненный цикл
    // ============================================================

    void Awake()
    {
        boxCollider = GetComponent


Выражаю благодарность Повелителю блинов за совет реализовать этот паттерн. 

Комментарии (0)

Войдите, чтобы написать комментарий

Войти
Large Spinner