ESP32 Embedded Game Programming: Fonts and Tile System

imagem




Início: montagem, sistema de entrada, display.



Continuação: unidade, bateria, som.



Parte 7: Texto



Agora que terminamos com a camada de código do Odroid Go, podemos começar a construir o próprio jogo.



Vamos começar desenhando um texto na tela porque esta será uma introdução suave a vários tópicos que serão úteis no futuro.



Esta parte será um pouco diferente das anteriores porque há muito pouco código que roda no Odroid Go. A maior parte do código estará relacionada à nossa primeira ferramenta.



Azulejos



Em nosso sistema de renderização, usaremos blocos . Vamos dividir a tela 320x240 em uma grade de blocos, cada um contendo 16x16 pixels. Isso criará uma grade com 20 ladrilhos de largura e 15 ladrilhos de altura.



Elementos estáticos, como fundos e texto, serão renderizados usando o sistema de blocos, enquanto elementos dinâmicos, como sprites, serão renderizados de maneira diferente. Isso significa que fundos e texto só podem ser colocados em locais fixos, enquanto sprites podem ser colocados em qualquer lugar da tela.





Um quadro de 320 x 240, conforme mostrado acima, pode conter 300 blocos. As linhas amarelas mostram as bordas entre os ladrilhos. Cada bloco terá um símbolo de textura ou elemento de fundo.





A imagem ampliada de um único bloco mostra os 256 pixels constituintes separados por linhas cinza.



Fonte



Normalmente, uma fonte TrueType é usada ao renderizar fontes em desktops . A fonte consiste em glifos que representam caracteres.



Para usar uma fonte, você a carrega usando uma biblioteca (como FreeType ) e cria um atlas de fontes contendo versões de bitmap de todos os glifos, que são amostrados na renderização. Isso geralmente acontece de antemão, não no jogo em si.



No jogo, a memória da GPU armazena uma textura com uma fonte rasterizada e uma descrição em código que permite determinar onde o glifo desejado está localizado na textura. O processo de renderização de texto consiste em renderizar uma parte da textura com um glifo em um quad 2D simples.



No entanto, adotamos uma abordagem diferente. Em vez de lutar com arquivos e bibliotecas TTF, criaremos nossa própria fonte simples.



O objetivo de um sistema de fonte tradicional como o TrueType é ser capaz de renderizar uma fonte em qualquer tamanho ou resolução sem modificar o arquivo de fonte original. Isso é feito descrevendo a fonte com expressões matemáticas.



Mas não precisamos dessa versatilidade, sabemos a resolução de exibição e o tamanho da fonte de que precisamos, para que possamos rasterizar nossa própria fonte manualmente.



Para isso, criei uma fonte simples de 39 caracteres. Cada símbolo ocupa um quadrado de 16x16. Não sou um designer de tipos profissional, mas o resultado me cai perfeitamente.





A imagem original é 160x64, mas aqui dobrei a escala para facilitar a visualização.



Claro, isso nos impedirá de escrever textos em idiomas que não usam as 26 letras do alfabeto inglês.
...

Codifique o glifo







Olhando para o exemplo do glifo “A”, podemos ver que ele tem dezesseis linhas de dezesseis pixels de comprimento. Em cada linha, um pixel está ligado ou desligado. Podemos usar esse recurso para codificar um glifo sem ter que carregar o bitmap da fonte na memória da maneira tradicional.



Cada pixel em uma linha pode ser considerado um bit, ou seja, uma linha contém 16 bits. Se o pixel estiver ativado, o bit estará ativado e vice-versa. Ou seja, a codificação do braço da guitarra pode ser armazenada como dezesseis inteiros de 16 bits.





Neste esquema, a letra “A” é codificada com a imagem mostrada acima. Os números à esquerda representam o valor da string de 16 bits.



O glifo completo é codificado em 32 bytes (2 bytes por linha x 16 linhas). Leva 1248 bytes para codificar todos os 39 caracteres.



Outra maneira de resolver o problema era salvar o arquivo de imagem no cartão SD Odroid Go, carregá-lo na memória na inicialização e, em seguida, referenciá-lo ao renderizar o texto para encontrar o glifo desejado.



Mas o arquivo de imagem terá que usar pelo menos um byte por pixel (0x00 ou 0x01), então o tamanho mínimo da imagem será (descompactado) 10240 bytes (160 x 64).



Além de economizar memória, nosso método nos permite codificar de forma bastante trivial matrizes de bytes de glifos de fontes diretamente no código-fonte, de modo que não tenhamos que carregá-los de um arquivo.



Tenho certeza de que o ESP32 poderia carregar uma imagem na memória e referenciá-la no tempo de execução, mas gostei da ideia de codificar blocos diretamente em matrizes como esta. É muito semelhante a como é implementado no NES.



A importância das ferramentas de escrita



O jogo deve ser executado em tempo real com uma frequência de pelo menos 30 frames por segundo. Isso significa que tudo no jogo deve ser processado em 1/30 de segundo, que é cerca de 33 milissegundos.



Para ajudar a atingir esse objetivo, é melhor pré-processar os dados sempre que possível para que possam ser usados ​​no jogo sem qualquer processamento. Ele também economiza memória e espaço de armazenamento.



Freqüentemente, existe algum tipo de canal de recursos que pega os dados brutos exportados da ferramenta de criação de conteúdo e os transforma em uma forma mais adequada para jogar no jogo.



No caso da nossa fonte, temos um conjunto de símbolos criados em Asepriteque pode ser exportado como um arquivo de imagem 160x64.



Em vez de carregar uma imagem na memória quando o jogo começa, podemos criar uma ferramenta para transformar os dados em uma forma mais otimizada para espaço e tempo de execução descrita na seção anterior.



Ferramenta de processamento de fonte



Temos que converter cada um dos 39 glifos da imagem original em matrizes de bytes que descrevam o estado de seus pixels constituintes (como no exemplo “A”).



Podemos colocar uma matriz de bytes pré-processados ​​em um arquivo de cabeçalho que é compilado no jogo e gravado em seu flash drive. O ESP32 tem muito mais memória Flash do que RAM, então podemos tirar vantagem disso compilando o máximo de informações possível no binário do jogo.



Pela primeira vez, podemos fazer os cálculos de conversão pixel a byte manualmente e será bastante factível (embora chato). Mas se quisermos adicionar um novo glifo ou alterar um antigo, o processo se torna monótono, demorado e sujeito a erros.



E esta é uma boa oportunidade para criar uma ferramenta.



A ferramenta carregará um arquivo de imagem, gerará uma matriz de bytes para cada um dos personagens e os gravará em um arquivo de cabeçalho que podemos compilar no jogo. Se quisermos alterar os glifos da fonte (o que já fiz muitas vezes) ou adicionar um novo, simplesmente executaremos novamente a ferramenta.



A primeira etapa é exportar o conjunto de glifos do Aseprite em um formato que nossa ferramenta possa ler facilmente. Usamos o formato de arquivo BMP porque tem um cabeçalho simples, não comprime a imagem e permite que a imagem seja codificada em 1 byte por pixel.



No Aseprite, criei uma imagem com uma paleta indexada, de modo que cada pixel seja um byte representando o índice de uma paleta contendo apenas cores pretas (Índice 0) e brancas (Índice 1). O arquivo BMP exportado retém esta codificação: um pixel desativado possui o byte 0x0 e um pixel ativado possui o byte 0x1.



Nossa ferramenta receberá cinco parâmetros:



  • BMP exportado de Aseprite
  • Arquivo de texto que descreve o esquema de glifos
  • Caminho para o arquivo de saída gerado
  • Largura de cada glifo
  • Altura de cada glifo


O arquivo de descrição do esquema de glifo é necessário para mapear as informações visuais da imagem para os próprios caracteres no código.



A descrição da imagem da fonte exportada é semelhante a esta:



ABCDEFGHIJ
KLMNOPQRST
UVWXYZ1234
567890:!?


Ela deve corresponder ao esquema da imagem.



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


A primeira coisa que fazemos é a validação e análise simples dos argumentos da linha de comando.



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);


O arquivo de imagem é lido primeiro.



O formato de arquivo BMP possui um cabeçalho que descreve o conteúdo do arquivo. Em particular, a largura e a altura da imagem são importantes para nós, assim como o deslocamento no arquivo onde os dados da imagem começam.



Vamos criar uma estrutura que descreve o esquema deste cabeçalho para que o cabeçalho possa ser carregado e os valores que desejamos possam ser acessados ​​por nome. A linha do pacote pragma garante que nenhum byte de preenchimento seja adicionado à estrutura, de modo que, quando o cabeçalho for lido do arquivo, ele corresponda corretamente.



O formato BMP é um pouco estranho porque os bytes após o deslocamento podem variar muito, dependendo da especificação BMP usada (a Microsoft a atualizou várias vezes). Com headerSizeverificamos qual versão do cabeçalho está em uso.



Verificamos se os primeiros dois bytes do cabeçalho são iguais a BM , porque isso significa que é um arquivo BMP. Em seguida, verificamos se a profundidade de bits é 8 porque esperamos que cada pixel tenha um byte. Também verificamos se o cabeçalho tem 40 bytes, porque isso significa que o arquivo BMP é a versão que desejamos.



Os dados da imagem são carregados no imageBuffer depois que fseek é chamado para ir para o local dos dados da imagem especificado por deslocamento .



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);


Lemos o arquivo de descrição do esquema de glifo em uma série de strings que precisamos a seguir.



Primeiro, contamos o número de linhas no arquivo para saber quanta memória precisa ser alocada para as linhas (um ponteiro por linha) e, em seguida, lemos o arquivo na memória.



As quebras de linha são truncadas para que não aumentem o comprimento da linha em caracteres.



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");


Geramos uma função chamada GetGlyphIndex que pega um caractere e retorna o índice de dados desse caractere no mapa de glifos (que geraremos em breve).



A ferramenta percorre iterativamente a descrição do esquema lida anteriormente e gera uma instrução switch que corresponde o caractere ao índice. Ele permite que você vincule caracteres minúsculos e maiúsculos ao mesmo valor e gera uma declaração se você tentar usar um caractere que não seja um caractere de mapa de glifo.



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");


Por fim, geramos nós mesmos os valores de 16 bits para cada um dos glifos.



Percorremos os caracteres da descrição de cima para baixo, da esquerda para a direita e, em seguida, criamos dezesseis valores de 16 bits para cada glifo, percorrendo seus pixels na imagem. Se um pixel estiver habilitado, o código grava na posição do bit desse pixel 1, caso contrário - 0.



Infelizmente, o código desta ferramenta é bastante feio devido às muitas chamadas para fprintf , mas espero que o significado do que está acontecendo nele esteja claro.



A ferramenta pode então ser executada para processar o arquivo de imagem de fonte exportado:



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


E gera o seguinte arquivo (abreviado):



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, .




Ao preencher o arquivo font.h com as matrizes de bytes dos glifos, podemos começar a desenhá-los na tela.



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;
				}
			}
		}
	}
}


Visto que transferimos a carga principal para nossa ferramenta, o código de renderização de texto em si será bastante simples.



Para renderizar uma string, percorremos seus caracteres constituintes e pulamos um caractere se encontrarmos um espaço.



Para cada caractere que não seja de espaço, obtemos o índice de glifo no mapa de glifo para que possamos obter sua matriz de bytes.



Para verificar os pixels em um glifo, percorremos 256 de seus pixels (16x16) e verificamos o valor de cada bit em cada linha. Se o bit estiver ativado, escrevemos no framebuffer a cor desse pixel. Se não estiver ativado, não faremos nada.



Geralmente, não vale a pena gravar dados em um arquivo de cabeçalho porque se esse cabeçalho for incluído em vários arquivos de origem, o vinculador reclamará de várias definições. Mas font.h só será incluído no código pelo arquivo text.c , então não causará problemas.


Demo



Testaremos a renderização do texto renderizando o famoso pangrama A Raposa Castanha Rápida Saltou sobre o Cachorro Preguiçoso , que usa todos os caracteres suportados pela fonte.



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)));


Chamamos DrawText três vezes para que as linhas apareçam em linhas diferentes e aumentamos a coordenada Y do bloco para cada uma, de modo que cada linha seja desenhada abaixo da anterior. Também definiremos uma cor diferente para cada linha para testar as cores.



Por enquanto, estamos calculando o comprimento da string manualmente, mas no futuro nos livraremos desse incômodo.





imagem


Links





Parte 8: o sistema de azulejos



Conforme mencionado na parte anterior, estaremos criando planos de fundo do jogo a partir de blocos. Os objetos dinâmicos na frente do fundo serão sprites , que veremos mais tarde. Exemplos de sprites são inimigos, balas e o personagem do jogador.



Colocaremos blocos de 16x16 em uma tela de 320x240 em uma grade fixa de 20x15. A qualquer momento, seremos capazes de exibir até 300 peças na tela.



Tile Buffer



Para armazenar tiles, devemos usar arrays estáticos, não memória dinâmica, para não nos preocuparmos com malloc e free , vazamentos de memória e memória insuficiente ao alocá-lo (Odroid é um sistema embarcado com uma quantidade limitada de memória).



Se quisermos armazenar o layout dos tiles na tela, e o total de tiles é 20x15, então podemos usar um array 20x15, em que cada elemento é um índice de tile no "mapa". O mapa de blocos contém os próprios gráficos de blocos.





Neste diagrama, os números no topo representam a coordenada X do bloco (em blocos) e os números à esquerda representam a coordenada Y do bloco (em blocos).



No código, ele pode ser representado assim:



uint8_t tileBuffer[15][20];


O problema com esta solução é que se quiséssemos mudar o que é mostrado na tela (mudando o conteúdo da peça), então o jogador verá a substituição da peça.



Isso pode ser resolvido expandindo a área do buffer para que você possa escrever enquanto está fora da tela e, quando exibida, parece contínua.





Os quadrados cinza indicam a "janela" visível no buffer do bloco, que é renderizada na tela. Enquanto a tela exibe o que está nos quadrados cinzas, o conteúdo de todos os quadrados brancos pode ser alterado para que o jogador não veja.



