Описание
Небольшая и простая в освоении система комбо-атаки для 2D и 3D проектов. Основана на машине состояний и может включать любое количество состояний атаки.

Я постарался описать материал подробно, включая информацию о машине состояний. Для тех, кто хочет перейти непосредственно к использованию системы, в конце я оставил ссылку на свой репозиторий с готовым проектом.
Благодарность
Хочу сказать огромное спасибо моему другу и ревьюеру с предыдущего места работы, Артёму, за то, что когда-то научил меня пользоваться машиной состояний. Твой вклад в мою деятельность неоценим.
State machine (Машина состояний)
Машина состояний - паттерн проектирования, реализующий поведение системы, в которой объект, в зависимости от своего внутреннего состояния, по-разному реагирует на одни и те же события.
Прежде чем понять что такое машина состояний, рассмотрим бытовой пример с феном. Допустим, фен имеет три скорости и всего одну кнопку, которая отвечает и за включение/выключение и за смену скорости. Причём, при включении фена, его режим переключается сразу на первую скорость. Отсюда следует, что он может находиться лишь в четырех следующих состояниях: выключен, первая скорость, вторая скорость, третья скорость.
Как я упомянул выше, при выключенном фене, нажав на кнопку, мы сразу переключим его работу в режим первой скорости. Нажав на кнопку, когда фен работает в режиме первой скорости, мы переключим его в режим второй скорости. Далее - в режим третьей скорости. Уже в этом состоянии, при нажатии на кнопку, фен будет выключен.
Таким образом мы можем составить блок-схему возможных переходов между состояниями фена:

Пользователь, который никогда не сталкивался с машиной состояний, вероятно, реализует логику взаимодействия с феном похожим образом:
public class HairDryer:
{
public void ClickButton()
{
if (_currentState == "unactiveState")
_currentState = "firstSpeedState";
else if (_currentState == "firstSpeedState")
_currentState = "secondSpeedState";
else if (_currentState == "secondSpeedState")
_currentState = "thirdSpeedState";
else
_currentState = "unactiveState";
}
}
Но, что если тот же фен имеет 20 режимов скоростей? С добавлением новых состояний условий становится больше, скорость выполнения программы сокращается, а контроль над таким кодом усложняется. А если кнопка не одна? В таком случае каждый метод, вызываемый по нажатию определённой кнопки, будет содержать внушительный набор условий.
И большинство реализаций комбо-атаки на просторах интернета реализовано похожим образом, что меня не устраивало, когда я пытался как-то иначе реализовать логику комбо-атак, не зная еще о машине состояний.
Машина состояний помогает оптимизировать код и процесс работы. Таким образом, машина состояний - легко расширяемая и интуитивно понятная система, позволяющая избавиться от лишнего нагромождения условий и делегировать работу объекта другим частям кода.
Реализация машины состояний
В контексте кода, состояния - это классы, имеющие общий интерфейс и принимающие на себя всю внутреннюю работу исходного объекта. Сам же объект называется контекстом.
Далее мы напишем шаблон машины состояний, который можно использовать не только в контексте комбо-атак, но и в любом другом проекте.
Нам понадобится два класса: StateMachine
и State
. Первый класс будет являться зависимостью для конкретного контекста, а State
- родительским классом для всех его последующих состояний.
Для переиспользования машины состояний, при написании класса, используем дженерики: параметр T
отвечает за тип контекста, который должен наследоваться от MonoBehaviour
, а параметр S
представляет собой тип состояния, наследуемый от абстрактного класса State<T>
:
public class StateMachine<T, S> where T : MonoBehaviour where S : State<T> {}
public abstract class State<T> where T : MonoBehaviour {}
Класс State<T>
Перейдём к классу State<T>
. Напомню, что он является родительским для всех состояний контекста. Для взаимодействия состояния с контекстом, каждому состоянию необходимо хранить ссылку на контекст, который будет передаваться в качестве параметра в его конструктор.
Каждый класс, который реализует конкретное состояние, будет переопределять три виртуальных метода, определенных в State<T>
: OnEnter
- для выполнения логики при входе состояния, OnExit
- при выходе, OnActivated
- при активации. Я оставил их именно виртуальными, а не абстрактными, так как для конкретных реализаций машины состояний может потребоваться общее поведение для всех состояний. Таким образом, класс State<T>
выглядит следующим образом:
public abstract class State<T> where T : MonoBehaviour
{
protected T _context;
protected State(T context)
{
_context = context;
}
public virtual void OnEnter() { }
public virtual void OnExit() { }
public virtual void OnActivated() { }
}
Класс StateMachine<T,S>
Для быстрого доступа к состояниям понадобится словарь _states
, в котором ключом является тип состояния, а значением - экземпляр состояния. Необходимо хранить ссылку на текущее состояние машины _currentState
. Для взаимодействия с объектом также необходимо хранить ссылку на экземпляр контекста _owner
.
Помимо этого, в проекте с комбо-атакой, как и, вероятно, в других проектах, потребуется запрашивать у машины состояний количество состояний. Для этого мы добавим свойство StatesCount
, которое будет возвращать количество элементов в коллекции, содержащей состояния.
Переопределим конструктор машины состояний, - в качестве параметра он будет принимать экземпляр контекста:
public class StateMachine<T, S> where T : MonoBehaviour where S : State<T>
{
private readonly T _owner;
private readonly Dictionary<Type, S> _states = new();
private S _currentState;
public int StatesCount => _states.Count;
public StateMachine(T owner)
{
_owner = owner;
}
}
Смена состояний будет происходить при помощи публичного метода SwitchState<TState>
. TState
является параметром типа, который наследуется от S
- базового типа состояния. Таким образом, используя дженерик, мы можем работать с различными типами состояний, не привязываясь к конкретному типу, а значит и сменять их.
При запросе на смену состояния мы определяем тип переданного состояния и проверяем его наличие в словаре состояний. Затем мы проверяем, не является ли запрашиваемое состояние текущим состоянием контекста. Если состояние существует и отличается от текущего, мы меняем текущее состояние на запрашиваемое и выполняем соответствующую логику: вызываем метод OnExit
для текущего состояния и метод OnEnter
для нового состояния, соблюдая логический порядок перехода.
Обязательно при вызове метода OnExit
используем оператор условного доступа ?
, чтобы обезопасить себя от ситуации, когда _currentState
равен null
, - например, при инициализации исходного состояния машины:
public void SwitchState<TState>() where TState : S
{
Type type = typeof(TState);
if (!_states.ContainsKey(type))
throw new Exception("This state does not exist");
S newState = _states[type];
if (newState == _currentState)
return;
_currentState?.OnExit();
_currentState = newState;
_currentState.OnEnter();
}
В качестве обработки нажатия кнопки фена или при запросе атаки игроком, реализуем публичный метод Activate
, - он будет вызывать метод OnActivated
у текущего состояния контекста. Здесь также для безопасности оставляем оператор условного доступа ?
:
public void Activate()
{
_currentState?.OnActivated();
}
Если ваш проект требует обработки большего количества различных событий, таких как нажатие на кнопку, то необходимо на каждое из них создать отдельный метод, аналогичный уже существующему Activate
, который будет вызывать соответствующий метод, определенный в базовом классе состояний, у текущего состояния.
Заведём публичный метод для добавления состояний AddState
, который в качестве параметра будет принимать экземпляр состояния S
. Внутри метода, на основе переданного аргумента, будет создаваться новая пара "ключ-значение" для записи в словарь _states
:
public void AddState(S state)
{
_states[state.GetType()] = state;
}
Добавим публичный метод для инициализации исходного состояния машины состояний SetInitialState<TState>
, в котором будем вызывать ранее определённый метод SwitchState<TState>
. Возможно, кто-то сочтёт это оверинжинирингом, так как в контексте машины состояний можно было бы напрямую вызывать метод SwitchState<TState>
, но в нашем случае мы явно прописываем контракт метода и даём пользователю понять, что исходное состояние для машины состояний является обязательным. Сигнатура нового метода идентична сигнатуре метода SwitchState<TState>
:
public void SetInitialState<TState>() where TState : S
{
SwitchState<TState>();
}
При реализации контекста, нам понадобится создать необходимые состояния и реализовать в них логику, после чего добавить их в саму машину состояний, которая будет являться зависимостью для этого контекста.
Полный код машины состояний можно посмотреть по ссылке.
Реализация системы комбо-атаки
Прежде чем перейдём к практическому применению машины состояний, для начала рассмотрим блок-схему комбо-атаки. Стоит отметить заранее, что все состояния, которые будут выполнять роль атаки, я буду называть активными состояниями, а чтобы обозначить, что состояние атаки не является последним в наборе, я буду называть его промежуточным. Состояние, когда игрок не атакует, называется, соответственно, неактивным.
Будем реализовывать три вида атак в одной комбинации, по аналогии с описанным выше примером с феном, однако теперь время пребывания контекста в активных состояниях будет ограничено:

Из каждого активного состояния существует переход в неактивное состояние. Помимо этого у каждого промежуточного активного состояния существует переход в следующее активное состояние. Переход между активными состояниями осуществляется при вводе пользователем определенного значения с клавиатуры или любого другого назначенного устройства ввода. Переход в неактивное состояние осуществляется, если пользователь долгое время не вводил это значение или была завершена последняя атака в комбинации.
В качестве контекста рассмотрим класс Attacker
, который является производным от MonoBehaviour
, а в качестве базового состояния — класс AttackState
, производный от State<Attacker>
:
public class Attacker : MonoBehaviour {}
public abstract class AttackState: State<Attacker> {}
Как я уже упоминал ранее, класс Attacker
должен хранить ссылку на реализованную нами машину состояний, конструктор которой мы будем вызывать в методе Start
, передавая в качестве аргумента сам объект Attacker
.
При получении от пользователя запроса на атаку мы будем вызывать метод Attack
, который внутри себя обращается к методу Activate
машины состояний. Этот метод активирует текущее состояние атаки, вызывая соответствующий метод OnActivated
у текущего состояния.
Также мы реализуем метод SwitchState<TState>
, который вызывает одноимённый метод машины состояний. В сигнатуре метода мы явно указываем, что TState
должен быть производным от AttackState
, что гарантирует, что только состояния атаки могут быть переданы в этот метод:
public class Attacker : MonoBehaviour
{
private StateMachine<Attacker, AttackState> _stateMachine;
private void Start()
{
_stateMachine = new StateMachine<Attacker, AttackState>(this);
}
public void Attack()
{
_stateMachine.Activate();
}
public void SwitchState<TState>() where TState : AttackState
{
_stateMachine.SwitchState<TState>();
}
}
О важности завершения анимации атаки
Во время выполнения комбо-атаки, при вводе игроком значения с клавиатуры, важно не перейти сразу в следующее активное состояние атаки, а дождаться завершения анимации текущей атаки. В этом нам помогут счетчик очереди атак AttackQueueCounter
и класс проигрывания анимации AnimationPlayer
в связке с встроенным в Unity механизмом Animation Event.
Счётчик очереди атак AttackQueueCounter
Во время анимации промежуточной атаки важно сохранять ввод пользователя, если он был осуществлён до завершения этой атаки. Таким образом, когда промежуточная атака завершится, программа проверит, был ли запрос на последующую атаку, и, в зависимости от результата, система перейдёт либо в следующее активное состояние, либо в неактивное состояние.
Для сохранения ввода пользователя внутри Attacker
мы будем использовать автоматически реализуемое свойство AttackQueueCounter
, которое хранит значение типа int
. Это свойство имеет публичный геттер и приватный сеттер, что позволит состояниям считывать значение, но не изменять его напрямую:
public int AttackQueueCounter { get; private set; }
Изменять счётчик мы будем с помощью публичного метода UpdateCounter
, который принимает аргумент типа bool
. Этот аргумент определяет, нужно ли увеличить или уменьшить счётчик.
Каждый раз, когда происходит выход из активного состояния атаки, значение счётчика будет уменьшаться на единицу. В то же время, каждый раз, когда пользователь вводит новое значение для атаки с устройства ввода, счётчик будет увеличиваться на единицу. Для упрощения записи, используем тернарный оператор, который в зависимости от выполнения заданного условия вернёт нужное нам значение.
Значение счётчика будет ограничено с одной стороны нулём, а с другой - количеством активных состояний. Для ограничения с двух сторон используем метод Clamp
класса Mathf
из стандартной библиотеки Unity:
public void UpdateCounter(bool isIncrement)
{
int valueToUpdate = isIncrement ? 1 : -1;
AttackQueueCounter = Mathf.Clamp(AttackQueueCounter + valueToUpdate, 0, _stateMachine.StatesCount - 1);
}
Также, для безопасности, добавим сброс счётчика, который будем использовать при переходе в неактивное состояние:
public void ResetAttackCounter()
{
AttackQueueCounter = 0;
}
Класс проигрывания анимации AnimationPlayer
Для работы системы потребуется класс, который будет управлять переключением состояний в Animator и оповещать другие классы о завершении анимации. В этом классе должен быть реализован метод Play
, который принимает на вход аргумент типа int
, представляющий собой хэш-код параметра Animator. Сам Animator необходимо будет передавать в класс в качестве зависимости.
Событие, возникающее при завершении анимации, мы назовём AnimationCompleted
. Позже мы свяжем это событие с встроенным в Unity механизмом Animation Event. Для этого в классе должен быть реализован обработчик события OnAnimationCompleted
, который будет вызываться при срабатывании Animation Event. Внутри этого обработчика будет происходить уведомление подписчиков о событии AnimationCompleted
:
public class AnimationPlayer : MonoBehaviour
{
public event Action AnimationCompleted;
[SerializeField] private Animator _animator;
public void Play(int animationHash)
{
_animator.SetTrigger(animationHash);
}
private void OnAnimationCompleted()
{
AnimationCompleted?.Invoke();
}
}
AnimationPlayer
необходимо будет передавать классу Attacker
в качестве зависимости.
Работа с анимациями
На каждый тип атаки у вас должна быть заготовлена анимация. Я имею ввиду, не состояние, а именно тип. Ваша комбо-атака может состоять из четырёх состояний, но, например, использовать лишь одну атаку, - в таком случае нужна будет лишь одна анимация. Неплохой пример комбинации, содержащей в себе несколько атак одного типа, - последовательность "C-C-C" у персонажа Noob Saibot из игры Ultimate Mortal Kombat 3.

Вам необходимо добавить в конец каждой анимации атаки событие анимации Animation Event
. Сделать это можно в окне Animation выбранной анимации, нажав ПКМ под таймлайном и выбрав Add Animation Event. В окне Inspector у Animation Event в поле Function обязательно введите OnAnimationCompleted
. Таким образом, мы всегда будем знать когда завершилась анимация атаки и вызывать определённый обработчик события из кода проекта, - в нашем случае - метод OnAnimationCompleted
из класса AnimationPlayer
, который, в свою очередь, так же будет оповещать подписчиков своего события.

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

Для хранения хэш-кодов параметров Animator Controller понадобится скрипт. Поскольку в примере мы создаём комбо-атаку для главного героя, назовём скрипт PlayerAnimations
. В этом скрипте мы будем хранить публичные статические поля типа int
, которые будут содержать конвертированные в хэш-коды параметры Animator Controller. Эти поля будут объявлены как readonly
, чтобы предотвратить их изменение после инициализации:
public class PlayerAnimations : MonoBehaviour
{
public static readonly int IsUnactiveAttackState = Animator.StringToHash(nameof(IsUnactiveAttackState));
public static readonly int IsFirstAttackState = Animator.StringToHash(nameof(IsFirstAttackState));
public static readonly int IsSecondAttackState = Animator.StringToHash(nameof(IsSecondAttackState));
public static readonly int IsThirdAttackState = Animator.StringToHash(nameof(IsThirdAttackState));
}
Далее мы будем передавать хэш-код нужного параметра в метод Play
класса AnimationPlayer
, чтобы вызвать соответствующую анимацию.
Во всех анимациях атаки важно отключить флаг Loop. Также в Animator необходимо установить флаг Has Exit Time для всех переходов из активного состояния в неактивное, чтобы Animator дожидался завершения анимации атаки. В переходе из неактивного состояния в состояние первой атаки флаг Has Exit Time следует отключить.
Класс AttackState
Абстрактный класс AttackState
служит шаблоном для всех последующих состояний атаки. Он наследуется от класса State<Attacker>
, который представляет собой базовый класс для состояний в контексте машины состояний:
public void UpdateCounter(bool isIncrement)
{
int valueToUpdate = isIncrement ? 1 : -1; \\тернарный оператор
AttackQueueCounter = Mathf.Clamp(AttackQueueCounter + valueToUpdate, 0, _stateMachine.StatesCount - 1);
}
Каждое состояние должно иметь доступ к контексту, чтобы изменять его поля и управлять его поведением. Кроме того, все состояния самостоятельно обрабатывают событие завершения анимации, что позволяет им влиять на счётчик очереди атак. Таким образом, в качестве аргументов конструктор AttackState
ожидает экземпляры классов Attacker
и AnimationPlayer
, а также вызывает базовый конструктор класса State<Attacker>
, передавая ему контекст:
public void ResetAttackCounter()
{
AttackQueueCounter = 0;
}
Неактивное состояние - класс UnactiveAttackState
Неактивное состояние является исходным для всей комбинации. Конструктор класса ожидает те же аргументы, что и базовый класс, и передает их ему:
public UnactiveAttackState(Attacker attacker, AnimationPlayer animationPlayer) : base(attacker, animationPlayer) { }
Предположим, персонаж находится в состоянии UnactiveAttackState
. Когда игрок нажимает клавишу для атаки, персонаж должен выполнить атаку, то есть перейти в состояние первой атаки, при этом счетчик очереди атак должен увеличиться. Обработка ввода пользователя в контексте машины состояний осуществляется с помощью метода Activate
, который вызывает метод OnActivated
у текущего состояния. Переопределим метод OnActivated
в классе UnactiveAttackState
:
public class AnimationPlayer : MonoBehaviour
{
public event Action AnimationCompleted;
[SerializeField] private Animator _animator;
public void Play(int animationHash)
{
_animator.SetTrigger(animationHash);
}
private void OnAnimationCompleted()
{
AnimationCompleted?.Invoke();
}
}
При выходе из состояния ничего не выполняется, поэтому метод OnExit
базового класса переопределять не требуется.
Теперь представим ситуацию, когда завершилась последняя атака в комбинации и произошло переключение на UnactiveAttackState
. В этом случае необходимо вызвать соответствующую анимацию и сбросить счетчик атак. Переопределим метод OnEnter
:
public override void OnEnter()
{
_attacker.ResetAttackCounter();
_animationPlayer.Play(PlayerAnimations.IsUnactiveAttackState);
}
Промежуточное активное состояние - класс FirstAttackState
Рассмотрим класс FirstAttackState
, который представляет собой первое промежуточное активное состояние в контексте системы атак. Логика работы этого класса будет схожа для всех активных состояний, за исключением состояния последней атаки в комбинации.
Как и в конструкторе класса UnactiveAttackState
, переданные аргументы должны быть переданы в конструктор базового класса:
public FirstAttackState(Attacker attacker, AnimationPlayer animationPlayer): base(attacker, animationPlayer) {}
При входе в состояние необходимо подписаться на событие завершения анимации, используя метод OnAnimationCompleted
в качестве обработчика. Это позволит нам реагировать на окончание анимации атаки.
Далее, с помощью AnimationPlayer
, в метод Play
передаём хэш-код соответствующего состоянию параметра Animator Controller:
public override void OnEnter()
{
_animationPlayer.AnimationCompleted += OnAnimationCompleted;
_animationPlayer.Play(PlayerAnimations.IsFirstAttackState);
}
Важно помнить, что при подписке на событие необходимо заранее позаботиться об отписке от этого события. Отписываться будем при выходе из состояния, то есть в методе OnExit
:
public override void OnExit()
{
_animationPlayer.AnimationCompleted -= OnAnimationCompleted;
}
В методе OnActivated
увеличиваем счетчик очереди атак:
public override void OnActivated()
{
_attacker.UpdateCounter(true);
}
Остается только обработать событие завершения анимации в обработчике события OnAnimationCompleted
. Завершение анимации атаки, по сути, означает завершение самой атаки, поэтому необходимо убрать атаку из очереди, уменьшив счетчик очереди атак. После этого, в зависимости от текущего значения счетчика, мы переходим в следующее состояние: если счетчик больше нуля, сменяем текущее состояние на следующее активное состояние атаки; иначе - переходим в неактивное состояние:
private void OnAnimationCompleted()
{
_attacker.UpdateCounter(false);
if (_attacker.AttackQueueCounter > 0)
{
// Переход к следующему активному состоянию атаки.
// Каждое промежуточное состояние имеет уникальное следующее состояние.
_attacker.SwitchState<SecondAttackState>();
}
else
{
// Переход в неактивное состояние, если очередь атак пуста.
_attacker.SwitchState<UnactiveAttackState>();
}
}
Последнее активное состояние
Логика последнего активного состояния комбо-атаки схожа с логикой промежуточных состояний, за исключением одного важного отличия: при завершении анимации в обработчике события OnAnimationCompleted
всегда вызывается переход в неактивное состояние. Это означает, что после завершения последней атаки игрок возвращается в состояние ожидания:
private void OnAnimationCompleted()
{
_attacker.UpdateCounter(false);
_attacker.SwitchState<UnactiveAttackState>();
}
Создание экземпляров состояний
Завершающим этапом в кодовой части является вызов конструкторов созданных классов состояний. Для этого потребуется метод FillStates
в классе Attacker
.
Внутри метода FillStates
для каждого состояния будет вызываться метод машины состояний AddState
, который добавляет состояние в словарь, обеспечивая быстрый доступ к нему во время выполнения.
При создании экземпляров состояний необходимо передать следующие зависимости: экземпляр контекста, используя ключевое слово this
, и ссылку на экземпляр класса AnimationPlayer
:
private void FillStates()
{
_stateMachine.AddState(new UnactiveAttackState(this, _animationPlayer));
_stateMachine.AddState(new FirstAttackState(this, _animationPlayer));
_stateMachine.AddState(new SecondAttackState(this, _animationPlayer));
_stateMachine.AddState(new ThirdAttackState(this, _animationPlayer));
}
Класс InputController
Чтобы вызывать метод атаки, необходим класс, обрабатывающий ввод от пользователя - InputController
. В качестве примера приведу ниже простую реализацию такого класса, работа которого будет служить отправной точкой для всей вышеописанной системы. Этот класс будет хранить ссылку на класс Attacker
и проверять в методе Update
, был ли ввод от пользователя:
public class InputController : MonoBehaviour
{
private readonly KeyCode _attackKey = KeyCode.J; \\атака будет происходить при нажатии клавиши J.
[SerializeField] private Attacker _attacker;
private void Update()
{
if (Input.GetKeyDown(_attackKey))
_attacker.Attack();
}
}
Распределение зависимостей на сцене
Прежде всего, в вашем проекте на сцене должен находиться персонаж MainCharacter. Я использую отображение персонажа ViewContainer как дочерний GameObject к самому объекту персонажа, поскольку это дает больший контроль над точкой вращения персонажа, позволяя регулировать отображение относительно родителя. На ViewContainer обязательно должны находиться компоненты Animator и AnimationPlayer
. Компоненту AnimationPlayer
в качестве зависимости передайте упомянутый ранее Animator.
Важно: AnimationPlayer
и Animator обязательно должны быть компонентами одного GameObject (в нашем случае это ViewContainer), иначе ранее настроенные события анимации работать не будут, так как не смогут найти упомянутый в них обработчик события.
Если вы используете InputController
, описанный выше, то желательно создать для него отдельный GameObject на сцене и обязательно передать зависимостью класс Attacker
.
В сам Attacker
необходимо передать зависимостью AnimationPlayer
.
Заключение
Надеюсь, моя статья помогла Вам лучше понять, как создать собственную систему комбо-атак, используя машину состояний и механизмы Unity. Реализация такой системы может значительно улучшить игровой процесс и придать Вашим персонажам больше интереса и уникальности.
Не забывайте, что полученные знания можно применять не только в боевых играх, но и в любых проектах, где требуется управление состояниями и анимациями. Например, Вы можете использовать подобный подход для создания уникальных механик в платформерах, ролевых играх или даже в симуляторах.
Полный код готового проекта доступен в моем репозитории, и я настоятельно рекомендую Вам изучить его, чтобы увидеть, как все элементы работают вместе. Если у Вас возникли вопросы или Вы хотите обсудить свои идеи, не стесняйтесь связаться со мной через мой телеграм-канал. Я буду рад помочь Вам на Вашем пути к созданию увлекательных игровых систем!
Желаю удачи в Ваших проектах и надеюсь увидеть Ваши собственные реализации комбо-атак!