Всем доброго времени суток! Это моя история о том, как я портировал исходный код одной фанатской Windows-игры о Марио с Visual Basic 6 на C++, и с какими трудностями я столкнулся в процессе создания порта.
Немного об оригинальной игре
Super Mario Bros. X (или коротко SMBX) - это фанатская игра по мотивам вселенной Марио, созданная в 2009 году американцем Эндрю Спинксом (который позже прославился как создатель игры Terraria). Эта фанатская игра была его первым опытом в разработке игр. В ней он познавал азы игростроя. Игра создавалась с использованием Visual Basic 6.
![Главное меню игры Super Mario Bros. X версии 1.3 Главное меню игры Super Mario Bros. X версии 1.3](https://habrastorage.org/getpro/habr/upload_files/204/b12/71a/204b1271adfcb9b51051852d03de0afc.png)
В игре имеется возможность играть за одного из пятерых персонажей: Марио, Луиджи, Пич, Тоад и Линк. Можно играть в одиночку, можно играть вдвоём в кооперативном режиме. Также имеется режим битвы, в котором игроки должны побить друг друга, пользуясь различными подручными средствами. Сама игра является неким подобием песочницы для сообщества, в которой игроки могут создавать свои уровни и целые эпизоды, используя предложенные элементы во встроенном редакторе. Также можно добавлять в игру собственные ресурсы (картинки и музыку).
Даже не смотря на полное прекращение разработки игры в 2011 году, она была востребована, и продолжала широко использовалаться сообществом. Игра также привлекла внимание разработчиков-энтузиастов и хакеров, которые создавали для неё вспомогательные инструменты, а также делали попытки модифицировать и расширить игру. Самыми известными из них являются набор разработки из тулкита Moondust Project (изначально называвшимся PGE Project), а также, библиотека LunaLua (изначально известная как LunaDll), расширяющая функционал игры посредством dll-инъекции.
Исходный код игры долгое время был закрытым. Однако, всё изменилось, когда 2 февраля 2020 года на форуме были опубликованы исходные коды игры.
Начало работ
Слухи о возможной публикации исходных кодов игры были ещё в 2016м году, однако, этого не произошло. Это дало мне идею переписать игру на C++, чтобы с ней удобно было возиться. Но, поскольку, исходников не было, идея слегла в долгий ящик. С выходом исходников в феврале 2020-го года, я буквально достал эту идею из глубокой ямы забвения. Первым делом, я запустил свою виртуальную машину с Windows XP, где я и развернул среду VB6, в которой затем я открыл проект игры, и, после нескольких исправлений ошибок компиляции, успешно запустил игру из исходного кода.
Работы начались с исследования устройства кода игры и его структуры, а также с замены кода воспроизведения аудио с древнего MCI на мою специальную сборку SDL Mixer X, созданную для работы в VB6-проектах. Таким образом, я решил проблему работы игры на Linux под Wine, а также значительно ускорил загрузку игры и уменьшил потребление ею памяти.
Инструментарий
Сначала я использовал самописный кусок кода на JavaScript, которым я через регулярные выражения парсил определения переменных, массивов и функций. Затем, я использовал бесплатную версию "VB to C++ Converter" от Tangible Software Solutions, чтобы относительно точно преобразовывать куски кода в C++ (Я пользовался режимом Snifit, поскольку конвертер был ориентирован на VB.NET, значительно отличавшийся от VB6. Также не обошлось без огромного числа дополнительных ручных манипуляций и макросов). Часть кода была переписана мною вручную, особенно на начальных этапах. Много модулей я писал с нуля (либо заимствовал из других моих проектов), чтобы обеспечить конечный проект всей необходимой функциональностью. Весь процесс разработки я вёл на Linux Mint, используя среду разработки Qt Creator, параллельно с CLion. Также, я использовал виртуальные машины с Windows XP и Windows 7 для запуска некоторых зависимых инструментов, а также VisualStudio Code для просмотра VB6-проекта игры из под Linux-системы.
Особенности Visual Basic и различия с C++
Кроме явного синтаксического и функционального различия между языками, имеется огромное число тонких не интуитивных различий, которых невозможно заметить без прямого взаимодействия и без чтения документации. Я сам лично мало работал с Visual Basic, и большую часть знаний о нём я узнал из поведения написанных программ и MSDN-документации (для Visual Basic 6, нужно качать редакцию 2001 года, к счастью, она присутствует на archive.org в свободном доступе).
Благодарю @firehacker за уточнения и за расширенные объяснения.
Видимость переменных и функций
Первый этап начался с создания файла globals.h, который описывал все глобальные переменные игры и все типы (массивы, структуры, константы, и т.п.). Всё дело в том, что по своей архитектуре, Visual Basic подразумевает, что ко всем публично доступным переменным и функциям (отмеченных соответствующим образом), можно обращаться напрямую без предварительных включений или импортов второго модуля в коде первого.
Потом, под каждый модуль, отдельно создавал списки прототипов функций и заголовочные файлы к ним. Затем, я включал эти заголовки между всеми модулями, чтобы обеспечить и полную видимость всех важных объектов кода, и навести некоторый порядок по файлам.
Структуры
Отдельной проблемой стало то, что Visual Basic прямо позволяет именовать структуры и переменные одинаково, буква-в-букву:
' Определение структуры
Public Type Controls
Up As Boolean
Down As Boolean
Left As Boolean
Right As Boolean
Jump As Boolean
AltJump As Boolean
Run As Boolean
AltRun As Boolean
Drop As Boolean
Start As Boolean
End Type
' Определение одноимённой переменной
Controls As Controls
С одной стороны, такое возможно и в C/C++ тоже (если не забывать указывать ключевое слово struct перед именем структуры). Однако, одноимённые, но разнотипные, объекты сильно вредят наглядности, а также создают неоднозначности в некоторых ситуациях, например, при попытке определить локальную переменную Controls ko
без ключевого слова struct, вылезет ошибка компиляции error: expected ‘;’ before ‘ko’. При использовании sizeof(Controls)
также создаётся неоднозначность, хотим ли мы узнать размер переменной Controls, или размер типа структуры Controls.
Из-за этого, я решил добавлять всем именам структур окончание "_t", чтобы устранить все неоднозначности:
struct Controls_t
{
bool Up = false;
bool Down = false;
bool Left = false;
bool Right = false;
bool Jump = false;
bool AltJump = false;
bool Run = false;
bool AltRun = false;
bool Drop = false;
bool Start = false;
};
extern Controls_t Controls;
Массивы с явным диапазоном
В Visual Basic предусмотрена возможность создавать массивы с различными диапазонами индексов, и не обязательно от 0 до N-1, а например, от 1 до 5, от -1000 до +1000, и т.п.:
Public BlockSwitch(1 To 4) As Boolean
В C++ такого нету. Однако, мне не помешало создать реализацию такой концепции, получился вот такой шаблон:
template <class T, long begin, long end>
class RangeArr
{
static constexpr long range_diff = begin - end;
static constexpr size_t size = (range_diff < 0 ? -range_diff : range_diff) + 1;
static const long offset = -begin;
T array[size];
public:
RangeArr()
{}
~RangeArr()
{}
RangeArr(const RangeArr &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o.array[i];
}
RangeArr& operator=(const RangeArr &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o.array[i];
return *this;
}
void fill(const T &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o;
}
T& operator[](long index)
{
assert(index <= end);
assert(index >= begin);
assert(offset + index < static_cast<long>(size));
assert(offset + index >= 0);
return array[offset + index];
}
};
А также вариант шаблона специально для целочисленных типов с предварительной инициализацией:
template <class T, long begin, long end, T defaultValue>
class RangeArrI
{
static constexpr long range_diff = begin - end;
static constexpr size_t size = (range_diff < 0 ? -range_diff : range_diff) + 1;
static const long offset = -begin;
T array[size];
public:
RangeArrI()
{
for(size_t i = 0; i < size; i++)
array[i] = defaultValue;
}
~RangeArrI()
{}
RangeArrI(const RangeArrI &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o.array[i];
}
RangeArrI& operator=(const RangeArrI &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o.array[i];
return *this;
}
void fill(const T &o)
{
for(size_t i = 0; i < size; i++)
array[i] = o;
}
T& operator[](long index)
{
assert(index <= end);
assert(index >= begin);
assert(offset + index < static_cast<long>(size));
assert(offset + index >= 0);
return array[offset + index];
}
};
Таким образом, выше представленный пример на C++ будет определён следующим образом:
extern RangeArrI<bool, 1, 4, false> BlockSwitch;
К счастью (или к сожалению), Эндрю не использовал в своём коде динамических массивов, хотя, с другой стороны, по сравнению с std::vector в С++, динамические массивы в Visual Basic 6 были не очень удобными в работе.
В моих шаблонах я также применил assert-ы, поскольку они позволяют отлавливать ошибки выхода за пределы диапазона массива в коде (в Visual Basic 6 типичная ошибка это "Runtime Error 9: Subscript out of range"). И тем самым, иметь возможность легко отлаживать их, не допуская последующей порчи памяти и возникновения SIGSEGV.
Upd: В комментариях, @Izaron-ом предложена альтернативная реализация шаблона, используя std::array в качестве носителя:
template<class T, long Begin, long End>
class range_arr : public std::array<T, End - Begin + 1> {
public:
using underlying_t = std::array<T, End - Begin + 1>;
using typename underlying_t::reference;
constexpr reference operator[](long pos) {
return underlying_t::operator[](pos - Begin);
}
constexpr reference operator[](long pos) const {
return underlying_t::operator[](pos - Begin);
}
};
Опциональные аргументы-ссылки
В Visual Basic, в функциях и процедурах, по умолчанию (если явно не указать ByVal
или ByRef
), аргументы передаются по принципу ссылок: их можно изменить непосредственно из кода функции:
Public Sub addValue(number As Integer)
number = 42
End Sub
В C++, по умолчанию, аргументы передаются по значению. Нужно явно указывать, что аргумент передаётся по ссылке:
void addValue(int &number)
{
number = 42;
}
При портировании кода с Visual Basic 6 я столкнулся со следующим явлением:
Public Sub addValue(step As Integer, Optional number As Integer = 0)
number = step
End Sub
Это т.н. опциональная ссылка. Её можно использовать, а можно не использовать, тогда записанное в неё значение просто потеряется.
В C++ из-за того, что я упустил тот факт, что аргументы в VB это ссылки, словил баг, что значение, переданное по опциональному аргументу, не обновилось в соответствии с кодом:
void addValue(int step, int number = 0)
{
number = step;
}
В итоге, я сначала решил сделать аргумент number указателем, однако потом до меня дошло, что я могу с лёгкостью использовать перегрузку функций, и получить желанную опциональную ссылку:
void addValue(int step, int &number)
{
number = step;
}
void addValue(int step)
{
int dummy = 0;
addValue(step, dummy);
}
Upd: В комментариях, @Izaron-ом предложена альтернативная реализация концепции через шаблоны:
template<typename T>
T dummy;
template<typename T>
T& dummy_ref(T value) {
dummy<T> = value;
return dummy<T>;
}
void addValue(int step, int& number = dummy_ref<int>(0)) {
number = step;
}
Таким образом, можно проще создавать такие функции без излишних дублирующих определений.
Округление чисел
В Visual Basic принципиально отличается политика округления чисел, чем от C++:
Попытка присвоить число с плавающей точкой целочисленной переменной, всегда округляет своё значение. В C++ идёт приведение типа с отсечением дробной части.
Округление идёт нестандартным образом, через x86-инструкцию FRNDINT, которая округляет серединные значения по типу 0.5 к ближайшему чётному целому. То есть, было 15.5, округление будет в 16, если было 42.5, то округлится к 42м.
Почему я обратил на это внимание? Потому что физика в игре во многом зависит от частей кода, использующего округление. Если округление делать не правильно (не так, как оно делалось в Visual Basic), то в итоге, физика будет искажена (Например, будет искажено движение пресса-давилки на уровне "Dungeon of Pain" в эпизоде "The Invasion 2").
Чтобы полностью решить проблему округления, я реализовал соответствующий велосипед:
const double power10[] =
{
1.0,
10.0,
100.0,
1000.0,
10000.0,
100000.0,
1000000.0,
10000000.0,
100000000.0,
1000000000.0,
10000000000.0,
100000000000.0,
1000000000000.0,
10000000000000.0,
100000000000000.0,
1000000000000000.0,
10000000000000000.0,
100000000000000000.0,
1000000000000000000.0,
10000000000000000000.0,
100000000000000000000.0,
1000000000000000000000.0
};
double vb6round(double x, int deimals);
int vb6Round(double x)
{
return static_cast<int>(vb6Round(x, 0));
}
static SDL_INLINE double toNearest(double x)
{
int round_old = std::fegetround();
if(round_old == FE_TONEAREST)
return std::nearbyint(x);
else
{
std::fesetround(FE_TONEAREST);
x = std::nearbyint(x);
std::fesetround(round_old);
return x;
}
}
double vb6Round(double x, int decimals)
{
double res = x, decmul;
if(decimals < 0 || decimals > 22)
decimals = 0;
if(SDL_fabs(x) < 1.0e16)
{
decmul = power10[decimals];
res = toNearest(x * decmul) / decmul;
}
return res;
}
Таким образом, получилась весьма точная реализация округления, соответствующая поведению Visual Basic 6.
Таймеры
В игре использовались встроенные функции и глобальные переменные, отвечающие за время: глобальная функция Timer, возвращающая Single-значение (аналог float в C++) секунд, прошедших с полуночи, и WinAPI-функция Sleep для различных задержек.
Public Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long)
К счастью, SDL2 легко заменяет эти функции на собственные SDL_GetTicks() и SDL_Delay(). Однако, имеется вопрос более серьёзный, чем просто выжидание времени и задержка: игра старается жёстко выдерживать частоту обновления в 65 кадров в секунду. Если специально не стараться, игра будет работать с частотой 67 кадров в секунду без вертикальной синхронизации. Это большая проблема для спидраннеров, использующих внешние таймеры для честного измерения времени прохождения игры.
Чтобы исправить проблему, я нашёл код, который выдерживает определённую частоту, и доработал его. Получился целый модуль, реализующий логику жёсткой выдержки частоты в 65 кадров в секунду. Пришлось отдельно повозиться, поскольку в Linux и macOS уже были нужные функции, считающие нановремя, а в Windows с этим большая проблема. Однако, спустя время я нашёл универсальное решение, которое будет идеально работать и на миллисекундах.
Боль логических выражений
Ещё одна особенность, которая заключается в том, что Visual Basic 6 и C++ по разному обрабатывают логические выражения:
В C++ присутствуют раздельные логические операторы
&&
,||
,!
,!=
и побитовые операторы&
,|
,~
,^
. Булевый тип соответствует 0 (false) и 1 (true).В Visual Basic, операторы
And
,Or
,Not
,Xor
, побитовые и прямо эквивалентны операторам&
,|
,~
,^
. Булевый тип представлен в виде полной инверсии целого числа: 0x0000 в качестве True и 0xFFFF в качестве False.Вследствие использования логических операторов, в С++ происходит постепенный расчёт значений и постепенное наложение. То есть, мы имеем выражение
if(A < 5 && Array[A] == 1)
, и в случае, когда выражениеA < 5
ложное, выражениеArray[A] == 1
рассчитываться не будет.В Visual Basic, члены выражения рассчитываются сразу, поскольку выполняется побитовый рассчёт всей формулы. То есть, в логическом выражении
if A < 5 And Array(A) = 1 Then
будут всегда рассчитываться оба члена (Код эквивалентенif((A < 5 ? 0xFFFF : 0) & (Array[A] == 1 ? 0xFFFF : 0)
). Здесь произойдёт ошибка из-за того, что индекс A выходит за пределы размера массива. Таким образом, при портировании на C++ я случайно исправил баг, рушивший игру. Из-за этой особенности языка в коде можно встретить большое число лазаний изif() { if {} if { .... } }
, которая вместе с тем, что выглядит неуклюже, является необходимым костылём над особенностью обработки логических выражений в Visual Basic.При преобразовании сложных логических выражений из Visual Basic в C++ может возникнуть путаница там, где не использовались скобки, а также всплывать предупреждения компилятора. Решается легко добавлением скобок вокруг ключевых логических групп. Также нужно учитывать, что в логических выражениях на VB очень легко перепутать побитовый рассчёт между логическим выражением, из-за чего можно исказить конечный результат: получить один бит вместо целочисленного значения.
Также было явно заметно, что Эндрю на тот момент абсолютно не имел никакого понятия о Select Case
(аналога оператора switch()
), и поэтому в его коде было чрезвычайное злоупотребление конструкциями if else if else if else...
.
Кошмарный спагетти-полиморфизм
![Фрагмент спагетти-кода, отвечающего за логику НИП разных типов Фрагмент спагетти-кода, отвечающего за логику НИП разных типов](https://habrastorage.org/getpro/habr/upload_files/517/6e8/6b3/5176e86b36dfe60f52b74423bb625ce9.png)
Ни для кого не секрет, что в Visual Basic реализация классов была сильно ограниченной и прямо завязанной на технологии COM. Эндрю решил не использовать её вовсе. Однако, Эндрю даже не стал создавать раздельные функции для разбиения логики разнотипных объектов. Вместо этого, он создал цепь громоздких божественных функций, каждая из которых содержала огромнейшие массивы кода, разбивающие логику разнотипных объектов через цепь if else if else if else
. Ещё одна особенность кода игры, это очень и очень длинные однострочные логические и арифметические выражения, почти никакого переноса кода использовано не было. Также Эндрю не позаботился об использовании перечислений, чтобы наглядно именовать каждый тип элементов, а просто использовал сырые числовые значения, чтобы обозначить тот или иной элемент (а их там сотни разных!). Большую часть кода я преобразовал достаточно точно, сохранив исходную логику. Часть этого всего "спагетти" мне пришлось разбить на секторы, вынося в отдельные функции, чтобы тем самым сохранить наглядность.
Поддержка текста и преобразований строк в числа
Visual Basic 6, даже не смотря на использование юникода в своём ядре, и даже на то, что строки базируются на COM-типе BSTR с юникодом, стандартными функциями работы с файловой системой, отображения в интерфейсе, и чтения-записи текстовых строк (если открывать файл как текстовый), по факту, не умеет ничего, кроме локалезависимых ANSI-кодировок и очень ограниченной поддержки UTF16 из-за того, что средам VB4-VB6 и программам, собранных на них, требовалось работать на Windows 9x, не поддерживающих юникод. Из-за этого возникают серьёзные проблемы при работе игры на компьютерах по всему миру. Поэтому, нельзя именовать игровые файлы на кириллице, иначе игра не заработает на компьютере в Китае. И наоборот. Я решил использовать в игре UTF8, поскольку эта кодировка является универсальной и повсеместной, хоть и очень неудобной для задач посимвольной обработки текста, требующих сканировать всю строку. Большинство операционных систем используют именно её в своих файловых системах. Отличается лишь Windows, которая предпочитает использовать локалезависимые ANSI-кодировки (на уровне ядер 9x) и UTF16 (на уровне ядер NT). Из-за чего, в функциях взаимодействия с Windows я применил прямое преобразование между UTF8 и UTF16, чтобы продолжать использовать UTF8 внутри игры, и UTF16 при обращении к функциям самой Windows.
Что касается преобразований строк в числа, здесь они тоже локалезависимы: Эндрю, как положено по американским стандартам, использовал точку в качестве разделителя целой и десятичной частями чисел. Из-за этого, если в полях файлов присутствовали числа с плавающей точкой, игра падала, выдавая ошибку "Runtime Error 13" лишь потому, что по локальному стандарту (например, в России, в Германии и других странах), в качестве десятичного разделителя требуется запятая. Эта проблема требовала от игроков менять настройки стандартов, чтобы указывать точку, либо убирать числа с плавающей точкой из игровых файлов. В итоге, мне пришлось реализовать обёртку, которая позволяет преобразовать числа как с точкой, так и с запятой, чтобы обеспечить полную совместимость со всеми.
Графика
Для работы основной игры была создана форма, на которой собственно и происходила отрисовка всей игры. Для работы с графикой использовалась библиотека GDI напрямую:
Public Declare Function BitBlt Lib "gdi32" (ByVal hDestDC As Long, ByVal X As Long, ByVal Y As Long, ByVal nWidth As Long, ByVal nHeight As Long, ByVal hSrcDC As Long, ByVal xSrc As Long, ByVal ySrc As Long, ByVal dwRop As Long) As Long
Public Declare Function StretchBlt Lib "gdi32" (ByVal hdc As Long, ByVal X As Long, ByVal Y As Long, ByVal nWidth As Long, ByVal nHeight As Long, ByVal hSrcDC As Long, ByVal xSrc As Long, ByVal ySrc As Long, ByVal nSrcWidth As Long, ByVal nSrcHeight As Long, ByVal dwRop As Long) As Long
Public Declare Function CreateCompatibleBitmap Lib "gdi32" (ByVal hdc As Long, ByVal nWidth As Long, ByVal nHeight As Long) As Long
Public Declare Function CreateCompatibleDC Lib "gdi32" (ByVal hdc As Long) As Long
Public Declare Function GetDC Lib "user32" (ByVal hWnd As Long) As Long
Public Declare Function SelectObject Lib "gdi32" (ByVal hdc As Long, ByVal hObject As Long) As Long
Public Declare Function DeleteObject Lib "gdi32" (ByVal hObject As Long) As Long
Public Declare Function DeleteDC Lib "gdi32" (ByVal hdc As Long) As Long
Public Declare Function GetWindowDC Lib "user32.dll" (ByVal hWnd As Long) As Long
![Главная форма игры и код, рисующий на ней игровую сцену из текстуры. Главная форма игры и код, рисующий на ней игровую сцену из текстуры.](https://habrastorage.org/getpro/habr/upload_files/539/b51/3bf/539b513bf7554c3427cd8c8096086ddf.png)
Игра позволяла работать с форматами GIF, JPEG и BMP. К несчастью, отрисовать с нормальной прозрачностью через GDI была сложная задача, Эндрю решил её методом битовой маски. То есть, простыми словами, картинка разбивается на две части: основная и маска. Основная часть это обычная картинка, но с полностью чёрным фоном.
![Фронтальная часть картинки - обязательный чёрный фон, который превратится в фон через ИЛИ Фронтальная часть картинки - обязательный чёрный фон, который превратится в фон через ИЛИ](https://habrastorage.org/getpro/habr/upload_files/871/b6d/c3a/871b6dc3a654f340efebb943581006a0.gif)
Маска - это чёрно-белая карта, отображающая пиксели с прозрачностью и без.
![Битовая маска, там где пиксели белые - прозрачность, там где чёрные - будут закрашены Битовая маска, там где пиксели белые - прозрачность, там где чёрные - будут закрашены](https://habrastorage.org/getpro/habr/upload_files/670/962/bfd/670962bfd93ee720101463272e8156e1.gif)
Сам процесс отрисовки состоит из двух этапов:
' Отрисовка маски, используя побитовое И
' между каждым пикселем целевой поверхности и маски.
' Белые пиксели оставят фон нетронутым, чёрные закрасят его
BitBlt targetDCSurface, X, Y, W, H, mask.hdc, 0, 0, vbSrcAnd
' Отрисовка переднего плана поверх маски на ту же поверхность,
' используя побитовое ИЛИ между каждым пикселем поверхности и маски.
' Там где чёрные пиксели, цвет не изменится, а там где цветные, будут побитого
' смешаны с фоном. Если на фоне уже нарисован чёрный силует, то передний план
' отрисуется поверх него без цветовых искажений.
BitBlt targetDCSurface, X, Y, W, H, front.hdc, 0, 0, vbSrcPaint
Такой метод отрисовки не смотря на свою простоту использования, имеет множество ограничений:
Отсутствует понятие полупрозрачности. Попытки изобразить полупрозрачность приводят к цветовым искажениям пикселей и артефактам.
Требуется предварительно подготавливать текстуру, сохраняя передний план и маску правильно.
В памяти находятся две части одного и того же изображения вместо одной.
Передо мной встала задача создать альтернативу для всего этого, и я применил библиотеку SDL2, которая мне полюбилась уже давно. Первым делом я создал класс-обёртку, названный "FrmMain", в честь главной формы игры. В этой "форме" я реализовал прямое взаимодействие с SDL2 по части графики, управления, событий и т.п.
Я у себя решил отказаться от концепции битовой маски в пользу альфа-канала, который полноценно поддерживался на стороне многих аппаратных графических интерфейсов, и на стороне SDL2, даже в режиме программной отрисовки. Чтобы сохранить совместимость старой графики, созданной сообществом для игры, я реализовал функцию, которая преобразует пару перед+маска в полноценную RGBA-картинку:
void bitmask_to_rgba(FIBITMAP *front, FIBITMAP *mask)
{
unsigned int x, y, ym, img_w, img_h, mask_w, mask_h, img_pitch, mask_pitch;
BYTE *img_bits, *mask_bits, *FPixP, *SPixP;
RGBQUAD Npix = {0x00, 0x00, 0x00, 0xFF}; /* Цвет целевого пикселя */
BYTE Bpix[] = {0x00, 0x0, 0x00, 0xFF}; /* Чёрный пиксель-заглушка */
unsigned short newAlpha = 0xFF; /* Рассчитанное значение альфа-канала */
BOOL endOfY = FALSE;
if(!mask)
return; /* Ничего не делать */
img_w = FreeImage_GetWidth(front);
img_h = FreeImage_GetHeight(front);
img_pitch = FreeImage_GetPitch(front);
mask_w = FreeImage_GetWidth(mask);
mask_h = FreeImage_GetHeight(mask);
mask_pitch = FreeImage_GetPitch(mask);
img_bits = FreeImage_GetBits(front);
mask_bits = FreeImage_GetBits(mask);
FPixP = img_bits;
SPixP = mask_bits;
ym = mask_h - 1;
y = img_h - 1;
while(1)
{
FPixP = img_bits + (img_pitch * y);
if(!endOfY)
SPixP = mask_bits + (mask_pitch * ym);
for(x = 0; (x < img_w); x++)
{
Npix.rgbBlue = ((SPixP[FI_RGBA_BLUE] & 0x7F) | FPixP[FI_RGBA_BLUE]);
Npix.rgbGreen = ((SPixP[FI_RGBA_GREEN] & 0x7F) | FPixP[FI_RGBA_GREEN]);
Npix.rgbRed = ((SPixP[FI_RGBA_RED] & 0x7F) | FPixP[FI_RGBA_RED]);
newAlpha = 255 - (((unsigned short)(SPixP[FI_RGBA_RED]) +
(unsigned short)(SPixP[FI_RGBA_GREEN]) +
(unsigned short)(SPixP[FI_RGBA_BLUE])) / 3);
if((SPixP[FI_RGBA_RED] > 240u) // Почти белый
&& (SPixP[FI_RGBA_GREEN] > 240u)
&& (SPixP[FI_RGBA_BLUE] > 240u))
newAlpha = 0;
newAlpha += (((unsigned short)(FPixP[FI_RGBA_RED]) +
(unsigned short)(FPixP[FI_RGBA_GREEN]) +
(unsigned short)(FPixP[FI_RGBA_BLUE])) / 3);
if(newAlpha > 255)
newAlpha = 255;
FPixP[FI_RGBA_BLUE] = Npix.rgbBlue;
FPixP[FI_RGBA_GREEN] = Npix.rgbGreen;
FPixP[FI_RGBA_RED] = Npix.rgbRed;
FPixP[FI_RGBA_ALPHA] = (BYTE)(newAlpha);
FPixP += 4;
if(x >= mask_w - 1 || endOfY)
SPixP = Bpix;
else
SPixP += 4;
}
if(ym == 0)
{
endOfY = TRUE;
SPixP = Bpix;
}
else
ym--;
if(y == 0)
break;
y--;
}
}
На заметку: Я использовал библиотеку FreeImage (а конкретно FreeImageLite, мой усечённый форк) для загрузки и предварительной обработки графики у себя.
Отдельная проблема у игры касалась её чрезвычайно долгой загрузки из-за обилия графических ресурсов. Чтобы решить эту проблему, я реализовал систему ленивой распаковки. То есть, я загружаю картинки с диска, но, я их не декодирую до тех пор, пока графический движок не запросит их отрисовку. Такая концепция позволила мне загружать игру почти мгновенно (в зависимости от мощности компьютера), а также значительно уменьшить потребление оперативной памяти с почти 600 мегабайт до 80~120 мегабайт. Однако, у этой концепции есть и недостаток: если диск или графический интерфейс недостаточно быстрые, игра будет слегка притормаживать при подгрузке каждой отдельной текстуры, впервые отрисовывающейся на экране, чего особенно хорошо заметно в главном меню, где воспроизводится демка с участием пятерых игровых персонажей, часто сменяющих свои состояния.
Саму отрисовку я делаю с использованием SDL_Renderer, сделав конечный результат проще и гибче. SDL2 поддерживает множество интерфейсов графических ускорителей: и OpenGL, и DirectX, и Metal. Также сохраняется поддержка программной отрисовки в случае, если невозможно задействовать один из аппаратных методов отрисовки.
Звук и музыка
Игра использовала для воспроизведения музыки и звука старинный интерфейс MCI, существующий ещё со времён Windows 3.1.
Public Declare Function mciSendString Lib "winmm.dll" Alias "mciSendStringA" (ByVal lpstrCommand As String, ByVal lpstrReturnString As String, ByVal uReturnLength As Integer, ByVal hwndCallback As Integer) As Integer
Этот интерфейс позволял с помощью одной функции посылать текстовые команды системе для открытия аудио-файлов и управления ими: воспроизведение, приостановка, повтор, громкость, и т.п. Самый большой недостаток подобного интерфейса заключался в том, что требовалось доустанавливать в систему дополнительные пакеты кодеков. Из-за того, что игра открывает десятки аудиофайлов через MCI, во время работы игры, системный трей засорялся значками FFDshow и подобными (если не было отключено в настройках соответствующих кодеков). Также сам процесс подобной загрузки был чрезвычайно долгим. По факту, через интерфейс работали форматы MP3, WAV, WMA, а также криво-косо MIDI.
![Системный лоток, засорённый значками кодека Lav Системный лоток, засорённый значками кодека Lav](https://habrastorage.org/getpro/habr/upload_files/b67/bc7/117/b67bc7117f191acc6cb6331e33328961.png)
Я решил избавиться от такого недоразумения как MCI ещё 5 лет назад. Мною, на базе хакерского расширения LunaLua, было реализовано использование библиотеки SDL2_mixer (а затем форка SDL Mixer X), которая работала с музыкой и звуковыми эффектами намного лучше, а также совершенно не требовала установки каких либо внешних кодеков, поскольку все нужные библиотеки уже были включены в её состав. Вместе с этим была добавлена поддержка большого числа новых форматов: и OGG Vorbis, и FLAC, и огромного числа форматов трекерной музыки, и улучшенная поддерка MIDI. Также появилась возможность играть дублирующие звуковые эффекты параллельно.
Файловая система
В Visual Basic было много функций, непосредственно работающих с файловой системой: была возможность проверять файл на существование, перечислять директории, переименовывать и удалять файлы и т.п. Поскольку у меня была цель сделать проект кроссплатформенным, я применил несколько своих обёрток (одна из них для работы с директориями), которыми я полностью обеспечил потребности кода в функциях работы с файловой системой.
Отдельно большая проблема - это регистрозависимые файловые системы. Из-за того, что для игры частенько можно встретить эпизоды, где полная каша в именах файлов (чаще всего, начинается с заглавной буквы вместо маленькой, либо расширение имени файла записано большими буквами, а не маленькими). Из-за этого, мне пришлось реализовать обёртку, которая прочёсывала директории, и приводила имена файлов и пути в соответствии с регистром в системе, обеспечивая видимость всем тем криво-именованным файлам на регистрозависимых файловых системах.
Клавиатура, мышь, геймпады
Для работы с клавиатурой, в игре использовались функции WinAPI на базе Virtual Key. Из-за этого возникали проблемы при переключении раскладок, а также возникали трудности на клавиатурах QWERTZ и AZERTY, популярных в Германии и Франции. Чтобы решить все эти проблемы, я применил возможности библиотеки SDL2, переориентировав игровое управление на сканкоды. Они привязаны к физическим клавишам и абсолютно не зависимы от текущей раскладки.
Для поддержки геймпадов в игре использовались возможности библиотеки WinMM:
Public Declare Function joyGetPosEx Lib "winmm.dll" (ByVal uJoyID As Integer, pji As JOYINFOEX) As Integer
Public Declare Function joyGetDevCapsA Lib "winmm.dll" (ByVal uJoyID As Integer, pjc As JOYCAPS, ByVal cjc As Integer) As Integer
Public Type JOYCAPS
wMid As Long
wPid As Long
szPname As String * 32
wXmin As Long
wXmax As Long
wYmin As Long
wYmax As Long
wZmin As Long
wZmax As Long
wNumButtons As Long
wPeriodMin As Long
wPeriodMax As Long
wRmin As Long
wRmax As Long
wUmin As Long
wUmax As Long
wVmin As Long
wVmax As Long
wCaps As Long
wMaxAxes As Long
wNumAxes As Long
wMaxButtons As Long
szRegKey As String * 32
szOEMVxD As String * 260
End Type
Public Type JOYINFOEX
dwSize As Long
dwFlags As Long
dwXpos As Long
dwYpos As Long
dwZpos As Long
dwRpos As Long
dwUpos As Long
dwVpos As Long
dwButtons As Long
dwButtonNumber As Long
dwPOV As Long
dwReserved1 As Long
dwReserved2 As Long
End Type
Public JoyNum As Long
Public MYJOYEX As JOYINFOEX
Public MYJOYCAPS As JOYCAPS
Public CenterX(0 To 7) As Long
Public CenterY(0 To 7) As Long
Public JoyButtons(-15 To 15) As Boolean
Public CurrentJoyX As Long
Public CurrentJoyY As Long
Public CurrentJoyPOV As Long
К счастью, в библиотеке SDL2 есть значительно более мощные подсистемы SDL Joystick и SDL GameController, которые позволяют использовать тысячи различных моделей геймпадов через всевозможные интерфейсы, работает и на Linux, и на macOS, и на Android. Также поддерживается горячее подключение и отключение игровых контроллеров прямо во время игры.
Что касается мыши, использовались стандартные события главной формы Form_MouseDown, Form_MouseMove и Form_MouseUp с последующей записью состояния кнопок и координат указателя в глобальные переменные, используемые в коде самой игры. В обновлённой игре, я делаю аналогичное, но через SDL_PollEvent().
Итог
В результате проделанной работы, в течении первых двух-трёх недель после выпуска исходных кодов игры, получилась полноценная и кроссплатформенная реплика игры, крайне похожая по поведению на оригинал (настолько, что люди до сих пор находят в ней баги 10-летней давности). Однако, вместе со старыми багами, местами появились и новые, в результате ошибок, допущенных при преобразовании кода или из-за упущенных различий в работе VB6 и C++, либо из-за опечаток. Большая часть подобных ошибок уже была исправлена мною в течении месяца, и после, в марте 2020го года я представил обновлённую и стабильную игру вместе со всеми исходными кодами. Далее, в течении последнего года, дорабатывал игру, исправляя баги и улучшая функционал.
Проект движка я назвал TheXTech по принципу: "The Super Mario Bros. X Tech".
![Главное меню игры Super Mario Bros. X на базе TheXTech 1.3.5.2 Главное меню игры Super Mario Bros. X на базе TheXTech 1.3.5.2](https://habrastorage.org/getpro/habr/upload_files/456/0a5/4e6/4560a54e69e7c554a2a6dc52997ac7fe.png)
В итоге, игра, изначально созданная жёстко под Windows на платформозависимом языке и на процессорах x86, превратилась в кроссплатформенную игру, которая работает не только на других операционных системах (Windows, Linux, macOS, Haiku, Android, Emscripten), но и на других процессорах (ARM, PowerPC, MIPS, и др.). Вместе с этим, игра наконец получила полноценную поддержку 64-битных процессоров и ARM-архитектуры. Энтузиасты также портируют игру на консоли (уже есть примеры, реализованные на 3DS и PS Vita).
В новом движке игры я не стал реализовывать встроенный редактор, и не стал реализовывать начальную форму для запуска игры или редактора, где показывалась веб-страница с новостями проекта (ныне просто сайт Nintendo), а также, была возможность отключить звук и пропуск кадров. Вместо этого, я решил реализовать интерфейс для настройки игры через аргументы командной строки, а также выделить конфигурационный INI-файл, который легко отредактировать, чтобы настроить игру под себя. А для создания новых уровней и эпизодов я решил рекомендовать девкит из набора Moondust Project, редактор которого я доработал, чтобы обеспечить его тесной интеграцией с игрой (поддержка специфичных полей, прямой запуск теста уровня, и т.п.).
Созданный проект я продолжаю развивать, улучшать, добавлять новые функции, стабилизировать.