Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР 120, ДЕКАБРЬ 2008 г.

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

Юрий «yurembo» Язев (yazevsoft@gmail.com)




Сегодня мы закончим разрабатывать однопользовательскую игру, доведя ее до совершенства. Добавим и реализуем пару классов, подключим несколько звуков (для поддержки геймплея), рассмотрим работу с дополнительными функциями и типами объектов библиотеки Dark GDK – и сделаем еще много интересного.

Объекты игрового мира

С заголовочным файлом Game_Obj.h мы встретились еще в прошлой статье, но из-за жестких ограничений (в плане размеров журнала) нам пришлось отложить его рассмотрение до лучших времен. И они наступили!

Этот заголовочный файл содержит объявления всех констант, используемых в игре, как то: объекты, текстуры, звуки и параметры экрана. Все эти константы числовые (если помнишь, ранее я говорил о том, что все типы объектов в Dark GDK представляются в виде чисел). Кроме объявления констант, файл содержит описание абстрактного супер-класса (в терминах Java и в C++ это будет просто базовый класс). Как и полагается, этот класс содержит минимум необходимых данных-членов и функций-членов, используемых всеми производными классами. Это такие переменные, как: номер текущего объекта, положение объекта в трехмерном пространстве по осям x, y, z, угол поворота (только по оси Y), индикаторы жизни и горения (переменные булевого типа) и переменная для хранения времени. Многие функции не только объявлены, но имеют реализацию прямо в заголовочном файле, что автоматически делает их встраиваемыми.

Действительно, если функция выполняет одну единственную инструкцию, то почему бы не реализовать ее прямо здесь? Использование встраиваемых функций уменьшает необходимость в препроцессоре – экономятся ресурсы, связанные с вызовом функции. Эти функции выполняют такие операции, как: возвращение номера текущего объекта, возвращение состояния (жив или мертв, горит или нет), возвращение позиции, возвращение угла поворота. Все перечисленные функции-члены не изменяют (и не должны изменять) своего поведения во всех классах-потомках, поэтому работают они одинаковым образом, прямо как программист-проектировщик прописал. Еще в описании интерфейса класса имеются объявления трех виртуальных функций, плюс деструктор (тоже виртуальный). В нашей программе деструктор можно было и не делать виртуальным, поскольку в ней не используются указатели на объекты классов-предков. Но yurembo (если кто не догадался, автор любит обращаться к себе в третьем лице, - Прим. ред.) решил не отходить от привычного стиля программирования и не отказываться от советов мастеров ООП. Да, кстати, деструктор сделан чисто виртуальным (или абстрактным), что не позволит программисту, наследующему свои классы от нашего, не реализовать в них деструктор. Ну да хватит о деструкторах, перейдем к трем сакраментальным виртуальным функциям:

  • Функция Draw(), производящая манипуляции над объектом, ее вызвавшим (перемещение, поворот, etc). Несмотря на название (Draw), она не отображает объект; воспроизведение сцены происходит после того, как все объекты будут размещены, а потом в глобальной функции перерисовки вызывается функция библиотеки Dark GDK, которая и отобразит сцену на экране. Объявлена она виртуальной по той причине, что в классах-потомках переписывается не только тело функции, но и прототип (изменяются передающиеся в нее параметры).
  • Функция PlaySound (), как и следует из ее названия, проигрывает звук(и) данного объекта, поэтому она и сделана виртуальной: в каких-то классах надо будет передавать звук в качестве параметра (где может быть несколько звуков); ну а в тех классах, где имеется только один звук, параметров у этой функции нет. Кроме того, в некоторых классах-потомках в эту функцию в качестве параметров передаются координаты источника звука, но пока не будем об этом.
  • Последняя виртуальная функция-член – Die() объявлена как чисто виртуальная ввиду того, что она должна быть реализована в каждом классе-потомке без изменения прототипа.

Замечу, что компилятор не выдает никаких сообщений, если в классе-предке объявить не виртуальную функцию, а затем переопределить ее в классе-потомке. Таким образом, мы столкнемся с неоднозначностью, которая заключается в вызове нужной функции: может быть вызвана совсем не та функция, вызов которой ожидается! Поэтому необходимость ключевого слова virtual очень существенна. Вообще, компилятор, в принципе, не может (и не должен) указывать на неявные ошибки при использовании модели ООП. Кстати, он этого и не делает, потому что он не пророк и не может знать, о чем думает и к какой цели стремится программист. В определенных случаях «огрызается» линковщик, сообщая об ошибках на только ему понятном наречии и выдавая довольно странную абракадабру, состоящую из имен заголовочных файлов и функций :).

Обрати внимание, каким образом yurembo объявил данные-члены и функции-члены. Все данные-члены объявлены со спецификатором доступа protected, который делает их видимыми для функций-членов этого класса (что не так важно), а также видимыми для функций-членов производных классов. С другой стороны, все функции-члены объявлены со спецификатором public, который делает их доступными из любого места программы. Это общепринятый подход создания интерфейса с классом: данные-члены недоступны извне функций данного класса, а вся работа с объектом происходит через его функции-члены. Подчеркну: только в этом файле (Game_Obj.h) подключается заголовочный файл библиотеки DarkGDK.h, а все остальные файлы проекта подключают уже наш файл - Game_Obj.h.

Ракеты

Взглянем на следующий заголовочный файл – Rocket.h. В этом файле мало кода, и нет ничего сложного. Как и следует из названия, в нем содержится описание класса игровых ракет, используемых как пользовательским роботом, так и врагами. Класс ракет наследует от рассмотренного выше основного класса игровых объектов все данные-члены и функции-члены, тем самым, приобретая всю его функциональность. Этот подход дарует нам множество плюсов. К примеру, код, написанный однажды (для класса-предка), используется всегда одинаково (во всех классах-потомках). Кстати, почитай любую хорошую книгу по ООП! Здесь и в остальных наследующих классах наследование открытое (public; если при наследовании не указать спецификатор доступа, то по умолчанию будет закрытое наследование, private) – мы ведь не хотим, чтобы открытые функции-члены в наследующем классе стали недоступными (закрытыми). Заметь, в этом классе не добавляется ни одна переменная, переопределяются все виртуальные функции (включая деструктор), объявляются два конструктора: по умолчанию и с параметрами, а также объявляются две отсутствующие в классе-предке функции.

Взрывы

В рамках этого раздела мы перейдем к третьему заголовочному файлу – Explode.h, содержащему описание класса взрывов. Этот класс тоже наследуется от GameObj, приобретая его функциональность; как и класс ракет, этот определяет два конструктора (конструкторы вообще по ряду причин никогда не наследуются :)). Переопределяются две виртуальные функции и деструктор. Третья виртуальная функция в этом классе не используется, такое возможно, так как в базовом классе она объявлена с ключевым словом virtual (попросту говоря, виртуальна). Две объявляемых функции имеют то же имя, что и в классе ракет, – они различаются только количеством (и типом) параметров. В таком случае можно было бы их объявить в базовом классе виртуальными, но yurembo этого не сделал. Почему? Ведь они не используются в классе роботов. Это, впрочем, можно сказать и о функции-члене PlaySound (третья виртуальная функция, неиспользуемая в классе взрывов, но используемая во всех других классах). Но здесь только одна функция, и автор решил сделать виртуальной лучше ее, чем тащить в и без того большой класс роботов неиспользуемый функционал (две рассмотренные выше функции, которые объявляются в двух производных классах).

Реализация: ракеты

Первый, по установленному нами порядку, класс – это Rocket (соответственно, его реализация находится в файле Rocket.cpp). После подключения заголовочного файла в нем идут два конструктора: первый (тот, который по умолчанию) не используется в нашей программе, но должен формально присутствовать – во избежание ошибок компиляции. Формален он потому, что имеет пустое тело во избежание ошибок линковщика. В конструктор с параметрами в качестве значений параметров передаются инициализирующие объект данные, которые и присваиваются данным-членам вновь создаваемой ракеты. Почти все используемые здесь функции библиотеки Dark GDK нами уже рассмотрены, поэтому не будем повторяться. Хотя стоп, одна не рассмотрена – dbCloneObject! Она ведет себя в полном соответствии с названием, клонируя объект. Взгляни на условный оператор в конструкторе: если ракета № 1, то загружаем ее из файла, а все последующие копируем с первой, так как они одинаковы. Таким образом, сокращается время загрузки: вместо того, чтобы загружать с диска все ракеты, грузится одна, а остальные быстро копируются с нее в оперативной памяти. Именно в оперативной, а не в видео – в последней хранятся только визуализированные сцены, готовые к выводу на экран. После создания объекта он скрывается (с помощью функции dbHideObject) и деактивируется, чтобы быть готовым появиться во время выстрела (функция Fire, – смотри ниже). Далее идет деструктор, который делает то, что ему и полагается: обнуляет все данные-члены, плюс функцией dbDeleteObject удаляет загруженный объект. Следующая функция Draw вовсе не отображает объект. Она вызывается при перерисовке (на каждом кадре) и производит различные манипуляции над объектом, в том числе, проверку столкновения ракеты с поверхностью ландшафта.

