Научись командовать

в 8:26, , рубрики: C#, command, game development, Gamedev, unity3d, архитектура, игры

Захотелось поделиться чудесным процессом разработки, с которым недавно познакомился. Я раньше не видел такого подхода, и люди, как только с ним знакомятся, долго не могут понять и принять такой способ построения игр. И, если честно, я сам понял все не в первую неделю. Но после некоторого освоения я уже забыл как делать игры иначе. В планах написать цикл статей, но начнем с малого и постепенно будем наращивать понимание что и зачем и с чем это есть.

Как кое-кто уже мог догадаться, я сегодня расскажу про паттерн “Command” и как его использовать для разработки игр с использованием движка Unity 3D. Это один из ключевых паттернов в этом подходе. Код будет упрощенным, но рабочим и должен дать понимание процесса.

Пролог

Вы наверное когда-нибудь уже видели статьи в которых разработчики рассказывают как использовать Actor-ы в Unity? Если нет, то я сейчас быстро объясню суть на примере: в вашей игре есть десяток игровых персонажей, которые должны, например, по-разному прыгать. Конечно задачу можно решить через всеми любимый полиморфизм: сделать базового юнита и просто перегружать виртуальный метод Jump для каждого юнита.

Как-то так

public class UnitController : MonoBehaviour 
{
    public Rigidbody AttachedRigidbody;
    //...
    public virtual void Jump()
    {
	rigidbody.velocity = new Vector3 (0, 10, 0);
    }
    //...
}

public class RabitUnitController : UnitController 
{
    //...
    public override void Jump ()
    {
        //very high jump
    }
    //...
}

Но в таком случае, если нужно будет чтобы несколько готовых юнитов, которые по разному прыгали, стали прыгать одинаково, то вам придется или чуть подправить иерархию классов, или просто скопипастить подходящий кусок кода во все нужные классы (что есть ужасающе).

С помощью Actor-oв эта задача решается иначе. Применяя этот подход^ нужно было бы также написать класс юнита, только теперь вместо виртуального метода Jump написать ряд отдельных компонент UnitJumper и просто цеплять подходящую компоненту на правильного юнита. И в момент прыжка вызывать метод Jump на прикрепленном компоненте:

Код Actor-ов

public class UnitJumper : MonoBehaviour 
{
    public virtual void Jump(Rigidbody rigidbody)
    {}
}

public class RegularJumper : UnitJumper 
{
    public override void Jump (Rigidbody rigidbody)
    {
        base.Jump (rigidbody);
        rigidbody.velocity = new Vector3 (0, 10, 0);
    }
}

public class MajesticAFJumper : UnitJumper 
{
    public override void Jump (Rigidbody rigidbody)
    {
        base.Jump (rigidbody);
        rigidbody.velocity = new Vector3 (0, 15, 10);
        /*
         * some magic here
         */
    }
}

А таким стал контроллер

public class UnitController : MonoBehaviour 
{
    [SerializeField]
    private UnitJumper _unitJumper;
    public Rigidbody AttachedRigidbody;
    
    //...
    public virtual void Jump()
    {
        if (_unitJumper != null)
            _unitJumper.Jump (AttachedRigidbody);
        else
            Debug.Log("UnitJumper Component is missing");
    }
    //...
}

Теперь стало всё просто и красиво. Меньше проблем с иерархией, код прыжка вынесен в отдельный маленький класс, из-за чего его легко изменять. Каждый способ прыжка может иметь сколько-угодно параметров и вы будете точно уверены, что изменяя их вы не сломаете, к примеру, бег. Также модификация способа прыжка для юнита теперь тоже очень проста. Кроме того, сама среда наталкивает нас на следование такой архитектуре, а с помощью атрибута [RequireComponent()] с редактором можно возиться еще меньше. Сейчас вы должны спросить зачем я это все рассказываю и в чем связь. А значит пришло время для логического перехода к паттерну Command.

Логический переход

Мы уже отошли от того, чтобы писать весь код прыжка в нашем примере в один класс, но как быть если нужно чтобы юниты не только сами по себе по-разному прыгали, но и чтобы, например, могли изменять способ прыжка в зависимости от обстоятельств (сделать сальто, пробежать по стене)? Вот здесь-то нам и нужна будет команда.

Суть остается всё та же — вынести все элементарные действия в отдельные классы. Только теперь мы будем добавлять необходимый компонент на юнита непосредственно перед применением и это позволит нам когда-угодно изменять поведение юнита и не будет такой сильной связи как в случае с Actor-ами. Напишем небольшой базовый класс для команды, который пока будет только служить для вызова команды на заданном объекте.

Базовая команда

public class Command : MonoBehaviour 
{
    public static T ExecuteOn<T>(GameObject target)
        where T : Command
    {
        return target.AddComponent <T>();
    }

    private void Start()
    {
        OnStart ();
    }

    protected virtual void OnStart()
    {}
}

Выше изложенный код служит только для удобного добавления компоненты на объект, а метод OnStart() пока (но только пока) исключительно для intellisense.

Теперь, когда у нас есть базовый класс, можно реализовывать простой прыжок.

Примерный класс прыжка

public class RegularJumpCommand : Command
{
    protected override void OnStart ()
    {
        base.OnStart ();
        gameObject.GetComponent <Rigidbody>().velocity = new Vector3(0, 10, 0);
    }
}

И теперь чтобы заставить юнита прыгнуть нам нужно будет только выполнить на нем команду:

Вызов команды

public class SomeController : MonoBehaviour 
{
    //don't forget to set this in editor
    public UnitController _targetUnit;

    private void Start()
    {
        if (_targetUnit != null)
        {
            Command.ExecuteOn<RegularJumpCommand> (_targetUnit.gameObject);
        }
    }
}

Первое, что бросается в глаза, — это то, что значения velocity у нас константы. Поэтому просто сделать прыжок чуть выше не получится. Раньше мы бы это решали передавая аргументы в метод прыжка, давайте и здесь так сделаем. Перепишем-ка нашу красивую команду:

Команда с аргументами

public class Command : MonoBehaviour 
{
    private object[] _args;

    public static T ExecuteOn<T>(GameObject target, object[] args = null)
        where T : Command
    {
        T result = target.AddComponent <T>();
        result._args = args;
        return result;
    }

    private void Start()
    {
            OnStart (_args);
    }

    protected virtual void OnStart(object[] args)
    {}
}

Теперь высоту и направление нашего прыжка можно менять передавая в команду аргументы (не забывайте приводить). Поскольку Start() вызывается немного позже создания объекта, то аргументы передадутся корректно в наш метод OnStart(object[] args).

Команда прыжка останется почти без изменений, но теперь мы можем в ней использовать аргументы переданные извне:

Использование аргументов в команде

public class RegularJumpCommand : Command
{
    protected override void OnStart (object[] args)
    {
        base.OnStart (args);
        gameObject.GetComponent <Rigidbody> ().velocity = (Vector3)args [0];
    }
}

Немного сильнее изменится вызов команды:

Вызов команды с аргументами

public class SomeController : MonoBehaviour 
{
    // don't forget to set this in editor
    public UnitController _targetUnit;

    private void Start()
    {
        if (_targetUnit != null)
        {
            Command.ExecuteOn<RegularJumpCommand> (_targetUnit.gameObject,
                                                   new object[]{new Vector3(0, 10, 0)});
        }
    }
}

После проделанных манипуляций команды стали гибкими и отдельный класс теперь нужно будет только для сальто. Но для инициализации параметров нужно будет пользоваться только методом OnStart(object[] args).

Вторая проблема которая у нас еще осталась — это то что каждый раз при прыжке у нас будет вызываться дорогой метод GetComponent(). Чтобы это решить давайте вспомним, что еще с Actor-ов у нас остался контроллер, который держит ссылки на все важные компоненты и в команде будем у него просить все что нам нужно. Контроллер мы тоже можем передавать в аргументы и я предлагаю сделать это немного более формализовано. Напишем для команды дочерний класс с контроллером:

Команда с контроллером

public class CommandWithType<T> : Command
    where T : MonoBehaviour
{
    protected T Controller
    {
        get;
        private set;
    }

    protected override void OnStart (object[] args)
    {
        base.OnStart (args);
        Controller = args [0] as T;
    }
}

В самой команде после этого изменился только номер аргумента, который мы используем, но об этом тоже не забывайте. Зато появился удобный способ получить контроллер не прибегая к GetComponent(). И обязательно нужно вызывать base.OnStart (args), иначе мы не сможем пользоваться контроллером:

Использование контроллера

public class RegularJumpCommand : CommandWithType<UnitController>
{
    protected override void OnStart (object[] args)
    {
        base.OnStart (args);
        Controller.AttachedRigidbody.velocity = (Vector3)args [1];
    }
}

Вызов команды тоже стал немножко другим:

public class SomeController : MonoBehaviour 
{
    //don't forget to set this in editor
    public UnitController _targetUnit;

    private void Start()
    {
        if (_targetUnit != null)
        {
            Command.ExecuteOn<RegularJumpCommand> (_targetUnit.gameObject,
                                                   new object[]{_targetUnit ,new Vector3(0, 10, 0)});
        }
    }
}

Теперь все стало совсем хорошо: у нас есть команды, которые могут обойтись без контроллера (показать рекламу, запостить что-то где-то) и команды которым нужен контроллер (идти, бежать, лететь). Команды с контроллером заточены под работу с семьей классов и не будут доступны для других семей, что вносит дополнительную упорядоченность. А так же у нас еще остались плюсы Actor-в. А еще вы не могли не заметить насколько они маленькие и аккуратные. Контроллеры от этого тоже только выиграли: стали такими-же лаконичными вместилищами ссылок на нужные нам компоненты (позже мы, конечно же, дадим им больше веса). Но это все еще только начало и с таким функционалом мы не очень далеко уедем.

Чтобы не уходить пока далеко от прыжка, давайте посмотрим что мы еще пропустили. Доведем до логического завершения этот неуклюжий, но полезный пример.

Первое, что бросается в глаза: после десяти прыжков на объекте будет висеть десять абсолютно бесполезных команд. Второе, что нужно заметить, — это то, что velocity изменяется в старте и это не гарантирует корректную работу физического движка.

Сосредоточимся пока на первом, поди второе и само решится, так ведь бывает. Самое логичное чтобы команда за собой сама убирала, как только напакостила. Добавим функционал завершения команды и два флага, чтобы знать завершила ли команда свое выполнение (об этом немного позже). И после небольших преобразований команда эволюционирует (но это еще даже не последняя ее форма).

Уборка команды за собой

public class Command : MonoBehaviour 
{
    private object[] _args;

    private bool _started = false;
    private bool _isReleased = false;

    public bool IsRunning
    {
        get{ return _started && !_isReleased;}
    }

    public static T ExecuteOn<T>(GameObject target, object[] args = null)
        where T : Command
    {
        T result = target.AddComponent <T>();
        result._args = args;
        return result;
    }

    private void Start()
    {
        _started = true;
        OnStart (_args);
    }

    protected virtual void OnStart(object[] args)
    {}

    private void OnDestroy()
    {
        if (!_isReleased)
            OnReleaseResources ();
    }

    protected virtual void OnReleaseResources()
    {
        _isReleased = true;
    }

    protected void FinishCommand()
    {
        OnReleaseResources ();
        Destroy (this, 1f);
    }

    protected virtual void OnFinishCommand(){}
}

Теперь, в нужный момент, команда, как порядочный гражданин и член социума самоуничтожится, стоит только вызвать метод FinishCommand() после всех необходимых манипуляций. Destroy() у нас слегка с задержкой, чтобы все, кому надо, могли воспользоваться командой перед исчезновением (взять из нее данные, но об этом позже), а флаг IsRunning нужен самой команде, чтобы она не начала работать раньше времени и не продолжала после завершения. Все отписки от событий и освобождение ресурсов можно легко сделать в OnReleaseResources() или в OnFinishCommand(). И не надо бояться что случайно напишешь OnDestory() и будешь долго мучатся (как я когда-то).

Теперь с этим всем мы сможем решить и вторую проблему:

Изменение velocity в FixedUpdate

public class RegularJumpCommand : CommandWithType<UnitController>
{
    private Vector3 _velocity;
    protected override void OnStart (object[] args)
    {
        base.OnStart (args);
        _velocity = (Vector3)args [1];
    }

    private void FixedUpdate()
    {
        if (!IsRunning)
            return;
        
        Controller.AttachedRigidbody.velocity = _velocity;
        FinishCommand ();
    }
}

Теперь значение velocity изменится в момент первой после Start-a итерации физического движка. Команда на данном этапе своего развития чудесно справится с задачами вроде наложения заклинания, бега, разных прыжков и визуальных эффектов.

Но как же?!

Этим еще не выйдет воспользоваться всюду, где только хочется. Если надо скачать конфиг или провести валидацию действий пользователя (не дать детям покупать плюшки за родительские деньги) или просто использовать команды как составные части бега зигзагами. Сплошь и рядом ситуации когда надо знать завершилась ли команда, успешно ли она выполнилась и если да, то взять из нее необходимые данные (для чего и делалась задержка уничтожения). Короче: здесь не разобраться без callback-ов. Лично мне новая система Unity UI нравится из-за них, но только когда их добавление или удаление происходит из кода (делать это в редакторе — грешно, не делайте так).

Можно сделать всё на событиях, даже думать не надо, но держать ссылки на все запущенные команды, отписываться в правильный момент не очень-то и хочется. А боль чуть ниже спины, в случае когда забыл отписаться, мало кому может принести удовольствие. Давайте сначала оговорим что нам надо сделать для того, чтобы начать пользоваться командами в полную силу и чтобы потом не было правок. Основная задача — сделать callback-и на успешное и не успешное завершение выполнение команды. Сделать их удобными для подписывания и без необходимости следить за отпиской. Также удобно будет при завершении команды передавать в аргумент callback-а саму команду, чтобы не держать ее отдельным полем в классе. А еще у нас не реализован способ остановки команды извне.

