[Перевод] Воссоздание старой DOS-игры на C++ 17
В 2016 году я начал работу над хобби-проектом по реверс-инжинирингу игры Duke Nukem II и воссозданию с нуля его движка. Проект имеет название Rigel Engine и выложен в open source (его страница на GitHub). Сегодня, более чем два с половиной года спустя на моём движке уже можно пройти весь shareware-эпизод оригинальной игры с практически идентичным оригиналу игровым процессом. Вот видео с прохождением первого уровня:
Что же он может делать? Rigel Engine работает как полная замена оригинального двоичного файла DOS (NUKEM2.EXE
). Можно скопировать его в каталог игры и он считает из него все данные, или же указать путь к данным игры как аргумент командной строки. Движок собирается и выполняется под Windows, Mac OS X и Linux. Он основан на SDL и OpenGL 3/OpenGL ES 2, а написан на C++ 17.
Он реализует игровую логику всех врагов и игровых механик из Shareware-эпизода, плюс бОльшую часть системы меню. Кроме того, в него можно импортировать сохранённые игры и таблицу рекордов из оригинальной игры.
Более того, движок имеет преимущества по сравнению с оригиналом:
- Не требуется эмулятор или старое оборудование, нет нужды в настройке параметров
- Никаких экранов загрузки — выберите в меню «new game», нажмите Enter, и сразу приступайте к игре
- Одновременно может воспроизводиться несколько звуковых эффектов, что в оригинале было невозможно
- Никаких ограничений на количество одновременных эффектов частиц, взрывов и так далее
- Файлы сохранений и списки рекордов для каждого игрока
- Гораздо более отзывчивые меню
Пока я не считаю Rigel Engine полностью «готовым». Но это отличный этап в развитии и хорошая возможность снова написать о движке (старые посты опубликованы здесь и здесь). Давайте начнём с того, что взглянем на текущее состояние кода, и узнаем, как я к нему пришёл.
Сколько в движке кода?
На момент написания статьи RigelEngine состоит из 270 исходных файлов, содержащих более 25 тысяч строк кода (без комментариев/пустых строк). Из них 10 файлов и 2,5 тысяч строк — юнит-тесты. Подробная разбивка с учётом пустых строк и комментариев выложена здесь.
Что же есть во всём этом коде? Немного общей инфраструктуры и вспомогательных функций, такие фундаментальные вещи, как рендеринг, и куча небольших кусочков логики. Кроме всего этого самые большие части — это:
- парсеры/загрузчики для 14 разных форматов файлов, используемых в оригинальной игре — 2 тысяч строк кода (LOC)
- логика поведений/игровая логика для 24 врагов/враждебных объектов — 3.8k LOC
- игровая логика для 14 интерактивных элементов и игровых механик — 2k LOC
- логика управления игроком — 1.2k LOC
- 154 записей конфигурации (величина здоровья каждого врага, количество очков, получаемых за собираемые предметы. и т.д.) — 1k LOC
- 31 спецификаций эффектов разрушений (эффектов, запускаемых при уничтожении врага или другого разрушаемого объекта) — 254 LOC
- код управления камерой — 159 LOC
- интерпретатор языка описания меню игры/катсцен — 643 LOC
- Код HUD и другого UI — 818 LOC
- 5 экранов/режимов вне меню, например, начальной анимации, бонусного экрана и т.д. — 789 LOC
Разумеется, весь этот код нужно было написать, и это приводит нас к следующему вопросу.
Сколько на это потребовалось работы?
Хотя с момента начала проекта прошло два с половиной года, я не работал над ним всё это время. Пару месяцев я вообще не занимался проектом, в некоторые другие уделял ему всего несколько часов. Но были и моменты, когда я работал над Rigel Engine достаточно активно. Взглянув на график коммитов на Github, можно получить примерное представление о том, как была распределена по времени моя работа:
По графику мы видим, что в ветку master был сделан 1081 коммит. Однако ещё до создания репозитория я работал над закрытым, в котором было ещё 247 коммитов, что в сумме даёт нам 1328 коммитов. Кроме того, было несколько веток прототипов, которые я использовал для исследований и экспериментов, но никогда не объединял с основной; к тому же перед слиянием я иногда сжимал большие истории коммитов в более краткие.
Нужно также сказать, что написание кода было не единственной частью проекта — ещё одной важной частью был реверс-инжиниринг. Я потратил довольно много часов на изучение дизассемблированного кода оригинального исполняемого файла в Ida Pro (в бесплатной версии), на ведение заметок, запись псевдокода и планирование реализации элементов моей версии. Кроме того, я проводил активное тестирование оригинальной игры, запуская её в DOSBox и на оригинальном оборудовании (разных машинах 386 и 486, купленных на eBay). Я собирал тестовые уровни для отдельного наблюдения за конкретными врагами и изучения игровой механики, записывал видеоклипы с помощью DOSBox, и покадрово просматривал записи, чтобы подтвердить свои выводы, сделанные при изучении ассемблерного кода. После реализации врага или игровой механики я обычно записывал видеоклип из моей версии и покадрово сравнивал её с оригиналом, чтобы подтвердить точность моей реализации.
Вот несколько фотографий моих заметок:
Реверс-инжиниринг кода управления камерой. Большой прямоугольник обозначает экран. Пунктирными линиями показаны зоны, в которые может двигаться игрок без перемещения камеры. Если вам интересно, то сам код управления камерой можно найти здесь.
Общие заметки, помогающие в понимании ассемблерного кода. Слева — порядок обновления оригинальной игры на высоком уровне. Справа — заметки о битовых полях, обозначающих состояние некоторых игровых объектов.
Транскрипция ассемблерного кода в псевдокод. Обычно я делаю это достаточно механистично, транскрибирую, не задумываясь о том, что конкретно делает код, а потом использую версию в псевдокоде, чтобы понять лежащую в основе логику. И на основании неё я уже придумываю свою реализацию. Готовый код см. здесь.
Псевдокод подчищенной версии логики врага. Заголовки обозначают состояния конечного автомата, код ниже объясняет, что должно происходить в соответствующих состояниях. Он был создан на основе сырого псевдокода, полученного при транскрибировании ассемблерного кода. Готовый код можно найти здесь.
В конечном итоге работа над проектом оказалась очень увлекательной, и я многое из него узнал: о реверс-инжиниринге, 16-битном ассемблере x86, низкоуровневом программировании VGA, строгих ограничениях, с которыми приходилось сталкиваться разработчикам игр для PC в начале 90-х; кроме того, я совершил множество открытий о внутренних особенностях оригинальной игры и о том, насколько странно и причудливо некоторые из них были реаизованы — эта тема сама по себе заслуживает отдельной серии постов.
Что дальше
Кроме добавление последних оставшихся функций и доделки поддержки зарегистрированной версии у меня есть несколько идей по улучшению и расширению возможностей движка Rigel Engine, не говоря уже о подчистке и рефакторинге кода — как обычно, наилучший способ создания архитектуры ПО становится очевидным только после завершения создания этого ПО.
Что касается будущих улучшений, то вот некоторые из пунктов, о реализации которых я думал:
- Плавное движение с интерполяцией. Игра обновляет свою логику примерно 15 раз в секунду, а в оригинале игры это ещё и частота кадров для рендеринга. С другой стороны, Rigel Engine может запросто работать с частотой 60 FPS и выше. В настоящий момент эти дополнительные кадры не дают никаких преимуществ, но я думаю, что их можно использовать для промежуточных кадров, чтобы реализовать более плавный скроллинг и движение объектов. Логика игры по-прежнему будет работать с той же скоростью, но объекты будут двигаться плавно, а не «прыгать» с инкрементом в 8 пикселей, как они делают это сейчас. Ранее я создал прототип такой системы, и он выглядит отлично, хоть и требует доработки.
- Поддержка геймпадов. В оригинальной игре есть поддержка джойстиков, а DosBox может эмулировать их на современных геймпадах, но их настройка может быть сложной — требуется подготовка конфигурации и калибровка в игре. Не говоря уже о том, что поддерживаются не все кнопки контроллера, а для пользования меню всё равно приходится брать клавиатуру. Поэтому я считаю, что нативная поддержка контроллеров значительно улучшит игровой процесс.
- Улучшение звука. В настоящее время все звуковые эффекты имеют одинаковую громкость. Издающие звук объекты, например, силовые поля, резко становятся слышимы при попадании на экран, и столь же резко обрываются. Мне стало любопытно, как они будут звучать, если громкость эффектов на расстоянии будет затухать. Например, мы могли бы едва слышать силовое поле, когда его ещё нет на экране, а при приближении оно бы становилось громче.
- Отдаление камеры/просмотр большей части уровня. Игра не была на это рассчитана, поэтому такая возможность может повредить геймплею — игрок начнёт видеть врагов, которые не активны за пределами экрана, и тому подобное. Но мне всё равно интересно, как это будет выглядеть и играться. В конце концов, игроки очень часто жаловались на эту игру за невозможность увидеть достаточную часть уровня. Было бы любопытно добавить опцию отключения HUD или его замены более минималистичным с использованием прозрачности.
- Повышение разрешения графики. Эта функция часто встречается во многих портах/воссозданиях игр, и было бы здорово добавить её и сюда. Движок уже позволяет заменять спрайтовую графику собственными изображениями, но пока они не могут быть большего разрешения, потому что всё рендерится в небольшой буфер с последующим повышением масштаба. Сначала нужно заменить этот подход, чтобы масштабирование можно было выполнять для отдельных объектов.
У меня нет никакой «дорожной карты» на будущее, поэтому я могу реализовывать эти пункты в любом порядке. Но перед всем этим следующим шагом будет интеграция Dear ImGui для дальнейшей сборки меню опций, которого в игре пока нет; кроме того, оно позволит включать и отключать перечисленные выше улучшения. В конце скажу, что буду благодарен за любой содействие в работе на GitHub!