«Сапёр» на движке Doom
Мое увлечение разработкой игр началось с создания карт для Doom и Heretic. В то время я ничего не смыслил в программировании, поэтому просто рисовал уровни и раскидывал по ним несопоставимое количество оружия и монстров. Выходило сносно, но некоторые карты было очень сложно пройти.
В этой заметке я расскажу как создать игру «Сапёр» на движке первых частей Doom (id Tech 1), а точнее — на модифицированной для порта GZDoom версии движка.
Инструментарий
GZDoom. Этот порт предназначен для запуска всех игр, написанных на id Tech 1 (Doom, Doom 2, Heretic: Shadow of the Serpent Riders, HeXen: Beyond Heretic и Strife: Quest of the Sigil). Поддерживает много функций, недоступных в «ванильной» версии.
GZDoom Builder. Модификация классического редактора уровней для игр, написанных на id Tech 1 — Doom Builder. Поддерживает все возможности GZDoom, в том числе динамическое освещение, наклонные поверхности и 3D полы.
Slade3. Программа для распаковки и просмотра игровых архивов с расширением WAD (и не только). Помимо прочего может выступать в качестве редактора уровней, графики и текста.
ZDoom Wiki. На этом сайте можно найти всю необходимую документацию.
Любой графический редактор. Я использовал свой любимый Aseprite.
Графика
Изначально я хотел реализовать «Сапер» на основе уже имеющейся в Doom графики. Создал игровое поле и раскрасил ячейки в разные цвета, но потом решил все сделать по правилам.
Нам потребуется тайл с бомбой, тайл с флагом, два пустых тайла (для разных положений кнопки) и восемь тайлов с цифрами.
Подумав, я нарисовал еще 12 кастомных тайлов для органов управления «Сапером». Эти кнопки будут переключателями, поэтому потребовалось нарисовать по два тайла на одну кнопку.
Создание карты
Открываем GZDoom Builder и создаем новую карту. В зависимости от игры, на основе которой будет создаваться модификация, выбираем нужную конфигурацию. Советую выбирать формат UDMF, так как он поддерживает все функции GZDoom.
Далее нужно выбрать базовый WAD файл. Так как модификация создается на основе Doom 2, то выбираем DOOM2.WAD.
Код будем писать на ACS — скриптовом языке, предназначенном для управления логикой происходящего на уровне.
Создание уровня
Не буду рассказывать подробно о процессе создания карт, ведь об этом уже написал товарищ Теплый Рыс:
Маппинг для DOOM: Часть 1 — наша первая комната.
Маппинг для DOOM: Часть 2 — тьма, дверь и небеса.
Маппинг для DOOM: Часть 3 — через ад и через реку.
Скажу только, что нам понадобится один большой сектор для игрового поля. Этот сектор надо разбить на 100 секторов поменьше (игровое поле 10×10).
Каждому маленькому сектору надо присвоить уникальный тэг (от 1 до 100). Это понадобится для управления игровым полем.
Далее надо нарисовать сектор, прилегающий к основному, и немного поднять пол. Этот сектор будет играть роль «командного пункта». Здесь же создаём 12 маленьких секторов, из которых получится 6 органов управления «Сапером».
Импорт графики и создание переключателей
Подробно эту тему разобрал Теплый Рыс в свежей статье:
Маппинг для DOOM: Часть 4 — Год кота
Для импорта графики понадобится Slade3. Открываем WAD архив с единственной картой. Внутри будут лежать несколько файлов, в том числе файл, содержащий данные карты. Переносим в архив новые тайлы в любом формате (я использовал png).
В оригинальной игре название каждого графического файла состояло из восьми символов, поэтому я постарался придерживаться этого правила, особенно для текстур переключателей.
Далее надо сконвертировать новую графику в формат Doom, иначе текстуры будут выглядеть как каша из пикселей. Для этого выделяем все графические файлы, нажимаем Convert to, ищем нужный формат и конвертируем. Скорее всего в окне предпросмотра все изображения будут черно-белыми, но это нормально.
После этого нужно сформировать текстовый файл, который называется TEXTURES и содержит параметры каждого нового тайла. Выделяем все файлы, нажимаем Add to TEXTUREx и выбираем TEXTURES (формат, который поддерживает GZDoom).
Позднее я узнал, что если использовать формат png и модифицировать игру для GZDoom, то текстуры конвертировать не обязательно. При этом обязательно нужно добавить их в TEXTURES.
Новую графику можно использовать в игре.
Переходим к созданию переключателей.
Примеры переключателей из оригинальной игры
Конфигурация переключателей находится в файле ANIMDEFS. Создаём новый файл в архиве, переименовываем его и пишем:
switch UPPPFBT1 on pic UPPPFBT2 tics 0
switch DOWNFBT1 on pic DOWNFBT2 tics 0
switch LEFTFBT1 on pic LEFTFBT2 tics 0
switch RGHTFBT1 on pic RGHTFBT2 tics 0
switch FLAGFBT1 on pic FLAGFBT2 tics 0
switch OPENFBT1 on pic OPENFBT2 tics 0
Каждая строка содержит информацию о названии двух текстур переключателя и длительности их задержки на экране. С помощью файла ANIMDEFS можно создавать более сложные анимационные переключатели, а также любые последовательности анимации для текстур стен, пола и потолка.
Осталось применить новые текстуры в редакторе уровней.
Пишем код. Много кода
Немного об ACS
ACS — скриптовый язык программирования. Писать код на нем можно не выходя из GZDoom Builder, а встроенная среда разработки даже будет подсказывать названия функций, ключевых слов и выражений. Об отладке, конечно, приходится мечтать, но все решается обычной функцией Print ().
Язык очень похож на C/C++, но с ограниченным функционалом. Я никогда не писал на этих языках, но синтаксис всех Си-подобных языков примерно одинаковый, поэтому особых проблем не возникло (кроме нежелания ставить кавычку после каждой строки). Тем не менее обращаться к ZDoom Wiki пришлось чуть ли не каждые 5 минут.
Глобальные переменные и константы
Первым делом нужно создать игровое поле, то есть двухмерный массив размером 10×10 элементов. Таких массивов нам понадобится два: первый — для определения положения мин, пустых ячеек и ячеек с цифрами, а второй — для определения состояния каждой ячейки (открыта/закрыта/поставлен флаг).
Константы определяются с помощью ключевой фразы define, а не const.
Помимо этого нужно определить:
Переменные указателей на ячейку игрового поля по X и Y (а также в одномерном массиве, что потребуется для активации необходимых тэгов).
Переменную, содержащую информацию о максимальном количестве мин.
Константы направлений.
Массив с наименованиями текстур с цифрами (чтобы не писать лишний код и не обращаться постоянно к разным константам).
Константы с наименованиями прочих текстур.
int bombs[10][10];
#define MINE_STATE -1
#define EMPTY_STATE 0
int cells_state[10][10];
#define CLOSED_CELL 0
#define OPENED_CELL 1
#define FLAG_CELL 2
int bombs_max = 15;
int pointer_x = 0;
int pointer_y = 0;
int pointer_one_d = 1;
#define LEFT 1
#define RIGHT 2
#define UP 3
#define DOWN 4
#define NO_PRESSED "NOTPRST"
#define FLAG "FLAGT"
#define BOMB "BOMBT"
str tex_names[9] = {
"PRST",
"ONET",
"TWOT",
"THREET",
"FOURT",
"FIVET",
"SIXT",
"SVNT",
"EGHTT"
};
#define FIELD_MAX 9
Размещение мин и цифр с их количеством
Мины будут размещаться в случайном порядке равномерно по площади всего поля. Для этого надо создать скрипт, который до тех пор, пока не будут размещены все мины, отмечает случайную точку на поле и помещает туда мину, если эта точка еще не занята.
script "CreateBombs" (void) {
int bombs_count = 0;
while (bombs_count < bombs_max) {
int rnd_x = Random(0,FIELD_MAX);
int rnd_y = Random(0,FIELD_MAX);
if (bombs[rnd_y][rnd_x] == 0){
bombs_count += 1;
bombs[rnd_y][rnd_x] = MINE_STATE;
}
}
create_mines_quantity();
Print(s: "Minefield has been created! \n Good luck, mortal! \n Put all the flags in the right positions!");
}
Пара слов про скрипты и функции.
Скрипты в ACS привязываются к какому-либо объекту на уровне и запускаются в момент совершения какого-либо действия. Вышеуказанный скрипт имеет тип (void). Этот скрипт можно привязать к линии или предмету на карте и определить условия его запуска. Существуют и другие типы скриптов. Например, скрипт с типом OPEN активируется при первой загрузке уровня.
Функции нельзя привязать к объекту на карте. Это вспомогательный элемент, который работает как обычная функция в любом языке программирования, но с некоторыми ограничениями. Функции могут возвращать какие-либо значения.
Далее надо поместить в ячейки, вокруг которых есть какие-либо мины, количество этих мин. Пишем функцию:
function void create_mines_quantity(void){
for (int y = 0; y < FIELD_MAX + 1; y++) {
for (int x = 0; x < FIELD_MAX + 1; x++) {
if (bombs[y][x] == EMPTY_STATE){
int mines_sum = 0;
for (int n_y = y - 1; n_y <= y + 1; n_y++) {
for (int n_x = x - 1; n_x <= x + 1 ; n_x++) {
if (n_x >= 0 && n_x < FIELD_MAX + 1 && n_y >= 0 && n_y < FIELD_MAX + 1) {
if (bombs[n_y][n_x] == MINE_STATE){
mines_sum ++;
}
}
}
}
bombs[y][x] = mines_sum;
mines_sum = 0;
}
}
}
}
Ее надо вызвать из предыдущего скрипта. Функция проверяет каждую пустую ячейку на карте, а потом — восемь ячеек вокруг изначальной. Если в этих ячейках есть мины, то функция считает их количество и сохраняет в первоначальном массиве.
Для того, чтобы поле было сформировано в игре, привязываем первый скрипт к какой-либо линии на уровне. Я привязал ее к линии перед «командным центром» и установил, что скрипт активируется при пересечении этой линии игроком (When player walks over).
Выбор ячейки игрового поля
Я долго думал, как отмечать выбранный сектор игрового поля. Остановился на варианте с изменением освещения сектора. При выборе сектора его освещенность вырастает со 168 до 256, и наоборот.
Чтобы была возможность перемещать указатель на ячейку игрового поля, я написал такой скрипт:
script "MovePointer" (int direction) {
// 1 - left, 2 - right, 3 - up, 4 - down
switch (direction) {
case LEFT:
if (pointer_x != 0) {
pointer_x -= 1;
}
break;
case RIGHT:
if (pointer_x != FIELD_MAX) {
pointer_x += 1;
}
break;
case UP:
if (pointer_y != 0) {
pointer_y -= 1;
}
break;
case DOWN:
if (pointer_y != FIELD_MAX) {
pointer_y += 1;
}
break;
}
// Change light
Light_ChangeToValue(pointer_one_d, 168);
pointer_one_d = TwoDtoOneD(pointer_x, pointer_y);
Light_ChangeToValue(pointer_one_d, 256);
}
Здесь все просто. Этот скрипт присоединяется ко всем кнопкам управления с помощью команды Script Execute с указанием аргумента, который отвечает за направление. Помимо этого надо указать, что скрипт будет срабатывать при нажатии игроком на кнопку (When player presses use), а также то, что действие можно производить более одного раза (Repeatable action).
Заметили, что скрипт взаимодействует с переменными указателя по X и Y, но при этом свет меняется по тэгу от 1 до 100. Для того, чтобы перевести двухмерные указатели в одномерный, нужно написать следующую функцию:
function int TwoDtoOneD(int p_x, int p_y){
return (FIELD_MAX+1) * p_y + p_x + 1;
}
Эта функция преобразовывает координаты X и Y в одномерную координату по формуле: ширина поля * Y + X.
Размещение флагов и условия победы
Игра будет пройдена, если разместить все флаги на ячейки с минами. Для того, чтобы это сработало, нужно написать такую функцию:
script "ChangeFlagState" (void) {
put_flag();
}
function void put_flag(void){
if (cells_state[pointer_y][pointer_x] == CLOSED_CELL){
cells_state[pointer_y][pointer_x] = FLAG_CELL;
ChangeFloor(pointer_one_d, FLAG);
}else{
if (cells_state[pointer_y][pointer_x] == FLAG_CELL){
cells_state[pointer_y][pointer_x] = CLOSED_CELL;
ChangeFloor(pointer_one_d, NO_PRESSED);
}
}
check_win_state();
}
Почему-то я решил поместить эту функцию внутрь скрипта, хотя можно было обойтись одним только скриптом. Видимо, новогодние праздники дали о себе знать.
Эта функция проверяет, открыта ли ячейка игрового поля под указателем (определяется массивом cells_state), а потом проверяет, установлен ли на закрытой ячейке флаг. Если установлен, то убирает его, если нет — ставит.
После каждой установки флага игра проверяет условия победы:
function void check_win_state(void){
int bombs_count = 0;
for (int y = 0; y < FIELD_MAX + 1; y++) {
for (int x = 0; x < FIELD_MAX + 1; x++) {
if (bombs[y][x] == MINE_STATE && cells_state[y][x] == FLAG_CELL) {
bombs_count ++;
}
}
}
if (bombs_count == bombs_max){
Print(s: "Congratulations Mortal");
Teleport_EndGame();
}
}
Если каждая ячейка с миной (в массиве bombs) соответствует ячейке с флагом (в массиве cells_state), то игрок побеждает.
Открытие игрового поля
Помимо установки флагов надо и поле открывать. Думаю, что все играли в «Сапер», и объяснять правила не надо.
Следующая функция получилась довольно сложной, и запросила больше всего драгоценного времени.
function void open_area(int p_x, int p_y){
// if cell is numbered
if (bombs[p_y][p_x] > EMPTY_STATE){
cells_state[p_y][p_x] = OPENED_CELL;
ChangeFloor(TwoDtoOneD(p_x,p_y), tex_names[bombs[p_y][p_x]]);
}else{
// else check nine surrounding blocks
for (int y = p_y - 1; y <= p_y + 1 ; y++) {
for (int x = p_x - 1; x <= p_x + 1 ; x++) {
if (x>=0 && x < FIELD_MAX + 1 && y>=0 && y < FIELD_MAX + 1){
if (cells_state[y][x] == CLOSED_CELL){
if (cells_state[y][x] != FLAG_CELL){
// open cell if it empty or with number (but not flag cell)
cells_state[y][x] = OPENED_CELL;
ChangeFloor(TwoDtoOneD(x,y), tex_names[bombs[y][x]]);
// if cell is empty recurse this function with new X and Y
if (bombs[y][x] == EMPTY_STATE) {
open_area(x,y);
}
}
}
}
}
}
}
}
Разберем по пунктам:
Если вы попытаетесь открыть ячейку с цифрой, то откроется только эта ячейка.
Иначе программа проверит все прилегающие ячейки.
Если прилегающая ячейка закрыта и без флага, то эта ячейка откроется.
Если прилегающая ячейка пустая, то функция запустится рекурсивно с координатами прилегающей ячейки.
Последние штрихи
Во-первых, нужно написать скрипт, который будет запускаться при старте уровня. В этом скрипте надо лишить игрока всего оружия (зачем оно в «Сапере»?) и поместить указатель на первую ячейку игрового поля.
script 1 OPEN {
ClearInventory();
Light_ChangeToValue(pointer_one_d, 256);
}
Во-вторых, нужно создать скрипт, который будет проверять, какую ячейку открыл игрок: с миной или пустую.
script "OpenField" (void) {
if (cells_state[pointer_y][pointer_x] == CLOSED_CELL){
if (bombs[pointer_y][pointer_x] >= EMPTY_STATE) {
open_area(pointer_x, pointer_y);
}else{
// on_death
ChangeFloor(pointer_one_d, BOMB);
delay(30);
Print(s: "You're dead, fool! \n Good luck. Ha! Ha! Ha!");
GiveInventory("Fist", 1);
GiveInventory("RocketLauncher", 1);
GiveInventory("RocketAmmo", 50);
delay(30);
SpawnSpotForced("CyberdemonSweeper",1001,1003,0);
Teleport(1002);
SetThingSpecial(1003, 80, 2,0,0,0,0);
}
}
}
Вы можете заметить, что здесь очень много лишних строк. И сейчас я объясню, зачем они нужны.
Этот скрипт нужно привязать к той кнопке, которая открывает игровое поле.
Если игрок открывает пустую ячейку или ячейку с цифрой, то запускается функция open_area. Если же он открывает ячейку с бомбой, то начинается самое интересное.
В редакторе я поместил на уровень два объекта, которые называются Map Spot. Эти объекты позволяют телепортировать в соответствующие координаты различные объекты. Одному Map Spot я присвоил тэг 1001. В этой позиции будет появляться кибердемон. Другому Map Spot я присвоил тэг 1002. В эту позицию будет телепортироваться игрок. Объекту игрока я присвоил тэг 1000.
При открытии ячейки с бомбой игроку выдаются кулаки и ракетница с полным боекомплектом. На минном поле появляется кибердемон. Туда же телепортируется игрок. Если кибердемон умирает, игра заканчивается победой.
Для того, чтобы игра завершалась при смерти босса, нужно присвоить объекту босса тэг, а также привязать к нему скрипт. Поэтому прошу обратить внимание на следующие две строчки:
SpawnSpotForced("CyberdemonSweeper",1001,1003,0);
SetThingSpecial(1003, 80, 2,0,0,0,0);
Первая строчка создает кибердемона, а вторая привязывает к этому объекту следующий скрипт:
script 2 (void) { // OnBossDeath
Print(s: "Congratulations, Mortal");
delay(100);
Teleport_EndGame();
}
Создание игры на этом закончилось, однако остался один момент. Когда я вызываю кибердемона, то использую название «CyberdemonSweeper», а не «Cyberdemon». Если говорить проще, то я создал нового монстра на основе имеющегося.
За все объекты в игре отвечает файл DECORATE. Если в архиве создать такой файл, скопировать конфигурацию кибердемона из оригинальной игры и поменять некоторые значения, то получится немного другой монстр.
Например, новому кибердемону я на 50% снизил количество здоровья, но при этом значительно увеличил его скорость. Из-за этого его легче убить, но попасть в него будет очень сложно.
ACTOR CyberdemonSweeper
{
Health 2000
Radius 40
Height 110
Mass 1000
Speed 60
PainChance 20
Monster
MinMissileChance 160
+BOSS
+MISSILEMORE
+FLOORCLIP
+NORADIUSDMG
+DONTMORPH
+BOSSDEATH
+USEKILLSCRIPTS
SeeSound "cyber/sight"
PainSound "cyber/pain"
DeathSound "cyber/death"
ActiveSound "cyber/active"
Obituary "$OB_CYBORG"
States
{
Spawn:
CYBR AB 10 A_Look
Loop
See:
CYBR A 3 A_Hoof
CYBR ABBCC 3 A_Chase
CYBR D 3 A_Metal
CYBR D 3 A_Chase
Loop
Missile:
CYBR E 6 A_FaceTarget
CYBR F 12 A_CyberAttack
CYBR E 12 A_FaceTarget
CYBR F 12 A_CyberAttack
CYBR E 12 A_FaceTarget
CYBR F 12 A_CyberAttack
Goto See
Pain:
CYBR G 10 A_Pain
Goto See
Death:
CYBR H 10
CYBR I 10 A_Scream
CYBR JKL 10
CYBR M 10 A_NoBlocking
CYBR NO 10
CYBR P 30
CYBR P -1 A_BossDeath
Stop
}
}
А потом я узнал, что можно было обойтись пятью строчками. Надо просто наследовать новый класс из существующего. Получается следующее:
ACTOR CyberdemonSweeper : Cyberdemon
{
Health 2000
Speed 60
}
Итак, проект завершен. Можно полюбоваться на получившийся результат.
Чтобы поиграть, можно скачать WAD. Для запуска потребуется GZDoom (желательно, последней версии).
Признаюсь, я как будто вернулся в прошлое, пока реализовывал этот проект. Как будто я все еще подросток, который пишет диплом (создает карты для Doom), а не офисный планктон.
Надеюсь, что на этом я не остановлюсь, и вас ждут такие «шедевры» как «Змейка на движке Doom», «Wolfenstein 3D на движке Doom» и «Doom на движке Doom».