Реализация шаблона проектирования Command в Unity

Автор оригинала: Najmm Shora
  • Перевод
image

Вы задавались когда-нибудь вопросом, как в играх наподобие Super Meat Boy реализована функция реплея? Один из способов её реализации — выполнять ввод точно так же, как это делал игрок, что, в свою очередь, означает, что ввод нужно как-то хранить. Для этого и многого другого можно использовать шаблон Command.

Шаблон Command («Команда») также полезен для создания функций «Отменить» (Undo) и «Повторить» (Redo) в стратегической игре.

В этом туториале мы реализуем шаблон Command на языке C# и используем его для того, чтобы провести персонажа-бота по трёхмерному лабиринту. Из туториала вы узнаете:

  • Основы шаблона Command.
  • Как реализовать шаблон Command
  • Как создавать очередь команд ввода и откладывать их выполнение.

Примечание: предполагается, что вы уже знакомы с Unity и обладаете средними знаниями C#. В этом туториале мы будем работать с Unity 2019.1 и C# 7.

Приступаем к работе


Для начала скачайте материалы проекта. Распакуйте файл и откройте в Unity проект Starter.

Перейдите в RW/Scenes и откройте сцену Main. Сцена состоит из бота и лабиринта, а также UI терминала, отображающего инструкции. Дизайн уровня выполнен в виде сетки, что пригодится, когда мы будем визуально перемещать бота по лабиринту.


Если нажать на Play, то мы увидим, что инструкции не работают. Это нормально, потому что мы добавим эту функциональность в туториале.

Самая интересная часть сцены — это GameObject Bot. Выберите его в окне Hierarchy, нажав на него.


В Inspector можно увидеть, что он имеет компонент Bot. Мы будем использовать этот компонент, отдавая команды ввода.


Разбираемся в логике бота


Перейдите в RW/Scripts и откройте в редакторе кода скрипт Bot. Вам не нужно знать, что происходит в скрипте Bot. Но взгляните на два метода: Move и Shoot. Повторюсь, вам необязательно разбираться, что происходит внутри этих методов, но нужно понимать, как их использовать.

Заметьте, что метод Move получает входящий параметр CardinalDirection. CardinalDirection — это перечисление. Элемент перечисления типа CardinalDirection может быть Up, Down, Right или Left. В зависимости от выбранного CardinalDirection бот перемещается ровно на один квадрат по сетке в соответствующем направлении.


Метод Shoot заставляет бота стрелять снарядами, уничтожающими жёлтые стены, но бесполезными против других стен.


Наконец, взгляните на метод ResetToLastCheckpoint; чтобы понять, что он делает, посмотрите на лабиринт. В лабиринте есть точки под названием checkpoint (контрольные точки). Для прохождения лабиринта боту нужно добраться до зелёной контрольной точки.


Когда бот наступает на новую контрольную точку, то она становится для него последней. ResetToLastCheckpoint сбрасывает позицию бота, перенося его в последнюю контрольную точку.


Пока мы не можем использовать эти методы, но скоро это исправим. Для начала вам нужно узнать о шаблоне проектирования Command.

Что такое шаблон проектирования Command


Шаблон Command — это один из 23 шаблонов проектирования, описанных в книге Design Patterns: Elements of Reusable Object-Oriented Software, написанной «бандой четырёх» — Эрихом Гамма, Ричардом Хелмом, Ральфом Джонсоном и Джоном Влиссидесом (GoF, Gang of Four).

Авторы сообщают, что «шаблон Command инкапсулирует запрос как объект, таким образом позволяя нам параметризировать другие объекты разными запросами, запросами очередей или лога, и поддерживать обратимые операции».

Ого! Это как?

Я понимаю, это определение не особо простое, так что давайте его разберём.

Инкапсуляция обозначает, что вызов метода можно инкапсулировать как объект.


Инкапсулированный метод может воздействовать на множество объектов в зависимости от входящего параметра. Это и называется параметризацией других объектов.

Получившуюся «команду» можно сохранить вместе с другими командами до их выполнения. Это и есть очередь запросов.


Очередь команд

Наконец, обратимость означает, что операции можно вернуть назад при помощи функции Undo.

Хорошо, но как это отражается в коде?

Класс Command будет иметь метод Execute, который получает в качестве входящего параметра объект (по которому выполняется команда), называемый Receiver. То есть, по сути, метод Execute инкапсулирован классом Command.

Множество экземпляров класса Command можно передавать как обычные объекты, то есть их можно хранить в структурах данных, таких как очередь, стек и т.п.

Для выполнения команды необходимо вызывать его метод Execute. Класс, запускающий выполнение, называется Invoker.

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

Перемещаем бота


Реализация шаблона Command


В этом разделе мы реализуем шаблон Command. Существует множество способов для его реализации. В этом туториале мы рассмотрим один из них.

Для начала перейдите в RW/Scripts and и откройте в редакторе скрипт BotCommand. Класс BotCommand пока пуст, но это не надолго.

Вставим в класс следующий код:

    //1
    private readonly string commandName;

    //2
    public BotCommand(ExecuteCallback executeMethod, string name)
    {
        Execute = executeMethod;
        commandName = name;
    }

    //3
    public delegate void ExecuteCallback(Bot bot);

    //4
    public ExecuteCallback Execute { get; private set; }

    //5
    public override string ToString()
    {
        return commandName;
    }

Что же здесь происходит?

  1. Переменная commandName используется просто для хранения человекочитаемого названия команды. Её необязательно использовать в шаблоне, но она понадобится нам позже в туториале.
  2. Конструктор BotCommand получает функцию и строку. Это поможет нам настроить метод Execute объекта Command и его name.
  3. Делегат ExecuteCallback определяет тип инкапсулированного метода. Инкапсулированный метод будет возвращать void и принимать в качестве входящего параметра объект типа Bot (компонент Bot).
  4. Свойство Execute будет ссылаться на инкапсулированный метод. Мы будем использовать его для вызова инкапсулированного метода.
  5. Метод ToString переопределён, чтобы он возвращал строку commandName. Это удобно, например, для использования в UI.

Сохраним изменения, и всё! Мы успешно реализовали шаблон Command.

Осталось его использовать.

Создание команд


Откройте BotInputHandler в папке RW/Scripts.

Здесь мы создадим пять экземпляров BotCommand. Эти экземляры будут инкапсулировать методы для перемещения GameObject Bot вверх, вниз, влево и вправо, а также для стрельбы.

Чтобы реализовать это, вставим внутрь этого класса следующее:

    //1
    private static readonly BotCommand MoveUp =
        new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Up); }, "moveUp");
    
    //2
    private static readonly BotCommand MoveDown =
        new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Down); }, "moveDown");

    //3
    private static readonly BotCommand MoveLeft =
        new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Left); }, "moveLeft");

    //4
    private static readonly BotCommand MoveRight =
        new BotCommand(delegate (Bot bot) { bot.Move(CardinalDirection.Right); }, "moveRight");

    //5
    private static readonly BotCommand Shoot =
        new BotCommand(delegate (Bot bot) { bot.Shoot(); }, "shoot");

В каждом из этих экземпляров конструктору передаётся анонимный метод. Этот анонимный метод будет инкапсулирован внутри соответствующего объекта команды. Как видите, сигнатура каждого из анонимных методов соответствует требованиям, заданным делегатом ExecuteCallback.

Кроме того, вторым параметром конструктора является строка, обозначающая название команды. Это имя будет возвращаться методом ToString экземпляра команды. Позже мы применим его для UI.

В первых четырёх экземплярах анонимные методы вызывают метод Move для объекта bot. Однако входящие параметры у них отличаются.

Команды MoveUp, MoveDown, MoveLeft и MoveRight передают Move параметры CardinalDirection.Up, CardinalDirection.Down, CardinalDirection.Left и CardinalDirection.Right. Как было сказано в разделе Что такое шаблон проектирования Command, они обозначают разные направления движения GameObject Bot.

В пятом экземпляре анонимный метод вызывает для объекта bot метод Shoot. Благодаря этому бот при выполнении команды будет стрелять снарядом.

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

Для этого вставим в BotInputHandler, сразу за экземплярами команд, такой код:

    public static BotCommand HandleInput()
    {
        if (Input.GetKeyDown(KeyCode.W))
        {
            return MoveUp;
        }
        else if (Input.GetKeyDown(KeyCode.S))
        {
            return MoveDown;
        }
        else if (Input.GetKeyDown(KeyCode.D))
        {
            return MoveRight;
        }
        else if (Input.GetKeyDown(KeyCode.A))
        {
            return MoveLeft;
        }
        else if (Input.GetKeyDown(KeyCode.F))
        {
            return Shoot;
        }

        return null;
    }

Метод HandleInput возвращает один экземпляр команды в зависимости от нажатой пользователем клавиши. Прежде чем двигаться дальше, сохраните изменения.

Применение команд


Отлично, теперь настало время использовать созданные нами команды. Снова зайдите в RW/Scripts и откройте в редакторе скрипт SceneManager. В этом классе вы заметите ссылку на переменную uiManager типа UIManager.

Класс UIManager обеспечивает полезные вспомогательные методы для UI терминала, который мы используем в этой сцене. Если метод из UIManager будет использоваться, то туториал объяснит, что он делает, но в целом для наших целей знать его внутреннее устройство необязательно.

Кроме того, переменная bot ссылается на компонент бота, прикреплённый к GameObject Bot.

Теперь добавим в класс SceneManager следующий код, заменив им комментарий //1:

    //1
    private List<BotCommand> botCommands = new List<BotCommand>();
    private Coroutine executeRoutine;
    
    //2
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Return))
        {
            ExecuteCommands();
        }
        else
        {
            CheckForBotCommands();
        }           
    }

    //3
    private void CheckForBotCommands()
    {
        var botCommand = BotInputHandler.HandleInput();
        if (botCommand != null && executeRoutine == null)
        {
            AddToCommands(botCommand);
        }
    }

    //4
    private void AddToCommands(BotCommand botCommand)
    {
        botCommands.Add(botCommand);
        //5
        uiManager.InsertNewText(botCommand.ToString());
    }

    //6
    private void ExecuteCommands()
    {
        if (executeRoutine != null)
        {
            return;
        }

        executeRoutine = StartCoroutine(ExecuteCommandsRoutine());
    }

    private IEnumerator ExecuteCommandsRoutine()
    {
        Debug.Log("Executing...");
        //7
        uiManager.ResetScrollToTop();

        //8
        for (int i = 0, count = botCommands.Count; i < count; i++)
        {
            var command = botCommands[i];
            command.Execute(bot);
            //9
            uiManager.RemoveFirstTextLine();
            yield return new WaitForSeconds(CommandPauseTime);
        }

        //10
        botCommands.Clear();
        
        bot.ResetToLastCheckpoint();

        executeRoutine = null;
    }

Ого, какой объём кода! Но не волнуйтесь; мы наконец готовы к первому настоящему запуску проекта в окне Game.

Код я объясню позже. Не забудьте сохранить изменения.

Запуск игры для тестирования шаблона Command


Итак, настало время для сборки; нажмите Play в редакторе Unity.

У вас должно получиться вводить команды перемещения при помощи клавиш WASD. Для ввода команды стрельбы нажмите клавишу F. Для выполнения команд нажмите клавишу Enter.

Примечание: пока процесс выполнения не завершён, вводить новые команды невозможно.



Заметьте, что в UI терминала добавляются строки. Команды в UI обозначаются своими названиями. Это стало возможно благодаря переменной commandName.

Кроме того заметьте, как перед выполнением UI прокручивается наверх и как при выполнении строки удаляются.

Изучим команды внимательнее


Настало время изучить код, который мы добавили в разделе «Применение команд»:

  1. В списке botCommands хранятся ссылки на экземпляры BotCommand. Помните, что для экономии памяти мы можем создавать только пять экземпляров команд, но могут существовать несколько ссылок на одну команду. Кроме того, переменная executeCoroutine ссылается на ExecuteCommandsRoutine, которая управляет выполнением команды.
  2. Update проверяет, нажал ли пользователь клавишу Enter; если да, то он вызывает ExecuteCommands, а в противном случае вызывается CheckForBotCommands.
  3. CheckForBotCommands использует статический метод HandleInput из BotInputHandler для проверки того, выполнил ли пользователь ввод, и если да, то команда возвращается. Возвращаемая команда передаётся AddToCommands. Однако если команды выполняются, т.е. если executeRoutine не равно null, то он выполнит возврат, не передавая ничего AddToCommands. То есть пользователю нужно дождаться завершения выполнения.
  4. AddToCommands добавляет новую ссылку на возвращаемый экземпляр команды в botCommands.
  5. Метод InsertNewText класса UIManager добавляет в UI терминала новую строку текста. Строка текста — это string, передаваемая как входной параметр. В данном случае мы передаём ему commandName.
  6. Метод ExecuteCommands запускает ExecuteCommandsRoutine.
  7. ResetScrollToTop из UIManager прокручивает UI терминала вверх. Это выполняется непосредственно перед началом выполнения.
  8. В ExecuteCommandsRoutine содержится цикл for, который производит итерации по командам внутри списка botCommands и одна за другой выполняет их, передавая объект bot методу, возвращённому свойством Execute. После каждого выполнения добавляется пауза в CommandPauseTime секунд.
  9. Метод RemoveFirstTextLine из UIManager удаляет самую первую строку текста в UI терминала, если она существует. То есть когда команда выполняется, её название удаляется из UI.
  10. После выполнения всех команд botCommands очищается и бот при помощи ResetToLastCheckpoint сбрасывается на последнюю контрольную точку. В конце executeRoutine присваивается null и пользователь может продолжать вводить команды.

Реализация функций Undo и Redo


Ещё раз запустите сцену и попытайтесь добраться до зелёной контрольной точки.

Вы заметите, что пока мы не можем отменить введённую команду. Это значит, что если вы сделаете ошибку, то не сможете вернуться назад, пока не выполните все введённые команды. Можно исправить это, добавив функции Undo и Redo.

Вернитесь к SceneManager.cs и добавьте следующее объявление переменной сразу после объявления List для botCommands:

       private Stack<BotCommand> undoStack = new Stack<BotCommand>();

Переменная undoStack — это стек (из семейства Collections), который будет хранить все ссылки на команды, которые можно отменить.

Теперь добавим два метода UndoCommandEntry and RedoCommandEntry, которые будут выполнять Undo и Redo. В классе SceneManager вставим следующий код после ExecuteCommandsRoutine:

    private void UndoCommandEntry()
    {
        //1
        if (executeRoutine != null || botCommands.Count == 0)
        {
            return;
        }

        undoStack.Push(botCommands[botCommands.Count - 1]);
        botCommands.RemoveAt(botCommands.Count - 1);
            
        //2
        uiManager.RemoveLastTextLine();
     }

    private void RedoCommandEntry()
    {
        //3
        if (undoStack.Count == 0)
        {
            return;
        }

        var botCommand = undoStack.Pop();
        AddToCommands(botCommand);
    }

Разберём код:

  1. Если выполняются команды или список botCommands пуст, то метод UndoCommandEntry ничего не делает. В противном случае он записывает ссылку на последнюю введённую команду в стек undoStack. При этом также удаляется ссылка на команду из списка botCommands.
  2. Метод RemoveLastTextLine из UIManager удаляет последнюю строку текста из UI терминала, чтобы UI соответствовал содержимому botCommands.
  3. Если стек undoStack пуст, то RedoCommandEntry ничего не делает. В противном случае он извлекает последнюю команду из вершины undoStack и добавляет её обратно в список botCommands при помощи AddToCommands.

Теперь мы добавим клавиатурный ввод для использования этих функций. Внутри класса SceneManager заменим тело метода Update следующим кодом:

    if (Input.GetKeyDown(KeyCode.Return))
    {
        ExecuteCommands();
    }
    else if (Input.GetKeyDown(KeyCode.U)) //1
    {
        UndoCommandEntry();
    }
    else if (Input.GetKeyDown(KeyCode.R)) //2
    {
        RedoCommandEntry();
    }
    else
    {
        CheckForBotCommands();
    }

  1. При нажатии клавиши U вызывается метод UndoCommandEntry.
  2. При нажатии клавиши R вызывается метод RedoCommandEntry.

Обработка пограничных случаев


Отлично, мы почти закончили! Но сначала нам нужно сделать следующее:

  1. При вводе новой команды должен очищаться стек undoStack.
  2. Перед выполнением команд должен очищаться стек undoStack.

Чтобы реализовать это, нам для начала нужно добавить в SceneManager новый метод. Вставим следующий метод после CheckForBotCommands:

    private void AddNewCommand(BotCommand botCommand)
    {
        undoStack.Clear();
        AddToCommands(botCommand);
    }

Этот метод очищает undoStack, а затем вызывает метод AddToCommands.

Теперь заменим вызов AddToCommands внутри CheckForBotCommands на следующий код:

    AddNewCommand(botCommand);

Затем вставим следующую строку после оператора if внутри метода ExecuteCommands, чтобы очистить перед выполнением команд undoStack:

    undoStack.Clear();

И на этом мы наконец-то закончили!

Сохраните свою работу. Соберите проект и нажмите в редакторе Play. Вводите команды, как и раньше. Нажимайте U для отмены команд. Нажимайте R для повтора отменённых команд.


Попытайтесь добраться до зелёной контрольной точки.

Куда двигаться дальше?


Чтобы узнать больше о шаблонах проектирования, используемых в программировании игр, я рекомендую изучить вам книгу Game Programming Patterns Роберта Нистрома.

Чтобы узнать больше о продвинутых методиках C#, пройдите курс C# Collections, Lambdas, and LINQ.

Задание


В качестве задания попробуйте добраться до зелёной контрольной точки в конце лабиринта. Один из вариантов решений я спрятал под спойлер.

Решение
  • moveUp × 2
  • moveRight × 3
  • moveUp × 2
  • moveLeft
  • shoot
  • moveLeft × 2
  • moveUp × 2
  • moveLeft × 2
  • moveDown × 5
  • moveLeft
  • shoot
  • moveLeft
  • moveUp × 3
  • shoot × 2
  • moveUp × 5
  • moveRight × 3

  • +13
  • 5,2k
  • 4
Поддержать автора
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 4

    +3
    Зачем писать эти уродливые анонимные методы?
    Зачем создавать контейнер для делегата?
      0
      romaan27, Вас не затруднит объяснить, как делать правильно?
        +3
        Команда оформляется через интерфейс. Выражать стратегии очень удобно через анонимные методы, но когда они состоят из одного метода и ситуативны. В данном случае автор зачем-то выделил разделил логику команды и её имя. Из-за чего система стала менее очевидной (команда с одним именем и типом имеет разные действия).

        Выражается это вот так
        public interface ICommand
        {
            void Execute();
            void Redo();
        }
        


        В коде автора статьи мы имеем связь Has-a, у него есть сущность «команда бота» которая содержит непосредственно действие и название. Я не вижу предпосылок для этого, как по мне логичней будет связь is-a. Команда движение есть команда бота и содержит направление движения. Ну а команда бота в свою очередь реализуют интерфейс ICommand (можно обойтись и без него).

        Какие недостатки у has-a в статье:
        1) Пришлось выделить фабричные методы для создания команд движения которые дублируют друг друга.
        2) Redo не показана в статье но там те же пляски с дубляжом будут.

        В данном случае в целом классическая дилемма: композиция против наследования. Обычно совет звучит о предпочтение композиции наследованию, но в данном случае избавления от наследования сделало систему менее стойкой и однородной.

        Насчёт уродливых анонимных методов

        Это просто рудемент. Вместо
        new delegate(Bot bot) { /*code*/ }
        Сейчас пишут
        (bot) => //code

          0
          Спасибо за ответ.
          Плюсовать не могу :(

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

    Самое читаемое