Пример применения паттерна Command в Unity

Давно я ничего не писал, пора подать какие-нибудь признаки жизни. Так как я сам сейчас осваиваю паттерны проектирования, то давайте и коснемся этой темы. Рассматривать паттерны мы будем в срезе игрового проектирования. Сегодня мы рассмотрим паттерн «Команда» (Command), который позволяет представить команду в виде объекта.

Описание паттерна можно почитать тут – или в любом другом месте в Интернете. Этот паттерн особенно полезен в играх, где нужно реализовать отложенное выполнение команд (например, постановка в очередь производства юнитов в стратегиях), отмену команд в том числе и уже выполненных (то самое undo/redo), свободное переназначение команд на кнопки клавиатуры/геймпада, при сетевом взаимодействии и т.д..

Итак, задача у нас будет следующая. С помощью стрелок на клавиатуре, мы будем управлять кубиком. Управление должно быть реализовано с помощью паттерна «Команды», и поддерживать операции undo/redo. Вот и вся задача. Этого достаточно, чтоб понять суть паттерна и его область применения, а так же заморочиться на полдня, если вы делаете это впервые.  :-)

Подготовка сцены.

Я выполнять пример буду в Unity. Для начала подготовим сцену. Создаем плейн (он будет полом), кубик (он будет плеером), и две кнопки (собственно undo/redo). Размеры, цвета, расположения выбираем по вкусу, это не принципиально. У меня сцена выглядит так:

Далее создаем папку Scripts, а в ней папки CommandController и PlayerController.

Скриптуем кубик.

В папке PlayerController создаем скрипт Player и вешаем на кубик, он будет управлять нашим кубиком. Я сразу подумал, что для наглядности работы undo/redo нужно, чтоб кубик передвигался не плавно, а скачкообразно, пошагово – нажал кнопку и кубик сдвинулся на 15 единиц, например. Поэтому, в скрипте я первым делом прописал поле, чтоб из инспектора можно было регулировать такой шаг:

public float Step;

Далее были написаны простейшие методы для манипулирования кубиком:

public void MoveUp()
{
    transform.position = new Vector3(transform.position.x + Step, transform.position.y, transform.position.z);
}
public void MoveDown()
{
    transform.position = new Vector3(transform.position.x - Step, transform.position.y, transform.position.z);
}
public void MoveLeft()
{
    transform.position = new Vector3(transform.position.x, transform.position.y, transform.position.z + Step);
}
public void MoveRight()
{
    transform.position = new Vector3(transform.position.x, transform.position.y, transform.position.z - Step);
}

Никаких заморочек, мы просто прибавляем/отнимаем наш Step к нужной оси. Кстати, на данном этапе не понятно почему методы публичные, это станет понятно позже. Теперь в методе Update(), в зависимости от нажатой клавиши вызываем нужный метод:

private void Update ()
{
    if (Input.GetKeyDown(KeyCode.UpArrow))
        MoveUp();
    if (Input.GetKeyDown(KeyCode.DownArrow))
        MoveDown();
    if (Input.GetKeyDown(KeyCode.LeftArrow))
        MoveLeft();
    if (Input.GetKeyDown(KeyCode.RightArrow))
        MoveRight();
}

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

Интерфейс ICommand

Ядром паттерна является интерфейс ICommand (так же часто вместо интерфейса используют абстрактный класс, но мне в данном случае не нужна частичная реализация, т.ч. логичнее будет воспользоваться интерфейсом). Интерфейс должен предоставлять методы работы с командой, т.е.: выполнить; отменить; повторить и т.п..

В папке CommandController я создал файл ICommand и написал в нем следующее:

public interface ICommand
{
    void Execute();
    void UnExecute();
    void ReExecute();
}

Кстати, если вы создадите скрипт через юнити, у вас получится типичный скрипт юнити – класс, наследуемый от MonoBehaviour, в этом случае вам нужно очистить все содержимое скрипта кроме неймспейса, или пересоздать файл с помощью вашей IDE.

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

Реализуем интерфейс плеером.

Сейчас у нас имеется только кубик-плеер, но в реальном проекте, у нас может быть куча сущностей с отличным набором команд. Например, кроме юнита, может быть здание, со своим набором команд и тд.. Для того чтоб команды не находились в одной куче, я и создал отдельную папку PlayerController, в нее мы и будем помещать классы-команды нашего кубика, реализующие интерфейс ICommand.

Не большое отступление. Пытливый ум может задаться вопросом, а если у нас имеется например, сущность танк, он ведь тоже может передвигаться аналогично кубику, неужели придется дублировать для него реализацию интерфейса, а так же и для любых других сущностей умеющих передвигаться? Нет, не придется. Ситуацию можно будет разрулить с помощью все тех же интерфейсов и наследования, а «общий функционал» вынести в «общую папку». В общем способы есть, и не скажу, что сейчас я их сам все знаю. Да и не важно это сейчас.

Итак, начнем реализовывать команды. Все 4 команды реализуются идентичным образом, так что я опишу процесс на примере только одной команды MoveUp. В папке PlayerController создаем класс MoveUp. По итогу он будет выглядеть так:

internal class MoveUp : ICommand
{
    private readonly Player _player;
    private readonly Vector3 _previusPos;

    public MoveUp(Player player)
    {
        this._player = player;
        this._previusPos = player.transform.position;
    }
    public void Execute()
    {
        _player.MoveUp();
    }
 
    public void UnExecute()
    {
        _player.transform.position = _previusPos;
    }
 
    public void ReExecute()
    {
        Execute();
    }
}

В конструктор класса будет передаваться ссылка на плеера, в самом конструкторе инициализируются поля: ссылка на плеера и текущая позиция плеера (на момент создания объекта-команды). Последнее поле, как некоторые из вас уже догадались, необходимо на случай, если данная команда будет отменена. Дальше идет реализация 3х методов интерфейса: Execute() – вызываем метод передвижения вверх объекта-плеера; UnExecute() – отменяем текущую данную команду, возвращая плеера в предшествующую позицию; ReExecute() – повторяем данную операцию. Остальные команды реализуются так же, отличия только в вызываемом методе внутри Execute(), и названиях класса и конструктора соответственно.

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

CommandManager

В папке CommandController я создал новый класс CommandManager. Для начала нам нужно три поля и конструктор по умолчанию, инициализирующий их:

private List<ICommand> _commands;
private int _currentCom;
private int _targetCom;   //"Отсечка"
 
public CommandManager()
{
    _commands = new List<ICommand>();
    _currentCom = -1;
    _targetCom = -1;
}

В списке будут храниться все наши команды (для самых маленьких: как видите, благодаря тому, что все команды наследуются от одного интерфейса, мы можем использовать его, как тип для списка, и он примет любую команду. Все методы, объявленные в интерфейсе можно будет вызывать без каких-либо приведений типов). Переменная _currentCom указывает на последнюю выполненную команду. Переменная _targetCom своеобразная «отсечка», задающая до какой позиции в списке, следует выполнять команды. В нашем случае, это нужно для «отрезания» той части, которая останется впереди после отмен.

В классе нам нужно реализовать всего 4 метода: добавление команды в очередь, метод выполнения следующей команды и методы отмены/повтора команды.

Метод добавления команды в список:

public void SetCommand(ICommand command)
{
    _ClearCommands();
    _commands.Add(command);
    _targetCom = _commands.Count - 1;
}

Ах да, забыл про метод _ClearCommands, это закрытый метод для очистки «отсеченных» команд. Например, у нас выполнилось 50 команд, 20 из них мы отменили, теперь мы можем либо повторно их выполнить с помощью redo, либо задать новые команды. В случае задания новых команд, нам нужно удалить те 20 отмененных команд, иначе у нас начнется не вполне логическое для рядового пользователя поведение, при последующем вызове undo/redo.

Метод выглядит так:

private void _ClearCommands()
{
    int lastIndex = _commands.Count - 1;
 
    if (_targetCom < lastIndex)    
        _commands.RemoveRange(_targetCom + 1, lastIndex - _targetCom);
}

Проверяем, не находимся ли мы в конце списка и, если нет – удаляем все, начиная со следующей позиции.

Далее идет метод выполняющий следующую команду:

public bool ExecuteCommand()
{
    if (_commands.Count > 0 && _currentCom != _targetCom)
    {
        _currentCom++;
        _commands[_currentCom].Execute();
 
        return true;
    }
 
    return false;
}

Метод возвращает булевое значение, в зависимости от того, выполнил он команду или достиг конца списка/отсечки. Зачем это нужно, станет понятно чуть позже. В методе мы проверяем, есть ли в списке хоть одна команда и не достигли ли мы «отсечки». Увеличиваем счетчик текущей команды (сдвигаем «каретку» вперед), и вызываем метод Execute() на элементе списка под «кареткой».

Метод отмены:

public bool UndoCommand()
{
    if (_commands.Count > 0 && _currentCom > -1)
    {
        _commands[_currentCom].UnExecute();
        _currentCom--;
        _targetCom = _currentCom;
    }
 
    return _currentCom > -1;
}

Этот метод также возвращает булевое значение в зависимости от того, уперлась ли «каретка» в начало списка или нет. Если у нас имеются команды, и мы не в самом начале списка, значит у нас есть что отменить, поэтому вызываем на текущем элементе списка метод UnExecute(). Сдвигаем «каретку» на одну позицию назад, и устанавливаем на ее же позицию «отсечку».

И наконец, последний метод класса:

public bool RedoCommand()
{
    if (_commands.Count > 0 && _currentCom < _commands.Count -1)
    {
        _currentCom++;
        _commands[_currentCom].ReExecute();
        _targetCom = _currentCom;
    }
 
    return _currentCom != _commands.Count - 1;
}

Метод возвращает бул в зависимости от того, уперлась ли «каретка» в конец списка. Если у нас имеются команды, и мы не в самом конце списка, значит у нас имеются отмененные команды, которые можно выполнить повторно. Поэтому переводим «каретку» вперед и вызываем на элементе списка под «кареткой» метод ReExecute().  Устанавливаем «отсечку» на позицию «каретки».

Теперь нам надо как-то порождать и передавать команды, в этот класс.

Точка входа.

Для начала, необходимо где-то создать объект класса CommandManager, и по-хорошему бы он должен быть один единственный в нашей игре т.е. его нужно создать по принципу синглтона, либо вообще сделать класс статичным. Ну да ладно, сейчас это не важно. Я не стал долго думать, и решил сделать некоторый инициализирующий скрипт, такая себе «точка входа», где будут порождаться и храниться необходимые объекты. Скрипт я повесил на камеру. Код скрипта очень простой:

public class InitScript : MonoBehaviour
{
    private CommandManager _commandManager;
 
    internal CommandManager CommandManager
    {
        get
        {
            return _commandManager;
        }
    }
 
    // Use this for initialization
    private void Start ()
    {
        this._commandManager = new CommandManager();
    }
}

Порождение команд и постановка в очередь.

В скрипте Player делаем некоторые изменения. Добавляем поле-ссылку на менеджер:

private CommandController.CommandManager _commandManager;

и инициализируем его в методе Start():

private void Start ()
{
    this._commandManager = GameObject.Find("Main Camera").GetComponent<InitScript>().CommandManager;
}

Переписываем метод Update() так:

private void Update ()
{
    if (Input.GetKeyDown(KeyCode.UpArrow))
        _commandManager.SetCommand(new MoveUp(this));
    if (Input.GetKeyDown(KeyCode.DownArrow))
        _commandManager.SetCommand(new MoveDown(this));
    if (Input.GetKeyDown(KeyCode.LeftArrow))
        _commandManager.SetCommand(new MoveLeft(this));
    if (Input.GetKeyDown(KeyCode.RightArrow))
        _commandManager.SetCommand(new MoveRight(this));
}

Всё. Команды создаются и становятся в очередь, но… не выполняются  :-) И правильно, ведь выполнение никто не инициирует, нам нужен еще один скрипт – инициатор (Invoker).

CommandController

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

Вызов команд будет производиться в методе Update():

private void Update ()
{
    GetComponent<InitScript>().CommandManager.ExecuteCommand();
}

Так же создаем два метода для кнопок undo/redo:

public void btn_Undo()
{
    GetComponent<InitScript>().CommandManager.UndoCommand();
}
 
public void btn_Redo()
{
    GetComponent<InitScript>().CommandManager.RedoCommand();
}

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

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

  1. Жмем плюсик в инспекторе в разделе “On Click()”;
  2. В появившееся пустое поле объекта, перетаскиваем объект со скриптом, в котором реализованы обработчики, в нашем случае это камера.
  3. В выпадающем списке функций ищем скрипт с обработчиками, и выбираем нужный метод (например, btn_Undo()).

Так же и со второй кнопкой.

Вот и всё, поставленная задача выполнена, кубик бегает, undo/redo работает как задумано.

Маленький бонус.

Мне захотелось, чтоб кнопки undo/redo меняли свою активность в зависимости от того, есть ли что отменять/повторять соответственно, как это сделано в большинстве приложений. Именно для этого мы и предусмотрели возврат булевого значения, методами класса CommandManager. Изменим наш CommandController следующим образом:

public Button UndoButton;
public Button RedoButton;
 
private void Start()
{
    //Изначально кнопки неактивны
    UndoButton.interactable = false;
    RedoButton.interactable = false;
}
// Update is called once per frame
private void Update()
{
    if (GetComponent<InitScript>().CommandManager.ExecuteCommand())
    {
        UndoButton.interactable = true;
        RedoButton.interactable = false;
    }
}
 
public void btn_Undo()
{
    UndoButton.interactable = GetComponent<InitScript>().CommandManager.UndoCommand();
    RedoButton.interactable = true;
}
 
public void btn_Redo()
{
    RedoButton.interactable = GetComponent<InitScript>().CommandManager.RedoCommand();
    UndoButton.interactable = true;
}

Как работает этот код, объяснять не буду, попробуйте разобраться самостоятельно – тут все просто. Главное не забудьте перетащить кнопки в инспекторе, в соответствующие поля. И да, еще раз повторюсь, реализовывать подобную логику правильнее будет в отдельно скрипте под обработчики событий кнопок.

На этом всё.

P.S. Для самых ленивых залил проект-пример сюда.

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