«Сапёр» на движке 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 графики. Создал игровое поле и раскрасил ячейки в разные цвета, но потом решил все сделать по правилам.

93f2da4fb5df3d913388a5c247770bfc.jpg

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

image-loader.svg

Подумав, я нарисовал еще 12 кастомных тайлов для органов управления «Сапером». Эти кнопки будут переключателями, поэтому потребовалось нарисовать по два тайла на одну кнопку.

image-loader.svg

Создание карты

Открываем GZDoom Builder и создаем новую карту. В зависимости от игры, на основе которой будет создаваться модификация, выбираем нужную конфигурацию. Советую выбирать формат UDMF, так как он поддерживает все функции GZDoom.

Далее нужно выбрать базовый WAD файл. Так как модификация создается на основе Doom 2, то выбираем DOOM2.WAD.

image-loader.svg

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

Создание уровня

Не буду рассказывать подробно о процессе создания карт, ведь об этом уже написал товарищ Теплый Рыс:

Маппинг для DOOM: Часть 1 — наша первая комната.

Маппинг для DOOM: Часть 2 — тьма, дверь и небеса.

Маппинг для DOOM: Часть 3 — через ад и через реку.

Скажу только, что нам понадобится один большой сектор для игрового поля. Этот сектор надо разбить на 100 секторов поменьше (игровое поле 10×10).

Каждому маленькому сектору надо присвоить уникальный тэг (от 1 до 100). Это понадобится для управления игровым полем.

6478cd5a8498eeb4911f82807eecb581.jpg

Далее надо нарисовать сектор, прилегающий к основному, и немного поднять пол. Этот сектор будет играть роль «командного пункта». Здесь же создаём 12 маленьких секторов, из которых получится 6 органов управления «Сапером».

Импорт графики и создание переключателей

Подробно эту тему разобрал Теплый Рыс в свежей статье:

Маппинг для DOOM: Часть 4 — Год кота

Для импорта графики понадобится Slade3. Открываем WAD архив с единственной картой. Внутри будут лежать несколько файлов, в том числе файл, содержащий данные карты. Переносим в архив новые тайлы в любом формате (я использовал png).

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

b884794845acaffa57d9a1fd83801123.jpg

Далее надо сконвертировать новую графику в формат Doom, иначе текстуры будут выглядеть как каша из пикселей. Для этого выделяем все графические файлы, нажимаем Convert to, ищем нужный формат и конвертируем. Скорее всего в окне предпросмотра все изображения будут черно-белыми, но это нормально.

87447bf01ac355d1b227d44a87928b05.jpg

После этого нужно сформировать текстовый файл, который называется 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 можно создавать более сложные анимационные переключатели, а также любые последовательности анимации для текстур стен, пола и потолка.

Осталось применить новые текстуры в редакторе уровней.

0694076970d08572da88da4ea9262b5f.jpg

Пишем код. Много кода

Немного об 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;
   }	
  }	
 }
}

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

image-loader.svg

Для того, чтобы поле было сформировано в игре, привязываем первый скрипт к какой-либо линии на уровне. Я привязал ее к линии перед «командным центром» и установил, что скрипт активируется при пересечении этой линии игроком (When player walks over).

46ef9da6886e69151da22ff8410dcd2b.jpg

Выбор ячейки игрового поля

Я долго думал, как отмечать выбранный сектор игрового поля. Остановился на варианте с изменением освещения сектора. При выборе сектора его освещенность вырастает со 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).

654bf89dc19e8fed1339811cadde42e7.jpg

Заметили, что скрипт взаимодействует с переменными указателя по 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.

1d8561cb512aaafb91f08469e6b7376e.jpg

Размещение флагов и условия победы

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

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
}

Итак, проект завершен. Можно полюбоваться на получившийся результат.

image-loader.svg

Чтобы поиграть,  можно скачать WAD. Для запуска потребуется GZDoom (желательно, последней версии).

Признаюсь, я как будто вернулся в прошлое, пока реализовывал этот проект. Как будто я все еще подросток, который пишет диплом (создает карты для Doom), а не офисный планктон.

Надеюсь, что на этом я не остановлюсь, и вас ждут такие «шедевры» как «Змейка на движке Doom», «Wolfenstein 3D на движке Doom» и «Doom на движке Doom».

© Habrahabr.ru