Développement GameBoy #10 : Projet 2 - Breakout (PARTIE 2)

Et on poursuit notre développement d'un casse-briques sur GameBoy ! Dans la première partie de cet article, on s'était laissés après avoir dessiné et affiché tous les éléments graphiques qui composent le jeu. Cette fois-ci on va voir comment déplacer la raquette et la balle et comment gérer les collisions. Promis, la prochaine fois on cassera des briques !

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 les nouveaux articles paraissent à un rythme d'environ un par mois.

Articles de la série :

Déplacement de la raquette

Dans un casse-briques, la raquette :

  • peut être déplacée uniquement sur l'axe horizontal (x), c'est-à-dire vers la gauche ou vers la droite,
  • et ne doit pas sortir de l'écran.

La seule « difficulté » ici c'est que la raquette est composée de 3 sprites (on parle d'ailleurs de metasprite dans ce genre de cas), qu'il va falloir déplacer de manière synchronisée.

Pour ne pas alourdir la boucle principale, je vais écrire une petite fonction pour déplacer la raquette :

#define PADDLE_Y 152  /* Le Y de la raquette ne change jamais */
UINT8 PADDLE_X = 76;  /* Variable globale contenant la position X actuelle de la raquette */

void move_paddle(INT8 delta) {
    // Mise à jour de la position de la raquette
    PADDLE_X += delta;

    // Vérification que la raquette ne sort pas de l'écran
    if (PADDLE_X < 8) {
        PADDLE_X = 8;
    } else if (PADDLE_X > 160 + 8 - 24) {  // LargeurÉcran + Offset - LargeurRaquette
        PADDLE_X = 160 + 8 - 24;
    }

    // Déplacement des sprites
    move_sprite(1, PADDLE_X, PADDLE_Y);
    move_sprite(2, PADDLE_X + 8, PADDLE_Y);
    move_sprite(3, PADDLE_X + 2 * 8, PADDLE_Y);
}

Quelques explications rapides sur ce bout de code:

Le paramètre delta me donne le déplacement relatif souhaité. C'est-à-dire que pour décaler la raquette d'un pixel vers la gauche, il faudra passer -1 à la fonction, et pour le décaler vers la droite il faudra lui passer +1.

Pour empêcher la raquette de sortir de l'écran, je regarde si x est plus petit que 8 ou plus grand que 160 + 8 - 24 (160 étant la largeur de l'écran et 24 la largeur de la raquette en pixel). Pourquoi ce décalage de 8px dans les calculs ? Eh bien tout simplement par ce que les coordonnées des sprites sont décalées de 8px sur l'axe x et de 16px sur l'axe y. Je vous invite à relire mon article sur les Sprites pour plus de détails.

