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

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

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

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

f2474d215ba28600435cb193f9c514a9.png

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

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

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

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

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

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

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

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

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

#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__)
}

После каждого изменения кода я запускаю тесты, если ошибок нет, консоль остается пустой. Если есть ошибка я смогу увидеть информацию о ней. Пример:

30942c04bbf796f886290bceead66009.png

Я вижу в каком файле и на какой строке, что то сломалось.

Ну, что же. Мы умеем загружать файлы, а что дальше?

Идём грузить графику, но прежде научимся конвертировать графику игры, в 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]);
	}

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

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

c169b7b7c63ffb4a752406d56627640a.png

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

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

© Habrahabr.ru