Programación de juegos integrados ESP32: fuentes y sistema de mosaicos

imagen




Inicio: montaje, sistema de entrada, visualización.



Continuación: unidad, batería, sonido.



Parte 7: Texto



Ahora que hemos terminado con la capa de código Odroid Go, podemos comenzar a construir el juego en sí.



Comencemos dibujando texto en la pantalla, porque será una introducción fluida a varios temas que nos serán útiles en el futuro.



Esta parte será ligeramente diferente a las anteriores porque hay muy poco código que se ejecute en Odroid Go. La mayor parte del código estará relacionado con nuestra primera herramienta.



Losas



En nuestro sistema de renderizado, usaremos mosaicos . Dividiremos la pantalla de 320x240 en una cuadrícula de mosaicos, cada uno con 16x16 píxeles. Esto creará una cuadrícula de 20 mosaicos de ancho y 15 mosaicos de alto.



Los elementos estáticos como los fondos y el texto se renderizarán utilizando el sistema de mosaicos, mientras que los elementos dinámicos como los sprites se renderizarán de forma diferente. Esto significa que los fondos y el texto solo se pueden colocar en ubicaciones fijas, mientras que los sprites se pueden colocar en cualquier lugar de la pantalla.





Un marco de 320x240, como se muestra arriba, puede contener 300 mosaicos. Las líneas amarillas muestran los límites entre los mosaicos. Cada mosaico tendrá un símbolo de textura o un elemento de fondo.





La imagen ampliada de un solo mosaico muestra los 256 píxeles constituyentes separados por líneas grises.



Fuente



Normalmente, se utiliza una fuente TrueType al representar fuentes en computadoras de escritorio . La fuente consta de glifos que representan caracteres.



Para usar una fuente, cárguela usando una biblioteca (como FreeType ) y cree un atlas de fuentes que contenga versiones de mapa de bits de todos los glifos, que luego se muestrean al renderizar. Esto suele suceder de antemano, no en el juego en sí.



En el juego, la memoria de la GPU almacena una textura con una fuente rasterizada y una descripción en código que te permite determinar dónde se encuentra el glifo deseado en la textura. El proceso de renderizado de texto consiste en renderizar una parte de la textura con un glifo en un cuadrante 2D simple.



Sin embargo, adoptamos un enfoque diferente. En lugar de luchar con archivos TTF y bibliotecas, crearemos nuestra propia fuente simple.



El objetivo de un sistema de fuentes tradicional como TrueType es poder representar una fuente en cualquier tamaño o resolución sin modificar el archivo de fuente original. Esto se hace describiendo la fuente con expresiones matemáticas.



Pero no necesitamos tanta versatilidad, conocemos la resolución de pantalla y el tamaño de fuente que necesitamos, por lo que podemos rasterizar nuestra propia fuente manualmente.



Para esto creé una fuente simple de 39 caracteres. Cada símbolo ocupa una loseta de 16x16. No soy un diseñador tipográfico profesional, pero el resultado me queda perfecto.





La imagen original es de 160x64, pero aquí he duplicado la escala para facilitar la visualización.



Por supuesto, esto evitará que escribamos texto en idiomas que no utilizan las 26 letras del alfabeto inglés.
...

Codificar el glifo







Mirando el ejemplo del glifo "A", podemos ver que tiene dieciséis líneas de dieciséis píxeles de largo. En cada línea, un píxel está encendido o apagado. Podemos utilizar esta función para codificar un glifo sin tener que cargar el mapa de bits de la fuente en la memoria de la forma tradicional.



Cada píxel de una línea se puede considerar como un bit, es decir, una línea contiene 16 bits. Si el píxel está activado, el bit está activado y viceversa. Es decir, la codificación del diapasón se puede almacenar como dieciséis enteros de 16 bits.





En este esquema, la letra "A" está codificada con la imagen que se muestra arriba. Los números de la izquierda representan el valor de cadena de 16 bits.



El glifo completo está codificado en 32 bytes (2 bytes por línea x 16 líneas). Se necesitan 1248 bytes para codificar los 39 caracteres.



Otra forma de resolver el problema era guardar el archivo de imagen en la tarjeta SD Odroid Go, cargarlo en la memoria durante la inicialización y luego hacer referencia a él al procesar el texto para encontrar el glifo que desea.



Pero el archivo de imagen tendrá que usar al menos un byte por píxel (0x00 o 0x01), por lo que el tamaño mínimo de la imagen será (sin comprimir) 10240 bytes (160 x 64).



Además de ahorrar memoria, nuestro método nos permite codificar de manera bastante trivial matrices de bytes de glifos de fuentes directamente en el código fuente para que no tengamos que cargarlas desde un archivo.