Enfin, je mets à jour la position de chacun des trois sprites en les décalant de 8px (soit la dimension d'une tuile) à chaque fois.

Maintenant que j'ai une fonction pour bouger ma raquette, il ne me reste plus qu'à lire les input du joueur (voir l'article sur le gamepad pour plus d'infos). Je vais donc rajouter une boucle infinie dans la fonction main() dans laquelle je vais lire les touches pressées et agir en conséquence :

void main(void) {

    // ...

    while (1) {
        UINT8 keys = joypad();

        if (keys & J_LEFT) {
            move_paddle(-2);
        } else if (keys & J_RIGHT) {
            move_paddle(+2);
        }

        // Synchronisation avec le rafraîchissement de l'écran
        wait_vbl_done();
    }
}

Petit bonus : je vais rajouter un bout de code à la fonction move_paddle() pour (ré)initialiser la position de la raquette au centre de l'écran lorsque qu'on lui passe 0 comme paramètre. Ça servira au début d'une nouvelle partie :

void move_paddle(INT8 delta) {
    // Mise à jour de la position de la raquette
    if (delta == 0) {
        PADDLE_X = 76;
    } else {
        PADDLE_X += delta;
    }

    // ...

}

On peut à présent tester tout ça dans un émulateur pour s'assurer que la raquette réponde bien :

Capture vidéo du déplacement de la raquette

Déplacement de la balle

Pour le déplacement de la balle, les choses se compliquent un peu (mais vraiment un tout petit peu hein, ne fuyez pas !). Pour déplacer la balle il va nous falloir savoir où elle se trouve, dans quelle direction elle va, et à quelle vitesse.

Si je traduis tout ça sous forme de code, ça me donne ceci :

UINT8 BALL_X = 50;
UINT8 BALL_Y = 120;
INT8 BALL_DELTA_X = 1;
INT8 BALL_DELTA_Y = -1;

Ici, BALL_X et BALL_Y me donnent, sans surprise, la position de la balle, et BALL_DELTA_X et BALL_DELTA_Y m'informent sur la direction et la vitesse de la balle : le signe m'informe de la direction et la valeur de la vitesse (du nombre de pixels dont la balle se déplace à chaque frame).

Pour contrôler la direction dans laquelle se déplace la balle, on va donc pouvoir modifier les signes de BALL_DELTA_X et BALL_DELTA_Y de la façon suivante :

  • lorsque BALL_DELTA_X est positif, la balle se déplacera vers la droite,
  • lorsque BALL_DELTA_X est négatif, la balle se déplacera vers la gauche,
  • lorsque BALL_DELTA_Y est positif, la balle se déplacera vers le bas,
  • lorsque BALL_DELTA_Y est négatif, la balle se déplacera vers le haut.
Schéma montrant la direction de la balle en fonction des valeures de delta_x et delta_y

À chaque frame (à chaque tour de ma boucle infinie), je vais donc simplement additionner BALL_X avec BALL_DELTA_X et BALL_Y avec BALL_DELTA_Y et j'obtiendrai la nouvelle position de la balle.

Voilà en gros comment ça se traduit dans le code :

void main(void) {

    // ...

    while (1) {

        // ...

        // Déplacement de la balle
        BALL_X += BALL_DELTA_X;
        BALL_Y += BALL_DELTA_Y;
        move_sprite(0, BALL_X, BALL_Y);

        wait_vbl_done();
    }
}

On peut maintenant lancer notre jeu pour admirer le résultat :

Capture vidéo du déplacement de la balle

Bon ici la balle passe à travers les briques et les bordures et boucle grâce à la magie des dépassements d'entier... mais on va vite corriger tout ça ! 😉️

Faire rebondir la balle

On a actuellement une balle qui se déplace en ligne droite, c'est déjà bien, mais maintenant on va voir comment faire pour qu'elle puisse rebondir.

Comme on l'a vu précédemment, j'utilise deux variables, BALL_DELTA_X et BALL_DELTA_Y, pour représenter les mouvements de la balle. Il est donc très facile de la faire rebondir : il suffit de « jouer » avec les signes de ces deux nombres.

Si par exemple la balle se déplace vers le coin en haut à droite (comme sur le GIF un peu plus haut) et qu'on rencontre une bordure à droite :

Schéma du rebond de la balle : situation initiale

il suffit d'inverser le signe de BALL_DELTA_X pour que la balle rebondisse sur la bordure :

Schéma du rebond de la balle : rebond sur la bordure de droite

Et si la balle vient taper la bordure du haut, il suffit cette fois-ci d'inverser le signe de BALL_DELTA_Y :

Schéma du rebond de la balle : rebond sur la bordure du haut

Collisions avec l'environnement

Maintenant qu'on a vu comment faire rebondir la balle, il reste à savoir quand la faire rebondir. Dans un premier temps, je vais mettre en œuvre une version simplifiée des collisions, avec l'environnement uniquement. Afin de simplifier les choses donc, je vais donc considérer que la balle est un point, c'est-à-dire qu'elle mesure 1×1 px.

Pour savoir s'il y a collision et donc si je dois faire rebondir la balle, ça va être très simple. À chaque frame :

  1. je vais regarder au-dessus de quelle case du Background se trouverait ma balle si je la déplaçais sur l'axe horizontal (x),
  2. puis je vais regarder quelle tuile est présente dans cette case :
    • s'il s'agit de la tuile blanche de mon tileset (la tuile numéro 128), alors la case est vide et donc il n'y a pas collision : je ne change pas le signe de BALL_DELTA_X.
    • si par contre la case n'est pas vide, alors il y a collision : donc je change le signe de BALL_DELTA_X (BALL_DELTA_X = -BALL_DELTA_X).
  3. Je répète les opérations 1 et 2 avec l'axe vertical (y).
  4. Et enfin, je modifie effectivement la position de la balle sur l'axe horizontal (BALL_X += BALL_DELTA_X).

Pour implémenter cet algorithme, je vais commencer par créer une fonction que je pourrai appeler pour savoir s'il y a collision ou non :

#define TILE_EMPTY  128

UINT8 check_ball_collide(INT8 delta_x, INT8 delta_y) {
    // On simule le déplacement de la balle
    UINT8 ball_x = BALL_X + delta_x;
    UINT8 ball_y = BALL_Y + delta_y;

    // Conversion de la position du sprite (exprimée en pixel)
    // vers les coordonnées d'une cellule (exprimées en tuile)
    UINT8 ball_next_cell_x = (ball_x - 8) / 8;  // (ball_y - DécalageSpriteX) / LargeurTuile
    UINT8 ball_next_cell_y = (ball_y - 16) / 8; // (ball_y - DécalageSpriteY) / HauteurTuile

    // On récupère le numéro de la tuile contenu dans la case du background
    UINT8 next_cell[1];
    get_bkg_tiles(ball_next_cell_x, ball_next_cell_y, 1, 1, next_cell);

    // On regarde si cette tuile est la tuile blanche du tileset
    return next_cell[0] != TILE_EMPTY;
}

Et enfin je modifie la boucle principale pour faire mes vérifications :

void main(void) {

    // ...

    while (1) {

        // ...

        // On simule un déplacement sur x
        if (check_ball_collide(BALL_DELTA_X, 0)) {
            // On inverse le signe de delta x s'il y a collision
            BALL_DELTA_X = -BALL_DELTA_X;
        }

        // On simule un déplacement sur y
        if (check_ball_collide(0, BALL_DELTA_Y)) {
            // On inverse le signe de delta y s'il y a collision
            BALL_DELTA_Y = -BALL_DELTA_Y;
        }

        // On déplace la balle...
        BALL_X += BALL_DELTA_X;
        BALL_Y += BALL_DELTA_Y;

        // ... et on met à jour la position du sprite de la balle
        move_sprite(0, BALL_X, BALL_Y);

        // ...
    }
}

Voici le résultat une fois tout ça lancé dans un émulateur :

Capture vidéo du projet avec la balle qui rebondit sur les différents éléments de l'environement

Note

NOTE : les plus attentifs d'entre-vous auront remarqué que la balle rebondit en bas de l'écran. C'est normal : on n'a pas initialisé cette partie de la couche Background (comme elle est en dehors de l'écran de jeu). Elle est donc composée de la tuile numéro 0 qu'on n'utilise pas dans notre programme (mais qui est donc différente de notre tuile vide qui a pour numéro 128). Le jeu considère donc qu'il y a une collision ici.

Collisions avec la raquette

Il ne reste à présent plus qu'à implémenter la collision avec la raquette pour que le jeu commence à devenir (presque) jouable. Ça va être très simple : quand la balle se trouve à la même coordonnée y que la raquette, il suffit de vérifier si sa coordonnée x tombe sur la raquette :

raquetteX < balleX < raquetteX + 3 * 8       (3 → nombre de tuiles qui composent la raquette)

Je vais donc rajouter le bout de code suivant à la fonction check_ball_collide() pour prendre en compte la raquette :

UINT8 check_ball_collide(INT8 delta_x, INT8 delta_y) {

    // ...

    if (ball_y >= PADDLE_Y && ball_x >= PADDLE_X && ball_x <= PADDLE_X + 3 * 8) {
        return 1;
    }

    // ...

}

Eh oui c'est tout ! On va donc pouvoir tester ça tout de suite 😁️ :

Capture vidéo de la collision de la balle avec la raquette

Et là vous vous dites... « Heu, mais attend une minute... tu essayes de m'arnaquer là, y a un problème ! Elle est trouée ta raquette ! J'ai vu la balle passer à travers ! ». Effectivement, si on regarde bien, la balle passe bien à travers la raquette :

Problème de collision entre la balle et la raquette

En réalité le même problème se produit partout... Si on ne l'a pas vu plus tôt, c'est juste qu'on avait de la chance : la collision est juste suivant la direction dans laquelle se déplace la balle... Mais pas de panique, tout ceci était prévu : on avance petit à petit, notre système de collision est juste un peu simpliste pour l'instant, mais on va voir tout de suite comment l'améliorer ! 😜️

Améliorer les collisions

Le problème rencontré ci-dessus vient de la simplification que j'ai faite plus tôt, quand j'ai considéré la balle comme un point. On se retrouve donc à considérer la collision uniquement du coin en haut à gauche du sprite de la balle :

Point de collision de la balle

En réalité cela marche généralement bien sur certaines collisions vers le haut ou vers la gauche, mais ça ne marchera jamais pour les collisions vers le bas ou vers la droite... Voici quelques exemples de cas pour lesquels cela fonctionne et d'autres pour lesquels cela ne fonctionne pas :

Exemples de collisions

Pour corriger ça, on va procéder de la manière suivante :

  • Pour la collision avec la raquette, on va toujours considérer la collision avec les points en bas à gauche et en bas à droite du sprite de la balle.
  • Pour la collision avec l'environnement, on va changer le point de collision considéré en fonction de la direction de la balle (si elle se dirige vers le bas à droite, on prendra le point en bas à droite comme référence).

Cette solution n'est bien sûr pas parfaite (la balle pourrait manquer une brique dans certains cas rares), mais cela devrait être très largement suffisant pour le moment 🙂️. Voici donc le code complet de la fonction de collision une fois les modifications apportées :

#define BALL_WIDTH 6

UINT8 check_ball_collide(INT8 delta_x, INT8 delta_y) {
    UINT8 ball_x = BALL_X + delta_x;
    UINT8 ball_y = BALL_Y + delta_y;

    // Collision avec la raquette
    if (ball_y + BALL_WIDTH - 1 >= PADDLE_Y) {
        // coin en bas à gauche
        if (ball_x >= PADDLE_X && ball_x <= PADDLE_X + 3 * 8) {
            return 1;
        }
        // coin en bas à droite
        if (ball_x + BALL_WIDTH - 1 >= PADDLE_X && ball_x + BALL_WIDTH - 1 <= PADDLE_X + 3 * 8) {
            return 1;
        }
    }

    // Collision avec l'environement

    // On change le point de collision en fonction de la direction de la balle
    if (BALL_DELTA_X > 0) {
        ball_x += BALL_WIDTH - 1;
    }
    if (BALL_DELTA_Y > 0) {
        ball_y += BALL_WIDTH - 1;
    }

    UINT8 ball_next_cell_x = (ball_x - 8) / 8;  // (ball_y - DécalageSpriteX) / LargeurTuile
    UINT8 ball_next_cell_y = (ball_y - 16) / 8; // (ball_y - DécalageSpriteY) / HauteurTuile
    UINT8 next_cell[1];

    get_bkg_tiles(ball_next_cell_x, ball_next_cell_y, 1, 1, next_cell);

    return next_cell[0] != TILE_EMPTY;
}

Et voici ce que ça donne cette fois si on teste le jeu :

Capture vidéo de la balle avec les collisions améliorées

C'est quand même beaucoup mieux ! 😁️

La suite, au prochain épisode !

Le jeu commence enfin à devenir intéressant : on a une balle qui rebondit, on peut utiliser la raquette,... il ne manque plus grand chose !

Note

Comme toujours, vous trouverez le code source en l'état actuel ainsi que la ROM dans le ZIP ci-dessous :

Je vous retrouve la semaine prochaine pour la troisième et dernière partie de cet article dans lequel on cassera enfin des briques ! 🤩️