Состояние такое

от автора

Опять здравствуйте, Хабровчане! В прошлой статье я рассказывал о командах и как ими пользоваться, а сегодня я буду развивать тему и расскажу как привязать команду к конечному автомату. Тема на хабре не нова, поэтому я не буду углубляться в объяснения что такое конечный автомат и зачем он используется, а сосредоточусь на реализации. Сразу оговорюсь что для понимания лучше прочитать предыдущую статью, ведь команды будут практически без изменения использоваться в качестве состояний. Перед началом хочу сказать спасибо OnionFan за его комментарий — не все привычки хороши и его вопрос позволил сделать удобней типизацию конечных автоматов, про которые я расскажу, просто добавив ключевое слово params (я уже поправил в предыдущей статье).

Проблема
В комментариях к прошлой статье встречалась мысль, что пример выбран не очень удачно и не все всерьёз его восприняли, поэтому сейчас я, немного поразмыслив, решил выбрать пример с более практическим оттенком. И так, сегодняшний пример будет немного выше уровнем и будет относиться к игровому процессу, а конкретнее к состояниям через которые проходит большинство игровых сцен.
Навскидку можно сразу назвать как минимум три этапа через которые в обязательном порядке проходит каждая игровая сцена: инициализации ресурсов и модели, само игровое состояние (оно может быть разделено на несколько разных состояний, если, например, происходит смена игровых механик или есть кат-сцены) и состояние завершения игры (сохранение прогресса и освобождение ресурсов). Не раз видел ситуации, когда это решалось либо через корутины в менеджере, которые откладывали вызов тех или иных методов, либо через тонкую настройку порядка вызова 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);         }     } 

Вуаля! Теперь для удобного использования ветвления состояний нам нужно только прописать последовательность команд, передать её в конечный автомат и запустить первое состояние, дальше всё произойдет без нашего участия. Теперь тема раскрыта полностью. После всего написанного у нас получился добротный, гибкий и легкий в управлении конечный автомат. Спасибо за внимание.

ссылка на оригинал статьи https://habrahabr.ru/post/281545/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *