Хабрахабр

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

image

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Это как? Ого!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

//1 private readonly string commandName; //2 public BotCommand(ExecuteCallback executeMethod, string 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.

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

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

//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.

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

Благодаря этому бот при выполнении команды будет стрелять снарядом. В пятом экземпляре анонимный метод вызывает для объекта 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 будет использоваться, то туториал объяснит, что он делает, но в целом для наших целей знать его внутреннее устройство необязательно. Класс UIManager обеспечивает полезные вспомогательные методы для UI терминала, который мы используем в этой сцене.

Кроме того, переменная 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.

Для ввода команды стрельбы нажмите клавишу F. У вас должно получиться вводить команды перемещения при помощи клавиш WASD. Для выполнения команд нажмите клавишу 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), который будет хранить все ссылки на команды, которые можно отменить.

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

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

Теги
Показать больше

Похожие статьи

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

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть