Вы задавались когда-нибудь вопросом, как в играх наподобие 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;
}
Что же здесь происходит?
- Переменная
commandName
используется просто для хранения человекочитаемого названия команды. Её необязательно использовать в шаблоне, но она понадобится нам позже в туториале. - Конструктор
BotCommand
получает функцию и строку. Это поможет нам настроить методExecute
объекта Command и егоname
. - Делегат
ExecuteCallback
определяет тип инкапсулированного метода. Инкапсулированный метод будет возвращать void и принимать в качестве входящего параметра объект типаBot
(компонент Bot). - Свойство
Execute
будет ссылаться на инкапсулированный метод. Мы будем использовать его для вызова инкапсулированного метода. - Метод
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 прокручивается наверх и как при выполнении строки удаляются.
Изучим команды внимательнее
Настало время изучить код, который мы добавили в разделе «Применение команд»:
- В списке
botCommands
хранятся ссылки на экземплярыBotCommand
. Помните, что для экономии памяти мы можем создавать только пять экземпляров команд, но могут существовать несколько ссылок на одну команду. Кроме того, переменнаяexecuteCoroutine
ссылается наExecuteCommandsRoutine
, которая управляет выполнением команды. Update
проверяет, нажал ли пользователь клавишу Enter; если да, то он вызываетExecuteCommands
, а в противном случае вызываетсяCheckForBotCommands
.CheckForBotCommands
использует статический методHandleInput
изBotInputHandler
для проверки того, выполнил ли пользователь ввод, и если да, то команда возвращается. Возвращаемая команда передаётсяAddToCommands
. Однако если команды выполняются, т.е. еслиexecuteRoutine
не равно null, то он выполнит возврат, не передавая ничегоAddToCommands
. То есть пользователю нужно дождаться завершения выполнения.AddToCommands
добавляет новую ссылку на возвращаемый экземпляр команды вbotCommands
.- Метод
InsertNewText
классаUIManager
добавляет в UI терминала новую строку текста. Строка текста — это string, передаваемая как входной параметр. В данном случае мы передаём емуcommandName
. - Метод
ExecuteCommands
запускаетExecuteCommandsRoutine
. ResetScrollToTop
изUIManager
прокручивает UI терминала вверх. Это выполняется непосредственно перед началом выполнения.- В
ExecuteCommandsRoutine
содержится циклfor
, который производит итерации по командам внутри спискаbotCommands
и одна за другой выполняет их, передавая объектbot
методу, возвращённому свойствомExecute
. После каждого выполнения добавляется пауза вCommandPauseTime
секунд. - Метод
RemoveFirstTextLine
изUIManager
удаляет самую первую строку текста в UI терминала, если она существует. То есть когда команда выполняется, её название удаляется из UI. - После выполнения всех команд
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);
}
Разберём код:
- Если выполняются команды или список
botCommands
пуст, то методUndoCommandEntry
ничего не делает. В противном случае он записывает ссылку на последнюю введённую команду в стекundoStack
. При этом также удаляется ссылка на команду из спискаbotCommands
. - Метод
RemoveLastTextLine
изUIManager
удаляет последнюю строку текста из UI терминала, чтобы UI соответствовал содержимомуbotCommands
. - Если стек
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();
}
- При нажатии клавиши U вызывается метод
UndoCommandEntry
. - При нажатии клавиши R вызывается метод
RedoCommandEntry
.
Обработка пограничных случаев
Отлично, мы почти закончили! Но сначала нам нужно сделать следующее:
- При вводе новой команды должен очищаться стек
undoStack
. - Перед выполнением команд должен очищаться стек
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
Автор: PatientZero