Опять здравствуйте, Хабровчане! В прошлой статье я рассказывал о командах и как ими пользоваться, а сегодня я буду развивать тему и расскажу как привязать команду к конечному автомату. Тема на хабре не нова, поэтому я не буду углубляться в объяснения что такое конечный автомат и зачем он используется, а сосредоточусь на реализации. Сразу оговорюсь что для понимания лучше прочитать предыдущую статью, ведь команды будут практически без изменения использоваться в качестве состояний. Перед началом хочу сказать спасибо 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 (); } }
Меня уже смущает громоздкий запуск команды подсчета по сравнению с запуском состояния. Также необходимость постоянно держать все ссылки на все запущенные команды меня, по меньшей мере, удручает и захламляет класс состояния. Конечно ссылки на некоторые команды держать придется, но в случае с подсчетом очков команда должна просто работать до окончания игрового состояния и прекратить выполнения в момент перехода состояний, чтобы не начислять лишнего. Следить за такими командами можно легко заставить сам конечный автомат, сказав ему просто останавливать все запущенные из состояния команды при завершении оного. Давайте и возложим на него эту ответственность:
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; } }
Кроме хранения в себе пар команд этот контейнер еще будет выполнять поиск следующего зарегистрированного состояния по текущему. Остается только привязать наш порядок выполнения к конечному автомату, чтобы он сам мог изменять состояния.
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/
Добавить комментарий