No código, isso pode ser considerado como uma matriz com o dobro do tamanho em X.



uint8_t tileBuffer[15][40];


Selecionando uma paleta



Por enquanto, usaremos uma paleta de quatro valores de tons de cinza.



No formato RGB888, eles se parecem com:



  • 0xFFFFFF (branco / valor 100%).
  • 0xABABAB (- / 67% )
  • 0x545454 (- / 33% )
  • 0x000000 ( / 0% )




Evitamos usar cores por enquanto, porque ainda estou aprimorando minhas habilidades artísticas. Usando tons de cinza, posso me concentrar no contraste e na forma sem me preocupar com a teoria das cores. Mesmo uma pequena paleta de cores requer bom gosto artístico.



Se você está em dúvida sobre a força da cor em tons de cinza de 2 bits, pense no Game Boy, que tinha apenas quatro cores em sua paleta. A tela do primeiro Game Boy era tingida de verde, então quatro valores eram exibidos em tons de verde, mas o Game Boy Pocket os exibia como verdadeiros tons de cinza.



A imagem abaixo de The Legend of Zelda: Link's Awakening mostra o quanto você pode alcançar com apenas quatro valores se tiver um bom artista.





Por enquanto, os gráficos dos blocos parecerão quatro quadrados com uma borda externa de um pixel e com cantos truncados. Cada quadrado terá uma das cores de nossa paleta.



Truncar cantos é uma pequena mudança, mas permite distinguir entre blocos individuais, o que é útil para renderizar a malha.





Ferramenta de paleta



Armazenaremos a paleta no formato de arquivo Paleta JASC, que é fácil de ler, analisar facilmente com ferramentas e é compatível com Aseprite.



A paleta se parece com isto



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


As primeiras duas linhas são encontradas em cada arquivo PAL. A terceira linha é o número de itens na paleta. O resto das linhas são os valores dos elementos vermelho, verde e azul da paleta.



A ferramenta de paleta lê o arquivo, converte cada cor em RGB565, inverte a ordem dos bytes e grava os novos valores no arquivo de cabeçalho que contém a paleta em uma matriz.



O código para ler e gravar o arquivo é semelhante ao código usado na Parte 7 deste artigo, e o processamento de cores é feito assim:



// 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;
}


A função strtok divide a string de acordo com os delimitadores. Os três valores de cor são separados por um espaço, então usamos isso. Em seguida, criamos o valor RGB565 deslocando os bits e invertendo a ordem dos bytes, como fizemos na terceira parte do artigo.



./palette_processor grey.pal grey.h


O resultado da ferramenta é assim:



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


Ferramenta de processamento de mosaico



Também precisamos de uma ferramenta que produza os dados dos blocos no formato esperado pelo jogo. O valor de cada pixel no arquivo BMP é um índice da paleta. Manteremos essa notação indireta para que um bloco de 16x16 (256) bytes ocupe um byte por pixel. Durante a execução do programa, vamos encontrar a cor do ladrilho na paleta.



A ferramenta lê o arquivo, percorre os pixels e grava seus índices em uma matriz no cabeçalho.



O código para ler e escrever o arquivo também é semelhante ao código na ferramenta de processamento de fontes, e a criação da matriz correspondente ocorre aqui:



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;
		}
	}
}


O índice é obtido a partir da posição do pixel no arquivo BMP e então gravado no arquivo como um elemento de array 16x16.



./tile_processor black.bmp black.h


A saída da ferramenta ao processar um bloco preto é assim:



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,
};


Se você olhar de perto, poderá entender a aparência de um ladrilho simplesmente pelos índices. Cada 3 significa preto e cada 0 significa branco.



Janela do quadro



Como exemplo, podemos criar um "nível" simples (e extremamente curto) que preenche todo o buffer do bloco. Temos quatro tiles diferentes, e não se preocupe com os gráficos, nós apenas usamos um esquema onde cada um dos quatro tiles tem uma cor diferente em tons de cinza.





Organizamos quatro blocos em uma grade de nível 40x15 para testar nosso sistema.





Os números acima indicam os índices de coluna do framebuffer. Os números abaixo são os índices das colunas da janela do quadro. Os números à esquerda são as linhas de cada buffer (sem movimento da janela vertical).





Para o player, tudo parecerá como mostrado no vídeo acima. Quando a janela é movida para a direita, parece ao jogador que o plano de fundo foi deslocado para a esquerda.



Demo





O número no canto superior esquerdo é o número da coluna da borda esquerda da janela do buffer do bloco e o número no canto superior direito é o número da coluna da borda direita da janela do buffer do bloco.



Fonte



O código-fonte de todo o projeto está aqui .



All Articles