Первое: сделаем небольшую обертку для тех самых callback-ов. Сказано — сделано, мы программисты — народ простой. Вышло у нас примерно так:

Callback

    public class Callback<T> 
        where T : Command
    {
        public readonly Action<T> Succeed;
        public readonly Action<T> Fault;

        public Callback (Action<T> succeed)
        {
            this.Succeed = succeed;
        }

        public Callback (Action<T> succeed, Action<T> fault)
        {
            this.Succeed = succeed;
            this.Fault = fault;
        }
    }

Просто и удобно. Заметьте что по-умолчанию если callback один, то мы автоматически считаем что нас интересует только успешное завершение команды и вызовется он только в этом случае. Следующим логическим шагом будет сделать контейнер для этих самых callback-ов, ведь одного всегда будет мало. И у нас получилось вот что:

CallbackToken

    public class CallbackToken <T>
        where T : Command
    {
        private List<Callback<T>> _callbacks;

        private T _command;

        public CallbackToken (T _command)
        {
            this._command = _command;
            _callbacks = new List<Callback<T>>();
        }

        public void AddCallback(Callback<T> callback)
        {
            _callbacks.Add (callback);
        }

        public void RemoveCallback(Callback<T> callback)
        {
            _callbacks.Remove (callback);
        }

        public void FireSucceed()
        {
            foreach (Callback<T> calback in _callbacks)
            {
                calback.Succeed(_command);
            }
        }

        public void FireFault()
        {
            foreach (Callback<T> callback in _callbacks)
            {
                if (callback.Fault != null)
                {
                    callback.Fault (_command);
                }
            }
        }
    }

Осталось только добавить CallbackToken в нашу команду и вызывать его в правильный момент. И не забудем сделать возможность завершения команды успешно, не успешно и извне. И сразу финальный код:

Команда с Callback-ом

public class Command : MonoBehaviour 
{
    private object[] _args;

    private bool _started = false;
    private bool _isReleased = false;

    public CallbackToken<Command> CallbackToken
    {
        get;
        private set;
    }

    public Command ()
    {
        CallbackToken = new CallbackToken<Command> (this);
    }

    public bool IsRunning
    {
        get{ return _started && !_isReleased;}
    }

    public static T ExecuteOn<T>(GameObject target, object[] args = null)
        where T : Command
    {
        T result = target.AddComponent <T>();
        result._args = args;
        return result;
    }

    private void Start()
    {
        _started = true;
        OnStart (_args);
    }

    protected virtual void OnStart(object[] args)
    {}

    private void OnDestroy()
    {
        if (!_isReleased)
            OnReleaseResources ();
    }

    protected virtual void OnReleaseResources()
    {
        _isReleased = true;
    }

    protected void FinishCommand(bool result = true)
    {
        OnReleaseResources ();
        OnFinishCommand ();

        if (result)
            CallbackToken.FireSucceed ();
        else
            CallbackToken.FireFault ();
        
        Destroy (this, 1f);
    }

    protected virtual void OnFinishCommand(){}

    public void Terminate(bool result = false)
    {
        FinishCommand (result);
    }

Теперь метод FinishCommand() будет принимать аргументом успешность выполнения, а для прерывания работы команды извне будет использоваться метод Terminate().

Посмотрим теперь как выглядит подписка:

Подписка на Callback

public class SomeController : MonoBehaviour 
{
    //don't forget to set this in editor
    public UnitController _targetUnit;

    private void Start()
    {
        if (_targetUnit != null)
        {
            Command.ExecuteOn<RegularJumpCommand> (_targetUnit.gameObject,
                                                   new object[]{_targetUnit ,new Vector3(0, 10, 0)})
                .CallbackToken.AddCallback (new Callback<Command>(OnJumpFinish));
        }
    }

    private void OnJumpFinish (Command command)
    {
        Debug.Log(string.Format("{0}", "Successfully jumped"));
    }
}

Теперь у нас легко получится решить и вторую поставленную задачу: забрать из команды данные, (ведь мы получаем её в callback метод), просто сделав публичный get-er для необходимой информации и — вуаля.

Конец!

В завершении хочется сказать, что подход очень удачный для разработки игр на Unity. Все просто и красиво, легко поддается модификациям, а утилитарные команды (скачать, запостить) легко переносятся в любой проект. Если эта статья вам понравится, мне будет не только приятно, но и появится стимул рассказать о конечных автоматах, MVC, стратегиях и о том как это живет и сосуществует в одном проекте.

P.S.: не забывайте, что код здесь исключительно для ознакомительных целей и тестировался только в редакторе.

Автор: martynko

Источник

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


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