image


Начало: сборка, система ввода, дисплей.

Продолжение: накопитель, аккумулятор, звук.

Часть 7: Текст


Завершив со слоем кода Odroid Go, мы можем приступить к созданию самой игры.

Начнём с отрисовки на экране текста, потому что это станет плавным введением в несколько тем, которые пригодятся нам в будущем.

Эта часть будет немного отличаться от предыдущих, потому что в нём очень мало кода, выполняемого на Odroid Go. Основная часть кода будет относиться к нашему первому инструменту.

Тайлы


В нашей системе рендеринга мы будем использовать тайлы. Мы разобьём экран 320x240 на сетку тайлов, каждый из которых содержит 16x16 пикселей. Так мы получим сетку шириной 20 тайлов и высотой 15 тайлов.

Статические элементы, например, фоны и текст, будут рендериться при помощи системы тайлов, а динамические элементы, например, спрайты — иным способом. Это означает, что фоны и текст можно будет располагать только в фиксированных местах, а спрайты можно поместить в любое место экрана.


Один кадр 320x240, как показано выше, может содержать 300 тайлов. Жёлтыми линиями показаны границы между тайлами. В каждом тайле будет текстурный символ или фоновый элемент.


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

Шрифт


При рендеринге шрифтов на настольных компьютерах обычно используется шрифт TrueType. Шрифт состоит из глифов, обозначающих символы.

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

В игре в памяти GPU хранится одна текстура с растеризированным шрифтом и описание в коде, позволяющее определить, где в текстуре находится нужный глиф. Процесс рендеринга текста заключается в отрисовке части текстуры с глифом на простой 2D-четырёхугольник.

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

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

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

Для этого я создал простой шрифт из 39 символов. Каждый символ занимает один тайл 16x16. Я не являюсь профессиональным шрифтовиком, но результат меня вполне устраивает.


Исходное изображение имеет размер 160x64, но здесь для удобства просмотра я увеличил масштаб в два раза.

Разумеется, это не позволит нам писать текст на языках, не использующих 26 букв английского алфавита
.

Кодируем глиф




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

Каждый пиксель в строке можно воспринимать как один бит, то есть строка содержит 16 бит. Если пиксель включен, то бит тоже включён, и наоборот. То есть кодировка грифа может храниться как шестнадцать 16-битных целых чисел.


По этой схеме буква “A” кодируется показанным выше изображением. Числа слева обозначают 16-битное значение строки.

Полный глиф кодируется 32 байтами (2 байта на строку x 16 строк). Для кодирования всех 39 символов требуется 1248 байт.

Можно было решить задачу по-другому: сохранить файл изображения на SD-карту Odroid Go, загружать его в память при инициализации, а затем ссылаться на него при рендеринге текста, чтобы найти нужный глиф.

Но в файле изображения придётся использовать по крайней мере один байт на пиксель (0x00 или 0x01), поэтому минимальный размер изображения будет составлять (без сжатия) 10240 байт (160 x 64).

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

Я почти точно уверен, что ESP32 смог бы справиться с загрузкой изображения в память и ссылками на него во время выполнения, но мне понравилась идея подобного кодирования тайлов непосредственно в массивы. Она очень похожа на то, как это реализовано на NES.

Важность написания инструментов


Игра должна выполняться в реальном времени с частотой не менее 30 кадров в секунду. Это значит, что обработка всего в игре должно выполняться за 1/30 секунды, то есть примерно за 33 миллисекунды.

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

Часто существует некий конвейер ресурсов, получающий сырые данные, экспортированные из инструмента создания контента, и преобразующий его в форму, лучше приспособленную для выполнения в игре.

В случае нашего шрифта у нас есть набор символов, созданных в Aseprite, который можно экспортировать как файл изображения 160x64.

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

Инструмент обработки шрифта


Мы должны преобразовать каждый из 39 глифов исходного изображения в массивы байтов, описывающие состояние составляющих их пикселей (как в примере с символом “A”).

Мы можем поместить массив предварительно обработанных байтов в файл заголовка, который компилируется в игру и записывается в её Flash-накопитель. У ESP32 гораздо больше Flash-памяти, чем RAM, поэтому мы можем воспользоваться этим, скомпилировав как можно больше информации в двоичный файл игры.

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

И это подходящий случай для создания инструмента.

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

Первым шагом будет экспорт набора глифов из Aseprite в формате, который легко сможет считать наш инструмент. Мы используем формат файлов BMP, потому что он имеет простой заголовок, не сжимает изображение и позволяет кодировать изображение 1 байтом на пиксель.

В Aseprite я создал изображение с индексированной палитрой, поэтому каждый пиксель является одним байтом, представляющим индекс палитры, содержащей только чёрный (Index 0) и белый (Index 1) цвета. Экспортированный файл BMP сохраняет эту кодировку: отключенный пиксель имеет байт 0x0, а включенный — байт 0x1.

Наш инструмент будет получать пять параметров:

  • BMP, экспортированный из Aseprite
  • Текстовый файл, описывающий схему глифов
  • Путь к сгенерированному выходному файлу
  • Ширина каждого глифа
  • Высота каждого глифа

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

Описание экспортированного изображения шрифта выглядит вот так:

ABCDEFGHIJ
KLMNOPQRST
UVWXYZ1234
567890:!?

Оно должно соответствовать схеме в изображении.

if (argc != 6)
{
	fprintf(stderr, "Usage: %s <input image> <layout file> <output header> <glyph width> <glyph height>\n", argv[0]);

	return 1;
}

const char* inFilename = argv[1];
const char* layoutFilename = argv[2];
const char* outFilename = argv[3];
const int glyphWidth = atoi(argv[4]);
const int glyphHeight = atoi(argv[5]);

Первое, что мы делаем — простая проверка и парсинг аргументов командной строки.

FILE* inFile = fopen(inFilename, "rb");
assert(inFile);

#pragma pack(push,1)
struct BmpHeader
{
	char magic[2];
	uint32_t totalSize;
	uint32_t reserved;
	uint32_t offset;
	uint32_t headerSize;
	int32_t width;
	int32_t height;
	uint16_t planes;
	uint16_t depth;
	uint32_t compression;
	uint32_t imageSize;
	int32_t horizontalResolution;
	int32_t verticalResolution;
	uint32_t paletteColorCount;
	uint32_t importantColorCount;
} bmpHeader;
#pragma pack(pop)

// Read the BMP header so we know where the image data is located
fread(&bmpHeader, 1, sizeof(bmpHeader), inFile);
assert(bmpHeader.magic[0] == 'B' && bmpHeader.magic[1] == 'M');
assert(bmpHeader.depth == 8);
assert(bmpHeader.headerSize == 40);

// Go to location in file of image data
fseek(inFile, bmpHeader.offset, SEEK_SET);

// Read in the image data
uint8_t* imageBuffer = malloc(bmpHeader.imageSize);
assert(imageBuffer);
fread(imageBuffer, 1, bmpHeader.imageSize, inFile);

int imageWidth = bmpHeader.width;
int imageHeight = bmpHeader.height;

fclose(inFile);

Сначала считывается файл изображения.

Формат файлов BMP имеет заголовок, описывающий содержимое файла. В частности, нам важны ширина и высота изображения, а также смещение в файле, где начинаются данные изображения.

Мы создадим struct, описывающую схему этого заголовка, чтобы можно было загрузить заголовок и получать доступ к нужным значениям по имени. Строка pragma pack гарантирует, что к struct не будут добавлены никакие заполняющие байты, чтобы при считывании заголовка из файла он сопоставлялся правильно.

Формат BMP немного странен тем, что байты после смещения могут сильно варьироваться в зависимости от используемой спецификации BMP (Microsoft много раз её обновляла). При помощи headerSize мы проверяем, какая версия заголовка используется.

Мы проверяем, что первые два байта заголовка равны BM, потому что это обозначает, что это файл BMP. Далее мы проверяем, что битовая глубина равна 8, потому что ожидаем, что каждый пиксель будет одним байтом. Также мы проверяем, что размер заголовка равен 40 байтам, потому что это обозначает, что файл BMP имеет нужную нам версию.

Данные изображения загружаются в imageBuffer после вызова fseek для перехода в место расположения данных изображения, указанное смещением offset.

FILE* layoutFile = fopen(layoutFilename, "r");
assert(layoutFile);


// Count the number of lines in the file
int layoutRows = 0;
while (!feof(layoutFile))
{
	char c = fgetc(layoutFile);

	if (c == '\n')
	{
		++layoutRows;
	}
}


// Return file position indicator to start
rewind(layoutFile);


// Allocate enough memory for one string pointer per row
char** glyphLayout = malloc(sizeof(*glyphLayout) * layoutRows);
assert(glyphLayout);


// Read the file into memory
for (int rowIndex = 0; rowIndex < layoutRows; ++rowIndex)
{
	char* line = NULL;
	size_t len = 0;

	getline(&line, &len, layoutFile);


	int newlinePosition = strlen(line) - 1;

	if (line[newlinePosition] == '\n')
	{
		line[newlinePosition] = '\0';
	}


	glyphLayout[rowIndex] = line;
}

fclose(layoutFile);

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

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

Переходы на новую строку обрезаются, чтобы они не увеличивали длину строки в символах.

fprintf(outFile, "int GetGlyphIndex(char c)\n");
fprintf(outFile, "{\n");
fprintf(outFile, "	switch (c)\n");
fprintf(outFile, "	{\n");

int glyphCount = 0;
for (int row = 0; row < layoutRows; ++row)
{
	int glyphsInRow = strlen(glyphLayout[row]);

	for (int glyph = 0; glyph < glyphsInRow; ++glyph)
	{
		char c = glyphLayout[row][glyph];

		fprintf(outFile, "		");

		if (isalpha(c))
		{
			fprintf(outFile, "case '%c': ", tolower(c));
		}

		fprintf(outFile, "case '%c': { return %d; break; }\n", c, glyphCount);

		++glyphCount;
	}
}

fprintf(outFile, "		default: { assert(NULL); break; }\n");
fprintf(outFile, "	}\n");
fprintf(outFile, "}\n\n");

Мы генерируем функцию под названием GetGlyphIndex, которая получает символ и возвращает индекс данных этого символа в карте глифов (которую мы вскоре сгенерируем).

Инструмент итеративно обходит считанное ранее описание схемы и генерирует оператор switch, сопоставляющий символ с индексом. Он позволяет привязать символы в нижнем и верхнем регистре к одному значению и генерирует assert, если попытались использовать символ, который не является символом из карты глифов.

fprintf(outFile, "static const uint16_t glyphMap[%d][%d] =\n", glyphCount, glyphHeight);
fprintf(outFile, "{\n");

for (int y = 0; y < layoutRows; ++y)
{
	int glyphsInRow = strlen(glyphLayout[y]);

	for (int x = 0; x < glyphsInRow; ++x)
	{
		char c = glyphLayout[y][x];

		fprintf(outFile, "	// %c\n", c);
		fprintf(outFile, "	{\n");
		fprintf(outFile, "	");

		int count = 0;

		for (int row = y * glyphHeight; row < (y + 1) * glyphHeight; ++row)
		{
			uint16_t val = 0;

			for (int col = x * glyphWidth; col < (x + 1) * glyphWidth; ++col)
			{
				// BMP is laid out bottom-to-top, but we want top-to-bottom (0-indexed)
				int y = imageHeight - row - 1;

				uint8_t pixel = imageBuffer[y * imageWidth + col];

				int bitPosition = 15 - (col % glyphWidth);
				val |= (pixel << bitPosition);
			}

			fprintf(outFile, "0x%04X,", val);
			++count;

			// Put a newline after four values to keep it orderly
			if ((count % 4) == 0)
			{
				fprintf(outFile, "\n");
				fprintf(outFile, "	");
				count = 0;
			}
		}

		fprintf(outFile, "},\n\n");
	}
}

fprintf(outFile, "};\n");

В конце мы генерируем сами 16-битные значения для каждого из глифов.

Мы обходим символы из описания сверху вниз, слева направо, а затем создаём шестнадцать 16-битных значений для каждого глифа обходом его пикселей в изображении. Если пиксель включен, то код записывает в позицию бита этого пикселя 1, а в противном случае — 0.

К сожалению, код этого инструмента довольно уродлив из-за множества вызовов fprintf, но я надеюсь, что смысл происходящего в нём понятен.

Затем инструмент можно запустить для обработки экспортированного файла изображения шрифта:

./font_processor font.bmp font.txt font.h 16 16

И он генерирует следующий (сокращённый) файл:

static const int GLYPH_WIDTH = 16;
static const int GLYPH_HEIGHT = 16;


