Опять здравствуйте, Хабровчане! В прошлой статье я рассказывал о командах и как ими пользоваться, а сегодня я буду развивать тему и расскажу как привязать команду к конечному автомату. Тема на хабре не нова, поэтому я не буду углубляться в объяснения что такое конечный автомат и зачем он используется, а сосредоточусь на реализации. Сразу оговорюсь что для понимания лучше прочитать предыдущую статью, ведь команды будут практически без изменения использоваться в качестве состояний. Перед началом хочу сказать спасибо OnionFan за его комментарий — не все привычки хороши и его вопрос позволил сделать удобней типизацию конечных автоматов, про которые я расскажу, просто добавив ключевое слово params (я уже поправил в предыдущей статье).
Проблема
В комментариях к прошлой статье встречалась мысль, что пример выбран не очень удачно и не все всерьёз его восприняли, поэтому сейчас я, немного поразмыслив, решил выбрать пример с более практическим оттенком. И так, сегодняшний пример будет немного выше уровнем и будет относиться к игровому процессу, а конкретнее к состояниям через которые проходит большинство игровых сцен.
Навскидку можно сразу назвать как минимум три этапа через которые в обязательном порядке проходит каждая игровая сцена: инициализации ресурсов и модели, само игровое состояние (оно может быть разделено на несколько разных состояний, если, например, происходит смена игровых механик или есть кат-сцены) и состояние завершения игры (сохранение прогресса и освобождение ресурсов). Не раз видел ситуации, когда это решалось либо через корутины в менеджере, которые откладывали вызов тех или иных методов, либо через тонкую настройку порядка вызова Awake() метода через редактор, либо просто в каждом Update() проверялась готовность сцены. Но, как несложно было бы уже догадаться, я предложу Вам способ намного приятнее и изящнее с использованием конечных автоматов. Уже на данном этапе можно легко заметить, что каждый этап можно оформить как команду (в которой можно даже использовать подкоманды) и переходить к следующему этапу только после полного завершения текущего. И сразу договоримся, что состояниями будут типизированные команды, поскольку им практически всегда будет нужен доступ к контроллеру. Давайте уже писать код, а то как-то много воды.
Начнем с простого, но уже типизированного, класса конечного автомата
Ничего необычного: останавливаем предыдущее состояние если такое имеется, запускаем новое на объекте контроллера, запоминаем его как текущее и возвращаем на всякий случай.
Опять все просто: публичный get-ер для объекта автомата и переход в состояние инициализации в Start(). Таким образом Start() контроллера стал точкой входа в сцену, что дает полную уверенность в правильной последовательности вызовов всех состояний.
И сразу болванка для состояния сцены и почти пустые классы двух первых состояний:
Легко поверить что игровое состояние при таком подходе начнет выполнятся только после полного завершения инициализации, чего мы и хотели.
Как-то мало получилось
Ежу понятно что сами игровые состояния не могут быть такими простыми как в примерах выше. Например в игровом состоянии нужно считать очки, обновлять состояние UI, создавать противников и монетки, двигать камеру и тому подобные вещи. И если мы будем весь этот код писать прямо в классе игрового состояния, то зачем я здесь?
Возьмем к примеру подсчет очков. Напишем для этого отдельную команду и будем запускать её в игровом состоянии (пока не знакомы с MVC, будем записывать счёт прямо в контроллер).
Меня уже смущает громоздкий запуск команды подсчета по сравнению с запуском состояния. Также необходимость постоянно держать все ссылки на все запущенные команды меня, по меньшей мере, удручает и захламляет класс состояния. Конечно ссылки на некоторые команды держать придется, но в случае с подсчетом очков команда должна просто работать до окончания игрового состояния и прекратить выполнения в момент перехода состояний, чтобы не начислять лишнего. Следить за такими командами можно легко заставить сам конечный автомат, сказав ему просто останавливать все запущенные из состояния команды при завершении оного. Давайте и возложим на него эту ответственность:
Теперь метод ApplyState () будет использоваться для запуска состояний, а метод Execute () для запуска команд в данном состоянии и при завершении состояний у нас будут автоматически завершаться все запущенные команды. И это сделало значительно приятнее вызов вспомогательных команд
Теперь вспомогательные команды можно просто запускать и забывать, автомат вспомнит о них, когда придет время.
Все получилось просто и красиво, минимум внимания нужно уделять менеджменту вызовов и остановок команд и все гарантировано пройдет в нужный момент.
Маленькие радости
Конечный автомат полностью готов к использованию, осталось только рассказать про одну небольшую удобность. С данной реализацией переходы между состояниями должны быть записаны в самих состояниях и это очень удобно для ветвления или системы принятия решений. Но есть ситуации, когда дерево состояний может быть не очень сложным и, в таком случае, удобно прописать всю цепочку состояний в одном месте.
Перед тем как добавить эту фичу, давайте вспомним, что состояние — это ничего кроме команды, а команда в нашей реализации может иметь два исхода: успешное и не успешное выполнение. Этого вполне достаточно чтобы строить несложные деревья поведения и даже с возможностью зацикливания (выстрелить, перезарядить, выстрелить а потом уже спрашивать кто там).
Из-за метода вызова команды, мы не можем сразу сделать экземпляры всех нужных нам команд и использовать их когда нужно. Поэтому остановимся на том что будем хранить всю цепочку (или дерево) в виде списка типов нужных команд. Но для начала для такой системы придется немного исправить класс команды, чтобы у нее был не только типизированный метод вызова, но и метод в который можно передать тип нужной команды и флаг успешности завершения команды.
Объяснять нечего, потому я и не буду. Теперь давайте напишем контейнер который будет держать в себе тип целевой команды и тип следующих команд для случаев с успешным и не успешным завершением целевой:
Обратите внимание, что если в конструктор передать только один тип следующей команды, то никакого ветвления не будет и команда соответствующего типа вызовется при любом исходе целевой команды.
Теперь очередь переходит к контейнеру наших пар:
Кроме хранения в себе пар команд этот контейнер еще будет выполнять поиск следующего зарегистрированного состояния по текущему. Остается только привязать наш порядок выполнения к конечному автомату, чтобы он сам мог изменять состояния.
Пускай сам автомат держит в себе последовательность и сам изменяет состояния как мы ему укажем, но и оставим возможность запускать его без заготовленной раньше последовательности.
Теперь осталось только научиться пользоваться этим всем:
Вуаля! Теперь для удобного использования ветвления состояний нам нужно только прописать последовательность команд, передать её в конечный автомат и запустить первое состояние, дальше всё произойдет без нашего участия. Теперь тема раскрыта полностью. После всего написанного у нас получился добротный, гибкий и легкий в управлении конечный автомат. Спасибо за внимание.
Проблема
В комментариях к прошлой статье встречалась мысль, что пример выбран не очень удачно и не все всерьёз его восприняли, поэтому сейчас я, немного поразмыслив, решил выбрать пример с более практическим оттенком. И так, сегодняшний пример будет немного выше уровнем и будет относиться к игровому процессу, а конкретнее к состояниям через которые проходит большинство игровых сцен.
Навскидку можно сразу назвать как минимум три этапа через которые в обязательном порядке проходит каждая игровая сцена: инициализации ресурсов и модели, само игровое состояние (оно может быть разделено на несколько разных состояний, если, например, происходит смена игровых механик или есть кат-сцены) и состояние завершения игры (сохранение прогресса и освобождение ресурсов). Не раз видел ситуации, когда это решалось либо через корутины в менеджере, которые откладывали вызов тех или иных методов, либо через тонкую настройку порядка вызова Awake() метода через редактор, либо просто в каждом Update() проверялась готовность сцены. Но, как несложно было бы уже догадаться, я предложу Вам способ намного приятнее и изящнее с использованием конечных автоматов. Уже на данном этапе можно легко заметить, что каждый этап можно оформить как команду (в которой можно даже использовать подкоманды) и переходить к следующему этапу только после полного завершения текущего. И сразу договоримся, что состояниями будут типизированные команды, поскольку им практически всегда будет нужен доступ к контроллеру. Давайте уже писать код, а то как-то много воды.
Начнем с простого, но уже типизированного, класса конечного автомата
Код
public class StateMachine<T>
where T : MonoBehaviour
{
private readonly T _stateMachineController;
private Command _currentState;
public StateMachine (T stateMachineController)
{
this._stateMachineController = stateMachineController;
}
public TCommand ApplyState<TCommand> (params object[] args)
where TCommand : CommandWithType<T>
{
if (_currentState != null)
_currentState.Terminate ();
_currentState = Command.ExecuteOn<TCommand> (_stateMachineController.gameObject, _stateMachineController, args);
return _currentState as TCommand;
}
}
Ничего необычного: останавливаем предыдущее состояние если такое имеется, запускаем новое на объекте контроллера, запоминаем его как текущее и возвращаем на всякий случай.
Теперь контроллер
public class SceneController : StateMachineHolder
{
public StateMachine<SceneController> StateMachine
{
get;
private set;
}
public SceneController ()
{
StateMachine = new StateMachine<SceneController> (this);
}
private void Start()
{
StateMachine.ApplyState<InitializeState> ();
}
}
Опять все просто: публичный get-ер для объекта автомата и переход в состояние инициализации в Start(). Таким образом Start() контроллера стал точкой входа в сцену, что дает полную уверенность в правильной последовательности вызовов всех состояний.
И сразу болванка для состояния сцены и почти пустые классы двух первых состояний:
Можно даже не смотреть
public class SceneState: CommandWithType<SceneController>
{
}
class InitializeState : SceneState
{
protected override void OnStart (object[] args)
{
base.OnStart (args);
//test
UnityEngine.Debug.Log(string.Format("{0}", "Initialize state"));
Controller.StateMachine.ApplyState<ReadyState> ();
}
}
class ReadyState : SceneState
{
protected override void OnStart (object[] args)
{
base.OnStart (args);
//test
UnityEngine.Debug.Log(string.Format("{0}", "ready state"));
}
}
Легко поверить что игровое состояние при таком подходе начнет выполнятся только после полного завершения инициализации, чего мы и хотели.
Как-то мало получилось
Ежу понятно что сами игровые состояния не могут быть такими простыми как в примерах выше. Например в игровом состоянии нужно считать очки, обновлять состояние UI, создавать противников и монетки, двигать камеру и тому подобные вещи. И если мы будем весь этот код писать прямо в классе игрового состояния, то зачем я здесь?
Возьмем к примеру подсчет очков. Напишем для этого отдельную команду и будем запускать её в игровом состоянии (пока не знакомы с MVC, будем записывать счёт прямо в контроллер).
Примитивный подсчет
public class UpdateScoreCommand : SceneState
{
protected override void OnStart (object[] args)
{
base.OnStart (args);
StartCoroutine (UpdateScore());
}
private IEnumerator UpdateScore ()
{
while (true)
{
if (!IsRunning)
yield break;
yield return new WaitForSeconds (1);
Controller.Score++;
}
}
}
Игровое состояние
class ReadyState : SceneState
{
private UpdateScoreCommand _updateScoreCommand;
protected override void OnStart (object[] args)
{
base.OnStart (args);
//test
UnityEngine.Debug.Log(string.Format("{0}", "ready state"));
_updateScoreCommand = Command.ExecuteOn<UpdateScoreCommand> (Controller.gameObject, Controller);
}
protected override void OnReleaseResources ()
{
base.OnReleaseResources ();
_updateScoreCommand.Terminate ();
}
}
Меня уже смущает громоздкий запуск команды подсчета по сравнению с запуском состояния. Также необходимость постоянно держать все ссылки на все запущенные команды меня, по меньшей мере, удручает и захламляет класс состояния. Конечно ссылки на некоторые команды держать придется, но в случае с подсчетом очков команда должна просто работать до окончания игрового состояния и прекратить выполнения в момент перехода состояний, чтобы не начислять лишнего. Следить за такими командами можно легко заставить сам конечный автомат, сказав ему просто останавливать все запущенные из состояния команды при завершении оного. Давайте и возложим на него эту ответственность:
StateMachine vol. 2.0
public class StateMachine<T>
where T : MonoBehaviour
{
private readonly T _stateMachineController;
private Command _currentState;
private List<CommandWithType<T>> _commands;
public StateMachine (T stateMachineController)
{
this._stateMachineController = stateMachineController;
_commands = new List<CommandWithType<T>> ();
}
public TCommand ApplyState<TCommand> (params object[] args)
where TCommand : CommandWithType<T>
{
if (_currentState != null)
_currentState.Terminate (true);
StopAllCommands ();
_currentState = Command.ExecuteOn<TCommand> (_stateMachineController.gameObject, _stateMachineController, args);
return _currentState as TCommand;
}
public TCommand Execute<TCommand> (params object[] args)
where TCommand : CommandWithType<T>
{
TCommand command = Command.ExecuteOn<TCommand> (_stateMachineController.gameObject, _stateMachineController, args);
_commands.Add (command);
return command as TCommand;
}
private void StopAllCommands()
{
for (int i = 0; i < _commands.Count; i++)
{
_commands [i].Terminate ();
}
}
}
Теперь метод ApplyState () будет использоваться для запуска состояний, а метод Execute () для запуска команд в данном состоянии и при завершении состояний у нас будут автоматически завершаться все запущенные команды. И это сделало значительно приятнее вызов вспомогательных команд
Вызов подкоманд
class ReadyState : SceneState
{
protected override void OnStart (object[] args)
{
base.OnStart (args);
//test
UnityEngine.Debug.Log(string.Format("{0}", "ready state"));
Controller.StateMachine.Execute<UpdateScoreCommand> ();
}
}
Теперь вспомогательные команды можно просто запускать и забывать, автомат вспомнит о них, когда придет время.
Все получилось просто и красиво, минимум внимания нужно уделять менеджменту вызовов и остановок команд и все гарантировано пройдет в нужный момент.
Маленькие радости
Конечный автомат полностью готов к использованию, осталось только рассказать про одну небольшую удобность. С данной реализацией переходы между состояниями должны быть записаны в самих состояниях и это очень удобно для ветвления или системы принятия решений. Но есть ситуации, когда дерево состояний может быть не очень сложным и, в таком случае, удобно прописать всю цепочку состояний в одном месте.
Перед тем как добавить эту фичу, давайте вспомним, что состояние — это ничего кроме команды, а команда в нашей реализации может иметь два исхода: успешное и не успешное выполнение. Этого вполне достаточно чтобы строить несложные деревья поведения и даже с возможностью зацикливания (выстрелить, перезарядить, выстрелить а потом уже спрашивать кто там).
Из-за метода вызова команды, мы не можем сразу сделать экземпляры всех нужных нам команд и использовать их когда нужно. Поэтому остановимся на том что будем хранить всю цепочку (или дерево) в виде списка типов нужных команд. Но для начала для такой системы придется немного исправить класс команды, чтобы у нее был не только типизированный метод вызова, но и метод в который можно передать тип нужной команды и флаг успешности завершения команды.
Приведу только изменения в команде
public bool FinishResult
{
get;
private set;
}
public static T ExecuteOn<T>(GameObject target, params object[] args)
where T : Command
{
return ExecuteOn (typeof(T), target, args) as T;
}
public static Command ExecuteOn(Type type, GameObject target, params object[] args)
{
Command command = (Command)target.AddComponent (type);
command._args = args;
return command;
}
protected void FinishCommand(bool result = true)
{
if (!IsRunning)
return;
OnReleaseResources ();
OnFinishCommand ();
FinishResult = result;
if (result)
CallbackToken.FireSucceed ();
else
CallbackToken.FireFault ();
Destroy (this, 1f);
}
Объяснять нечего, потому я и не буду. Теперь давайте напишем контейнер который будет держать в себе тип целевой команды и тип следующих команд для случаев с успешным и не успешным завершением целевой:
Контейнер пар
public sealed class CommandPair
{
public readonly Type TargetType;
public readonly Type SuccesType;
public readonly Type FaultType;
public CommandPair (Type targetType, Type succesType, Type faultType)
{
this.TargetType = targetType;
this.SuccesType = succesType;
this.FaultType = faultType;
}
public CommandPair (Type targetType, Type succesType)
{
this.TargetType = targetType;
this.SuccesType = succesType;
this.FaultType = succesType;
}
Обратите внимание, что если в конструктор передать только один тип следующей команды, то никакого ветвления не будет и команда соответствующего типа вызовется при любом исходе целевой команды.
Теперь очередь переходит к контейнеру наших пар:
Контейнер контейнеров
public sealed class CommandFlow
{
private List<CommandPair> _commandFlow;
public CommandFlow ()
{
this._commandFlow = new List<CommandPair>();
}
public void AddCommandPair(CommandPair commandPair)
{
_commandFlow.Add (commandPair);
}
public Type GetNextCommand(Command currentCommand)
{
CommandPair nextPair = _commandFlow.FirstOrDefault (pair => pair.TargetType.Equals (currentCommand.GetType ()));
if (nextPair == null)
return null;
if (currentCommand.FinishResult)
return nextPair.SuccesType;
return nextPair.FaultType;
}
}
Кроме хранения в себе пар команд этот контейнер еще будет выполнять поиск следующего зарегистрированного состояния по текущему. Остается только привязать наш порядок выполнения к конечному автомату, чтобы он сам мог изменять состояния.
StateMachine vol. 3.0
public class StateMachine<T>
where T : MonoBehaviour
{
private readonly T _stateMachineController;
private readonly CommandFlow _commandFlow;
private Command _currentState;
private List<CommandWithType<T>> _commands;
public StateMachine (T stateMachineController)
{
this._stateMachineController = stateMachineController;
_commands = new List<CommandWithType<T>> ();
}
public StateMachine (T _stateMachineController, CommandFlow _commandFlow)
{
this._stateMachineController = _stateMachineController;
this._commandFlow = _commandFlow;
_commands = new List<CommandWithType<T>> ();
}
public TCommand ApplyState<TCommand> (params object[] args)
where TCommand : CommandWithType<T>
{
return ApplyState (typeof(TCommand), args) as TCommand;
}
public Command ApplyState(Type type, params object[] args)
{
if (_currentState != null)
_currentState.Terminate (true);
StopAllCommands ();
_currentState = Command.ExecuteOn (type ,_stateMachineController.gameObject, _stateMachineController, args);
_currentState.CallbackToken.AddCallback (new Callback<Command>(OnStateFinished, OnStateFinished));
return _currentState;
}
private void OnStateFinished (Command command)
{
if (_commandFlow == null)
return;
Type nextCommand = _commandFlow.GetNextCommand (command);
if (nextCommand != null)
ApplyState (nextCommand);
}
public TCommand Execute<TCommand> (params object[] args)
where TCommand : CommandWithType<T>
{
TCommand command = Command.ExecuteOn<TCommand> (_stateMachineController.gameObject, _stateMachineController, args);
_commands.Add (command);
return command as TCommand;
}
private void StopAllCommands()
{
for (int i = 0; i < _commands.Count; i++)
{
_commands [i].Terminate ();
}
}
}
Пускай сам автомат держит в себе последовательность и сам изменяет состояния как мы ему укажем, но и оставим возможность запускать его без заготовленной раньше последовательности.
Теперь осталось только научиться пользоваться этим всем:
Использование
public class SceneController : StateMachineHolder
{
public int Score = 0;
public StateMachine<SceneController> StateMachine
{
get;
private set;
}
public SceneController ()
{
CommandFlow commandFlow = new CommandFlow ();
commandFlow.AddCommandPair (new CommandPair(typeof(InitializeState), typeof(ReadyState), typeof(OverState)));
StateMachine = new StateMachine<SceneController> (this, commandFlow);
}
private void Start()
{
StateMachine.ApplyState<InitializeState> ();
}
}
class InitializeState : SceneState
{
protected override void OnStart (object[] args)
{
base.OnStart (args);
//test
UnityEngine.Debug.Log(string.Format("{0}", "Initialize state"));
FinishCommand (Random.Range (0, 100) < 50);
}
}
Вуаля! Теперь для удобного использования ветвления состояний нам нужно только прописать последовательность команд, передать её в конечный автомат и запустить первое состояние, дальше всё произойдет без нашего участия. Теперь тема раскрыта полностью. После всего написанного у нас получился добротный, гибкий и легкий в управлении конечный автомат. Спасибо за внимание.