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

2019-10-09

Couverture de l'article

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 !

la première partie de cet 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 :

re-Hello World

Utiliser le gamepad

Projet 1 - Tic Tac Toe

Afficher des images

Créer des tilesets

La couche « Background »

Les sprites

La couche « Window »

Les palettes

Projet 2 - Breakout

PARTIE 1

PARTIE 2

PARTIE 3

Gérer et afficher du texte

--------------------------------------------------------------------------------

Déplacement de la raquette

Dans un casse-briques, la raquette :

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.

sprites

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 8 px dans les calculs ? Eh bien tout simplement par ce que les coordonnées des sprites sont décalées de 8 px sur l'axe x et de 16 px sur l'axe y. Je vous invite à relire mon article sur les Sprites pour plus de détails.

mon article sur les Sprites

Enfin, je mets à jour la position de chacun des trois sprites en les décalant de 8 px (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 :

l'article sur le gamepad pour plus d'infos

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 :

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 :

Background

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 :

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 :

10-breakout-p2.zip

--------------------------------------------------------------------------------

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

--------------------------------------------------------------------------------

🏠 Accueil