Estoy bastante seguro de que el ESP32 podría manejar la carga de una imagen en la memoria y hacer referencia a ella en tiempo de ejecución, pero me gustó la idea de codificar mosaicos directamente en matrices como esta. Es muy similar a cómo se implementa en la NES.



La importancia de las herramientas de escritura



El juego debe ejecutarse en tiempo real con una frecuencia de al menos 30 cuadros por segundo. Esto significa que todo en el juego debe procesarse en 1/30 de segundo, que es aproximadamente 33 milisegundos.



Para ayudar a lograr este objetivo, es mejor preprocesar los datos siempre que sea posible para que los datos se puedan utilizar en el juego sin ningún procesamiento. También ahorra memoria y espacio de almacenamiento.



A menudo, existe algún tipo de canalización de recursos que toma los datos sin procesar exportados desde la herramienta de creación de contenido y los transforma en una forma más adecuada para jugar en el juego.



En el caso de nuestra fuente, tenemos un conjunto de símbolos creados en Asepriteque se puede exportar como un archivo de imagen de 160x64.



En lugar de cargar una imagen en la memoria cuando comienza el juego, podemos crear una herramienta para transformar los datos en una forma más optimizada para el espacio y el tiempo de ejecución descrita en la sección anterior.



Herramienta de procesamiento de fuentes



Tenemos que convertir cada uno de los 39 glifos de la imagen original en matrices de bytes que describen el estado de sus píxeles constituyentes (como en el ejemplo "A").



Podemos poner una matriz de bytes preprocesados ​​en un archivo de encabezado que se compila en el juego y se escribe en su unidad flash. ESP32 tiene mucha más memoria Flash que RAM, por lo que podemos aprovechar esto compilando la mayor cantidad de información posible en el binario del juego.



Por primera vez, podemos hacer los cálculos de conversión de píxel a byte manualmente y será bastante factible (aunque aburrido). Pero si queremos agregar un nuevo glifo o cambiar uno antiguo, el proceso se vuelve monótono, largo y propenso a errores.



Y esta es una buena oportunidad para crear una herramienta.



La herramienta cargará un archivo de imagen, generará una matriz de bytes para cada uno de los personajes y los escribirá en un archivo de encabezado que podemos compilar en el juego. Si queremos cambiar los glifos de la fuente (lo que he hecho muchas veces) o agregar uno nuevo, simplemente volvemos a ejecutar la herramienta.



El primer paso es exportar el conjunto de glifos de Aseprite en un formato que nuestra herramienta pueda leer fácilmente. Usamos el formato de archivo BMP porque tiene un encabezado simple, no comprime la imagen y permite que la imagen se codifique en 1 byte por píxel.



En Aseprite, creé una imagen con una paleta indexada, por lo que cada píxel es un byte que representa el índice de la paleta que contiene solo colores negro (índice 0) y blanco (índice 1). El archivo BMP exportado conserva esta codificación: un píxel deshabilitado tiene el byte 0x0 y un píxel habilitado tiene el byte 0x1.



Nuestra herramienta recibirá cinco parámetros:



  • BMP exportado de Aseprite
  • Archivo de texto que describe el esquema de glifos
  • Ruta al archivo de salida generado
  • Ancho de cada glifo
  • Altura de cada glifo


El archivo de descripción del esquema de glifo es necesario para asignar la información visual de la imagen a los propios caracteres del código.



La descripción de la imagen de fuente exportada se ve así:



ABCDEFGHIJ
KLMNOPQRST
UVWXYZ1234
567890:!?


Debe coincidir con el esquema de la imagen.



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


Lo primero que hacemos es una simple validación y análisis de los argumentos de la línea de comandos.



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


Primero se lee el archivo de imagen.



El formato de archivo BMP tiene un encabezado que describe el contenido del archivo. En particular, el ancho y el alto de la imagen son importantes para nosotros, así como el desplazamiento en el archivo donde comienzan los datos de la imagen.



Crearemos una estructura que describa el esquema de este encabezado para que el encabezado se pueda cargar y se pueda acceder a los valores que queremos por su nombre. La línea del paquete pragma asegura que no se agreguen bytes de relleno a la estructura para que cuando se lea el encabezado del archivo, coincida correctamente.



El formato BMP es un poco extraño porque los bytes después del desplazamiento pueden variar mucho según la especificación BMP utilizada (Microsoft lo actualizó muchas veces). Con headerSizecomprobamos qué versión del encabezado está en uso.



Comprobamos que los dos primeros bytes del encabezado sean iguales a BM , porque eso significa que es un archivo BMP. A continuación, verificamos que la profundidad de bits sea 8 porque esperamos que cada píxel sea de un byte. También verificamos que el encabezado sea de 40 bytes, porque eso significa que el archivo BMP es la versión que queremos.



Los datos de la imagen se cargan en imageBuffer después de que se llame a fseek para navegar a la ubicación de los datos de la imagen indicada por el desplazamiento .



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


Leemos el archivo de descripción del esquema de glifo en una matriz de cadenas que necesitamos a continuación.



Primero, contamos el número de líneas en el archivo para saber cuánta memoria se necesita asignar para las líneas (un puntero por línea), y luego leemos el archivo en la memoria.



Los saltos de línea se truncan para que no aumenten la longitud de la línea en 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");


Generamos una función llamada GetGlyphIndex que toma un carácter y devuelve el índice de datos de ese carácter en el mapa de glifos (que generaremos en breve).



La herramienta recorre iterativamente la descripción del esquema leída anteriormente y genera una declaración de cambio que hace coincidir el carácter con el índice. Le permite vincular caracteres en minúsculas y mayúsculas al mismo valor y genera una afirmación si intenta utilizar un carácter que no es un carácter de mapa de glifos.



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


Finalmente, generamos nosotros mismos los valores de 16 bits para cada uno de los glifos.



Atravesamos los caracteres de la descripción de arriba a abajo, de izquierda a derecha, y luego creamos dieciséis valores de 16 bits para cada glifo atravesando sus píxeles en la imagen. Si un píxel está habilitado, entonces el código escribe en la posición de bit de este píxel 1, de lo contrario - 0.



Desafortunadamente, el código de esta herramienta es bastante feo debido a las muchas llamadas a fprintf , pero espero que el significado de lo que está sucediendo en él sea claro.



Luego, la herramienta se puede ejecutar para procesar el archivo de imagen de fuente exportado:



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


Y genera el siguiente archivo (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, .




Una vez que llenamos el archivo font.h con las matrices de bytes de glifos , podemos comenzar a dibujarlas en la pantalla.



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


Dado que transferimos la carga principal a nuestra herramienta, el código de representación de texto en sí será bastante simple.



Para representar una cadena, recorremos sus caracteres constituyentes y omitimos un carácter si encontramos un espacio.



Para cada carácter sin espacio, obtenemos el índice de glifo en el mapa de glifo para que podamos obtener su matriz de bytes.



Para verificar los píxeles en un glifo, recorremos 256 de sus píxeles (16x16) y verificamos el valor de cada bit en cada línea. Si el bit está activado, escribimos el color de este píxel en el búfer de fotogramas. Si no está habilitado, no hacemos nada.



Por lo general, no vale la pena escribir datos en un archivo de encabezado porque si este encabezado se incluye en varios archivos de origen, el vinculador se quejará de múltiples definiciones. Pero font.h solo será incluido en el código por el archivo text.c , por lo que no causará problemas.


Manifestación



Probaremos la renderización del texto renderizando el famoso pangrama El zorro marrón rápido saltó sobre el perro perezoso , que utiliza todos los caracteres admitidos por la fuente.



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


Llamamos DrawText tres veces para hacer que las líneas aparezcan en diferentes líneas, e incrementamos el mosaico Y para cada una de modo que cada línea se dibuje debajo de la anterior. También estableceremos un color diferente para cada línea para probar los colores.



Por ahora, calculamos la longitud de la cadena manualmente, pero en el futuro nos desharemos de esta molestia.





imagen


Enlaces





Parte 8: el sistema de mosaicos



Como se mencionó en la parte anterior, crearemos fondos de juegos a partir de mosaicos. Los objetos dinámicos delante del fondo serán sprites , que veremos más adelante. Ejemplos de sprites son enemigos, balas y el personaje del jugador.



Colocaremos mosaicos de 16x16 en una pantalla de 320x240 en una cuadrícula fija de 20x15. En cualquier momento, podremos mostrar hasta 300 mosaicos en la pantalla.



Búfer de mosaico



Para almacenar mosaicos, debemos usar matrices estáticas, no memoria dinámica, para no preocuparnos por malloc y free , pérdidas de memoria y memoria insuficiente a la hora de asignarla (Odroid es un sistema embebido con una cantidad limitada de memoria).



Si queremos almacenar el diseño de los mosaicos en la pantalla, y el total de mosaicos es 20x15, entonces podemos usar una matriz de 20x15, en la que cada elemento es un índice de mosaicos en el "mapa". El mapa de mosaicos contiene los gráficos de mosaicos en sí.





En este diagrama, los números en la parte superior representan la coordenada X del mosaico (en mosaicos) y los números de la izquierda representan la coordenada Y del mosaico (en mosaicos).



En código, se puede representar así:



uint8_t tileBuffer[15][20];


El problema con esta solución es que si quisiéramos cambiar lo que se muestra en la pantalla (cambiando el contenido del mosaico), el jugador verá el reemplazo del mosaico.



Esto se puede resolver expandiendo el área del búfer para que pueda escribir en él mientras está fuera de la pantalla y, cuando se muestra, parece continuo.





Los cuadrados grises indican la "ventana" visible en el búfer de mosaico, que se representa en la pantalla. Mientras que la pantalla muestra lo que está en los cuadrados grises, el contenido de todos los cuadrados blancos se puede cambiar para que el jugador no lo vea.



En código, esto se puede considerar como una matriz dos veces mayor que en X.



uint8_t tileBuffer[15][40];


Seleccionar una paleta



Por ahora, usaremos una paleta de cuatro valores de escala de grises.



En formato RGB888, se ven así:



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




Evitamos usar colores por ahora porque todavía estoy mejorando mis habilidades artísticas. Al usar la escala de grises, puedo concentrarme en el contraste y la forma sin preocuparme por la teoría del color. Incluso una pequeña paleta de colores requiere un buen gusto artístico.



Si tiene dudas sobre la fuerza del color en escala de grises de 2 bits, piense en Game Boy, que solo tenía cuatro colores en su paleta. La primera pantalla de Game Boy estaba teñida de verde, por lo que los cuatro valores se mostraban como tonos de verde, pero Game Boy Pocket los mostraba como una verdadera escala de grises.



La imagen a continuación para The Legend of Zelda: Link's Awakening muestra cuánto puedes lograr con solo cuatro valores si tienes un buen artista.





Por ahora, los gráficos de mosaico se verán como cuatro cuadrados con un borde de un píxel en el exterior y con esquinas truncadas. Cada cuadrado tendrá uno de los colores de nuestra paleta.



Truncar esquinas es un pequeño cambio, pero le permite distinguir entre mosaicos individuales, lo cual es útil para renderizar la malla.





Herramienta paleta



Almacenaremos la paleta en el formato de archivo JASC Palette, que es fácil de leer, fácil de analizar con herramientas y compatible con Aseprite.



La paleta se ve así



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


Las dos primeras líneas se encuentran en todos los archivos PAL. La tercera línea es el número de elementos de la paleta. El resto de las líneas son los valores de los elementos rojo, verde y azul de la paleta.



La herramienta de paleta lee el archivo, convierte cada color a RGB565, invierte el orden de bytes y escribe los nuevos valores en un archivo de encabezado que contiene la paleta en una matriz.



El código para leer y escribir el archivo es similar al código utilizado en la Parte 7 de este artículo, y el procesamiento del color se realiza así:



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


La función strtok divide la cadena de acuerdo con los delimitadores. Los tres valores de color están separados por un solo espacio, así que lo usamos. Luego creamos el valor RGB565 cambiando los bits e invirtiendo el orden de los bytes, como hicimos en la tercera parte del artículo.



./palette_processor grey.pal grey.h


El resultado de la herramienta se ve así:



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


Herramienta de procesamiento de azulejos



También necesitamos una herramienta que genere datos de mosaicos en el formato esperado por el juego. El valor de cada píxel en el archivo BMP es un índice de paleta. Mantendremos esta notación indirecta para que un mosaico de 16x16 (256) bytes ocupe un byte por píxel. Durante la ejecución del programa, encontraremos el color del mosaico en la paleta.



La herramienta lee el archivo, recorre los píxeles y escribe sus índices en una matriz en el encabezado.



El código para leer y escribir el archivo también es similar al código en la herramienta de procesamiento de fuentes, y la creación de la matriz correspondiente ocurre aquí:



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


El índice se obtiene de la posición del píxel en el archivo BMP y luego se escribe en el archivo como un elemento de matriz de 16x16.



./tile_processor black.bmp black.h


La salida de la herramienta al procesar un mosaico negro se ve así:



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


Si observa de cerca, puede comprender la apariencia de un mosaico simplemente por los índices. Cada 3 significa negro y cada 0 significa blanco.



Ventana de marco



Como ejemplo, podemos crear un "nivel" simple (y extremadamente corto) que llene todo el búfer de mosaicos. Tenemos cuatro mosaicos diferentes, y no se preocupe por los gráficos, solo usamos un esquema en el que cada uno de los cuatro mosaicos tiene un color diferente en escala de grises.





Organizamos cuatro mosaicos en una cuadrícula de 40x15 para probar nuestro sistema.





Los números de arriba indican los índices de columna del framebuffer. Los números siguientes son los índices de las columnas de la ventana del marco. Los números de la izquierda son las líneas de cada búfer (sin movimiento de ventana vertical).





Para el jugador, todo se verá como se muestra en el video de arriba. Cuando la ventana se mueve hacia la derecha, le parece al jugador que el fondo se desplaza hacia la izquierda.



Manifestación





El número en la esquina superior izquierda es el número de columna del borde izquierdo de la ventana de búfer de mosaico, y el número en la esquina superior derecha es el número de columna del borde derecho de la ventana de búfer de mosaico.



Fuente



El código fuente de todo el proyecto se encuentra aquí .



All Articles