Функция Fire представляет собой место «рождения» ракеты, в ней вызываются функции проигрывания звука и позиционирования (к месту выстрела) ракеты. Функция Pos делает то, что и должна, – то есть перемещает ракету в пространстве к месту старта (выстрела). Предпоследняя функция Die вовсе не уничтожает ракету, а просто делает ее невидимой и неактивной, чтобы не пришлось снова загружать ее при следующем выстреле (в целях ускорения игрового движка и процесса). Однако, в таком раскладе есть и минусы: расходуется память, но если посмотреть на комплектацию современного (или даже морально устаревшего) компьютера, то мы в подавляющем большинстве обнаружим не менее 512 Мб оперативки, а на машинах геймеров – не ниже 1 Гб. Поэтому автор решил пожертвовать оперативкой в пользу скорости работы, ведь игры не могут быть тормознутыми, а если таковые найдутся – пиши пропало. Последняя в этом файле функция PlaySound выполняет два действия: позиционирует (в координаты, переданные в качестве параметров) источник звука и, собственно, проигрывает звук (также переданный в качестве параметра).

Взрывы

Класс Explode, содержащийся в файле Explode.cpp, представляет взрывы и вообще крайне интересен для нас, поскольку в нем реализуется новый (ранее не используемый нами) тип объектов – частицы. Но обо всем по порядку. Сначала, когда у автора возникла идея добавить в игру взрывы, он полез в интернет, чтобы нагуглить анимированные х-файлы с огнем (загружаемые нашим движком). Обнаружилось, что таких файлов приемлемого качества в Сети нема. Попытка смоделить анимированный огонь в любимой trueSpace также не увенчалась успехом: частицы наотрез отказались экспортироваться в х-файл. Тогда, у автора возникла идея: если частицы не экспортируются в х-файл, то их надо создать в самой программе. В DirectX есть такая возможность. Yurembo уже собрался кодить под голый DirectX (со всеми вытекающими отсюда трудностями), как внезапно наткнулся на функцию с очень заманчивым названием – dbMakeParticles. Отсюда он и начал «плясать» :).
Здесь мы приведем обзор кода из указанного файла, одновременно останавливаясь на новых функциях. Итак, в начале имеют место два конструктора: один из них пустой (который по умолчанию), а второй – инициализирует систему частиц. В нем есть три новые для нас функции. Во-первых, нам надо создать систему частиц в виде огня. Пожалуйста, в Dark GDK для этого есть специальная функция – dbMakeFireParticles. Ей передаются девять параметров: число, под которым сохранится создаваемая система (в нашем случае – это константа, которая передается в конструктор в качестве параметра). Заметь, частицы – относятся к другому типу объектов, из чего следует, что и нумерация у них своя.

Второй параметр – номер изображения, которое будет использоваться, как искра. В результате, нетрудно догадаться, получится множество таких искр. В нашей программе этим параметром передается числовая константа, за которой загружено маленькое квадратное изображение (20 х 20). Оно представляет собой красный квадрат, посреди которого нарисован желтый круг. Издалека огонь-фонтан из таких искорок выглядит очень эффектно!

Третий параметр – частота частиц (искр). Не стоит задавать слишком большое число, иначе время загрузки игры чересчур возрастет. Разумной кажется цифра в 3000.
Следующая тройка параметра задает положение системы частиц в пространстве. И, наконец, последние три параметра задают, соответственно, ширину, высоту и глубину – то есть размеры по осям X, Y, Z. Затем функцией dbHideParticles мы скрываем систему частиц. Причины такого взаимодействия с объектом подробно рассмотрены в предыдущем разделе. Последней вызывается функция dbPositionParticles, которая перемещает частицы в заданные координаты. Впрочем, все координаты нулевые. Со взрывами – такая же история, что и с ракетами: при загрузке мы создаем все взрывы, а во время игры показываем и скрываем их, когда надо. После конструкторов идет деструктор, очищающий все данные-члены. Далее следует функция Draw, которая, как и в прошлый раз, позиционирует объект в пространстве, одновременно отвечая за отображение (при равенстве переменной alive значению true) и скрытие (в противоположном случае, который наступает по истечению 999 мс после появления) взрыва. Функция Die скрывает взрыв (подробно рассмотрена выше). Следующая за ней функция Fire занимается реинициализацией взрывов во время игры. И, наконец, последняя функция Pos позиционирует взрыв по переданным координатам, вызывая рассмотренную ранее функцию dbPositionParticles.

