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

в 5:11, , рубрики: C#, Gamedev, statemachine, unity3d, архитектура, игры, разработка игр

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

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

Автор: martynko

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js