Пилим движок Arcanum. Урок 02. Работа с файлами игры, рисуем первый спрайт
Приветствую, Хабравчане!
В данном уроке, я опишу формат архивов игры, напишу код, для загрузки файлов и выведем первый спрайт. Урок находится в ветке Урок находится в ветке 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& 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& ResourceManager::GetFile(const std::string& dir, const std::string& file)
{
const std::vector& fromDir = _FileManager.GetFile(_PathManager.GetFileFromDir(dir, file));
if (fromDir.size() > 0)
{
return fromDir;
}
else
{
const std::vector& fromModule = _FileManager.GetFile(_PathManager.GetFileFromModuleDir(dir, file));
if (fromModule.size() > 0)
{
return fromModule;
}
else
{
const std::vector& 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
#include
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
#include
#include
using namespace Arcanum;
using namespace Pollux;
int main()
{
std::vector buffer;
std::vector 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
#include
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& artBuffer, std::vector& 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 artBuffer;
std::vector 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]);
}
В дальнейших уроках, мы улучшим код, добавим класс спрайт который будет хранить текстуры и сопутствующую информацию. Менеджер срайтов, который будет скрывать от нас создание текстур и формирование спрайта. Добавим код для вывода карты и объектов на ней.
В итоге на экран будет выведен спрайт парового двигателя.
Буду рад критике, советам и предложениям. Понимаю, что писать код намного легче, чем потом описывать вот это вот всё непотребство:)
Буду рад, пообщаться в комментариях. Я могу по вашим советам об улучшении кода движка, дополнить статью, улучшениями кода от зрителей.