Грамотная архитектура играет ключевую роль при разработке любого программного продукта. Корни большинства распространенных проблем с производительностью, расширяемостью или понятностью кода растут именно из ее низкого качества или полного отсутствия. Отсутствие строго определенной структуры проекта лишает разработчиков возможности мыслить абстракциями, понимать написанный коллегой код с первого взгляда и предугадывать место возникновения ошибки. А в некоторых случаях человек может запутаться даже в своем собственном коде, перенасыщенном сущностями и компонентами. Но практически каждый программист рано или поздно, сам ли или с помощью умной книжки, знакомится с решениями, которые хороши вне зависимости от контекста. Они настолько эффективны и универсальны, что находят место в решении множества задач, и... Да, знаю, можно не продолжать, все уже и так поняли, что я о шаблонах проектирования. Одни на них молятся, другие находили среди них свои велосипеды. Некоторые утверждают на собеседовании, что изучили их вдоль и поперек и уличили в полной бесполезности. Но все, так или иначе, слышали о них. Сегодня речь пойдет об одном из паттернов - "Состояние". А точнее, о конечных автоматах. Даже если вы относитесь к последней из перечисленных выше групп, вы наверняка сталкивались со следующим инструментом:
Аниматоры в Unity построены как раз на конечных автоматах. Каждая анимация группы объектов представлена в виде состояния. Условия и порядок переходов между ними определяется в аниматоре, который является конечным автоматом. Также, неоднократно поднималась тема использования конечных автоматов для описания логики работы объектов со сложным поведением. AI ботов, управление главным героем, вот это все.
Я бы хотел сделать акцент на менее изъезженной теме. Конечный автомат позволяет эффективно управлять состоянием игры в целом. Более того, с его помощью можно однажды описать бизнес-логику абстрактной игры, и отталкиваться в дальнейшем от этой заготовки при разработке прототипов. Так, многие ГК игры, построенные по такому принципу могут отличаться друг от друга на полсотни строк и структуру сцены, реализуя всю логику на автомате. А в сложнейших и запутанных проектах новый функционал может быть внедрен максимально просто путем добавления в существующий автомат нового состояния и определения переходов в него, исключая возможность возникновения багов и неожиданного поведения. Также, среди плюсов построения бизнес-логики приложения на автоматах можно выделить следующее:
Каждое состояние описывает свою логику максимально просто. Нет никаких условных блоков, каждое состояние представляет собой последовательность действий на входе и выходе и список сигналов, которые автомат обрабатывает в текущем состоянии. Таким образом, мы отображаем джойстик на входе в состояние Play и скрываем на выходе. Но даже если каким-то чудом игрок увидел джойстик на экране паузы, он не сможет двигать персонажа. И не потому, что активен еще один флаг isPaused, а просто потому, что в состоянии паузы не предусмотрено принимать сигналы с джойстика.
В каждый момент времени однозначно видно, в каком состоянии мы сейчас находимся. Логируя переходы между состояниями и сигналы, посылаемые в автомат мы видим четкую последовательность событий, и состояние, в котором мы находимся.
В дополнение к предыдущему пункту, следует отметить, что это также очень удобно в контексте дебага. Посмотрев логи, мы сразу видим, что бот сломался потому, что был инициализирован не после старта уровня, а во время генерации сцены, когда еще не был готов контекст для работы его AI.
Многие баги становятся просто невозможны, потому что мы строго определяем условия переходов. Мы точно не попадем в состояние Play, пока состояние WaitMatch не получит сигнал "match_ready", а если мы захотим вернуться в лобби, мы сначала отправим серверу команду об этом, и только после сигнала "room_left" выполним переход.
Сама идея логики на автоматах максимально прозрачна. Видя ТЗ мы сразу понимаем, каким будет список состояний, логика, реализованная в каждом из них и граф переходов. Причем, как было отмечено выше, "внешняя" часть графа в большинстве игр будет оставаться неизменной.
Но давайте по порядку. А начну я с одного важного принципа, встречающегося в программировании на всех уровнях. Разделение реализации и интерфейса. Логики и визуализации. Разница между серверной архитектурой и клиентской. На каждом уровне мы изолируем сложность внутренней системы, обеспечивая её корректную работу и предоставляя удобный интерфейс для работы с ней.
Теория автоматов - довольно крупный раздел дискретной математики. Автоматов бывает много, хороших и разных. Так что попытаемся сформулировать наши требования к абстрактному автомату, который будет лежать в основе архитектуры приложения и будет наилучшим образом реализовывать алгоритм его работы.
Определим, что из себя представляет наш конечный автомат. Для этого опишем его структуру:
FSM | AState |
- public FSM(AState initState) - public void Signal(string name, object data = null) - private void ChangeState(AState newState) | - void Enter() - void Exit() - AState Signal() |
Итак, мы имеем 2 сущности:
Конечный автомат. Интерфейс у него невелик. Начиная работу в некотором состоянии, он может принимать сигналы извне, передавая их в текущее состояние. И если оно в ответ на сигнал вернуло новое состояние, автомат выполняет переход в него. В момент перехода мы выполняем Exit
для последнего состояния и Enter
для следующего. Таким образом, у нас получается следующая конструкция:
public class FSM
{
private AState currentState;
public FSM(AState initState) => ChangeState(initState);
private void ChangeState(AState newState)
{
if (newState == null) return;
currentState?.Exit();
currentState = newState;
currentState.Enter();
}
public void Signal(string name, object arg = null)
{
var result = currentState.Signal(name, arg);
ChangeState(result);
}
}
Абстрактное состояние. Из соображений удобства и микрооптимизации его методы имеют нехитрую реализацию, которая наследуется потомками, в которых оные не определены.
public class AState
{
public virtual void Enter() => null;
public virtual void Exit() => null;
public virtual AState Signal(string name, object arg) => null;
}
А в самих состояниях мы просто описываем логику
public class SLoad : AState
{
public override void Enter()
{
Game.Data.Set("loader_visible",true);
var load = SceneManager.LoadSceneAsync("SceneGameplay");
load.completed+=a=>Game.Fsm.Signal("scene_loaded");
}
public override void Exit()
{
Game.Data.Set("loader_visible",false);
}
public override AState Signal(string name, object arg)
{
if (name == "scene_loaded")
return new SLobby();
return null;
}
}
Как вы могли заметить, я описал логику переходов между состояниями, но даже вскользь не затронул тему хранения и изменения их списка. Дело в том, что в этом нет смысла, мы можем просто создавать новые состояния при переходе. Экземпляр состояния в большинстве случаев представляет собой 3 ссылки на функции, хранящиеся статически. И переходы между состояниями - не такое частое действие, чтобы дать сколько-то существенный оверхед. Зато мы получаем гору синтаксического сахара и статический анализ в подарок! При желании, проблема аллокаций решается очень легко, но это будет не так удобно. Кроме того, вот пример того, как такое состояние можно легко параметризовать, не усложняя при этом общий интерфейс
public class SMessage : AState
{
private string msgText;
private AState next;
public SMessage(string messageText, AState nextState)
{
msgText = messageText;
btnText = buttonText;
next = nextState;
}
public override void Enter()
{
Game.Data.Set("message_text", msgText);
Game.Data.Set("window_message_visible",true);
}
public override void Exit()
{
Game.Data.Set("window_message_visible",false);
}
public override AState Signal(string name, object arg)
{
if (name == "message_btn_ok")
return next;
return null;
}
}
Вместо того, чтобы делать усложненную систему переходов c аргументами, мы передаем их в конструктор и создаем экземпляр состояния с желаемыми параметрами.
...
case "iap_ok":
return new SMessage("Item purchased! Going back to store.", new SStore());
...
В этой статье я не буду задерживаться на устройстве структуры Game.Data
, используемой в состояниях, достаточно просто отметить, что это словарь, в элементах которого реализован шаблон "Наблюдатель". Объекты сцены, например, элементы UI, могут подписываться на изменения в нем и отображать гарантированно актуальные данные. Она играет ключевую роль в связи между визуализацией и логикой в моих примерах, но не является необходимым условием для работы с конечными автоматами. Сигналы в автомат же можно отправлять откуда угодно, но одним из самых универсальных примеров будет следующий скрипт.
public class ButtonFSM : MonoBehaviour, IPointerClickHandler
{
public string key;
public override void OnPointerClick(PointerEventData eventData)
{
Game.Fsm.Signal(key);
}
}
Мы при клике по кнопке(на самом деле, любому CanvasRenderer) передаем соответствующий сигнал в автомат. С использованием набора таких скриптов можно реализовать любой UI, полностью описав логику его работы в автомате. С геймплеем все еще проще, так как управление персонажами происходит на другом уровне абстракции. При переходе между состояниями мы можем любым удобным нам способом включать и выключать разные Canvas, менять маски, используемые в Physics.Raycast
и даже иногда менять Time.timeScale! Как бы ужасно и бескультурно это ни казалось на первый взгляд, пока сделанное в Enter
отменяется в Exit
, оно гарантированно не может доставить каких-либо неудобств, так что вперед! Главное - не переусердствуйте.
UPD: Очевидным минусом предложенной реализации является то, что строковые ключи, на которых завязаны сигналы, имеют ряд недостатков, например, потенциально раздутый switch при обработке сигналов и полная беспомощность синтаксического анализатора. Она же является и огромным преимуществом, так как за счет слабого связывания можно писать код с высоким уровнем абстракции. Мы можем добавлять или исключать автономные модули системы, создавать обобщенные реализации и использовать их многократно с разными строковыми ключами. Но также есть ряд более стабильных и изолированных задач, которые было бы эффективнее вынести в отдельный тип сигналов. В комментариях @SadOcean предложил блестящее решение в виде интерфейсов, позволяющих принимать сигналы различных типов. Ценой дополнительного сопоставления шаблонов при обработке каждого сигнала мы получаем возможность выносить некоторые типы сигналов в отдельные методы
...
public interface ISignalHandler<T>
{
public AState Signal(T signalData);
}
...
public void Signal<T>(T signalData)
{
if (currentState is ISignalHandler<T> handler)
{
var nextState = handler.Signal(signalData);
ChangeState(nextState);
}
}
...
Таким образом, мы можем вынести прием сигналов разных типов в разные функции, например, простенькие однострочные команды обрабатывать все так же перебором строк, а некоторые категории сигналов, такие, как ввод с джойстика, могут быть вынесены в отдельный интерфейс и обрабатываться в соответствующих методах
public class SPlay : AState, ISignalHandler<string>, ISignalHandler<JoystickData>
{
...
public AState Signal(string signal)
{
switch (signal)
{
case "win":
return new SResult(true, score);
case "lose":
return new SResult(false, score);
case "pick_coin":
score += 100;
break;
}
return null;
}
public AState Signal(JoystickData input){
Game.Event.Invoke("joystick_updated", input)
}
}
В результате, такой подход позволяет лучше структурировать код, классифицируя сигналы по типам, но требует отдельного описания для таких типов. А также, добавляет несущественный оверхед в потенциально нагруженном участке кода.
SadOcean
Статья неплохая, но вводная. Непонятно, как в таком методе разделять ответственности, внедрять зависимости.
Сигналы хоть и развязывают сущности, основаны на ненадежных строках — можно ошибиться, плохо искать реализации.
Можно заморочиться с типизированными сигналами (тогда класс сигнала будет его id, а внутри можно принеобходимости получать параметры)
Правда для качественного приема таких сигналов необходимо будет включать дополнительную инфраструктуру.
SadOcean
Мне стало самому интересно, и попробовал прикинуть
Можно реализовать типизированные сигналы вот так.
Получится много методов, но в авторской реализации все равно есть тот же switch
NikS42 Автор
Если ставить задачу избавиться от строковых ключей, в сигналах можно целиком заменить их enum, но это решение плохо масштабируется, мы просто утонем в его значениях. Также, можно использовать опыт разработчиков самой Unity с аниматорами, и брать хеш от этих же самых строк. Это слабо защищает от человеческого фактора, но полностью решает техническую сторону вопроса. В своем опыте я пробовал разные варианты, но пока плюсы работы со строковыми ключами перевесили, и не только в автоматах. И спасибо за обратную связь! Замечание, что тема раскрыта недостаточно, дельное. Эта статья долго зрела, но я не мог придумать, как преподнести тему максимально обще, чтобы она не выглядела как описание инструмента, полезного лишь в рамках конкретного стека. При возможности, я постараюсь раскрыть больше контекста и привести больше примеров решения тех или иных задач.
SadOcean
Выше вариант с типизированными ключами-классами
Масштабируется лучше enum.
А так мы тоже строковыми во многих подобных системах пользуемся.
HexGrimm
Собственно, а для чего тогда в целом абстрактные сигналы в любом виде и проблемы с ними? Не лучше ли дать контроль над переходом самому стейту? пусть прям сам создаст следующий стейт и вернет его в апи контекста, и сразу же при создании использует сигнатуру его контруктора. В весьма масштабном проекте у меня получилось это использовать с приемлимыми показателями.
NikS42 Автор
Сигналы — это интерфейс автомата. Через них мы передаем команды, но логика работы, правила переходов, как раз и описаны внутри состояний.
HexGrimm
Да, согласен. Я не правильно выразился. Я хотел сказать что универсализация апи через сигналы а затем разворачивание обратно не обязательно для ФСМ. Как альтернатива — сделать интерфейс с конкретным и типизированным апи для автомата и реализовать его и в контексте (и пробрасывать сразу в текущий стейт) и в абстрактном стейте (пустыми реализациями например). И в необходимых реализациях уже самих стейтов оверрайдить методы на которые нужно реагировать. Тогда страшный свитч исчезнет, и сигнатуры все будут строгими.
SadOcean
Переходами управляет сам стейт, все верно.
Сигналы для обобщенной коммуникации со стейтом.