Главный файл реализации

В функции инициализации по сравнению с прошлым примером ничего не изменилось. Но в главный файл реализации добавилась новая функция, название которой – BSOD – расшифровывается, как Black Screen Of Death. Она вызывается по окончанию игры, в двух равновероятных случаях: проигрыш или выигрыш (смотри картинки).

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

В функции DarkGDK() кроме создания роботов происходит создание всех остальных динамических объектов: ракеты, взрывы. Из-за того, что теперь в игре участвуют новые объекты, код главного цикла заметно вырос и требует обратить на себя внимание. Сперва мы запускаем цикл (которых будет здесь множество) по вражеским роботам, в котором проверяем их состояние: если механический упырь уже «отбросил кони», а его ракета запущена, то уничтожаем ее, заодно увеличив значение переменной, заботливо учитывающей число погибших врагов. После этого цикла выполняется проверка, выясняющая победил ли юзер. В ней, кроме прочего, участвует упоминавшаяся выше скорбная переменная. Если количество мертвых механизмов из этой самой переменной оказывается равным количеству врагов в общей сложности, – победа засчитывается игроку и нам становится необходимо подчистить ресурсы и перевести пользователя на экран победы (вызвать BSOD :)). Ресурсы подчищаются только за динамическими объектами, для этого явно вызываются деструкторы наших классов. Остальные же объекты оставляются в покое, чтобы не возникло потом необходимости пересоздавать их перед началом новой игры.

После этого идут проверки и манипуляции с главным персонажем, которые были рассмотрены в прошлой статье. Однако и здесь есть свои новинки – это код, в результате выполнения которого геймерский робот осуществляет залп огня. Затем, как обсуждалось в прошлой статье, идут циклы проверки столкновений между роботами. После этих циклов следует новая проверка, которая на этот раз осуществляется для выяснения: не погиб ли под обстрелом юзверский робот? И если да, то выполняется код, аналогичный коду конца игры. Затем снова начинаются циклы, в которых на этот раз осуществляются манипуляции над ракетами: выполняется проверка на столкновения между ними и, собственно, мишенями – роботами. В результате вызываются их (ракет) функции-члены. Под конец осуществляет свою работу еще один цикл, в теле которого происходит обновление взрывов. Взрывы, кстати, создаются тогда и в том месте, когда и где объект (ракета, робот) принимает состояние горения (burn).

Закат солнца вручную

В конце программы ресурсы, занятые объектами наших классов, освобождаются автоматически - мы создали объекты в стеке, и когда объект выходит из области видимости, то автоматически вызывается деструктор определенного класса, который выполняет предписанные ему действия – очищает память, удаляя объект.
Но кроме объектов наших классов, в программе мы создали объекты, не принадлежащие тому или иному самописному классу. И хотя в Dark GDK присутствуют функции для удаления объектов любого типа (3D-объекты, текстуры, звуки, etc), при завершении программы вызывать их не надо. Библиотека сама позаботится об очистке ресурсов, занятых ее объектами. Спросишь, зачем тогда нужны эти функции? А для того, чтобы очищать ресурсы во время работы приложения, для замены или просто перезагрузки их содержимого - как, например, мы явно вызывали деструкторы наших классов перед перезапуском игры.

Тем не менее, в качестве эксперимента автор решил написать функции удаления объектов и вот, что он получил. Как и следовало ожидать, процесс компиляции завершился успешно, – программа запустилась под дебагером. Кроме того, по команде автора она спокойно завершила свое выполнение. Однако, в следующий раз, когда yurembo снова запустил программу, угробил под огнем врагов своего робота, начал игру заново и попытался прикрыть ее… – дебагер запаниковал. И, даже выполняя проверку на существование объекта (функции dbImageExist, dbObjectExist, etc) перед их удалением, дебагер не успокоился. Тогда, углубившись в дебри листингов (как сишных, так и дизассемблерных), автор пришел к вышеописанному выводу.

Заключение

В аутсайдерах остался еще один модуль DirectX – это DirectPlay. О мультиплеере мы, будем надеяться, подробно поговорим в следующей статье, где нашей целью будет разработать мультиплеерную баталию.

DVD

На диске лежит полный исходный код финальной версии однопользовательской игры DarkRobot, для компиляции которого нужны: Visual C++ 2008 Express Edition, DirectX 9.0 SDK, Dark GDK.

WWW

Если влом вставлять диск, можешь скачать исходник с сайта ][акера – www.xakep.ru.

INFO

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

Содержание
ttfb: 8.9571475982666 ms