Как я портировал игру с VisualBasic 6 на С++, сделав её кросс-платформенной

в 18:59, , рубрики: c++, VB6, VisualBasic 6, Игры и игровые приставки, портирование, разработка игр, разработка игры

Всем доброго времени суток! Это моя история о том, как я портировал исходный код одной фанатской Windows-игры о Марио с VisualBasic 6 на C++, и с какими трудностями я столкнулся в процессе создания.

Немного об оригинальной игре

Super Mario Bros. X (или коротко SMBX) - это фанатская игра по мотивам вселенной Марио, созданная в 2009 году американцем Эндрю Спинксом (который позже прославился как создатель игры Terraria). Эта фанатская игра была его первым опытом в разработке игр. В ней он познавал азы игростроя. Игра создавалась с использованием VisualBasic 6.

Главное меню игры Super Mario Bros. X версии 1.3
Главное меню игры Super Mario Bros. X версии 1.3

В игре имеется возможность играть за одного из пятерых персонажей: Марио, Луиджи, Пич, Тоад и Линк. Можно играть в одиночку, можно играть вдвоём в кооперативном режиме. Также имеется режим битвы, в котором игроки должны побить друг друга, пользуясь различными подручными средствами. Сама игра является неким подобием песочницы для сообщества, в которой игроки могут создавать свои уровни и целые эпизоды, используя предложенные элементы во встроенном редакторе. Также можно добавлять в игру собственные ресурсы (картинки и музыку). Даже не смотря на полное прекращение разработки игры в 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-системы.

Особенности VisualBasic и различия с C++

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

Видимость переменных и функций

Первый этап начался с создания файла globals.h, который описывал все глобальные переменные игры и все типы (массивы, структуры, константы, и т.п.). Всё дело в том, что по своей архитектуре, VisualBasic подразумевает, что все переменные и функции по умолчанию глобальны и видимы между всеми модулями проекта без предварительных включений или импортов модуля. Исключение лишь составляли элементы, отмеченные ключевым словом "Private". Потом, под каждый модуль, отдельно создавал списки прототипов функций и заголовочные файлы к ним. Затем, я включал эти заголовки по остальным модулям, чтобы обеспечить и полную видимость всех важных объектов, и навести некоторый порядок по файлам.

Структуры

Отдельной проблемой стало то, что VisualBasic прямо позволяет именовать структуры и переменные одинаково, буква-в-букву:

' Определение структуры
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

Из-за этого мне пришлось добавлять всем именам структур окончание "_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;

Массивы с явным диапазоном

В VisualBasic предусмотрена возможность создавать массивы с различными диапазонами индексов, и не обязательно от 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 в С++, динамические массивы в VisualBasic 6 были не очень удобными в работе.

В моих шаблонах я также применил assert-ы, поскольку они позволяют отлавливать ошибки выхода за пределы диапазона массива в коде (в VisualBasic 6 типичная ошибка это "Runtime Error 9: Subscript out of range"). И тем самым, иметь возможность легко отлаживать их, не допуская последующей порчи памяти и возникновения SIGSEGV.

Опциональные аргументы-ссылки

В VisualBasic, как ни странно, в функциях и процедурах, аргументы передаются по принципу ссылок: их можно изменить непосредственно из кода функции:

Public Sub addValue(number As Integer)
  number = 42
End Sub

В C++ для создания подобной функции необходимо явно указывать, что аргумент является ссылкой:

void addValue(int &number)
{
  number = 42;
}

Однако, при портировании кода с VisualBasic 6 я столкнулся со следующим явлением:

Public Sub addValue(step As Integer, Optional number As Integer = 0)
  number = step
End Sub

То есть, самая настоящая опциональная ссылка. Её можно использовать, а можно не использовать, тогда записанное в неё значение просто потеряется.

В C++ из-за этого я словил баг, что значение, переданное по опциональному аргументу, не обновилось в соответствии с кодом.

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);
}

Округление чисел

