Готовый перевод Game Programming Patterns / Паттерны программирования в играх: 1.4. Prototype
Впервые, я услышал термин "прототип" в Паттернах Проектирования.
Сегодня, кажется, его использую все подряд, но только не в разговорах о паттернах проектирования.
Мы обсудим это, а также я покажу другие места, поинтереснее, откуда термин "прототип" и концепция, стоящая за этим словом, появились на свет.
Но сперва давайте вернемся к оригинальным паттернам
Говоря "оригинальным" - я не говорю об этом легкомысленно
Паттерны Проектирования цитируют легендарный проект Ивана Сазерленда в 1963 году, как один из первых примеров этого паттерна в "дикой природе".
Пока остальные слушали Дилана и Битлз, Сазерленд был занят, что бы вы понимали, изобретением базовых концепций CAD, интерактивной графики, и объектно-ориентированного программирования.
Смотрите демо и готовьтесь восхищаться.
Паттерн проектирования Prototype
Представьте, что мы делаем игру в стиле Gauntlet.
У нас есть существа и демоны, которые роятся вокруг героя, борясь за свою долю его плоти.
Эти сомнительные братья по ужину выходят на арену с помощью "спавнера", и для каждого вида врага есть свой спавнер.
Ради этого примера, скажем, у нас есть различные типы для каждого вида монстра в игре.
У нас есть реальные классы C + + для Призрака, Демона, Волшебника и т.д.
, такие как:
class Monster
{
// Всякая всячина...
};
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};
Спавнер создает экземпляр одного конкретного вида монстра.
Для поддержки всех монстров в игре, мы могли бы перебрать их и написать для каждого свой спавнер, создав параллельную иерархию классов:
Реализация выглядит следующим образом:
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
class GhostSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Ghost();
}
};
class DemonSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Demon();
}
};
// Ну вы поняли...
Очевидно, такой способ может быть забавным, только если вам платят за количество строк кода.
Куча классов, шаблонов, много избыточности, дублирования, повторений...
Паттерн prototype предлагает решение
Ключевая идея - объект может производить другие, подобные себе объекты.
Пусть у вас есть призрак - можно сделать еще парочку из него.
У вас есть демон - можно создать еще демонов.
Любой монстр может рассматриваться как прототип монстра, используемого для генерации других версий себе подобного.
Чтобы осуществить это, мы даем нашему базовому класс, Monster, абстрактный метод clone() :
class Monster
{
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
// Ещё всякая всячина...
};
Каждый подкласс монстра предоставляет реализацию, которая возвращает новый объект идентичный объекту в классе и состояния самого себя.
Например:
class Ghost : public Monster {
public:
Ghost(int health, int speed)
: health_(health)
,
speed_(speed)
{}
virtual Monster* clone()
{
return new Ghost(health_, speed_);
}
private:
int health_;
int speed_;
};
После того, как все наши монстры это поддерживают, нам больше не нужен класс Spawner для каждого класса монстра.
Вместо этого, мы определяем всего один:
class Spawner
{
public:
Spawner(Monster* prototype)
: prototype_(prototype)
{}
Monster* spawnMonster()
{
return prototype_->clone();
}
private:
Monster* prototype_;
};
Он содержит монстра, скрытого, словно пчелиная матка в своем улье, единственным предназначением которого, быть шаблоном для штамповки других таких же монстров.
Для создания "спавнера" призраков, мы просто создаем экземпляр призрака-прототипа, а затем создаем содержащий его "спавнер".
Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);
;
Одна важная деталь - этот паттерн копирует не только класс прототипа, он копирует и его состояние.
Это означает, что мы можем создать генератор быстрых, медленных или слабых призраков, просто создав соответствующий прототип призрака.
Я нахожу этот паттерн одновременно изящным и удивительным.
Я не представляю, что дошел бы до него сам, но и не могу представить, как смог бы работать без него.
Насколько хорошо он работает?
Нам не нужно создавать отдельный генератор для каждого монстра - это здорово.
Но мы должны реализовать метод clone() в каждом классе, описывающем конкретного монстра.
А ведь это примерно столько же кода, как и в случае с отдельными генераторами.
Грамотная реализация clone() не обойдется без "темных углов", обусловленных семантикой языка. Будет выполняться глубокое клонирование или же поверхностное?
Скажем, когда у вас одно из полей класса - ссылка на объект, нужно ли копировать объект или достаточно скопировать ссылку на него?
Как этот объект взаимодейтсвует со своими данными?
Вообщем, не только не похоже, что этот подход сильно сэкономит нам количество кода в этой надуманной проблеме, но и то, что эта проблема надуманна - факт.
Мы должны принять как данность, что для каждого монстра имеется отдельный класс.
Впрочем, в наши дни - это не тот путь, по которому идет большинство игровых движков.
Большинство из нас научены горьким опытом, что управление большой иерархией классов - это головная боль, и именно поэтому используются паттерны Component и Type Object для моделирования различного рода объектов без создания для них собственных классов.
Spawn-функции
Даже в случае отдельных классов для каждого монстра, существуют другие пути создавать таке объекты.
Вместо того, чтобы создавать отдельные spawn-классы для каждого монстра, мы можем реализовать spawn-функции. Например:
Monster* spawnGhost()
{
return new Ghost();
}
Этот подход используется реже, чем выкатывание класса для создания монстра определенного типа.
Теперь один-единственный spawner-класс может просто хранить указатель на spawn-функцию:
typedef Monster* (*SpawnCallback)();
();
class Spawner
{
public:
Spawner(SpawnCallback spawn)
: spawn_(spawn)
{}
Monster* spawnMonster()
{
return spawn_();
}
private:
SpawnCallback spawn_;
};
Создание spawner для призрака выглядит так:
Spawner* ghostSpawner = new Spawner(spawnGhost);
;
Шаблоны
Большинство C++ разработчиков знакомы с шаблонами.
Нашему spawner-классу необходимо создавать инстанс определенного типа, но мы не хотим вписывать в него какой-то конкретный класс монстра.
Естественным решением будет передача типа в качестве параметра, что позволяют сделать шаблоны.
Я не уверен, пришлись ли они по душе C++ разработчикам, или наоборот, оттолкнули их от языка. В любом случае, все те, кого я вижу, используют шаблоны в C++.
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
template <class T>
class SpawnerFor : public Spawner
{
public:
virtual Monster* spawnMonster() { return new T(); }
};
Использовать его можно так:
Spawner* ghostSpawner = new SpawnerFor<Ghost>();
Код класса "Спавнер" не заботится о том, какого типа монстр будет создаваться, он просто работает с указателем на "Монстра".
Если у нас есть только класс SpawnerFor<T> - не будет не единого супертипа, реализации шаблона которого были бы распространены, поэтому код, работающий со спавнерами любого типа монстра сам должен принять параметр шаблона.
Типы первого класса
Два предыдущих решения выявили необходимость в типизируемом классе Spawner.
В С++ типы, в целом, не являются типами первого класса, так что потребуется некоторая ловкость.
Если вы используете языки с динамической типизацией, такие как JavaScript, Python или Ruby, где классы являются обычными объектами, которые можно передавать, вы можете решить это напрямую.
В некотором смысле, шаблон "Type Object" является одним из способов обойти отсутствие типов первого класса.
Этот паттерн полезен и для языков, где такие типы есть,так как он позволяет определить, что является "типом".
Вам может захотеться отличной от встроенной в язык семантики классов.
Когда вы делаете спавнер, просто передайте класс монстра, который требуется создавать - буквально, объект, который представляет класс монстра во время выполнения.
Проще простого.
Со всеми этими вариантами я, если честно, так и не нашёл случая, когда шаблон "Прототип" являлся бы лучшим решением.
Возможно, ваш опыт будет отличаться, но давайте отложим сейчас это в сторону и поговорим чём-то другом: прототипы как парадигма языка.
Прототипное программирование
Многие люди считают, что "объектно-ориентированное программирование" - это синоним "классу". Определения ООП сродни противоположным религиозным конфессиям, но неоспоримо следующее - ООП позволяет определить "объекты" как набор данных и поведения.
По сравнению со структурными языками, как C, и функциональными языками, как Scheme, определяющей характеристикой ООП является то, что он прочно связывается состояние и поведение вместе.
Вы можете считать, что классы - это единственный способ реализовать это, но горстка парней, в том числе Dave Ungar и Randall Smith, позволили себе не согласиться.
В 80х был создан язык под названием Self.
Максимальный ООП, но без классов.
Self
В чистом виде, Self более объектно-ориентированный, чем язык на основе классов.
Мы думаем, что ООП сводит состояние и поведение вместе, но языки с классами имеют линию, разделяющую их.
Рассмотрим семантику вашего любимого языка, основанного на классах.
Что бы получить некое состояние объекта, вы проверяете память самим экземпляром.
Состояние содержится в экземпляре.
Тем не менее, что бы вызвать метод, вы сначала проверить класс экземпляра, и затем уже заметите есть ли этот метод.
Поведение содержится в классе.
Экземпляр просто ссылка на класс.
Всегда присутствует промежуточный уровень доступа к методу.
Например, вызывая виртуальный метод в C++, вы проверите у экземпляра указатель на его виртуальную таблицу и затем найдёте метод в ней.
Self устраняет это разграничение.
Что бы что-то найти, вы просто проверяете объект.
Экземпляр может содержать как состояние, так и поведение.
Вы можете иметь один объект, имеющий полностью уникальный для него метод.
Один в поле не воин, но этот объект - воин.
Если бы это было все, что делал Self, его было бы сложно использовать.
Наследование в языках основанных на классах, несмотря на его недостатки, дает полезный механизм для повторного использования полиморфного кода и избежания дублирования.
Чтобы достигать такого без классов, у Self есть делегирование.
Чтобы найти поле или вызвать метод некоторого объекта, сначала заглянем в сам объект.
Если в нем они есть, то все.
Если нет - мы проверим родителя объекта.
Это просто ссылка на другой объект.
Не находим свойство в первом объекте - повторяем поиск на его родителе, затем на родителе родителя и т.д.
Другими словами, в случае неудачи поиск делегируется родителю.
Я, конечно, упрощаю.
Self на самом деле поддерживает множественное наследование.
Родители - это просто специально отмеченное поле, а значит, вы можете делать такие вещи, как наследование родителей и их изменение, в реальном времени. Это называется динамическим наследованием.
Родительские объекты позволяют нам повторно использовать поведение (и состояние!)
в нескольких объектах, так что мы покрываем пользу от классов.
Другая ключевая способность классов - создание экземляров объектов.
Когда вам нужен новый объект класса Thingamabob, вы просто пишите new Thingamabob() или как-то по-другому в зависимости от синтаксиса языка.
Класс является неявной фабрикой своих экземпляров.
Как бы мы создавали новые объекты без классов?
В частности, как бы мы создавали кучу новых объектов, которые бы взаимодействовали между собой?
Так же как и в шаблонах проектирования, мы бы сделали это в Self с помощью клонирования.
В Self каждый объект поддерживает шаблон Прототип по умолчанию.
Любой объект может быть клонирован.
Для создания нескольких одинаковых объектов вам нужно всего лишь:
Предайте объекту тот вид, который вам необходим.
Вы можете просто скопировать базовый Объект, созданный системой, а после наполнить его различными полями и методами.
Создайте столько его клонов, сколько... эм... клонов вам нужно.
Мы получили всю элегантность шаблона Prototype без скучной реализации метода clone(); теперь это встроено в систему.
Как только я узнал об этой красивой, умной и небольшой системе, я начал создавать язык, основанный на Prototype, чтобы получше разобраться в шаблоне.
Я понимаю, что создание языка с нуля не самый хороший способ обучения, но что я могу сказать, я немного странный.
Если вам интересно, язык называется Finch.
Как все прошло?
Я был под большим впечатлением, играя с чисто prototype-ориентированным языком, но в процессе я понял один неприятный факт: программировать на нем было куда менее интересно.
С тех пор я слышал через сарафанное радио как многие Self программисты приходили к такому же мнению.
Думаю, проект был далек от провала.
Self был таким динамичным, что он нуждался во всех нововведениях виртуальной машины для достаточно быстрого запуска.
Идеи которые они придумали для компиляции "на лету", сборщика мусора и оптимизации обработки методов, имевшие одинаковые технические приёмы - часто реализовались одними и теми же людьми.
Так что теперь большинство созданных во всём мире языков с динамической типизацией достаточно быстры для использования в очень популярных приложениях.
Конечно, язык был прост в реализации, но это было потому что вся ответственность ложилась на пользователя.
Как только я попытался работать с ним, обнаружил, что мне не хватает структурности, которую дают классы.
В конце концов я повторил ее на уровне библиотеки, т.к. в языке ее не было.
Возможно вследствие моего предыдущего опыта с языками, основаными на классах, мой мозг был заражен этой парадигмой.
Но я чувствую, что большинству людей просто нравится хорошо определенная "суть вещей".
В дополнение к ошеломляющему успеху объектно ориентированных языков: посмотрите как много игр имеют явные классы игровых героев и точный набор различных видов врагов, предметов и навыков, каждый из которых четко описан.
Можно видеть, что не так много игр, где каждый монстр - уникальная снежинка, вроде "смесь тролля, гоблина и змеи".
Хоть Prototype - реально крутая парадигма, о которой, хотелось бы, знали больше людей, я рад, что большинство из нас не пользуются ей ежедневно.
Тот код, полностью использующий возможности прототипов, что я видел, я нахожу трудным к пониманию.
Еще одним аргументом выступает то, как мало кода в действительности написано в стиле прототипов.
О, я поискал.
А как же JavaScript?
Ладно, если, языки берущие за основу прототипы, так недружелюбны, как я могу JavaScript?
Вот язык с прототипированием, которым пользуются миллионы людей ежедневно.
JavaScript используется на большем числе компьютеров на Земле, чем любой другой язык.
Брендан Айк, создатель JavaScript, черпал вдохновение прямо из Self, и многое из семантики JavaScript основано на прототипах.
У любого объекта может быть произвольный набор свойств: полей и "методов" (которые на самом деле функции, хранящиеся в виде полей).
У объекта может быть другой объект, называемый прототипом, которому делегируется обращение к полю в случае неудачи.
Для дизайнера языка, одной из привлекательнейших черт прототипов является то, что их проще реализовать чем классы.
Айк воспользовался этим в полной мере: первая версия JavaScript была создана за 10 дней.
Несмотря на это, я полагаю на практике у JavaScript больше общего с языками, основанными на классах, чем с прототипированными.
Одно из улучшений в JavaScript по сравнению с Self - то, что ключевая для прототипированных языков операция - клонирование, скрыта.
В JavaScript нет метода для клонирования объекта.
Самое похожее в нем - это Object,
создавая который вы создаете новый объект с делегатом на уже существующий.
Хотя это было добавлено лишь со спецификацией ECMAScript 5, 14 лет спустя с момента появления JavaScript.
Вместо клонирования, давайте пройдемся по стандартному пути определения типа и создания объектов в JavaScript.
Начинайте с функции-конструктора:
function Weapon(range, damage)
{
this.
range = range;
this.
damage = damage;
}
Она создает новый объект и инициализирует его поля.
Вызывается она так:
var sword = new Weapon(10, 16);
Здесь new вызывает тело функции Weapon, в рамках которого this указывает на новый пустой объект.
В теле функции к этому объекту добавляется несколько полей, затем только что созданный и заполненный объект автоматически возвращается.
Кроме того, new делает для вас еще пару вещей.
Когда new создает новый пустой объект, она связывает его делегат с объектом-прототипом.
Можно получить этот объект напрямую, используя прототип Weapon.
-
В то время как состояние добавляется в теле конструктора, для определения поведения обычно методы добавляют в прототип.
Примерно так:
Weapon.
prototype.
attack = function(target)
{
if (distanceTo(target)
> this.
range)
{
console.
og("Out of range!"
);
} else {
target.
health -= this.
damage;
}
}
Это добавит в прототип оружия свойство attack, значение свойства - это функция.
Так как каждый объект, возвращаемый конструкцией new Weapon(), делегирует прототипу Weapon,
теперь можно вызвать sword.
attack() и прототип вызовет эту функцию.
Это выглядит примерно так:
Давайте подытожим:
Что бы создать объекты используется оператор "new", который вызывает объект, представляющий тип - функцию-конструктор.
Состояние хранится в самом экземпляре.
Поведение, проходит через уровень абстракции - делегирование прототипу, и хранится в отдельном объекте, представляющему набор методов, общих для всех объектов определенного типа.
Называйте меня психом, но это звучит почти как мое описание классов, данное ранее.
Можно писать код в стиле прототипирования в JavaScript (без клонирования),
но синтаксис и идиомы языка поощряют подход, основанный на классах.
Лично я считаю, что это хорошо.
Я, как уже сказал, нахожу код, делающий ставку на прототипы, трудным для работы, поэтому мне нравится то, что JavaScript оборачивает внутреннюю семантику в более классические вещи.
Прототипы для Моделирования Данных
Ладно, я тут говорил о том, почему мне не нравятся прототипы, поэтому эта глава навевала депрессию.
Думаю, эта книга больше комедия, чем трагедия, так что давайте закончим главу областью, где по моему мнению прототипы, или точнее делегирование, может быть полезным.
Если Вы сравните объём занимаемой памяти в игре ответственный за код и за данные, то заметите, что последний стабильно растёт с самого зарождения программирования.
На заре игростроения, игры, чтобы уместиться на одну-две дискеты или картриджа процедурно генерировалось почти все.
Сегодня в большинстве игр, код - это всего лишь "движок", исполняющий игру, которая полностью определяется данными.
Это здорово, но запихивание кучи контента в файлы данных не решит волшебным образом организационные задачи в крупном проекте.
Вообще-то, даже усложнит.
Языки программирования используют по причине того, что в них есть инструменты для управления сложностью.
Вместо копирования-вставки участка кода в десяти местах, мы перемещаем его в функцию, которую сможем вызывать по имени.
Вместо копирования метода в кучу классов, мы поместим его в отдельный класс и отнаследуемся от него или используем его как "mix in".
Когда объём данных в вашей игре достигает значительного размера, вы действительно захотите объединить некоторый функционал.
Моделирование Данных глубокая тема, так что я не хочу подробно обсуждать её, но я хочу донести одну идею - использование прототипов и делегирования позволяет повторно использовать данные.
Давайте поговорит о определении данных в бесстыдном плагиате Gauntlet, упомянутом ранее.
Игровые дизайнеры нуждаются в особых атрибутах для монстров и предметов в некоторых файлах.
Я имею в виду полностью оригинальную серию, ни коем образом не вдохновлённый существующими аркадными играми на нескольких игроков о последовательном исследовании подземелий.
Пожалуйста, не судите меня.
Один из распространённых подходов - использовать JSON и представлять объекты данных картами (или контейнероми свойств, или любым из дюжины других терминов, ведь нет ничего более программерского, чем придумывать новое название для уже существующего термина).
Мы так часто изобретали этот подход, что Steve Yegge назвал его "Универсальным шаблоном проектирования".
Например, гоблин в игре будет представлен как-то так:
{
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
}
Это крайне просто, и даже дизайнер, питающий отвращение к любому тексту сможет справиться с этим.
А таким образом вы сможете добавить несколько ветвей в Великое Семейное Древо Гоблинов:
{
"name": "goblin wizard",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"spells": ["fire ball", "lightning bolt"]
}
{
"name": "goblin archer",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"],
"attacks": ["short bow"]
}
В этот момент, если это было кодом, внутреннее чувство прекрасного дало бы о себя знать.
Огромное количество дублирования между этими сущностями, которое так ненавидят квалифицированные программисты.
Это занимает лишнее пространство и тратит время автора.
Вы должны внимательно прочитать всё, даже если данные одинаковые.
Это гарантирует головную боль.
Если мы захотим сделать гоблинов в игре сильнее, нужно не забыть обновить здоровье всех трёх.
Плохо. Очень плохо.
Будь это кодом, мы бы создали абстракцию "гоблин" и использовали бы её во всех трёх видах гоблинов.
Но глупый JSON ничего не знает об этом.
Так что давайте сделаем его немного умнее.
Мы договариваемся, что если объект имеет поле "prototype", то оно определяет имя другого объекта, который выступает делегатом.
Все свойства, которые не существуют в первом объекте, будут искаться в прототипе.
Это делает "prototype" набором методанных.
У гоблинов бородавчатая зеленая кожа и желтые зубы.
Они не имеют прототипов.
Прототипы это свойство объекта данных представляющего конкретного гоблина, а не гоблина самого по себе.
С этим мы можем упростить JSON для нашей орды гоблинов:
{
"name": "goblin grunt",
"minHealth": 20,
"maxHealth": 30,
"resists": ["cold", "poison"],
"weaknesses": ["fire", "light"]
}
{
"name": "goblin wizard",
"prototype": "goblin grunt",
"spells": ["fire ball", "lightning bolt"]
}
{
"name": "goblin archer",
"prototype": "goblin grunt",
"attacks": ["short bow"]
}
Теперь, когда archer и wizard имеют в качестве прототипа grunt, нам не требуется повторять здоровье, сопротивления и слабости для каждого из них.
Логика, которую мы добавили в нашу модель данных очень проста - обычное одиночное делегирование - но мы избавились от кучи дублирования.
Одна интересная вещь - мы не стали создавать четвертую сущность "base goblin" как абстрактный прототип для остальных трёх типов гоблинов.
Вместо этого мы просто взяли самого простого гоблина и делегировали его.
Это вполне естественно в любой системе, базирующейся на прототипах, где любой объект может быть клонирован для создания новых необычных объектов, так что я думаю это естественно и тут.
Это отлично подходит для данных в играх, где имеется много одноразовых особых сущностей в игровом мире.
Такие как боссы или уникальные предметы.
Они более подробно описаны, чем большинство обычных объектов в игре и делегирование прототипов это отличный способ для их определения.
Волшебный Меч Отделения Голов, который на самом деле просто длинный меч с некоторыми бонусами, описывается так:
{
"name": "Sword of Head-Detaching",
"prototype": "longsword",
"damageBonus": "20"
}
Немного дополнительной мощности в системе моделирования данных вашего игрового движка, позволяет облегчить дизайнерам добавление небольших изменений к вооружению и тварям населяющим ваш игровой мир. Ведь именно разнообразие восхищает игроков.
Переведено на Нотабеноиде
http://notabenoid.org/book/51192/215213
Переводчики: GromHoll, Deo, eko24, RSlafka
rools, pxqr, Qeruim, Dmitry84
fregate, BaMnup16
Настройки
Готово:
