Début: montage, système d'entrée, affichage.
Suite: lecteur, batterie, son.
Partie 7: Texte
Maintenant que nous en avons terminé avec la couche de code Odroid Go, nous pouvons commencer à créer le jeu lui-même.
Commençons par dessiner du texte à l'écran, car ce sera une introduction en douceur à plusieurs sujets qui nous seront utiles à l'avenir.
Cette partie sera légèrement différente des précédentes car il y a très peu de code qui fonctionne sur Odroid Go. La plupart du code sera lié à notre premier outil.
Carrelage
Dans notre système de rendu, nous utiliserons des tuiles . Nous allons diviser l'écran 320x240 en une grille de tuiles, chacune contenant 16x16 pixels. Cela créera une grille de 20 carreaux de large et 15 carreaux de haut.
Les éléments statiques tels que les arrière-plans et le texte seront rendus à l'aide du système de tuiles, tandis que les éléments dynamiques tels que les sprites seront rendus différemment. Cela signifie que les arrière-plans et le texte ne peuvent être placés qu'à des emplacements fixes, tandis que les sprites peuvent être placés n'importe où sur l'écran.
Un cadre 320x240, comme illustré ci-dessus, peut contenir 300 tuiles. Les lignes jaunes montrent les frontières entre les tuiles. Chaque tuile aura un symbole de texture ou un élément d'arrière-plan.
L'image agrandie d'une seule tuile montre les 256 pixels constituants séparés par des lignes grises.
Police de caractère
En règle générale, une police TrueType est utilisée lors du rendu des polices sur les ordinateurs de bureau . La police se compose de glyphes qui représentent des caractères.
Pour utiliser une police, vous la chargez à l'aide d'une bibliothèque (telle que FreeType ) et créez un atlas de polices contenant des versions bitmap de tous les glyphes, qui sont ensuite échantillonnés lors du rendu. Cela se produit généralement à l'avance, pas dans le jeu lui-même.
Dans le jeu, la mémoire GPU stocke une texture avec une police pixellisée et une description dans le code qui vous permet de déterminer où se trouve le glyphe souhaité dans la texture. Le processus de rendu de texte consiste à rendre une partie de la texture avec un glyphe sur un simple quad 2D.
Cependant, nous adoptons une approche différente. Au lieu de nous battre avec les fichiers et bibliothèques TTF, nous créerons notre propre police simple.
L'intérêt d'un système de police traditionnel comme TrueType est de pouvoir rendre une police à n'importe quelle taille ou résolution sans modifier le fichier de police d'origine. Ceci est accompli en décrivant la police avec des expressions mathématiques.
Mais nous n'avons pas besoin d'une telle polyvalence, nous connaissons la résolution d'affichage et la taille de police dont nous avons besoin, nous pouvons donc pixelliser notre propre police manuellement.
Pour cela, j'ai créé une simple police de 39 caractères. Chaque symbole occupe une tuile 16x16. Je ne suis pas un typographe professionnel, mais le résultat me convient parfaitement.
L'image originale est 160x64, mais ici j'ai doublé l'échelle pour une visualisation facile.
Bien entendu, cela nous empêchera d'écrire du texte dans des langues qui n'utilisent pas les 26 lettres de l'alphabet anglais....
Encoder le glyphe
En regardant l'exemple du glyphe «A», nous pouvons voir qu'il fait seize lignes de seize pixels de long. Dans chaque ligne, un pixel est activé ou désactivé. Nous pouvons utiliser cette fonctionnalité pour encoder un glyphe sans avoir à charger le bitmap de police en mémoire de manière traditionnelle.
Chaque pixel d'une ligne peut être considéré comme un bit, c'est-à-dire qu'une ligne contient 16 bits. Si le pixel est activé, alors le bit est activé et vice versa. Autrement dit, le codage du manche peut être stocké sous la forme de seize entiers 16 bits.
Dans ce schéma, la lettre «A» est codée avec l'image ci-dessus. Les nombres sur la gauche représentent la valeur de la chaîne 16 bits.
Le glyphe complet est codé sur 32 octets (2 octets par ligne x 16 lignes). Il faut 1248 octets pour encoder les 39 caractères.
Une autre façon de résoudre le problème consistait à enregistrer le fichier image sur la carte SD Odroid Go, à le charger en mémoire lors de l'initialisation, puis à le référencer lors du rendu du texte pour trouver le glyphe souhaité.
Mais le fichier image devra utiliser au moins un octet par pixel (0x00 ou 0x01), donc la taille minimale de l'image sera (non compressée) 10240 octets (160 x 64).
En plus d'économiser de la mémoire, notre méthode nous permet d'encoder de manière très simple des tableaux d'octets de glyphes de polices directement dans le code source afin que nous n'ayons pas à les charger à partir d'un fichier.
Je suis presque sûr que l'ESP32 pourrait gérer le chargement d'une image en mémoire et la référencer au moment de l'exécution, mais j'ai aimé l'idée d'encoder les tuiles directement dans des tableaux comme celui-ci. Il est très similaire à la façon dont il est mis en œuvre sur le NES.
L'importance des outils d'écriture
Le jeu doit être exécuté en temps réel avec une fréquence d' au moins 30 images par seconde. Cela signifie que le traitement de tout ce qui se trouve dans le jeu doit être effectué en 1/30 de seconde, c'est-à-dire en 33 millisecondes environ.
Pour atteindre cet objectif, il est préférable de pré-traiter les données chaque fois que possible afin que les données puissent être utilisées dans le jeu sans aucun traitement. Il économise également de la mémoire et de l'espace de stockage.
Il existe souvent une sorte de pipeline de ressources qui prend les données brutes exportées de l'outil de création de contenu et les transforme en une forme mieux adaptée pour jouer dans le jeu.
Dans le cas de notre police, nous avons un ensemble de symboles créés dans Asepritequi peut être exporté sous forme de fichier image 160x64.
Au lieu de charger une image en mémoire au démarrage du jeu, nous pouvons créer un outil pour transformer les données en un formulaire plus optimisé pour l'espace et le temps d'exécution décrit dans la section précédente.
Outil de traitement des polices
Nous devons convertir chacun des 39 glyphes de l'image d'origine en tableaux d'octets décrivant l'état de leurs pixels constitutifs (comme dans l'exemple avec le caractère «A»).
Nous pouvons mettre un tableau d'octets prétraités dans un fichier d'en-tête qui est compilé dans le jeu et écrit sur son lecteur Flash. ESP32 a beaucoup plus de mémoire Flash que de RAM, nous pouvons donc en profiter en compilant autant d'informations que possible dans le binaire du jeu.
La première fois que nous pourrons faire les calculs de conversion pixel à octet à la main, ce sera tout à fait faisable (bien que ennuyeux). Mais si nous voulons ajouter un nouveau glyphe ou modifier un ancien, le processus devient monotone, prend du temps et est sujet aux erreurs.
Et c'est une bonne occasion de créer un outil.
L'outil chargera un fichier image, générera un tableau d'octets pour chacun des caractères et les écrira dans un fichier d'en-tête que nous pouvons compiler dans le jeu. Si nous voulons changer les glyphes de la police (ce que j'ai fait plusieurs fois) ou en ajouter un nouveau, nous allons simplement réexécuter l'outil.
La première étape consiste à exporter le jeu de glyphes d'Aseprite dans un format que notre outil peut lire facilement. Nous utilisons le format de fichier BMP car il a un en-tête simple, ne compresse pas l'image et permet à l'image d'être encodée à 1 octet par pixel.
Dans Aseprite, j'ai créé une image avec une palette indexée, donc chaque pixel est un octet représentant l'index de la palette contenant uniquement les couleurs noires (Index 0) et blanches (Index 1). Le fichier BMP exporté conserve ce codage: un pixel désactivé a l'octet 0x0 et un pixel activé a l'octet 0x1.
Notre outil recevra cinq paramètres:
- BMP exporté depuis Aseprite
- Fichier texte décrivant le schéma de glyphe
- Chemin vers le fichier de sortie généré
- Largeur de chaque glyphe
- Hauteur de chaque glyphe
Le fichier de description de schéma de glyphe est nécessaire pour mapper les informations visuelles de l'image aux caractères eux-mêmes dans le code.
La description de l'image de police exportée ressemble à ceci:
ABCDEFGHIJ
KLMNOPQRST
UVWXYZ1234
567890:!?
Elle doit correspondre au schéma de l'image.
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]);
La première chose que nous faisons est la simple validation et l'analyse des arguments de la ligne de commande.
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);
Le fichier image est lu en premier.
Le format de fichier BMP a un en-tête qui décrit le contenu du fichier. En particulier, la largeur et la hauteur de l'image sont importantes pour nous, ainsi que le décalage dans le fichier où les données d'image commencent.
Nous allons créer une structure décrivant le schéma de cet en-tête afin que l'en-tête puisse être chargé et que les valeurs souhaitées soient accessibles par nom. La ligne pragma pack garantit qu'aucun octet de remplissage n'est ajouté à la structure de sorte que lorsque l'en-tête est lu à partir du fichier, il correspond correctement.
Le format BMP est un peu étrange en ce que les octets après le décalage peuvent varier considérablement en fonction de la spécification BMP utilisée (Microsoft l'a mise à jour plusieurs fois). Avec headerSizenous vérifions quelle version de l'en-tête est utilisée.
Nous vérifions que les deux premiers octets de l'en-tête sont égaux à BM , car cela signifie qu'il s'agit d'un fichier BMP. Ensuite, nous vérifions que la profondeur de bits est de 8 car nous nous attendons à ce que chaque pixel soit un octet. Nous vérifions également que l'en-tête est de 40 octets, car cela signifie que le fichier BMP est la version que nous voulons.
Les données d'image sont chargées dans l' imageBuffer après l' appel de fseek pour naviguer jusqu'à l'emplacement des données d'image indiqué par 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);
Nous lisons le fichier de description du schéma de glyphe dans un tableau de chaînes dont nous avons besoin ci-dessous.
Tout d'abord, nous comptons le nombre de lignes dans le fichier pour savoir combien de mémoire doit être allouée pour les lignes (un pointeur par ligne), puis nous lisons le fichier en mémoire.
Les sauts de ligne sont tronqués afin qu'ils n'augmentent pas la longueur de la ligne en caractères.
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");
Nous générons une fonction appelée GetGlyphIndex qui prend un caractère et renvoie l'index de données de ce caractère dans la carte de glyphes (que nous générerons sous peu).
L'outil parcourt de manière itérative la description de schéma précédemment lue et génère une instruction switch qui fait correspondre le caractère à l'index. Il vous permet de lier des caractères minuscules et majuscules à la même valeur et génère une assertion si vous essayez d'utiliser un caractère qui n'est pas un caractère de carte de glyphe.
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");
Enfin, nous générons nous-mêmes les valeurs 16 bits pour chacun des glyphes.
Nous parcourons les caractères de la description de haut en bas, de gauche à droite, puis créons seize valeurs 16 bits pour chaque glyphe en parcourant ses pixels dans l'image. Si un pixel est activé, alors le code écrit à la position de bit de ce pixel 1, sinon - 0.
Malheureusement, le code de cet outil est plutôt moche en raison des nombreux appels à fprintf , mais j'espère que la signification de ce qui se passe est claire.
L'outil peut ensuite être exécuté pour traiter le fichier image de police exporté:
./font_processor font.bmp font.txt font.h 16 16
Et il génère le fichier (abrégé) suivant:
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, .
En remplissant le fichier font.h avec les tableaux d'octets des glyphes, nous pouvons commencer à les dessiner à l'écran.
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;
}
}
}
}
}
Puisque nous avons transféré la charge principale vers notre outil, le code de rendu de texte lui-même sera assez simple.
Pour rendre une chaîne, nous bouclons sur ses caractères constitutifs et sautons un caractère si nous rencontrons un espace.
Pour chaque caractère non-espace, nous obtenons l'index de glyphe dans la carte de glyphe afin que nous puissions obtenir son tableau d'octets.
Pour vérifier les pixels dans un glyphe, nous bouclons sur 256 de ses pixels (16x16) et vérifions la valeur de chaque bit dans chaque ligne. Si le bit est activé, nous écrivons la couleur de ce pixel dans le tampon d'image. S'il n'est pas activé, nous ne faisons rien.
Cela ne vaut généralement pas la peine d'écrire des données dans un fichier d'en-tête car si cet en-tête est inclus dans plusieurs fichiers source, l'éditeur de liens se plaindra de plusieurs définitions. Mais font.h ne sera inclus dans le code que par le fichier text.c , donc cela ne posera pas de problèmes.
Démo
Nous allons tester le rendu du texte en rendant le fameux pangram The Quick Brown Fox Jumped Over The Lazy Dog , qui utilise tous les caractères supportés par la police.
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)));
Nous appelons DrawText trois fois pour que les lignes apparaissent sur des lignes différentes, et nous augmentons la coordonnée Y de la tuile pour chacune afin que chaque ligne soit dessinée sous la précédente. Nous allons également définir une couleur différente pour chaque ligne afin de tester les couleurs.
Pour l'instant, nous calculons la longueur de la chaîne manuellement, mais à l'avenir, nous nous débarrasserons de ces tracas.
Liens
Partie 8: le système de tuiles
Comme mentionné dans la partie précédente, nous allons créer des arrière-plans de jeu à partir de tuiles. Les objets dynamiques devant l'arrière-plan seront des sprites , que nous examinerons plus tard. Les ennemis, les balles et le personnage du joueur sont des exemples de sprites.
Nous placerons des tuiles 16x16 sur un écran 320x240 dans une grille fixe 20x15. À tout moment, nous pourrons afficher jusqu'à 300 tuiles à l'écran.
Tampon de tuile
Pour stocker des tuiles, nous devons utiliser des tableaux statiques, pas de la mémoire dynamique, afin de ne pas se soucier de malloc et de la libre , des fuites de mémoire et de la mémoire insuffisante lors de son allocation (Odroid est un système embarqué avec une quantité de mémoire limitée).
Si nous voulons stocker la disposition des tuiles à l'écran, et que le total des tuiles est de 20x15, alors nous pouvons utiliser un tableau 20x15, dans lequel chaque élément est un index de tuiles dans la "carte". Le tilemap contient les graphiques de tuiles lui-même.
Dans ce diagramme, les nombres en haut représentent la coordonnée X de la tuile (en tuiles), et les nombres sur la gauche représentent la coordonnée Y de la tuile (en tuiles).
Dans le code, il peut être représenté comme ceci:
uint8_t tileBuffer[15][20];
Le problème avec cette solution est que si nous voulions changer ce qui est affiché à l'écran (en changeant le contenu de la tuile), alors le joueur verra la tuile de remplacement.
Cela peut être résolu en agrandissant la zone tampon afin que vous puissiez y écrire pendant qu'elle est hors écran, et lorsqu'elle est affichée, elle semble continue.
Les carrés gris indiquent la «fenêtre» visible dans le tampon de tuiles, qui est rendue à l'écran. Pendant que l'écran affiche ce qui se trouve dans les carrés gris, le contenu de tous les carrés blancs peut être modifié pour que le joueur ne le voie pas.
Dans le code, cela peut être considéré comme un tableau deux fois plus grand en X.
uint8_t tileBuffer[15][40];
Sélection d'une palette
Pour l'instant, nous allons utiliser une palette de quatre valeurs de niveaux de gris.
Au format RGB888, ils ressemblent à:
- 0xFFFFFF (blanc / valeur 100%).
- 0xABABAB (- / 67% )
- 0x545454 (- / 33% )
- 0x000000 ( / 0% )
Nous évitons d'utiliser les couleurs pour l'instant car j'améliore encore mes compétences artistiques. En utilisant les niveaux de gris, je peux me concentrer sur le contraste et la forme sans me soucier de la théorie des couleurs. Même une petite palette de couleurs nécessite un bon goût artistique.
Si vous avez un doute sur la force de la couleur en niveaux de gris 2 bits, pensez à la Game Boy, qui n'avait que quatre couleurs dans sa palette. Le premier écran de la Game Boy était teinté de vert, donc les quatre valeurs étaient affichées sous forme de nuances de vert, mais la Game Boy Pocket les affichait en véritables niveaux de gris.
L'image ci-dessous pour The Legend of Zelda: Link's Awakening montre tout ce que vous pouvez réaliser avec seulement quatre valeurs si vous avez un bon artiste.
Pour l'instant, les graphiques de tuiles ressembleront à quatre carrés avec une bordure d'un pixel à l'extérieur et avec des coins tronqués. Chaque carré aura l'une des couleurs de notre palette.
La troncature des coins est un petit changement, mais elle vous permet de faire la distinction entre les carreaux individuels, ce qui est utile pour le rendu du maillage.
Outil Palette
Nous stockerons la palette au format de fichier JASC Palette, qui est facile à lire, facile à analyser avec des outils et pris en charge par Aseprite.
La palette ressemble à ceci
JASC-PAL
0100
4
255 255 255
171 171 171
84 84 84
0 0 0
Les deux premières lignes se trouvent dans chaque fichier PAL. La troisième ligne est le nombre d'éléments dans la palette. Le reste des lignes correspond aux valeurs des éléments rouge, vert et bleu de la palette.
L'outil de palette lit le fichier, convertit chaque couleur en RGB565, inverse l'ordre des octets et écrit les nouvelles valeurs dans le fichier d'en-tête qui contient la palette dans un tableau.
Le code de lecture et d'écriture du fichier est similaire au code utilisé dans le septième article, et le traitement des couleurs se fait comme ceci:
// 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 fonction strtok divise la chaîne en fonction des délimiteurs. Les trois valeurs de couleur sont séparées par un espace, nous utilisons donc cela. Nous créons ensuite la valeur RGB565 en décalant les bits et en inversant l'ordre des octets, comme nous l'avons fait dans la troisième partie de l'article.
./palette_processor grey.pal grey.h
La sortie de l'outil ressemble à ceci:
uint16_t palette[4] =
{
0xFFFF,
0x55AD,
0xAA52,
0x0000,
};
Outil de traitement de carreaux
Nous avons également besoin d'un outil qui génère les données des tuiles dans le format attendu par le jeu. La valeur de chaque pixel du fichier BMP est un index de palette. Nous garderons cette notation indirecte pour qu'une tuile de 16x16 (256) octets occupe un octet par pixel. Lors de l'exécution du programme, nous trouverons la couleur de la tuile dans la palette.
L'outil lit le fichier, parcourt les pixels et écrit leurs index dans un tableau de l'en-tête.
Le code de lecture et d'écriture du fichier est également similaire au code de l'outil de traitement des polices, et la création du tableau correspondant se produit ici:
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;
}
}
}
L'index est obtenu à partir de la position du pixel dans le fichier BMP, puis écrit dans le fichier en tant qu'élément de tableau 16x16.
./tile_processor black.bmp black.h
La sortie de l'outil lors du traitement d'une tuile noire ressemble à ceci:
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 vous regardez de plus près, vous pouvez comprendre l'apparence d'une tuile simplement par les indices. Tous les 3 signifie noir et chaque 0 signifie blanc.
Fenêtre Frame
Par exemple, nous pouvons créer un "niveau" simple (et extrêmement court) qui remplit tout le tampon de tuiles. Nous avons quatre tuiles différentes, et ne pas s'inquiéter des graphiques, nous utilisons simplement un schéma dans lequel chacune des quatre tuiles a une couleur différente en niveaux de gris.
Nous organisons quatre tuiles dans une grille de niveau 40x15 pour tester notre système.
Les nombres ci-dessus indiquent les index de colonne du framebuffer. Les nombres ci-dessous sont les index des colonnes de la fenêtre frame. Les nombres sur la gauche sont les lignes de chaque tampon (pas de mouvement de fenêtre vertical).
Pour le lecteur, tout ressemblera à celui indiqué dans la vidéo ci-dessus. Lorsque la fenêtre est déplacée vers la droite, il apparaît au joueur que l'arrière-plan est décalé vers la gauche.
Démo
Le nombre dans le coin supérieur gauche est le numéro de colonne du bord gauche de la fenêtre de tampon de tuiles, et le nombre dans le coin supérieur droit est le numéro de colonne du bord droit de la fenêtre de tampon de tuiles.
La source
Le code source de l'ensemble du projet est ici .