int GetGlyphIndex(char c)
{
	switch (c)
	{
		case 'a': case 'A': { return 0; break; }
		case 'b': case 'B': { return 1; break; }
		case 'c': case 'C': { return 2; break; }

		[...]

		case '1': { return 26; break; }
		case '2': { return 27; break; }
		case '3': { return 28; break; }

		[...]

		case ':': { return 36; break; }
		case '!': { return 37; break; }
		case '?': { return 38; break; }
		default: { assert(NULL); break; }
	}
}

static const uint16_t glyphMap[39][16] =
{
	// A
	{
	0x0000,0x7FFE,0x7FFE,0x7FFE,
	0x781E,0x781E,0x781E,0x7FFE,
	0x7FFE,0x7FFE,0x781E,0x781E,
	0x781E,0x781E,0x781E,0x0000,
	},

	// B
	{
	0x0000,0x7FFC,0x7FFE,0x7FFE,
	0x780E,0x780E,0x7FFE,0x7FFE,
	0x7FFC,0x780C,0x780E,0x780E,
	0x7FFE,0x7FFE,0x7FFC,0x0000,
	},

	// C
	{
	0x0000,0x7FFE,0x7FFE,0x7FFE,
	0x7800,0x7800,0x7800,0x7800,
	0x7800,0x7800,0x7800,0x7800,
	0x7FFE,0x7FFE,0x7FFE,0x0000,
	},


	[...]


	// 1
	{
	0x0000,0x01E0,0x01E0,0x01E0,
	0x01E0,0x01E0,0x01E0,0x01E0,
	0x01E0,0x01E0,0x01E0,0x01E0,
	0x01E0,0x01E0,0x01E0,0x0000,
	},

	// 2
	{
	0x0000,0x7FFE,0x7FFE,0x7FFE,
	0x001E,0x001E,0x7FFE,0x7FFE,
	0x7FFE,0x7800,0x7800,0x7800,
	0x7FFE,0x7FFE,0x7FFE,0x0000,
	},

	// 3
	{
	0x0000,0x7FFE,0x7FFE,0x7FFE,
	0x001E,0x001E,0x3FFE,0x3FFE,
	0x3FFE,0x001E,0x001E,0x001E,
	0x7FFE,0x7FFE,0x7FFE,0x0000,
	},


	[...]


	// :
	{
	0x0000,0x0000,0x3C00,0x3C00,
	0x3C00,0x3C00,0x0000,0x0000,
	0x0000,0x0000,0x3C00,0x3C00,
	0x3C00,0x3C00,0x0000,0x0000,
	},

	// !
	{
	0x0000,0x3C00,0x3C00,0x3C00,
	0x3C00,0x3C00,0x3C00,0x3C00,
	0x3C00,0x3C00,0x0000,0x0000,
	0x3C00,0x3C00,0x3C00,0x0000,
	},

	// ?
	{
	0x0000,0x7FFE,0x7FFE,0x7FFE,
	0x781E,0x781E,0x79FE,0x79FE,
	0x01E0,0x01E0,0x0000,0x0000,
	0x01E0,0x01E0,0x01E0,0x0000,
	},
};

Я ожидал, что компилятор сгенерирует из оператора switch таблицу переходов, чтобы GetGlyphIndex выполнялась за O(1), однако компилятор генерирует для каждого глифа карты команду сравнения, например, 39 операторов if.

В результате получилась функция, работающая медленнее моих ожиданий. При тестировании рендеринга текста с большим количеством строк из-за этой функции частота кадров сильно падает.

Однако в создаваемой нами игре будет совсем мало текста, поэтому производительность останется удовлетворительной.

Можно было бы также создать хэш-таблицу, сопоставляющую char c int, но на этом этапе я не хочу реализовывать подобные структуры данных.

Отрисовка текста


Заполнив файл font.h массивами байтов глифов, мы можем начать отрисовывать их на экране.

static const int MAX_GLYPHS_PER_ROW = LCD_WIDTH / GLYPH_WIDTH;
static const int MAX_GLYPHS_PER_COL = LCD_HEIGHT / GLYPH_HEIGHT;

void DrawText(uint16_t* framebuffer, char* string, int length, int x, int y, uint16_t color)
{
	assert(x + length < MAX_GLYPHS_PER_ROW);
	assert(y < MAX_GLYPHS_PER_COL);

	for (int charIndex = 0; charIndex < length; ++charIndex)
	{
		char c = string[charIndex];

		if (c == ' ')
		{
			continue;
		}

		int xStart = GLYPH_WIDTH * (x + charIndex);
		int yStart = GLYPH_HEIGHT * y;

		for (int row = 0; row < GLYPH_HEIGHT; ++row)
		{
			for (int col = 0; col < GLYPH_WIDTH; ++col)
			{
				int bitPosition = 1U << (15U - col);
				int glyphIndex = GetGlyphIndex(c);

				uint16_t pixel = glyphMap[glyphIndex][row] & bitPosition;

				if (pixel)
				{
					int screenX = xStart + col;
					int screenY = yStart + row;

					framebuffer[screenY * LCD_WIDTH + screenX] = color;
				}
			}
		}
	}
}

Так как основную нагрузку мы переложили на наш инструмент, сам код отрисовки текста будет довольно простым.

Для рендеринга строки мы обходим в цикле составляющие её символы и пропускаем символ, если встретили пробел.

Для каждого символа, не являющегося пробелом мы получаем индекс глифа в карте глифов, чтобы можно было получить его массив байтов.

Для проверки пикселей в глифе мы обходим в цикле 256 его пикселей (16x16) и проверяем значение каждого бита в каждой строке. Если бит включен, то мы записываем в буфер кадра цвет для этого пикселя. Если он не включен, то ничего не делаем.

Обычно не стоит записывать данные в файл заголовка, потому что если этот заголовок включён в несколько исходных файлов, то компоновщик будет жаловаться на многочисленные определения. Но font.h будет включаться в код только файлом text.c, поэтому это не вызовет проблем.

Демо


Мы протестируем рендеринг текста отрисовкой известной панграммы The Quick Brown Fox Jumped Over The Lazy Dog, в которой используются все поддерживаемые шрифтом символы.

DrawText(gFramebuffer, "The Quick Brown Fox", 19, 0, 5, SWAP_ENDIAN_16(RGB565(0xFF, 0, 0)));
DrawText(gFramebuffer, "Jumped Over The:", 16, 0, 6, SWAP_ENDIAN_16(RGB565(0, 0xFF, 0)));
DrawText(gFramebuffer, "Lazy Dog?!", 10, 0, 7, SWAP_ENDIAN_16(RGB565(0, 0, 0xFF)));

Мы вызываем DrawText трижды, чтобы строки отображались на разных строчках, и увеличиваем для каждой тайловую координату Y, чтобы каждая строчка отрисовывалась под предыдущей. Также мы зададим для каждой строчки свой цвет, чтобы протестировать цвета.

Пока мы вычисляем длину строки вручную, но в будущем избавимся от этой мороки.


image

Ссылки



Часть 8: система тайлов


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

Тайлы 16x16 мы разместим на экране 320x240 в фиксированной сетке 20x15. В любой момент времени мы сможем отображать на экране до 300 тайлов.

Буфер тайлов


Для хранения тайлов нам стоит использовать статические массивы, а не динамическую память, чтобы не беспокоиться о malloc и free, утечках памяти и нехватке памяти при её выделении (Odroid — это встроенная система с ограниченным объёмом памяти).

Если мы хотим хранить схему расположения тайлов на экране, а всего тайлов 20x15, то можно использовать массив размером 20x15, в котором каждый элемент является индексом тайла в «карте». Тайловая карта содержит саму графику тайла.


На этой схеме числа сверху обозначают координату X тайла (в тайлах), а числа слева — координату Y тайла (в тайлах).

В коде это можно представить так:

uint8_t tileBuffer[15][20];

Проблема такого решения заключается в том, что если бы мы захотели изменить то, что отображается на экране (изменив содержимое тайла), то игрок увидит замену тайла.

Её можно решить, расширив область буфера так, чтобы можно было записывать в него, пока он за пределами экрана, а при отображении он выглядел непрерывным.


Серыми квадратами обозначено видимое «окно» в буфер тайлов, которое рендерится на экране. Пока на экране отображается то, что находится в серых квадратах, содержимое всех белых квадратов можно менять так, что игрок этого не видит.

В коде это можно представить как массив вдвое большего размера по X.

uint8_t tileBuffer[15][40];

Выбор палитры


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

В формате RGB888 они имеют вид:

  • 0xFFFFFF (белый / 100% значения).
  • 0xABABAB (светло-серый / 67% значения)
  • 0x545454 (тёмно-серый / 33% значения)
  • 0x000000 (чёрный / 0% значения)


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

