01.09.2026 21 мин чтения Просмотров:34

Конечный автомат (State Machine) в Unity: как организовать состояния персонажа

Разбираемся, что такое конечный автомат (State Machine), зачем он нужен в Unity и как с его помощью организовать состояния игрока, врагов, NPC и других игровых объектов.

Конечный автомат (State Machine) в Unity: как организовать состояния персонажа

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

Есть движение:

if (horizontal != 0)
{
    Move();
}

Есть прыжок:

if (Input.GetKeyDown(KeyCode.Space))
{
    Jump();
}

Всё выглядит вполне нормально.

Но затем появляются бег, падение, атака, перекат, получение урона, оглушение, взаимодействие с предметами и смерть.

Вместе с ними появляются переменные:

bool isRunning;
bool isJumping;
bool isAttacking;
bool isRolling;
bool isStunned;
bool isDead;

А проверки постепенно начинают выглядеть примерно так:

if (!isDead && !isStunned && !isAttacking && !isRolling)
{
    Jump();
}

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

Количество условий растёт, а вместе с ним растёт вероятность ошибки.

В какой-то момент становится сложно ответить даже на простой вопрос:

в каком состоянии персонаж находится прямо сейчас?

Для решения подобных задач и используется конечный автомат — State Machine.

Что такое State Machine

State Machine, или конечный автомат, — это подход, при котором объект может находиться в одном из заранее определённых состояний.

Например, у игрока могут быть состояния:

  • Idle — стоит на месте;
  • Running — бежит;
  • Jumping — находится в прыжке;
  • Attacking — атакует;
  • Dead — мёртв.

При этом в конкретный момент времени активно одно основное состояние.

Если игрок стоит, его состояние — Idle.

Он начал двигаться — состояние меняется на Running.

Нажал прыжок — переходим в Jumping.

Здоровье закончилось — переходим в Dead.

То есть вместо нескольких независимых bool мы получаем одну понятную сущность:

currentState

которая отвечает на вопрос: что персонаж делает сейчас?

Простой пример из жизни

Хороший пример конечного автомата — обычный светофор.

У него есть несколько состояний:

  • красный;
  • жёлтый;
  • зелёный.

В конкретный момент времени светофор находится в определённом состоянии. После выполнения нужного условия он переходит в следующее.

С игровым персонажем происходит то же самое.

В состоянии Idle персонаж ждёт ввода.

В Running обрабатывает движение.

В Attack выполняет атаку.

В Dead перестаёт реагировать на управление.

При этом каждое состояние отвечает только за то поведение, которое ему действительно нужно.

Зачем State Machine нужен в Unity

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

Например:

isJumping = true;
isAttacking = true;
isDead = true;

Если архитектура построена только на флагах, технически персонаж может оказаться одновременно прыгающим, атакующим и мёртвым.

Конечно, это можно исправлять дополнительными проверками:

if (!isDead)
{
    if (!isStunned)
    {
        if (!isAttacking)
        {
            Jump();
        }
    }
}

Но чем больше механик появляется в игре, тем сложнее поддерживать такой код.

С State Machine мы можем явно сказать:

currentState = PlayerState.Attacking;

Пока персонаж находится в этом состоянии, выполняется логика атаки.

Когда атака закончилась, состояние меняется, например, на Idle.

Так поведение объекта становится гораздо предсказуемее.

Самый простой State Machine через enum

Необязательно сразу создавать сложную архитектуру из десятка классов.

Простейший State Machine можно реализовать через обычный enum.

Создадим состояния игрока:

public enum PlayerState
{
    Idle,
    Running,
    Jumping,
    Attacking,
    Dead
}

Теперь добавим текущее состояние:

private PlayerState currentState;

При запуске игры персонаж, например, находится в Idle:

private void Start()
{
    currentState = PlayerState.Idle;
}

Полный код пока выглядит так:

using UnityEngine;

public class Player : MonoBehaviour
{
    public enum PlayerState
    {
        Idle,
        Running,
        Jumping,
        Attacking,
        Dead
    }

    private PlayerState currentState;

    private void Start()
    {
        currentState = PlayerState.Idle;
    }
}

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

Переключаем состояния

Для смены состояния создадим отдельный метод:

private void ChangeState(PlayerState newState)
{
    currentState = newState;
}

Теперь начать бег можно так:

ChangeState(PlayerState.Running);

Начать прыжок:

ChangeState(PlayerState.Jumping);

Начать атаку:

ChangeState(PlayerState.Attacking);

А при смерти:

ChangeState(PlayerState.Dead);

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

Это позволит добавить в него дополнительную логику.

Обрабатываем текущее состояние

Теперь нужно выполнять разный код в зависимости от текущего состояния.

Для простого варианта удобно использовать switch:

private void Update()
{
    switch (currentState)
    {
        case PlayerState.Idle:
            UpdateIdle();
            break;

        case PlayerState.Running:
            UpdateRunning();
            break;

        case PlayerState.Jumping:
            UpdateJumping();
            break;

        case PlayerState.Attacking:
            UpdateAttacking();
            break;

        case PlayerState.Dead:
            UpdateDead();
            break;
    }
}

А саму логику разделить на методы:

private void UpdateIdle()
{
}

private void UpdateRunning()
{
}

private void UpdateJumping()
{
}

private void UpdateAttacking()
{
}

private void UpdateDead()
{
}

Теперь логика бега находится в UpdateRunning(), атаки — в UpdateAttacking(), а прыжка — в UpdateJumping().

Это уже значительно удобнее одного огромного Update().

Реализуем Idle и Running

Допустим, пока игрок стоит, мы ждём ввода:

private void UpdateIdle()
{
    float horizontal = Input.GetAxisRaw("Horizontal");

    if (horizontal != 0)
    {
        ChangeState(PlayerState.Running);
    }
}

Как только появляется ввод, состояние меняется с Idle на Running.

Теперь реализуем бег:

private void UpdateRunning()
{
    float horizontal = Input.GetAxisRaw("Horizontal");

    transform.position +=
        Vector3.right *
        horizontal *
        5f *
        Time.deltaTime;

    if (horizontal == 0)
    {
        ChangeState(PlayerState.Idle);
    }
}

Пока есть ввод, персонаж двигается.

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

Получается очень простое правило: Idle отвечает за ожидание движения, а Running — непосредственно за движение.

Добавляем прыжок

Теперь разрешим игроку прыгать из состояния Idle.

private void UpdateIdle()
{
    float horizontal = Input.GetAxisRaw("Horizontal");

    if (Input.GetKeyDown(KeyCode.Space))
    {
        ChangeState(PlayerState.Jumping);
        return;
    }

    if (horizontal != 0)
    {
        ChangeState(PlayerState.Running);
    }
}

Обратите внимание на return.

После перехода в Jumping мы прекращаем выполнение логики Idle в текущем кадре.

Прыгать можно и во время бега:

private void UpdateRunning()
{
    float horizontal = Input.GetAxisRaw("Horizontal");

    if (Input.GetKeyDown(KeyCode.Space))
    {
        ChangeState(PlayerState.Jumping);
        return;
    }

    transform.position +=
        Vector3.right *
        horizontal *
        5f *
        Time.deltaTime;

    if (horizontal == 0)
    {
        ChangeState(PlayerState.Idle);
    }
}

Таким образом мы явно определяем, из каких состояний разрешён переход в Jumping.

Например, в Dead такой проверки вообще не будет, поэтому мёртвый персонаж не сможет прыгнуть.

Enter, Update и Exit

Пока у нас есть только логика, которая выполняется каждый кадр.

Но у состояния часто есть действия, которые нужно выполнить только один раз.

Например, когда персонаж начал атаку, нужно один раз запустить анимацию:

animator.Play("Attack");

Если поставить этот код в UpdateAttacking(), анимация будет запускаться заново каждый кадр.

Поэтому состояния удобно разделять на три этапа:

  • Enter — вход в состояние;
  • Update — выполнение состояния;
  • Exit — выход из состояния.

Enter вызывается один раз, когда состояние становится активным.

Update вызывается каждый кадр, пока состояние активно.

Exit вызывается один раз перед переключением на другое состояние.

Это одна из основных идей хорошей реализации State Machine.

Добавляем вход и выход из состояния

Изменим наш ChangeState:

private void ChangeState(PlayerState newState)
{
    ExitState(currentState);

    currentState = newState;

    EnterState(currentState);
}

Теперь перед переключением мы вызываем:

ExitState(currentState);

После этого устанавливаем новое состояние:

currentState = newState;

И выполняем его начальную логику:

EnterState(currentState);

Реализовать EnterState можно через switch:

private void EnterState(PlayerState state)
{
    switch (state)
    {
        case PlayerState.Idle:
            Debug.Log("Enter Idle");
            break;

        case PlayerState.Running:
            Debug.Log("Enter Running");
            break;

        case PlayerState.Jumping:
            Debug.Log("Enter Jumping");
            break;

        case PlayerState.Attacking:
            Debug.Log("Enter Attack");
            break;
    }
}

Аналогично создаём выход:

private void ExitState(PlayerState state)
{
    switch (state)
    {
        case PlayerState.Idle:
            Debug.Log("Exit Idle");
            break;

        case PlayerState.Running:
            Debug.Log("Exit Running");
            break;

        case PlayerState.Jumping:
            Debug.Log("Exit Jumping");
            break;

        case PlayerState.Attacking:
            Debug.Log("Exit Attack");
            break;
    }
}

Теперь при переходе из Idle в Running сначала выполнится логика выхода из Idle, а затем логика входа в Running.

Используем State Machine вместе с Animator

Разделение на вход и обновление состояния особенно удобно при работе с анимациями.

Допустим, у игрока есть:

[SerializeField]
private Animator animator;

При входе в каждое состояние можно запускать нужную анимацию:

private void EnterState(PlayerState state)
{
    switch (state)
    {
        case PlayerState.Idle:
            animator.Play("Idle");
            break;

        case PlayerState.Running:
            animator.Play("Run");
            break;

        case PlayerState.Jumping:
            animator.Play("Jump");
            break;

        case PlayerState.Attacking:
            animator.Play("Attack");
            break;

        case PlayerState.Dead:
            animator.Play("Death");
            break;
    }
}

Теперь нам не нужно постоянно проверять, какую анимацию следует проигрывать.

Состояние изменилось — нужная анимация запускается автоматически.

Пример состояния атаки

Допустим, игрок нажимает кнопку атаки в Idle:

if (Input.GetMouseButtonDown(0))
{
    ChangeState(PlayerState.Attacking);
    return;
}

При входе в состояние запускаем анимацию:

case PlayerState.Attacking:
    animator.Play("Attack");
    break;

А в UpdateAttacking() ждём завершения атаки:

private void UpdateAttacking()
{
    if (attackFinished)
    {
        ChangeState(PlayerState.Idle);
    }
}

Само состояние атаки теперь отвечает только за атаку.

Проверки движения и прыжка здесь отсутствуют, если по дизайну игры они во время атаки запрещены.

Это намного проще, чем постоянно писать:

if (!isAttacking)
{
    // ...
}

во всех остальных частях контроллера.

Когда enum и switch становится мало

Для небольшого персонажа вариант с enum вполне нормальный.

Проблема начинается, когда состояний становится много.

Представьте:

public enum PlayerState
{
    Idle,
    Running,
    Jumping,
    Falling,
    Attacking,
    Blocking,
    Rolling,
    Stunned,
    Climbing,
    Interacting,
    Dead
}

Теперь у нас разрастаются сразу несколько методов:

EnterState();
UpdateState();
ExitState();

В каждом появляется большой switch.

А вся логика по-прежнему находится внутри одного Player.

В результате Player.cs может снова превратиться в огромный класс.

В такой ситуации можно перейти к следующему варианту реализации — State Pattern.

State Pattern: каждое состояние становится классом

