Готовый перевод Game Programming Patterns / Паттерны программирования в играх: 1.1. Command

Я воодушевлен по поводу этой главы, поскольку Команда - один из моих любимых паттернов.

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

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

Для такого раздутого паттерна у "Банды четырех" есть предсказуемо малопонятное описание:

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

Согласитесь, страшноватая фраза.

Во-первых, она искажает смысл того, что пытается до нас донести.

Вне странного мира программного обеспечения, где слова могут означать всё, что угодно, «клиент» — это человек, кто-то, с кем вы можете вести дела.

Я проверял, людей нельзя "параметризовывать".

Во-вторых, оставшаяся часть фразы - это всего лишь список возможного применения этого паттерна.

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

Вот мое содержательное определение для паттерна Command:

Команда — это материализованный вызов метода.

"Материализовывать" ("reify") происходит от латинского "res" — "вещь", с добавлением английского суффикса "–fy". То есть, это буквально означает "овеществлять", а этот синоним, честно говоря, гораздо забавнее.

Естественно, "содержательное" часто значит "предельно краткое", так что это, возможно, не значительное улучшение.

Позвольте мне дать немного более развернутое описание.

"Материализовать" (если вы никогда не слышали этого слова) означает "сделать реальным".

Другое определение материализации - это сделать нечто "первоклассным". Оба определения подразумевают взятие некого концепта и превращения его в данные - объект, который вы можете "засунуть" в переменную, передать в функцию и т.д.

Таким образом, называя паттерн "команду" "вызовом материализованного метода", я имею в виду, что это вызов метода, обернутый в объект.

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

Далее «Банда четырёх» пишет:

Команды представляют собой объектно-ориентированную альтернативу функциям обратного вызова.

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

Но всё это несколько абстрактно и туманно.

Я люблю начинать главы с конкретики, а в этот раз у меня это не получилось.

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

Системы рефлексии в некоторых языках программирования позволяют вам работать с типами в программе императивно во время выполнения.

Можно получить объект, представляющий класс другого объекта, и посмотреть, что может делать тип.

Другими словами, рефлексия - это материализованная система типов.

Настраиваем кнопки ввода

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

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

Самая простая реализация выглядит примерно так:

void InputHandler::handleInput()

{

if (isPressed(BUTTON_X))

jump();

else if (isPressed(BUTTON_Y))

fireGun();

else if (isPressed(BUTTON_A))

swapWeapon();

else if (isPressed(BUTTON_B))

lurchIneffectively();

}

Совет: не нажимайте B слишком часто.

Эта функция обычно вызывается раз в кадр паттерном Game Loop ("игровой цикл").

Вы можете понять, что она делает.

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

Для такого нам понадобится превратить прямые вызовы методов jump() и fireGun() в нечто такое, что мы сможем заменить.

"Заменить" звучит совсем как "присвоить значение переменной", а значит нам понадобится объект, который мы используем для представления игрового действия.

Встречайте: шаблон Команда.

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

class Command

{

public:

virtual ~Command() {}

virtual void execute() = 0;

};

Если у вас есть интерфейс с единственным методом, который ничего не возвращает, очень вероятно, что это паттерн "команда".

Затем мы создадим подклассы для каждого из различных игровых действий:

class JumpCommand : public Command

{

public:

virtual void execute() { jump(); }

};

class FireCommand : public Command

{

public:

virtual void execute() { fireGun(); }

};

// Ну вы поняли идею...

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

class InputHandler

{

public:

void handleInput();

// Методы для привязки команд...

private:

Command* buttonX_;

Command* buttonY_;

Command* buttonA_;

Command* buttonB_;

};

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

void InputHandler::handleInput()

{

if (isPressed(BUTTON_X))

buttonX_->execute();

else if (isPressed(BUTTON_Y))

buttonY_->execute();

else if (isPressed(BUTTON_A))

buttonA_->execute();

else if (isPressed(BUTTON_B))

buttonB_->execute();

}

Обратили внимание, что мы не проверяем указатель на равенство null?

Здесь предполагается, что каждая кнопка имеет привязанную к ней команду.

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

Затем, вместо того, чтобы устанавливать обработчик кнопки в null, мы просто устанавливаем его в этот объект.

Это паттерн, называемый Null Object ("нулевой объект").

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

Если кратко, это и есть суть паттерна Command

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

Инструкции для "акторов" (Actors)

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

Проблема в том, что они предполагают существование высокоуровневых методов jump(), fireGun() и других

которые неявно знают, как найти аватар игрока и заставить его танцевать, словно он является марионеткой на ниточках.

Эта предполагаемая связь ограничивает полезность данных команд.

Единственный, кто может подпрыгнуть по команде JumpCommand, - это игрок.

Давайте снимем это ограничение.

Вместо вызова функций, которые самостоятельно находят объект команды, мы передадим на вход объект, которым хотим управлять:

class Command

{

public:

virtual ~Command() {}

virtual void execute(GameActor& actor)

= 0;

};

Здесь GameActor и есть наш класс «игрового объекта», который представляет персонажа в мире игры.

Мы передаем его в execute(), чтобы унаследованная команда могла вызвать методы, применяя их к актору по нашему выбору, например:

class JumpCommand : public Command

{

public:

virtual void execute(GameActor& actor)

{

actor.

jump();

}

};

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

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

Сперва мы изменим метод handleInput() так, чтобы он возвращал команды:

Command* InputHandler::handleInput()

{

if (isPressed(BUTTON_X))

return buttonX_;

if (isPressed(BUTTON_Y))

return buttonY_;

if (isPressed(BUTTON_A))

return buttonA_;

if (isPressed(BUTTON_B))

return buttonB_;

// Ничего не нажато, поэтому ничего не делаем.

return NULL;

}

Он не может выполнить команду немедленно, потому что не знает, какого актора передать на вход.

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

Далее нам нужен какой-то код, который берет команду и запускает на акторе, представляющем игрока.

Что-то вроде этого:

Command* command = inputHandler.

handleInput();

if (command)

{

command->execute(actor)

;

}

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

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

На практике это не заурядная функция.

Но есть аналогичный сценарий использования, который действительно часто всплывает в играх.

До сих пор мы рассматривали только управляемого игроком персонажа, но что насчет других акторов?

А они управляются искусственным интеллектом игры.

Мы можем использовать тот же самый паттерн "команду" в качестве интерфейса между движком искусственного интеллекта и акторами: код искусственного интеллекта просто генерирует объекты команды.

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

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

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

Хотите получить более агрессивного врага?

Просто включите более агрессивный искусственный интеллект, чтобы генерировать команды для него.

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

Сделав команды, управляющие актором, объектами первого класса, мы убрали жесткую связь прямого вызова метода.

Вместо этого получилось что-то вроде очереди или потока команд.

Чтобы увидеть намного больше примеров постановки в очередь, см. паттерн Event Queue ("Очередь событий").

Почему я чувствую потребность нарисовать картинку "потока"?

И почему он выглядит, как труба?

Некоторый код (обработчик пользовательского ввода или искусственный интеллект)

генерирует команды и помещает их в поток.

Другой код (диспетчер или сам актор)

получает команды и вызывает их.

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

Если мы возьмем эти команды и сериализуем их, то сможем послать их поток по сети.

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

Это важная часть создания сетевой многопользовательской игры.

Отмена и повтор

Последний пример - наиболее известное использование данного паттерна.

Если объект команды может что-то делать, малый шаг отделяет его от возможности отмены своих действий.

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

Она обязательно присутствует в инструментах, используемых для создания игр.

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

Возможно, сейчас я рассуждаю исходя из своего опыта.

Без паттерна "команда" реализация отмены действия на удивление сложна.

С ним - это пара пустяков.

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

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

Например, перемещение единицы техники может выглядеть так:

class MoveUnitCommand : public Command

{

public:

MoveUnitCommand(Unit* unit, int x, int y)

: unit_(unit)

,

x_(x)

,

y_(y)

{}

virtual void execute()

{

unit_->moveTo(x_, y_);

}

private:

Unit* unit_;

int x_, y_;

};

Обратите внимание, что есть маленькое отличие от предыдущих команд.

В последнем примере мы хотели абстрагировать команду от актора, которого она модифицировала.

В данном же случае мы специально хотим привязать её к перемещаемой единице.

Экземпляр данной команды не является обобщённой операцией вида «двигать то-то», которую вы могли бы использовать в куче ситуаций, наоборот, это конкретное перемещение в игровой последовательности шагов.

В этом разница в реализации паттерна "команда".

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

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

Здесь же команды более специфичны.

Они представляют собой нечто, что может быть сделано в определённый момент времени.

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

Что-то вроде этого:

Command* handleInput()

{

// Принимаем выбранную единицу техники...

Unit* unit = getSelectedUnit();

if (isPressed(BUTTON_UP))

{

// Перемещаем единицу техники на клетку вверх.

int destY = unit->y() - 1;

return new MoveUnitCommand(unit, unit->x(), destY)

;

}

if (isPressed(BUTTON_DOWN))

{

// Перемещаем единицу техники на клетку вниз.

int destY = unit->y() + 1;

return new MoveUnitCommand(unit, unit->x(), destY)

;

}

// Другие шаги...

return NULL;

}

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

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

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

class Command

{

public:

virtual ~Command() {}

virtual void execute() = 0;

virtual void undo() = 0;

};

Метод undo() возвращает игровое состояние, измененное соответствующим методом execute().

Вот наша предыдущая команда перемещения с поддержкой отмены:

class MoveUnitCommand : public Command

{

public:

MoveUnitCommand(Unit* unit, int x, int y)

: unit_(unit)

,

xBefore_(0),

yBefore_(0),

x_(x)

,

y_(y)

{}

virtual void execute()

{

// Запоминаем позицию единицы техники перед шагом,

// чтобы мы смогли восстановить ее.

xBefore_ = unit_->x();

yBefore_ = unit_->y();

unit_->moveTo(x_, y_);

}

virtual void undo()

{

unit_->moveTo(xBefore_, yBefore_);

}

private:

Unit* unit_;

int xBefore_, yBefore_;

int x_, y_;

};

Обратите внимание, что мы добавили к классу ещё несколько состояний.

После перемещения единицы она уже не помнит, где находилась раньше.

Если мы хотим иметь возможность отмены данного шага, то нужно запомнить предыдущее положение единицы техники самостоятельно, для чего в команде используются xBefore_ и yBefore_.

Это очевидное место для использования паттерна Memento ("Хранитель").

К сожалению, я никогда не видел, чтобы он хорошо справлялся с задачей.

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

Сохранение остальных не изменившихся данных объекта является пустой тратой памяти.

Дешевле вручную сохранить только те части данных, которые вы изменяете.

Другим вариантом является использование неизменных структур данных.

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

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

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

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

Когда же он судорожно жмёт на Control-Z, мы вызываем метод undo() команды.

(Если игрок уже делал отмену, тогда метод превращается в "повторить", и мы выполняем команду снова.)

Поддержка нескольких уровней отмены действия не намного сложнее.

Вместо того, чтобы запомнить последнюю команду, мы храним список команд и ссылку на текущую.

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

Когда игрок выбирает "Отменить", мы отменяем текущую команду и перемещаем текущий указатель назад.

Когда игрок повторяет шаг, мы перемещаем указатель вперед, а затем выполняем эту команду.

Если игрок выбирает новую команду после отмены чего-то, все, стоящее в списке после текущей команды, отбрасывается.

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

Я был поражен простотой реализации и тем, насколько хорошо все работало.

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

Повтор действия, возможно, не так распространен в играх, но повтор игры встречается часто.

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

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

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

Классы, а не функции?

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

Если вы знакомы с функциональным программированием, то, возможно, заинтересуетесь - а где же функции?

Я писал примеры в этом стиле, поскольку C++ имеет очень ограниченную поддержку функций первого класса.

Указатели на функции не имеют состояний, функторы странные и все еще требуют определения класса, а с лямбда-выражениями в C++11 сложно работать из-за ручного управления памятью.

Это не значит, что нельзя использовать функции для паттерна "команда" в других языках программирования.

Если ваш язык программирования так хорош, что в нем есть реальные замыкания, непременно используйте их!

В некоторых отношениях паттерн "команда" - это способ эмуляции замыканий в языках программирования, которые их не поддерживают.

"В некоторых отношениях" - потому что построение реальных классов или структур для команд все еще полезно даже в языках программирования, имеющих замыкания.

Если ваша команда имеет несколько операций (например, отменяемых команд)

, то ее маппинг в единственную функцию неудобен.

Определение фактического класса с полями также помогает легко определить, какие данные содержит команда.

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

Например, если бы мы писали игру на JavaScript, то могли создать команду перемещения единицы техники просто так:

function makeMoveUnitCommand(unit, x, y)

{

// Здесь функция - это объект команды:

return function() {

unit.

moveTo(x, y)

;

}

}

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

function makeMoveUnitCommand(unit, x, y)

{

var xBefore, yBefore;

return {

undo: function() {

xBefore = unit.

x();

yBefore = unit.

y();

unit.

moveTo(x, y)

;

},

redo: function() {

unit.

moveTo(xBefore, yBefore)

;

}

};

}

Если вам удобно использовать стиль функций, то данный способ будет естественным.

Если же нет, я надеюсь, что данная глава немного помогла вам на этом пути.

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

См. также

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

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

Это превращает главный исполняемый метод команды в паттерн Subclass Sandbox ("Подкласс песочница").

В наших примерах был явный выбор актора для обработки команды.

В некоторых случаях, особенно когда ваша модель объекта иерархическая, это может быть не так явно.

Объект может сам ответить на команду или решить делегировать ее некоему подчиненному объекту.

Если вы сделаете так, то получите паттерн Chain of Responsibility ("Цепочка обязанностей").

Некоторые команды - куски чистого поведения без состояний, например, JumpCommand из первого примера.

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

Здесь вам поможет паттерн Flyweight ("Приспособленец").

Переведено на Нотабеноиде
http://notabenoid.org/book/51192/215210

Переводчики: Mingun, IngaJolly, Deo, GromHoll

Настройки

Готово:

100.00% КП = 1.5