В VisualBasic принципиально отличается политика округления чисел, чем от C++:

  • Попытка присвоить число с плавающей точкой целочисленной переменной, всегда округляет своё значение. В C++ идёт приведение типа с отсечением дробной части.

  • Округление идёт нестандартным образом, через x86-инструкцию FRNDINT, которая округляет серединные значения по типу 0.5 к ближайшему чётному целому. То есть, было 15.5, округление будет в 16, если было 42.5, то округлится к 42м.

Почему я обратил на это внимание? Потому что физика в игре во многом зависит от частей кода, использующего округление. Если округление делать не правильно (не так, как оно делалось в VisualBasic), то в итоге, физика будет искажена (Например, будет искажено движение пресса-давилки на уровне "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;
}

Таким образом, получилась весьма точная реализация округления, соответствующая поведению VisualBasic 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 с этим большая проблема. Однако, спустя время я нашёл универсальное решение, которое будет идеально работать и на миллисекундах.

Боль логических выражений

Ещё одна особенность, которая заключается в том, что VisualBasic 6 и C++ по разному обрабатывают логические выражения:

  • В С++ происходит постепенный расчёт значений и постепенное наложение. То есть, мы имеем выражение if(A < 5 && Array[A] == 1), и в случае, когда выражение A < 5 ложное, выражение Array[A] == 1 рассчитываться не будет.

  • В VisualVasic 6 члены выражения рассчитываются сразу, и только тогда идёт проверка логики. То есть, в выражении if A < 5 And Array(A) = 1 Then будут всегда рассчитываться оба члена. Здесь произойдёт ошибка из-за того, что индекс A выходит за пределы размера массива. Таким образом, при портировании на C++ я случайно исправил баг, рушивший игру. Из-за этой особенности языка в коде можно встретить больше число лазаний из if() { if {} if { .... } }, которая вместе с тем, что выглядит неуклюже, является необходимым костылём над особенностью обработки логических выражений в VisualBasic.

  • В VisualBasic 6, оператор And имеет приоритет над оператором Or, ровно как и умножение перед сложением.

  • В C++ оператор && также имеет приоритет над || , однако, при преобразовании сложных логических выражений может возникнуть путаница там, где не использовались скобки, а также всплывать предупреждения компилятора. Решается легко добавлением скобок вокруг ключевых логических групп.

Также было явно заметно, что Эндрю на тот момент абсолютно не имел никакого понятия о Select Case (аналога оператора switch()), и поэтому в его коде было чрезвычайное злоупотребление конструкциями if else if else if else... .

Кошмарный спагетти-полиморфизм

Фрагмент спагетти-кода, отвечающего за логику НИП разных типов
Фрагмент спагетти-кода, отвечающего за логику НИП разных типов

Ни для кого не секрет, что в VisualBasic полностью отсутствовало полноценное понятие классов, а реализация классов, представленная в VisualBasic 6, была сильно ограниченной. Эндрю решил не использовать её вовсе. Однако, Эндрю даже не стал создавать раздельные функции для разбиения логики разнотипных объектов. Вместо этого, он создал цепь громоздких божественных функций, каждая из которых содержала огромнейшие массивы кода, разбивающие логику разнотипных объектов через цепь if else if else if else. Ещё одна особенность кода игры, это очень и очень длинные однострочные логические и арифметические выражения, почти никакого переноса кода использовано не было. Также Эндрю не позаботился об использовании перечислений, чтобы наглядно именовать каждый тип элементов, а просто использовал сырые числовые значения, чтобы обозначить тот или иной элемент (а их там сотни разных!). Большую часть кода я преобразовал достаточно точно, сохранив исходную логику. Часть этого всего "спагетти" мне пришлось разбить на секторы, вынося в отдельные функции, чтобы тем самым сохранить наглядность.

Поддержка текста и преобразований строк в числа

VisualBasic 6 не умеет ничего, кроме локалезависимых ANSI-кодировок и очень ограниченной поддержки UTF16. Из-за этого возникают серьёзные проблемы при работе игры на компьютерах по всему миру. Поэтому, нельзя именовать игровые файлы на кириллице, иначе игра не заработает на компьютере в Китае. И наоборот. Я решил использовать в игре UTF8, поскольку эта кодировка является универсальной и повсеместной. Большинство операционных систем используют именно её в своих файловых системах. Отличается лишь Windows, которая предпочитает использовать локалезависимые ANSI-кодировки и UTF16. Из-за чего, в функциях взаимодействия с 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
Главная форма игры и код, рисующий на ней игровую сцену из текстуры.
Главная форма игры и код, рисующий на ней игровую сцену из текстуры.

Игра позволяла работать с форматами GIF, JPEG и BMP. К несчастью, отрисовать с нормальной прозрачностью через GDI была сложная задача, Эндрю решил её методом битовой маски. То есть, простыми словами, картинка разбивается на две части: основная и маска. Основная часть это обычная картинка, но с полностью чёрным фоном.

Фронтальная часть картинки - обязательный чёрный фон, который превратится в фон через ИЛИ
Фронтальная часть картинки - обязательный чёрный фон, который превратится в фон через ИЛИ

Маска - это чёрно-белая карта, отображающая пиксели с прозрачностью и без.

Битовая маска, там где пиксели белые - прозрачность, там где чёрные - будут закрашены
Битовая маска, там где пиксели белые - прозрачность, там где чёрные - будут закрашены

Сам процесс отрисовки состоит из двух этапов:

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

Я решил избавиться от такого недоразумения как MCI ещё 5 лет назад. Мною, на базе хакерского расширения LunaLua, было реализовано использование библиотеки SDL2_mixer (а затем форка SDL Mixer X), которая работала с музыкой и звуковыми эффектами намного лучше, а также совершенно не требовала установки каких либо внешних кодеков, поскольку все нужные библиотеки уже были включены в её состав. Вместе с этим была добавлена поддержка большого числа новых форматов: и OGG Vorbis, и FLAC, и огромного числа форматов трекерной музыки, и улучшенная поддерка MIDI. Также появилась возможность играть дублирующие звуковые эффекты параллельно.

Файловая система

В VisualBasic было много функций, непосредственно работающих с файловой системой: была возможность проверять файл на существование, перечислять директории, переименовывать и удалять файлы и т.п. Поскольку у меня была цель сделать проект кроссплатформенным, я применил несколько своих обёрток (одна из них для работы с директориями), которыми я полностью обеспечил потребности кода в функциях работы с файловой системой.

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

Клавиатура, мышь, геймпады

Для работы с клавиатурой, в игре использовались функции 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

В итоге, игра, изначально созданная жёстко под Windows на платформозависимом языке и на процессорах x86, превратилась в кроссплатформенную игру, которая работает не только на других операционных системах (Windows, Linux, macOS, Haiku, Android, Emscripten), но и на других процессорах (ARM, PowerPC, MIPS, и др.). Вместе с этим, игра наконец получила полноценную поддержку 64-битных процессоров и ARM-архитектуры. Энтузиасты также портируют игру на консоли (уже есть примеры, реализованные на 3DS и PS Vita).

В новом движке игры я не стал реализовывать встроенный редактор, и не стал реализовывать начальную форму для запуска игры или редактора, где показывалась веб-страница с новостями проекта (ныне просто сайт Nintendo), а также, была возможность отключить звук и пропуск кадров. Вместо этого, я решил реализовать интерфейс для настройки игры через аргументы командной строки, а также выделить конфигурационный INI-файл, который легко отредактировать, чтобы настроить игру под себя. А для создания новых уровней и эпизодов я решил рекомендовать девкит из набора Moondust Project, редактор которого я доработал, чтобы обеспечить его тесной интеграцией с игрой (поддержка специфичных полей, прямой запуск теста уровня, и т.п.).

Созданный проект я продолжаю развивать, улучшать, добавлять новые функции, стабилизировать.

Материалы

Исходный код оригинальной игры как есть

Мой полигон, мод оригинала

Репозиторий с кодом созданной игры

Автор: Виталий Новичков

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js