Основная идея State Pattern заключается в том, что мы больше не храним всю логику состояний внутри Player.

Вместо этого создаём отдельные классы:

  • PlayerIdleState;
  • PlayerRunState;
  • PlayerJumpState;
  • PlayerAttackState;
  • PlayerDeadState.

Все эти классы должны иметь одинаковые основные методы: вход, обновление и выход.

Для этого создадим базовый класс:

public abstract class PlayerState
{
    public abstract void Enter();

    public abstract void Update();

    public abstract void Exit();
}

Теперь любое состояние игрока должно реализовать эти три метода.

Создаём IdleState

using UnityEngine;

public class PlayerIdleState : PlayerState
{
    public override void Enter()
    {
        Debug.Log("Enter Idle");
    }

    public override void Update()
    {
        Debug.Log("Idle Update");
    }

    public override void Exit()
    {
        Debug.Log("Exit Idle");
    }
}

То же самое можно сделать для бега:

using UnityEngine;

public class PlayerRunState : PlayerState
{
    public override void Enter()
    {
        Debug.Log("Enter Run");
    }

    public override void Update()
    {
        Debug.Log("Run Update");
    }

    public override void Exit()
    {
        Debug.Log("Exit Run");
    }
}

Теперь логика Idle и Run физически находится в разных классах.

Создаём сам State Machine

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

public class StateMachine
{
    public PlayerState CurrentState { get; private set; }

    public void ChangeState(PlayerState newState)
    {
        CurrentState?.Exit();

        CurrentState = newState;

        CurrentState.Enter();
    }

    public void Update()
    {
        CurrentState?.Update();
    }
}

Это и есть основа нашего конечного автомата.

Когда вызывается:

ChangeState(newState);

State Machine сначала завершает старое состояние:

CurrentState?.Exit();

Затем устанавливает новое:

CurrentState = newState;

И вызывает его Enter():

CurrentState.Enter();

После этого метод:

stateMachine.Update();

каждый кадр вызывает Update() только у текущего состояния.

Подключаем State Machine к Player

Теперь создадим состояния внутри игрока:

using UnityEngine;

public class Player : MonoBehaviour
{
    private StateMachine stateMachine;

    private PlayerIdleState idleState;
    private PlayerRunState runState;

    private void Awake()
    {
        stateMachine = new StateMachine();

        idleState = new PlayerIdleState();
        runState = new PlayerRunState();
    }

    private void Start()
    {
        stateMachine.ChangeState(idleState);
    }

    private void Update()
    {
        stateMachine.Update();
    }
}

При запуске игры активируется idleState.

После этого Unity каждый кадр вызывает обычный Player.Update(), а он передаёт выполнение текущему состоянию:

stateMachine.Update();

Если текущее состояние — PlayerIdleState, будет вызван его Update().

Если состояние изменится на PlayerRunState, начнёт вызываться Update() уже этого класса.

Как состоянию получить доступ к Player

Пока наши состояния почти бесполезны, потому что они не имеют доступа к самому персонажу.

Например, RunState должен как-то изменить позицию игрока.

Для этого передадим Player через конструктор:

public abstract class PlayerState
{
    protected Player player;

    protected PlayerState(Player player)
    {
        this.player = player;
    }

    public abstract void Enter();
    public abstract void Update();
    public abstract void Exit();
}

Теперь конструктор IdleState будет выглядеть так:

public PlayerIdleState(Player player)
    : base(player)
{
}

А создаём его следующим образом:

idleState = new PlayerIdleState(this);

this здесь — текущий экземпляр Player.

Теперь внутри состояния можно обращаться к игроку через:

player

Например:

player.transform.position

Как состоянию переключить другое состояние

Есть ещё одна проблема.

IdleState может понять, что игрок начал двигаться, но ему нужно как-то переключиться на RunState.

Для этого передадим в состояние ещё и сам StateMachine.

Базовый класс теперь выглядит так:

public abstract class PlayerState
{
    protected Player player;
    protected StateMachine stateMachine;

    protected PlayerState(
        Player player,
        StateMachine stateMachine)
    {
        this.player = player;
        this.stateMachine = stateMachine;
    }

    public abstract void Enter();

    public abstract void Update();

    public abstract void Exit();
}

Теперь любое состояние может вызвать:

stateMachine.ChangeState(...);

Полноценный IdleState

Сделаем состояние ожидания:

using UnityEngine;

public class PlayerIdleState : PlayerState
{
    public PlayerIdleState(
        Player player,
        StateMachine stateMachine)
        : base(player, stateMachine)
    {
    }

    public override void Enter()
    {
        Debug.Log("Enter Idle");
    }

    public override void Update()
    {
        float horizontal = Input.GetAxisRaw("Horizontal");

        if (horizontal != 0)
        {
            stateMachine.ChangeState(player.RunState);
        }
    }

    public override void Exit()
    {
    }
}

Пока игрок ничего не нажимает, состояние остаётся активным.

Как только появляется горизонтальный ввод, вызывается:

stateMachine.ChangeState(player.RunState);

После этого IdleState.Exit() вызывается один раз, а текущим состоянием становится RunState.

Реализуем RunState

using UnityEngine;

public class PlayerRunState : PlayerState
{
    public PlayerRunState(
        Player player,
        StateMachine stateMachine)
        : base(player, stateMachine)
    {
    }

    public override void Enter()
    {
        Debug.Log("Enter Run");
    }

    public override void Update()
    {
        float horizontal = Input.GetAxisRaw("Horizontal");

        player.transform.position +=
            Vector3.right *
            horizontal *
            player.MoveSpeed *
            Time.deltaTime;

        if (horizontal == 0)
        {
            stateMachine.ChangeState(player.IdleState);
        }
    }

    public override void Exit()
    {
    }
}

Здесь уже находится вся логика, связанная с бегом.

Состояние получает ввод, двигает персонажа и проверяет условие выхода.

Если ввод пропадает, персонаж возвращается в IdleState.

Обновляем Player

Теперь сам Player может выглядеть так:

using UnityEngine;

public class Player : MonoBehaviour
{
    [field: SerializeField]
    public float MoveSpeed { get; private set; } = 5f;

    private StateMachine stateMachine;

    public PlayerIdleState IdleState { get; private set; }
    public PlayerRunState RunState { get; private set; }

    private void Awake()
    {
        stateMachine = new StateMachine();

        IdleState =
            new PlayerIdleState(this, stateMachine);

        RunState =
            new PlayerRunState(this, stateMachine);
    }

    private void Start()
    {
        stateMachine.ChangeState(IdleState);
    }

    private void Update()
    {
        stateMachine.Update();
    }
}

Обратите внимание, насколько небольшим стал основной Player.

Ему больше не нужно содержать всю логику всех возможных состояний.

Он хранит данные персонажа и его состояния, а конкретное поведение находится в отдельных классах.

Добавляем JumpState

Создадим ещё одно состояние:

public PlayerJumpState JumpState { get; private set; }

И инициализируем его:

JumpState =
    new PlayerJumpState(this, stateMachine);

Сам класс может выглядеть так:

using UnityEngine;

public class PlayerJumpState : PlayerState
{
    public PlayerJumpState(
        Player player,
        StateMachine stateMachine)
        : base(player, stateMachine)
    {
    }

    public override void Enter()
    {
        player.Jump();
    }

    public override void Update()
    {
        if (player.IsGrounded)
        {
            stateMachine.ChangeState(player.IdleState);
        }
    }

    public override void Exit()
    {
    }
}

Теперь в IdleState можно добавить:

if (Input.GetKeyDown(KeyCode.Space))
{
    stateMachine.ChangeState(player.JumpState);
    return;
}

То же самое можно сделать в RunState, если по правилам игры разрешено прыгать во время бега.

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

Например, если в AttackState нет перехода в JumpState, во время атаки прыгнуть нельзя.

Зачем нужен Enter

Метод Enter() особенно полезен для действий, которые должны выполниться только один раз.

Например, при начале атаки:

public override void Enter()
{
    player.Animator.Play("Attack");
}

Если поместить этот код в Update(), вызов будет происходить каждый кадр:

public override void Update()
{
    player.Animator.Play("Attack");
}

В большинстве случаев это неправильно.

Поэтому в Enter() обычно удобно:

  • запускать анимацию;
  • устанавливать начальные значения;
  • включать необходимые эффекты;
  • блокировать управление;
  • подписываться на события.

Enter() отвечает именно за начало состояния.

Зачем нужен Update

Update() выполняется всё время, пока состояние активно.

Например, в состоянии бега:

public override void Update()
{
    player.Move();
}

В состоянии преследования врага:

public override void Update()
{
    enemy.MoveToPlayer();
}

А в состоянии атаки можно ждать завершения анимации:

public override void Update()
{
    if (player.AttackFinished)
    {
        stateMachine.ChangeState(player.IdleState);
    }
}

То есть здесь находится логика, которую необходимо постоянно обновлять.

Зачем нужен Exit

Exit() вызывается непосредственно перед тем, как состояние перестанет быть активным.

Например, при атаке мы можем заблокировать движение:

public override void Enter()
{
    player.CanMove = false;
}

А после окончания атаки вернуть его:

public override void Exit()
{
    player.CanMove = true;
}

Другой хороший пример — события.

При входе подписываемся:

public override void Enter()
{
    player.AnimationFinished += OnAnimationFinished;
}

При выходе обязательно отписываемся:

public override void Exit()
{
    player.AnimationFinished -= OnAnimationFinished;
}

Таким образом состояние самостоятельно отвечает за собственный жизненный цикл.

Защищаемся от повторного перехода

Представим, что где-то вызывается:

stateMachine.ChangeState(player.RunState);

хотя RunState уже является текущим состоянием.

Без дополнительной проверки произойдёт выход из RunState, а затем мгновенный вход обратно.

Если такой вызов происходит каждый кадр, Exit() и Enter() тоже будут выполняться каждый кадр.

Исправить это можно непосредственно внутри State Machine:

public void ChangeState(PlayerState newState)
{
    if (CurrentState == newState)
        return;

    CurrentState?.Exit();

    CurrentState = newState;

    CurrentState.Enter();
}

Теперь попытка переключиться на уже активное состояние просто игнорируется.

Это небольшая проверка, которую полезно добавить практически сразу.

State Machine для врагов

State Machine особенно хорошо подходит для игрового AI.

Например, обычный противник может иметь следующие состояния:

  • IdleState — стоит на месте;
  • PatrolState — патрулирует территорию;
  • ChaseState — преследует игрока;
  • AttackState — атакует;
  • StunnedState — находится в оглушении;
  • DeadState — мёртв.

В PatrolState враг может проверять, появился ли игрок поблизости. Если появился — переключаться на ChaseState.

В ChaseState враг движется к игроку. Когда расстояние становится достаточно маленьким, он переходит в AttackState.

Если игрок убежал, враг может снова вернуться к патрулированию.

При этом логика патрулирования не смешивается с кодом атаки.

Пример ChaseState

Например:

using UnityEngine;

public class EnemyChaseState : EnemyState
{
    public EnemyChaseState(
        Enemy enemy,
        EnemyStateMachine stateMachine)
        : base(enemy, stateMachine)
    {
    }

    public override void Enter()
    {
        enemy.Animator.Play("Run");
    }

    public override void Update()
    {
        enemy.MoveToPlayer();

        float distance = Vector3.Distance(
            enemy.transform.position,
            enemy.Player.position
        );

        if (distance <= enemy.AttackDistance)
        {
            stateMachine.ChangeState(enemy.AttackState);
        }
    }

    public override void Exit()
    {
    }
}

Этот класс занимается только преследованием.

Ему не нужно содержать код патрулирования, смерти или самой атаки.

Когда противник приблизился к игроку, состояние просто передаёт управление AttackState.

Именно такое разделение ответственности и делает State Machine удобным для сложного AI.

