Пилим движок Arcanum. Урок 02. Работа с файлами игры, рисуем первый спрайт

от автора

Приветствую, Хабравчане!

В данном уроке, я опишу формат архивов игры, напишу код, для загрузки файлов и выведем первый спрайт. Урок находится в ветке Урок находится в ветке ArcanumTutorial_02_WorkingWithFiles.

Игра Arcanum загружает все свои ресурсы из архивов игры с расширением .dat и каталогов самой игры.

Вы нверное замечали, что оригинальная игра, нга старом железе довольно долго запускается и висит на экране заставки. Так вот вначале игра загружает список файлов из архивов dat их довольно много, около 70k записей. После чего переходит к загрузке записей из архивов в каталоге модуля, модуль это новая игра которая использует общие ресурсы, лежащие в корневом каталоге.

Как происходит загрузка файла в игре.

  1. Проверяется корень каталога игры data/

  2. Если файла нет, загрузка файла из каталога modules/модуль/data/

  3. Если файла нет, загрузка из dat файлов modules/модуль/модуль.dat

  4. Если и там нет, тогда загружаем файл из 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")
  1. Это путь до каталога игры, по умолчанию он пуст,

  2. Это имя каталога в котором лежать общие файлы игры

  3. Это имя каталога в котором находятся модули

  4. Название текущего модуля игры

Создадим ещё один класс 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 зависим от данного класса и принимает зависимость через конструктор. И осталные классы так же разработаны по такому же принципу.

Это даёт несколько преимуществ:

  1. Классы реально маленькие и умещаются на одном экране. Открыл файл посмотрел реализацию и сразу понял, что он делает и как он это делает.

  2. Это конечно же возможность написания тестов по каждому классу. Написание движка, вообще не тривиальная задача и достаточно сложная. По крайней мере мне как бэкендеру, область довольно нова и из -за этого очень интересна.

Я не стал обмазывать каждый класс интерфейсом, так как это просто не имеет смысла. Мне не нужно подставлять какие то фейковые классы или их мокать и ещё больше усложнять кодобазу. Для тестирования я использую реальные данные игры. Далее о тестировании.

Тесты лежат в каталоге Tests. И для примера приведу пару тестов,

  1. Очень простой тест.

#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, формирует пути к файлам игры в движке. В конструкторы передаем стартовые параметры и уже класс ими оперирует.

  1. Тест более сложный:

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

В дальнейших уроках, мы улучшим код, добавим класс спрайт который будет хранить текстуры и сопутствующую информацию. Менеджер срайтов, который будет скрывать от нас создание текстур и формирование спрайта. Добавим код для вывода карты и объектов на ней.

В итоге на экран будет выведен спрайт парового двигателя.

Буду рад критике, советам и предложениям. Понимаю, что писать код намного легче, чем потом описывать вот это вот всё непотребство:)

Буду рад, пообщаться в комментариях. Я могу по вашим советам об улучшении кода движка, дополнить статью, улучшениями кода от зрителей.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

В статье нужно больше кода или текста описывающего код и функционал?

26.32% Больше кода5
26.32% Больше текста5
47.37% Норм9

Проголосовали 19 пользователей. Воздержались 3 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/838118/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *