Développement GameBoy #7 : Les sprites

Après une petite pause pendant les vacances de Noël (et tout le mois de janvier...), on reprend enfin les articles sur le développement GameBoy. On va s'attaquer cette fois-ci aux sprites, éléments essentiels de tout jeu un minimum interactif.

Les sprites sont les éléments graphiques les plus « complexes » que propose la GameBoy. Je vais donc commencer par vous lister leurs caractéristiques, et je détaillerais comment les utiliser dans la suite de l'article.

Note

Cet article fait partie d'une série sur le développement GameBoy en C avec le compilateur SDCC et la bibliothèque gbdk-n. Cette série est toujours en cours et de nouveaux articles paraissent de temps à autre.

Articles de la série :

Pour commencer, contrairement aux couches Background et Window, les sprites ne sont pas positionnés sur une grille, ils peuvent être positionnés n'importe où sur l'écran, au pixel près :

Exemple de positionnement d'un sprite

Les sprites peuvent être composés de 1 ou 2 tuiles disposées verticalement et peuvent donc avoir pour dimensions 8×8 ou 8×16 pixels (16×8 pixels n'est pas possible) :

Représentation des différentes tailles de sprite

On peut afficher simultanément jusqu'à 40 sprites à l'écran, il y a toutefois une limitation : on ne peut pas en afficher plus de 10 sur une ligne de pixel. Lorsque cela se produit, une partie du sprite le moins prioritaire ne sera pas affiché :

Illustration de ce qu'il se passe si on aligne plus de 10 sprites sur une ligne

Note

À propos de la priorité des sprites : Sur la GameBoy, la priorité entre deux sprites se résouds de la manière suivante :

  • le sprite ayant la coordonnée x la plus petite (donc se trouvant le plus à gauche) est prioritaire.
  • Si les deux sprites ont la même coordonnées x, alors c'est celui qui apparait le premier dans la table des sprites qui est prioritaire (le sprite numéro 0 est prioritaires sur le numéro 1).

Lorsque deux sprites se chevauchent, celui ayant la priorité la plus élevée sera dessiné par-dessus celui qui à la priorité la plus faible.

Notez toutes-fois que sur GameBoy Color (lorsque l'on est pas en mode compatibilité GameBoy), la priorité est uniquement calculée par l'ordre d'apparition du sprite dans la table des sprites, sa position à l'écran n'influe pas.

Contrairement aux tuiles utilisées sur les couches Background ou Window, les tuiles utilisées pour composer des sprites ne peuvent comporter que 3 couleurs (au lieu de 4). En effet, l'une des couleurs de la palette est transparente afin d'afficher ce qui se trouve derrière. Pour compenser la perte d'une couleur, on a le choix entre deux palettes pour les tuiles des sprites. Par défaut, ces palettes sont composées des couleurs suivantes (on verra dans le prochain article comment les modifier) :

  • Gris clair, gris foncé et noir, le blanc étant transparent,
  • ou gris foncé, gris clair et blanc avec la couleur noire transparente.
Palettes disponibles pour afficher les sprites

Il est également possible d'afficher les sprites devant (par défaut) ou derrière les couches Background et Window. Si un sprite est positionné à l'arrière, il ne sera visible qu'aux endroits où les couches Background et Window sont composés de pixels blancs :

Les sprites peuvent aussi être placés en dessous les autres couches

Enfin, il est possible de retourner horizontalement et verticalement les sprites, ce qui permet de réutiliser les mêmes tuiles dans plusieurs situations et ainsi économiser de la place en mémoire :

Retournement vertical et horizontal des sprites

Lorsque l'on a évoqué la couche Background dans un précédent article, nous avions parlé d'une zone de la mémoire permettant de stocker la carte des tuiles de la couche (Background map), eh bien pour les sprites, il y a également une zone de mémoire, appelée OAM (Object Attribute Memory) qui contient toutes les propriétés que j'ai listées ci-dessus pour chacun des 40 sprites. On ne va bien évidement pas la modifier directement étant donné que GBDK nous fournit quelques fonctions pour cela.

Charger des tuiles en mémoire

Avant de pouvoir afficher le moindre sprite à l'écran, il faut, comme pour les autres couches, charger des tuiles dans la mémoire vidéo de la GameBoy. GBDK nous fournit bien sûr une fonction pour ça :

void set_sprite_data(UINT8 first_tile, UINT8 nb_tiles, unsigned char *data);

La seule différence avec set_bkg_data(), la fonction utilisée pour charger les tuiles utilisées par les couches Backrgound et Window, c'est que les données ne sont pas envoyées au même endroit dans la mémoire. Je ne vais donc pas rentrer plus dans les détails étant donné que ça marche de la même façon.

Activer / désactiver la « couche » Sprites

Comme pour les autres couches, les sprites ne sont pas affichés par défaut, il faudra donc les rendre visibles avec la macro suivante :

SHOW_SPRITES;

... et il est possible de les masquer avec la macro :

HIDE_SPRITES;

Afficher un sprite et le positionner

Passons enfin aux choses sérieuses : afficher un sprite là où on veut à l'écran. Comme je l'ai dit en introduction, la GameBoy peut afficher jusqu'à 40 sprites à l'écran. Chacun d'entre eux est identifié par un numéro (de 0 à 39) qui sera passé en paramètre aux fonctions manipulant les sprites.

Pour commencer, il va falloir assigner une tuile à l'un des sprites (sinon la GameBoy ne saurait pas quoi afficher). Cela se fait à l'aide de la fonction suivante :

void set_sprite_tile(UINT8 nb, UINT8 tile);
  • nb correspond au numéro du sprite à modifier (0-39),
  • tile correspond au numéro de la tuile qui sera contenu dans ce sprite (0-255)

Note

NOTE : Il y a également une fonction UITN8 get_sprite_tile(UINT8 nb), qui permet de récupérer l'id de la tuile utilisée par un sprite donné.

On peut ensuite déplacer le sprite là où on le souhaite à l'écran à l'aide de l'une des deux fonction suivantes :

void move_sprite(UINT8 nb, UINT8 x, UINT8 y);

Cette fonction déplace le sprite nb aux coordonnées (x, y) indiquée.

void scroll_sprite(INT8 nb, INT8 x, INT8 y);

Cette fonction fait la même chose, mais le déplacement est relatif (si on met x à -5, le sprite sera déplacé de 5 pixels vers la gauche).

Il y a une petite subtilité que je n'ai pas encore abordé dans le placement des sprites : le coin en haut à gauche de l'écran a pour coordonnées (8, 16) (et pas (0, 0) comme on aurait pu s'y attendre). Ceci permet de faire déborder un sprite sur les bords gauche et haut de l'écran :

Coordonnées des sprites

Enfin, dernière information à propos du placement des sprites : les sprites se retrouvent masqués lorsque leurs coordonnées les placent en dehors de l'écran :

  • lorsque x est égal à 0 ou supérieur ou égal à 168,
  • ou lorsque y est égal à 0 ou supérieur ou égal à 160.

C'est d'ailleurs de cette façon que l'on masque individuellement un sprite.

Note

NOTE : Pour masquer un sprite, il est préférable de mettre sa coordonnée y à 0 plutôt que d'utiliser x. En effet, si on masque le sprite en mettant x à 0, il continue à être pris en compte dans le calcul de priorité et continue de limiter le nombre de sprite par ligne.

Modifier les propriétés d'un sprite

Comme on l'a vu dans l'introduction, la GameBoy permet d'opérer un certain nombre de modifications sur un sprite :

  • changer la palette de couleur servant à l'afficher,
  • le retourner horizontalement,
  • le retourner verticalement,
  • changer sa priorité (pour le placer devant ou derrière les autres couches).

Toutes ces modifications peuvent être effectuées à l'aide de la fonction set_sprite_prop() :

void set_sprite_prop(UINT8 nb, UINT8 prop);
  • nb correspond au numéro du sprite à modifier (0-39),
  • prop correspond aux modifications à apporter au sprite.

Note

NOTE : Il existe là encore une fonction UINT8 get_sprite_prop(UINT8 nb) pour récupérer l'état actuel d'un sprite.

L'argument prop est donc un nombre dont 4 des 8 bits correspondent à des flags activant l'une des modifications. Les 4 bits restants ne sont pas utilisés sur la GameBoy originale, mais le seront plus tard par la GameBoy Color (mais ça,c'est une autre histoire).

GBDK nous fournit des constantes contenant les masques permettant de manipuler chacun des bits qui nous intéressent. Pour utiliser ces masques, il faudra donc encore jouer avec des opérations binaires... mais pas de panic, je vous explique ça tout de suite.

Pour activer l'une des modifications (passer le bit à 1), il faudra appliquer le masque correspondant au bit à modifier à la valeur actuelle de prop à l'aide de l'opération binaire OU. Ceci peut être réalisé à l'aide du code suivant :

UINT8 nb = 0;  // Numéro de la tuile à modifier
set_sprite_prop(nb, get_sprite_prop(nb) | MASQUE_DU_BIT);
//                                      ---------------
//                                    passe le bit désigné
//                                      par le masque à 1

Et pour désactiver l'une des modifications (et donc passer le bit correspondant à 0), il faudra appliquer le complément du masque à la valeur courante de prop à l'aide de l'opération binaire ET :

UINT8 nb = 0;  // Numéro de la tuile à modifier
set_sprite_prop(nb, get_sprite_prop(nb) & ~MASQUE_DU_BIT);
//                                      ----------------
//                                     passe le bit désigné
//                                      par le masque à 0

Note

À propos du complément binaire : comme l'un des cobayes ayant relu mon article m'a posé des questions sur le complément (merci à lui), je vais expliquer rapidement ce que c'est 🙂️

  • Pour commencer, c'est le tilde (~) qui effectue le complément dans le code précédent (c'est vrai qu'on a pas forcément l'habitude de voir cet opérateur).
  • Ensuite, qu'est-ce qu'un complément ? Eh bien c'est tout simplement l'inverse, bit à bit, du nombre. Par exemple, si on prend le nombre binaire 00010000 (qui correspond au masque du bit permettant le changement de palette), son complément est 11101111.

Changer la palette d'un sprite

Pour changer la palette utilisée pour dessiner un sprite, il faut utiliser le masque S_PALETTE pour modifier le 5ème bit de prop :

  • le passer à 0 permet d'utiliser la palette par défaut,
  • le passer à 1 permet d'utiliser la palette alternative.

Par exemple, pour que le sprite numéro 10 utilise la palette alternative, il faudra écrire le code suivant :

set_sprite_prop(10, get_sprite_prop(10) | S_PALETTE);

et pour le repasser sur la palette par défaut :

set_sprite_prop(10, get_sprite_prop(10) & ~S_PALETTE);

Retourner un sprite horizontalement

Pour retourner un sprite horizontalement le masque à utiliser est S_FLIPX, qui permettra de modifier le 6ème bit de prop.

  • Lorsque le bit est à 0, le sprite est affiché normalement (pas retourné),
  • lorsque le bit est à 1, le sprite est retourné.

Retourner un sprite verticalement

Pour retournement un sprite verticalement, il faut utiliser le masque S_FLIPY, qui nous permettra de modifier le 7ème bit de prop.

  • Lorsque le bit est à 0, le sprite est affiché normalement (pas retourné),
  • lorsque le bit est à 1, le sprite est retourné.

Afficher un sprite derrière la couche Background

Enfin, pour afficher le sprite derrière les couches Background et Window, c'est le 8ème bit qu'il faudra modifier à l'aide du masque S_PRIORITY :

  • passer le bit à 0 affichera le sprite devant (comportement par défaut),
  • passer le bit à 1 l'affichera derrière.

Note

NOTE : Lorsque qu'un sprite est affiché derrière les couches Background et Window, il n'est visible qu'aux endroits où la couleur de ces couches est 0 (blanc).

Utiliser des sprites en 8×16

Dernière possibilité offerte par la GameBoy : utiliser des sprites de 8×16 pixels. Cette configuration s'active par contre de manière globale : il n'est pas possible d'utiliser en même temps des sprites de 8×8 px et de 8×16 px.

Les sprites de 8×16 px affichent donc 2 tuiles, l'une en dessous de l'autre, et il y a quelques subtilités à savoir. On a vu plus tôt comment définir la tuile qui doit être affichée par un sprite de 8×8 px, eh bien pour les sprites plus grand c'est la même chose... sauf que les tuiles fonctionnent par paire : il suffit d'indiquer n'importe laquelle des deux tuiles d'une paire, et la première s'affichera en haut et la seconde en bas (donc indiquer la tuile 0 ou 1 aura strictement le même effet) :

Représentation des paires en mémoire

Pour activer les sprites de 8×16 px, il faut utiliser la macro suivante :

SPRITES_8x16;

Et pour repasser les sprites en 8×8 px, la macro à utiliser est :

SPRITES_8x8;

Un petit exemple pour digérer

Après tous ces détails techniques sur le fonctionnement des sprites il est enfin temps de mettre les mains dans le cambouis histoire de voir comment ça marche en situation réellement. Je vous propose donc ce petit exemple dans lequel on va animer un petit personnage en forme de GameBoy, que nous appellerons Gaby (pour Gameboy Animated Blayer Yolo, #JaiPasTrouvéMieux). Gaby pourra être déplacée dans les quatre directions à l'aide du D-Pad, et... ce sera déjà bien 😁️

Note

NOTE : Dans cet exemple, je vais essayer d'utiliser un maximum des possibilités présentées dans cet article, il ne sera donc pas aussi basique que ceux des articles précédents... Mais pas de panique, je vais bien le commenter et prendre le temps d'expliquer deux ou trois trucs.

Voici là liste des caractéristiques de Gaby la GameBoy :

  • Elle mesure 16×16 px : il faudra donc 2 sprites de 8×16 px pour la représenter.
  • Elle se promène dans les 4 directions : il faudra donc prévoir des tuiles pour chaque direction (en réalité 3 directions suffiront : on va utiliser la fonction miroir pour les directions droite et gauche).
  • Elle est animée : il va falloir dessiner toutes les tuiles de l'animation et prévoir du code pour changer les tuiles avec le bon timing.
  • Étant donnée que Gaby est une GameBoy blanche, on va utiliser la palette de couleur alternative (la couleur noire sera donc transparente).

Pour commencer, il faut dessiner les tuiles qui seront utilisées dans les sprites. Voici quelques petites précisions à noter pour le dessin :

  • Je vais dessiner les tuiles comme si j'allais utiliser la palette de couleur normale (en gardant à l'esprit que le noir sera la couleur transparente), et je laisserais img2gb inverser les couleurs à l'aide de son option --alternative-palette.
  • Comme on l'a vu plus tôt, il faut agencer les tuiles d'une manière particulière pour les utiliser dans des sprites de 8×16 : il faut positionner les tuiles d'une même paire l'une à côté de l'autre, alors qu'elles seront affichées l'une en dessous de l'autre... ce qui n'est pas pratique pour le dessin. Je vais donc quand même les dessiner l'une en dessous de l'autre, et utiliser l'option --sprite8x16 d'img2gb pour les réorganiser automatiquement.

Voici donc à quoi ressemble les tuiles qui composent Gaby une fois mon dessin terminé (j'ai zoomé l'image et mis en évidence les deux premières paires afin de bien illustrer la transformation qui sera opérée par img2gb) :

Tuiles composant les sprites du joueur

Maintenant que l'on a dessiné les tuiles, on peut générer le tileset qui pourra être utilisé sur GameBoy. Pour ce faire, on va utiliser img2gb, le petit outil que je vous avais présenté dans un précédent article :

img2gb tileset \
    --output-c-file=src/player.sprites.c \
    --output-header-file=src/player.sprites.h \
    --output-image=src/player.sprites.png \
    --alternative-palette \
    --sprite8x16 \
    --name PLAYER_SPRITES \
    ./player.png

Cette commande nous génère des fichiers .c et .h que nous pourrons utiliser dans notre programme, et un fichier .png qui nous permet de voir le résultat de la transformation (cette fois encore j'ai zoomé l'image et mis en évidence les deux premières paires) :

Tileset des tuiles composant le joueur

Maintenant qu'on a tout ce qu'il faut du côté des graphismes, on peut passer au code. Voici donc le code complet de l'exemple tout bien commenté, (oui il est un peu long, mais si on prend le temps de le lire tranquillement en commençant par la fonction main(), ça se comprend assez bien) :

#include <gb/gb.h>

#include "player.sprites.h"

// Id ("nb") des deux sprites utilisés pour représenter le joueur
#define PLAYER_SPRITE_L_ID 0
#define PLAYER_SPRITE_R_ID 1

// Données de l'animation des sprites du joueur
UINT8 PLAYER_SPRITE_ANIM_L[] = {
// TAILLE | MIROIR | ID DES TUILES   | DIRECTION
   4,       0,        0 , 4,  0,  8,   // Bas
   4,       0,       12, 16, 12, 20,   // Haut
   4,       0,       24, 28, 24, 32,   // Droite
   4,       1,       26, 30, 26, 34,   // Gauche
};
UINT8 PLAYER_SPRITE_ANIM_R[] = {
// TAILLE | MIROIR | ID DES TUILES   | DIRECTION
   4,       0,        2,  6,  2, 10,   // Bas
   4,       0,       14, 18, 14, 22,   // Haut
   4,       0,       26, 30, 26, 34,   // Droite
   4,       1,       24, 28, 24, 32,   // Gauche
};

// Liste des sous-animation (les nombre représentent l'octet où la
// sous-animation commence dans les données globales de l'animation du
// joueur)
#define PLAYER_DIRECTION_DOWN  0
#define PLAYER_DIRECTION_UP    6
#define PLAYER_DIRECTION_RIGHT 12
#define PLAYER_DIRECTION_LEFT  18

// Variables stockant l'état du joueur
UINT8 player_x;
UINT8 player_y;
UINT8 player_direction;
UINT8 player_animation_frame;
UINT8 is_player_walking;

// Retourne un sprite horizontalement (sur l'axe X).
//
// sprite_id: l'id ("nb") du sprite à modifier.
void flip_sprite_horiz(UINT8 sprite_id) {
    set_sprite_prop(sprite_id, get_sprite_prop(sprite_id) | S_FLIPX);
}

// Supprime le retournement horizontal d'un sprite.
//
// sprite_id: l'id ("nb") du sprite à modifier.
void unflip_sprite_horiz(UINT8 sprite_id) {
    set_sprite_prop(sprite_id, get_sprite_prop(sprite_id) & ~S_FLIPX);
}

// Met à jour les tuiles d'un sprite pour l'animer.
//
// sprite_id: l'id ("nb") du sprite à modifier
// anim:      pointeur vers les données de l'animation
// direction: direction de l'animation (= offset de la sous-animation)
// frame:     la nouvelle frame de l'animation qui doit être affichée
//
// Retourne la prochaine frame de l'animation.
UINT8 update_sprite_animation(UINT8 sprite_id, UINT8 *anim, UINT8 direction, UINT8 frame) {
    UINT8 len = anim[direction];
    UINT8 flip = anim[direction + 1];
    UINT8 tile_id = anim[direction + 2 + frame];

    if (flip) {
        flip_sprite_horiz(sprite_id);
    } else {
        unflip_sprite_horiz(sprite_id);
    }

    set_sprite_tile(sprite_id, tile_id);

    return (frame + 1) % len;
}

void main(void) {
    UINT8 keys = 0;
    UINT8 frame_skip = 8;  // On met à jour l'animation toutes les 8 frames
                           // seulement, sinon l'animation serait trop
                           // rapide (8 frames = ~133 ms entre chaque frame
                           // de l'animation)

    // On initialise l'état du joueur
    player_x = 80;
    player_y = 72;
    player_direction = PLAYER_DIRECTION_DOWN;
    player_animation_frame = 0;
    is_player_walking = 0;

    // Charge les tuiles des sprites dans la mémoire vidéo
    set_sprite_data(0, PLAYER_SPRITES_TILE_COUNT, PLAYER_SPRITES);

    // On utilise des sprites de 8×16 px
    SPRITES_8x16;

    // On rend les sprites visibles
    SHOW_SPRITES;

    // On initialise les deux sprites qui représentent le joueur
    move_sprite(PLAYER_SPRITE_L_ID, player_x, player_y);
    set_sprite_prop(PLAYER_SPRITE_L_ID, S_PALETTE);

    move_sprite(PLAYER_SPRITE_R_ID, player_x + 8, player_y);
    set_sprite_prop(PLAYER_SPRITE_R_ID, S_PALETTE);

    while (1) {
        // On attend le rafraichissement de l'écran (v-sync)
        wait_vbl_done();

        // On lit les touche du gamepad pour savoir si le joueur
        // se déplace, et dans quelle direction
        keys = joypad();
        if (keys & J_UP) {
            player_direction = PLAYER_DIRECTION_UP;
            is_player_walking = 1;
        } else if (keys & J_DOWN) {
            player_direction = PLAYER_DIRECTION_DOWN;
            is_player_walking = 1;
        } else if (keys & J_LEFT) {
            player_direction = PLAYER_DIRECTION_LEFT;
            is_player_walking = 1;
        } else if (keys & J_RIGHT) {
            player_direction = PLAYER_DIRECTION_RIGHT;
            is_player_walking = 1;
        } else {
            is_player_walking = 0;
            frame_skip = 1;  // On force le rafraîchissement de l'animation
        }

        // On met à jour la position du joueur s'il est en train de marcher
        if (is_player_walking) {
            if (player_direction == PLAYER_DIRECTION_RIGHT) player_x += 1;
            else if (player_direction == PLAYER_DIRECTION_LEFT) player_x -= 1;
            else if (player_direction == PLAYER_DIRECTION_UP) player_y -= 1;
            else if (player_direction == PLAYER_DIRECTION_DOWN) player_y += 1;
            move_sprite(PLAYER_SPRITE_L_ID, player_x, player_y);
            move_sprite(PLAYER_SPRITE_R_ID, player_x + 8, player_y);

            // Ici on compte les frames pour ne pas mettre à jour l'animation
            // à chaque rafraîchissement de l'écran : l'animation serait trop
            // rapide sinon...
            frame_skip -= 1;
            if (!frame_skip) {
                frame_skip = 8;
            } else {
                continue;
            }
        } else {
            player_animation_frame = 0;
        }

        // On met à jour les tuiles utilisées dans les sprites
        update_sprite_animation(
                PLAYER_SPRITE_L_ID,
                PLAYER_SPRITE_ANIM_L,
                player_direction,
                player_animation_frame);
        player_animation_frame = update_sprite_animation(
                PLAYER_SPRITE_R_ID,
                PLAYER_SPRITE_ANIM_R,
                player_direction,
                player_animation_frame);
    }
}

Lorsque l'on compile tout ça et qu'on le lance dans un émulateur, ça donne le résultat suivant :

Video du résultat de l'exemple

Comme d'habitude vous retrouverez le code complet du projet sur Github :

C'est ainsi que s'achève cet article sur les sprites. Le prochain article, que je vais essayer de sortir rapidement (edit: raté... 😅️), traitera de la couche Window, et sera l'avant dernier article de la partie « graphismes » de cette série d'articles.