State Machine подходит не только для персонажей

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

Например, дверь может иметь состояния:

  • Closed;
  • Opening;
  • Open;
  • Closing.

Турель:

  • Searching;
  • Targeting;
  • Shooting;
  • Reloading.

NPC:

  • Idle;
  • Walking;
  • Talking;
  • Working.

Босс:

  • PhaseOne;
  • PhaseTwo;
  • PhaseThree;
  • Dead.

Даже интерфейс игры иногда удобно представить через состояния:

  • главное меню;
  • настройки;
  • игра;
  • пауза;
  • Game Over.

Сам принцип от этого не меняется: объект находится в конкретном состоянии и переключается на другое при выполнении определённых условий.

State Machine и Animator — это одно и то же?

В Unity сам Animator Controller тоже построен вокруг состояний.

В нём есть состояния анимации и переходы между ними.

Из-за этого возникает логичный вопрос: зачем делать ещё один State Machine в коде?

Потому что состояние анимации и состояние игровой логики — не обязательно одно и то же.

Например, PlayerRunState может отвечать за:

  • чтение ввода;
  • перемещение персонажа;
  • возможность прыгнуть;
  • проверку земли;
  • переход в другие игровые состояния.

Animator при этом отвечает непосредственно за визуальную часть.

Игровое состояние может просто сообщить ему:

player.Animator.Play("Run");

или изменить параметр:

player.Animator.SetBool("IsRunning", true);

Поэтому не стоит переносить всю игровую логику персонажа внутрь Animator только потому, что там тоже существуют состояния.

enum или отдельные классы?

Не существует правила, что любой State Machine обязательно нужно реализовывать через отдельные классы.

Для простой системы:

enum DoorState
{
    Closed,
    Opening,
    Open,
    Closing
}

и обычный switch могут быть идеальным решением.

Создавать четыре отдельных класса для простой двери иногда просто нет смысла.

Вариант с enum хорошо подходит, когда состояний немного, а логика каждого из них короткая.

Отдельные классы становятся полезными, когда состояний много, каждое из них содержит заметный объём логики, а основной класс начинает разрастаться.

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

Главное — не использовать сложную архитектуру ради самой архитектуры.

State Machine должен упрощать код, а не делать простую задачу сложнее.

Можно ли использовать несколько State Machine?

Да.

Иногда это даже удобнее одного огромного автомата.

Представим персонажа, который может одновременно бежать и стрелять.

Если пытаться описать всё одним State Machine, могут появиться состояния вроде:

RunningAndShooting
JumpingAndShooting
IdleAndShooting
RunningAndReloading
JumpingAndReloading

Количество комбинаций быстро начинает расти.

Вместо этого можно разделить ответственность.

Один State Machine отвечает за движение:

  • Idle;
  • Run;
  • Jump;
  • Fall.

Другой — за оружие:

  • Ready;
  • Shooting;
  • Reloading.

Тогда персонаж может одновременно находиться, например, в Run для движения и в Shooting для оружия.

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

Частая ошибка: состояние знает обо всей игре

Представим, что PlayerAttackState начинает напрямую управлять:

  • UI;
  • камерой;
  • звуками;
  • системой достижений;
  • врагами;
  • сохранениями;
  • инвентарём.

В итоге мы просто переносим проблему из Player в другой класс.

Состояние желательно использовать для управления поведением того объекта, которому оно принадлежит.

Для общения с другими системами можно использовать события.

Например:

public event Action OnAttackStarted;

Тогда система камеры, звука или UI сможет отдельно подписаться на нужное событие.

Это уменьшает связанность кода.

Как организовать файлы

Для игрока структура проекта может выглядеть примерно так:

Player.cs хранит основные данные и ссылки персонажа.

StateMachine.cs отвечает за переключение состояний.

PlayerState.cs является базовым классом состояния.

А конкретные состояния можно вынести в отдельную папку States:

  • PlayerIdleState.cs;
  • PlayerRunState.cs;
  • PlayerJumpState.cs;
  • PlayerAttackState.cs;
  • PlayerDeadState.cs.

