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 de nouveaux articles paraissent de temps à autre.
Articles de la série :
- 1 —
Hello World(obsolète) 1bis — re-Hello World
2 — Utiliser le gamepad (+ aparté sur les types de variable)
3 — Projet 1 - Tic Tac Toe (+ présentation du mode texte et de "gb/console.h")
4 — Afficher des images (création et affichage de tilemaps)
5 — Créer des tilesets (format d'image de la GameBoy et conversion avec img2gb)
7 — Les sprites
9 — Les palettes
10 — Projet 2 - Breakout • PARTIE 1 • PARTIE 2 • PARTIE 3
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 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.
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 :
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 :
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.
À 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 :
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 :
il suffit d'inverser le signe de BALL_DELTA_X pour que la balle rebondisse sur la bordure :
Et si la balle vient taper la bordure du haut, il suffit cette fois-ci d'inverser le signe de BALL_DELTA_Y :
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 :
- 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),
- 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).
- Je répète les opérations 1 et 2 avec l'axe vertical (y).
- 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 :
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 😁️ :
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 :
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 :
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 :
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 :
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 ! 🤩️