Если вы сомневаетесь в силе 2-битного цвета в градациях серого, то вспомните Game Boy, в палитре которого было всего четыре цвета. Экран первого Game Boy имел зелёный оттенок, поэтому четыре значения отображались в как оттенки зелёного, но Game Boy Pocket отображал их как настоящие градации серого.

На показанном ниже изображении The Legend of Zelda: Link’s Awakening видно, как много можно достичь всего с четырьмя значениями, если иметь хорошего художника.


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

Усечение углов — это мелкое изменение, но оно позволяет различать отдельные тайлы, что полезно для визуализации сетки.


Инструмент обработки палитры


Мы будем хранить палитру в формате файлов JASC Palette, который легко читаем, удобно парсится инструментами и поддерживается Aseprite.

Палитра выглядит вот так

JASC-PAL
0100
4
255 255 255
171 171 171
84 84 84
0 0 0

Первые две строки есть в каждом файле PAL. Третья строка — это количество элементов в палитре. Остальные строки — это значения красного, зелёного и синего элементов палитры.

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

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

// Each line is of form R G B
for (int i = 0; i < paletteSize; ++i)
{
	getline(&line, &len, inFile);

	char* tok = strtok(line, " ");
	int red = atoi(tok);

	tok = strtok(NULL, " ");
	int green = atoi(tok);

	tok = strtok(NULL, " ");
	int blue = atoi(tok);

	uint16_t rgb565 =
		  ((red >> 3u) << 11u)
		| ((green >> 2u) << 5u)
		| (blue >> 3u);

	uint16_t endianSwap = ((rgb565 & 0xFFu) << 8u) | (rgb565 >> 8u);

	palette[i] = endianSwap;
}

Функция strtok разбивает строку в соответствии с разделителями. Три значения цвета разделены одним пробелом, поэтому мы используем его. Затем мы создаём значение RGB565 при помощи сдвига битов и перемены порядка следования байтов, как мы делали это в третьей части статьи.

./palette_processor grey.pal grey.h

Выходные данные инструмента выглядят так:

uint16_t palette[4] =
{
	0xFFFF,
	0x55AD,
	0xAA52,
	0x0000,
};

Инструмент обработки тайлов


Также нам понадобится инструмент, выводящий данные тайлов в ожидаемом игрой формате. Значение каждого пикселя в файле BMP является индексом палитры. Мы сохраним такую косвенную запись, чтобы тайл размером 16x16 (256) байт занимал один байт на пиксель. Во время выполнения программы мы будем находить цвет тайла в палитре.

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

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

for (int row = 0; row < tileHeight; ++row)
{
	for (int col = 0; col < tileWidth; ++col)
	{
		// BMP is laid out bottom-to-top, but we want top-to-bottom (0-indexed)
		int y =  tileHeight - row - 1;

		uint8_t paletteIndex = tileBuffer[y * tileWidth + col];

		fprintf(outFile, "%d,", paletteIndex);
		++count;

		// Put a newline after sixteen values to keep it orderly
		if ((count % 16) == 0)
		{
			fprintf(outFile, "\n");
			fprintf(outFile, "	");

			count = 0;
		}
	}
}

Индекс получается из позиции пикселя в файле BMP, а затем записывается в файл как элемента массива 16x16.

./tile_processor black.bmp black.h

Выходные данные инструмента при обработке чёрного тайла выглядят так:

static const uint8_t tile[16][16] =
{
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
    0,0,3,3,3,3,3,3,3,3,3,3,3,3,0,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,
    0,0,3,3,3,3,3,3,3,3,3,3,3,3,0,0,
    0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
};

Если присмотреться, можно понять внешний вид тайла просто по индексам. Каждая 3 означает чёрный цвет, а каждый 0 — белый.

Окно кадра


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


Мы выстраиваем четыре тайла в сетку уровня размером 40x15, чтобы протестировать нашу систему.


Числа сверху обозначают индексы столбцов буфера кадров. Числа снизу — это индексы столбцов окна кадра. Числа слева — это строки каждого буфера (вертикальное движение окна отсутствует).


Для игрока всё будет выглядеть так, как показано в видео выше. При перемещении окна вправо игроку кажется, что фон сдвигается влево.

Демо



Число в верхнем левом углу — это номер столбца левого края окна буфера тайлов, а число в верхнем правом углу — это номер столбца правого края окна буфера тайлов.

Исходный код


Исходный код всего проекта находится здесь.