Приветствую, Хабравчане!
В данном уроке, я опишу формат архивов игры, напишу код, для загрузки файлов и выведем первый спрайт. Урок находится в ветке Урок находится в ветке ArcanumTutorial_02_WorkingWithFiles.
Игра Arcanum загружает все свои ресурсы из архивов игры с расширением .dat и каталогов самой игры.

Вы нверное замечали, что оригинальная игра, нга старом железе довольно долго запускается и висит на экране заставки. Так вот вначале игра загружает список файлов из архивов dat их довольно много, около 70k записей. После чего переходит к загрузке записей из архивов в каталоге модуля, модуль это новая игра которая использует общие ресурсы, лежащие в корневом каталоге.
Как происходит загрузка файла в игре.
-
Проверяется корень каталога игры data/
-
Если файла нет, загрузка файла из каталога modules/модуль/data/
-
Если файла нет, загрузка из dat файлов modules/модуль/модуль.dat
-
Если и там нет, тогда загружаем файл из dat файлов корня игры.
Исходя из логики будем писать код.
Для этого заводим структуру: DatItem, данная структура содержит поля, лежащие в dat архиве отсортированные по имени файла. Сами архивы это объединенные gzip файлы в один большой файл с заголовком, который описывает с какого смещения идут записи о файлах и их количестве.
class DatItem { public: enum { Uncompressed = 0x01, Compressed = 0x02, MaxPath = 128, MaxArchive = 64 }; DatItem(); int PathSize; //Размер пути в байтах int Unknown1; //I don't now int Type; // Сжатый или нет int RealSize; //Размер несжатого файла int PackedSize; //Размер сжатого файла int Offset; // Смещение файла в архиве char Path[MaxPath]; //Наименование файла, его полный путь char Archive[MaxArchive]; //Путь до dat файла, в котором находится файл };
Класс DatReader умеет открывать файл dat, считывать заголовок и проходить по каждой записи в файле.
bool DatReader::Open(const std::string& file) { _File.open(file.c_str(), std::ios::binary); if (_File.is_open()) { int treesubs = 0; _File.seekg(-0x1Cl, std::ios::end); _File.seekg(16, std::ios::cur); _File.seekg(4, std::ios::cur); _File.seekg(4, std::ios::cur); _File.read((char*)&treesubs, 0x04); _File.seekg(-treesubs, std::ios::end); _File.read((char*)&_TotalFiles, 0x04); return true; } return false; }
Чтение записи выглядит так:
bool DatReader::Next(DatItem& item) { if (_CurrentFile < _TotalFiles) { _File.read((char*)&item.PathSize , 4); _File.read((char*)&item.Path , item.PathSize); _File.read((char*)&item.Unknown1 , 4); _File.read((char*)&item.Type , 4); _File.read((char*)&item.RealSize , 4); _File.read((char*)&item.PackedSize, 4); _File.read((char*)&item.Offset , 4); _CurrentFile++; return true; } return false; }
Имея данные о записи мы можем сохранить эту информацию к примеру в таблицу, в моем случае это std::map, с открытыми методами добавить и получить запись по имени, так как имя для записи уникально.
Для этого я добавил класс DatLoader, который используя DatReader, считывает и обновляет DatList. Так же к каждой записи я добавляю путь до dat файла, это нужно для того, что бы физически не обходить все dat файлы при поиске файла, а только лишь обратиться к индексированному списку файлов в DatList.
Для оперирования путями в каталоге игры и правильному поиску в модулях, добавлен класс PathManager
Пример инициализации:
PathManager("", "data/", "modules/", "Arcanum")
-
Это путь до каталога игры, по умолчанию он пуст,
-
Это имя каталога в котором лежать общие файлы игры
-
Это имя каталога в котором находятся модули
-
Название текущего модуля игры
Создадим ещё один класс DatManager
Данный класс используя список записей, умеет искать и распаковывать gzip файлы с помощью zlib. В итоге функция GetFile, обращается к полю Archive и читает из указанного в нем архива gzip файл. После чего, простой метод Uncompress, распаковывает данный файл в ОЗУ, заранее подготовленный буфер.
const std::vector<unsigned char>& DatManager::GetFile(const std::string& path) { _Result.clear(); DatItem* p = _DatList.Get(path); if (p != NULL) { _File.open(p->Archive, std::ios::binary); if (_File.is_open()) { _File.seekg(p->Offset, std::ios::beg); _Result.resize(p->RealSize); _Buffer.resize(p->PackedSize); if (p->Type == DatItem::Uncompressed) { _File.read((char*)&_Result[0], p->RealSize); } else if (p->Type == DatItem::Compressed) { _File.read((char*)&_Buffer[0], p->PackedSize); if (!_Unpacker.Uncompress((unsigned char*)&_Result[0], p->RealSize, (unsigned char*)&_Buffer[0], p->PackedSize)) { throw std::runtime_error("Can't uncompress file: " + path); } } _File.close(); } } return _Result; }
Далее игра работает уже с данным буфером помещенные для удобства оперирования им в ОЗУ, классом MemoryReader. Класс очень простой, он позволяет читать данные и оперировать смещением. Это нужно, что бы другие загрузчики форматов могли иметь универсальный интерфейс к файлам игры.
Так же у нас остались файлы в каталогах игры. С ними поступлю похожим образом. Класс FileLoader загружает в буфер, файл с диска и так же для обобщения работы, создал класс FileManager. Это калька с DatManager, только позволяющая работать с файлами из каталога.
Теперь для объединения двух типов загрузки файлов к единообразию. Я создал класс ResourceManager, который имея зависимости от предыдущих классов, содержит унифицированный метод GetFile, и уже сам ищет в доступных dat файлах, корневом каталоге и каталоге модуля игры.
const std::vector<unsigned char>& ResourceManager::GetFile(const std::string& dir, const std::string& file) { const std::vector<unsigned char>& fromDir = _FileManager.GetFile(_PathManager.GetFileFromDir(dir, file)); if (fromDir.size() > 0) { return fromDir; } else { const std::vector<unsigned char>& fromModule = _FileManager.GetFile(_PathManager.GetFileFromModuleDir(dir, file)); if (fromModule.size() > 0) { return fromModule; } else { const std::vector<unsigned char>& fromDat = _DatManager.GetFile(_PathManager.GetFileFromDat(dir, file)); if (fromDat.size() == 0) { throw std::runtime_error("Can't found file: " + dir + file); } return fromDat; } } }
По коду видно, что я использую принцип SOLID на минималках. Точнее первые два принципа. Каждый класс обладает минимальным функционалом и делает одно действие. К примеру класс DatReader, только читает dat архивы, DatLoader зависим от данного класса и принимает зависимость через конструктор. И осталные классы так же разработаны по такому же принципу.
Это даёт несколько преимуществ:
-
Классы реально маленькие и умещаются на одном экране. Открыл файл посмотрел реализацию и сразу понял, что он делает и как он это делает.
-
Это конечно же возможность написания тестов по каждому классу. Написание движка, вообще не тривиальная задача и достаточно сложная. По крайней мере мне как бэкендеру, область довольно нова и из -за этого очень интересна.
Я не стал обмазывать каждый класс интерфейсом, так как это просто не имеет смысла. Мне не нужно подставлять какие то фейковые классы или их мокать и ещё больше усложнять кодобазу. Для тестирования я использую реальные данные игры. Далее о тестировании.
Тесты лежат в каталоге Tests. И для примера приведу пару тестов,
-
Очень простой тест.
#include <Arcanum/Managers/PathManager.hpp> #include <Pollux/Common/TestEqual.hpp> using namespace Arcanum; int main() { PathManager pathManager("C:/Games/", "data/", "modules/", "Arcanum"); POLLUX_TEST(pathManager.GetFileFromDir("art/item/", "P_tesla_gun.ART") == "C:/Games/data/art/item/P_tesla_gun.ART"); POLLUX_TEST(pathManager.GetFileFromDat("art/item/", "P_tesla_gun.ART") == "art/item/P_tesla_gun.ART"); POLLUX_TEST(pathManager.GetFileFromModuleDir("art/item/", "P_tesla_gun.ART") == "C:/Games/data/modules/Arcanum/art/item/P_tesla_gun.ART"); POLLUX_TEST(pathManager.GetDat("arcanum1.dat") == "C:/Games/arcanum1.dat"); POLLUX_TEST(pathManager.GetModules("arcanum.dat") == "C:/Games/modules/arcanum.dat"); POLLUX_TEST(pathManager.GetModule() == "Arcanum"); return 0; }
Из кода видно, что класс PathManager, формирует пути к файлам игры в движке. В конструкторы передаем стартовые параметры и уже класс ими оперирует.
-
Тест более сложный:
#include <Arcanum/Formats/Dat/DatLoader.hpp> #include <Arcanum/Managers/ResourceManager.hpp> #include <Pollux/Common/TestEqual.hpp> using namespace Arcanum; using namespace Pollux; int main() { std::vector<unsigned char> buffer; std::vector<unsigned char> result; DatList datList; DatReader datReader; DatLoader datLoader(datReader); DatManager datManager(buffer, result, datList); Pollux::FileLoader fileLoader(buffer); FileManager fileManager(fileLoader); PathManager pathManager("", "data/", "modules/", "Arcanum/"); ResourceManager resourceManager(pathManager, datManager, fileManager); datLoader.Load("TestFiles/arcanum4.dat", datList); MemoryReader* data = resourceManager.GetData("art/item/", "P_tesla_gun.ART"); POLLUX_TEST(data != NULL); POLLUX_TEST(data->Buffer() != NULL); POLLUX_TEST(data->Buffer()->size() == 6195); return 0; }
Тест проверяет чтение и рапаковку файлов из каталогов или dat файлов игры. В начале я инициализирую все зависимые классы, как я это делаю и в движке. После чего просто подаю тестовые данные и проверяю выходные данные. В данном случае, ResourceManager должен вернуть, объект с определенным размером. Для того, что бы убедиться, что ошибок при поиске и распаковке не произошло.
Тесты написанные единожды, позволяют постоянно проверять, не поломал ли я предыдущий код. По коммитам можете, посмотреть количество коммитов по рефакторингу кода, их около трех штук и в них я изменяю половину кодобазы и мне это удается сделать только благодаря тестам.
Я не стал использовать встроенные в cmake тесты. Это нужно что бы упростить тестирование на других платформах, к примеру где cmake может отсутствовать. Для этого я написал простейшую функцию для тестирования:
Pollux::TestEqual проверяет утверждение и если оно не равно истине, выводит сообщение об ошибке. Макрос POLLUX_TEST просто оборачивает функцию для удобства.
#include <Pollux/Common/TestEqual.hpp> #include <iostream> using namespace Pollux; void Pollux::TestEqual(bool condition, const char* description, const char* file, int line) { if (!condition) { std::cout << "Test fail: " << description << " File: " << file << " Line: " << line << '\n'; } }
namespace Pollux { void TestEqual(bool condition, const char* description, const char* file, int line); #define POLLUX_TEST(x) Pollux::TestEqual(x, #x, __FILE__, __LINE__) }
После каждого изменения кода я запускаю тесты, если ошибок нет, консоль остается пустой. Если есть ошибка я смогу увидеть информацию о ней. Пример:

Я вижу в каком файле и на какой строке, что то сломалось.
Ну, что же. Мы умеем загружать файлы, а что дальше?
Идём грузить графику, но прежде научимся конвертировать графику игры, в rgba массив из которого мы сможем создать текстуру.
Создадим класс ArtReader. Он умеет читать графические файлы игры с расширением art и конвертировать в rgb массив.
За основу был взят исходник конвертера art файлов в bmp от Alex’a.
Адаптировав его для своего движка, получился не самый красивый, но работающий код.
void ArtReader::Frame(size_t index, std::vector<unsigned char>& artBuffer, std::vector<unsigned char>& rgbBuffer) { size_t offset = _FrameOffset.at(index); size_t size = _FrameHeader.at(index).size; size_t width = _FrameHeader.at(index).width; size_t height = _FrameHeader.at(index).height; _Reader->Offset(offset); artBuffer.resize(size); _Reader->Read(&artBuffer[0], size); rgbBuffer.resize(width * height * 4); size_t j = 0; if ((width * height) == size) { for (size_t i = 0; i < size; i++) { unsigned char src = artBuffer.at(i); if (src != 0) { rgbBuffer.at(j + 0) = _Pallete[0].colors[src].r; rgbBuffer.at(j + 1) = _Pallete[0].colors[src].g; rgbBuffer.at(j + 2) = _Pallete[0].colors[src].b; rgbBuffer.at(j + 3) = 255; } else { rgbBuffer.at(j + 0) = 0; rgbBuffer.at(j + 1) = 0; rgbBuffer.at(j + 2) = 0; rgbBuffer.at(j + 3) = 0; } j += 4; } } else { for (size_t i = 0; i < size; i++) { unsigned char ch = artBuffer.at(i); if (ch & 0x80) { int to_copy = ch & (0x7F); while (to_copy--) { i++; unsigned char src = artBuffer.at(i); if (src != 0) { rgbBuffer.at(j + 0) = _Pallete[0].colors[src].r; rgbBuffer.at(j + 1) = _Pallete[0].colors[src].g; rgbBuffer.at(j + 2) = _Pallete[0].colors[src].b; rgbBuffer.at(j + 3) = 255; } else { rgbBuffer.at(j + 0) = 0; rgbBuffer.at(j + 1) = 0; rgbBuffer.at(j + 2) = 0; rgbBuffer.at(j + 3) = 0; } j += 4; } } else { int to_clone = ch & (0x7F); i++; unsigned char src = artBuffer.at(i); while (to_clone--) { if (src != 0) { rgbBuffer.at(j + 0) = _Pallete[0].colors[src].r; rgbBuffer.at(j + 1) = _Pallete[0].colors[src].g; rgbBuffer.at(j + 2) = _Pallete[0].colors[src].b; rgbBuffer.at(j + 3) = 255; } else { rgbBuffer.at(j + 0) = 0; rgbBuffer.at(j + 1) = 0; rgbBuffer.at(j + 2) = 0; rgbBuffer.at(j + 3) = 0; } j += 4; } } } } }
Не спешите кидать помидоры, в следующих уроках, я обязательно его поправлю и проведу рефакторинг. Думаю, не имеет смысла мне описывать формат art файлов, данный формат прекрасно описан в стате на хабре.
Код выше читает art файл, каждый фрейм преобразовывается из палитровых пикселей в 32 битные пиксели и сохраняются в буфер. Если в буфере есть даннные, тогда мы создаем тексуру из полученных данных и выводим ее на экран.
MemoryReader* mem = _ResourceManager.GetData("art/scenery/", "engine.ART"); ArtReader artReader; artReader.Reset(mem); if (artReader.Frames() > 0) { std::vector<unsigned char> artBuffer; std::vector<unsigned char> rgbBuffer; artReader.Frame(0, artBuffer, rgbBuffer); int w = artReader.Width(0); int h = artReader.Height(0); _Texture = new Texture(_Canvas, Point(w, h), 4, &rgbBuffer[0]); }
В дальнейших уроках, мы улучшим код, добавим класс спрайт который будет хранить текстуры и сопутствующую информацию. Менеджер срайтов, который будет скрывать от нас создание текстур и формирование спрайта. Добавим код для вывода карты и объектов на ней.
В итоге на экран будет выведен спрайт парового двигателя.

Буду рад критике, советам и предложениям. Понимаю, что писать код намного легче, чем потом описывать вот это вот всё непотребство:)
Буду рад, пообщаться в комментариях. Я могу по вашим советам об улучшении кода движка, дополнить статью, улучшениями кода от зрителей.
ссылка на оригинал статьи https://habr.com/ru/articles/838118/
Добавить комментарий