Для врагов можно сделать аналогичную структуру с EnemyState, EnemyPatrolState, EnemyChaseState и другими состояниями.

Так значительно проще ориентироваться в проекте, чем в одном PlayerController.cs на несколько тысяч строк.

Минимальная реализация State Machine

Если отбросить всё остальное, сам конечный автомат получается очень небольшим:

public class StateMachine
{
    public PlayerState CurrentState { get; private set; }

    public void ChangeState(PlayerState newState)
    {
        if (CurrentState == newState)
            return;

        CurrentState?.Exit();

        CurrentState = newState;

        CurrentState.Enter();
    }

    public void Update()
    {
        CurrentState?.Update();
    }
}

А базовое состояние:

public abstract class PlayerState
{
    protected Player player;
    protected StateMachine stateMachine;

    protected PlayerState(
        Player player,
        StateMachine stateMachine)
    {
        this.player = player;
        this.stateMachine = stateMachine;
    }

    public abstract void Enter();

    public abstract void Update();

    public abstract void Exit();
}

Вся остальная система строится поверх этих двух классов.

Как проектировать State Machine

Перед тем как писать код, полезно сначала определить, какие состояния вообще существуют.

Например, для простого персонажа:

  • Idle;
  • Run;
  • Jump;
  • Attack;
  • Dead.

После этого стоит определить разрешённые переходы.

Из Idle можно начать бег, прыгнуть или атаковать.

Из Run можно остановиться, прыгнуть или атаковать.

Из Attack после завершения атаки можно вернуться в Idle.

А при смерти персонаж должен перейти в Dead независимо от того, чем он занимался до этого.

После этого для каждого состояния можно ответить на три вопроса.

Что должно произойти при входе в состояние?

Что должно происходить, пока состояние активно?

Что нужно сделать перед выходом из состояния?

Ответы на эти вопросы практически напрямую превращаются в:

Enter();
Update();
Exit();

Так проектировать State Machine обычно проще, чем сразу начинать писать классы.

Когда State Machine действительно нужен

State Machine особенно полезен, когда поведение объекта начинает зависеть от большого количества условий.

Если вы постоянно пишете проверки вроде:

if (!isDead && !isAttacking && !isStunned)

это хороший повод посмотреть на архитектуру состояний.

Другой признак — большое количество bool, описывающих режим работы одного объекта:

isRunning
isJumping
isAttacking
isRolling
isStunned
isDead

Ещё один сигнал — огромный Update(), внутри которого одновременно находится логика движения, атаки, прыжка, анимаций и AI.

Но это не означает, что State Machine нужен абсолютно везде.

Если у объекта два простых режима и несколько строк логики, обычного bool вполне может быть достаточно.

Итог

State Machine — это способ организовать поведение объекта через отдельные состояния.

Вместо того чтобы постоянно проверять множество флагов:

if (!isDead && !isStunned && !isAttacking)
{
    // ...
}

мы можем явно определить текущее состояние:

currentState = PlayerState.Running;

Для простых систем достаточно использовать enum и switch.

Когда состояний и логики становится больше, можно перейти к отдельным классам:

PlayerIdleState
PlayerRunState
PlayerJumpState
PlayerAttackState

Каждое состояние при этом обычно имеет три основных метода:

Enter();
Update();
Exit();

Enter() выполняет действия при входе в состояние, Update() содержит его основную логику, а Exit() выполняет необходимые действия перед выходом.

Сам State Machine отвечает за переключение между ними:

CurrentState?.Exit();

CurrentState = newState;

CurrentState.Enter();

Главная идея конечного автомата достаточно проста: вместо того чтобы заставлять один объект одновременно проверять всю возможную игровую логику, мы определяем его текущее состояние и выполняем только то поведение, которое относится к этому состоянию.

Для небольших объектов это может быть обычный enum. Для сложного игрока, противника или босса — полноценная система отдельных классов состояний.

Главное, чтобы State Machine делал код понятнее, а не превращался в ещё один слой сложности.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также