Un Casse-briques sur ta NumWorks !

Projets

Le jeu-vidéo mythique des années 70 ! Casse-briques est un jeu de réflexion qui vous demandera beaucoup d’agilité. Le principe est simple, une balle est lancée, utilisez la plateforme mobile mis à votre disposition pour la faire rebondir et tentez de toucher les briques.

Genèse du projet

Le Casse-briques sur NumWorks est un jeu que j’ai voulu initialement développé il y a de cela un peu moins d’un an, juste après la publication de l’application Colors. J’avais commencé très brièvement la partie graphique du jeu, mais c’était assez rudimentaire et ça ne plaisait pas trop. Ensuite l’été a débuté et suite à des problèmes techniques lors du développement, le projet a peu à peu pris la poussière… Jusqu’à récemment.

Déterrage

Pour le dernier projet de l’année en NSI (le projet libre), j’ai décidé de sortir du placard le jeu, initialement abandonné, pour le redévelopper. Néanmoins, 2 semaines (pas à temps plein malheureusement, au contraire), ça reste peu pour développer un jeu de cette envergure, même pas l’IDE encore ouvert que j’avais énormément de questions qui me traverser l’esprit :

  • Comment faire bouger la balle et la faire rebondir correctement ? (rien que ça)
  • Comment gérer les collisions de la balle avec les briques ? (sans aucun doute la chose la plus complexe)
  • Comment générer des niveaux inédits et cohérents ?

C’est pour ça qu’en réalité, j’ai commencé doucement le développement du projet en avance (mi-mars), pour avoir pleinement le temps de proposer une réalisation finale convenable et fonctionnelle. J’avais énormément d’idées pour la conception du jeu, notamment avec un système de powers-up. Mais tout n’a pas pu voir le jour malheureusement.

La main à la pâte

Maintenant que les idées sont là, il faut retrousser ses manches et plonger dans la phase de développement.

L’ébauche d’un menu

Élaborons d’abord la page le menu du jeu, celui-ci devra afficher notamment, le score, le niveau, ainsi que le nombre de vies restantes. Et c’est le cas :

C’est une partie plutôt simple (on commencer doucement), rien de bien sorcier :

[...]
def menu():
    rec(0, 0, 320, 222, dark_c)
    rec(0, 200, 320, 22, game_c)
    for k in range(len(chars)):  # Display method inspired by @riko_schraf
        for j in range(19):
            if chars[k] >> j & 1 == 1:
                rec(110 + 12 * k + (j % 3) * 3, 40 + (j // 3) * 3, 3, 3, light_c)
    txt("Score: " + str(score), 160-5*(7+len(max(str(score), str(stage)))), 70, bright_c, dark_c)
    txt("Stage: " + str(stage), 160-5*(7+len(max(str(score), str(stage)))), 90, bright_c, dark_c)
    txt("Lives: " + str(pv), 160-5*(7+len(max(str(score), str(stage)))), 110, bright_c, dark_c)
    if pv > 0:
        txt("Press OK to play", 80, 150, bright_c, dark_c)
    else:
        txt("GAME OVER", 115, 150, bright_c, dark_c)
    txt("Gameplay by nsi.xyz/breakout", 20, 202, bright_c, game_c)

Une fonction menu(), assez simpliste dans le fond. L’affichage du nom du jeu « BREAKOUT » est géré entre les lignes 5 et 8. C’est un système d’affichage initié par Eric SCHRAFSTETTER (@riko_schraf), que j’ai modifié pour afficher le texte voulu, il utilise notamment la décomposition d’entiers en binaire pour savoir si une case est « allumée » ou non. Pour les plus déterminés et les plus curieux qui voudrons découvrir comment ça fonctionne, voici quelques ressources : Opération bit à bit — Wikipédia (wikipedia.org) [FR] • Python Bitwise Operators – GeeksforGeeks [EN]

Les fondations

Ensuite, voyons par quoi repose le jeu :

p_size = 40
p_speed = 3
p_x, p_y = 160, 210
p_color = bright_c

b_size = 7
b_dir = [2, -2]
b_pos = [160, 180]

br_width = 30
br_height = 15

game_speed = 0.01
game_state = "IN_MENU"

chars = (121579, 186351, 234191, 188271, 187117, 252783, 252781, 74903)

points = {"red": 100, "orange": 80, "yellow": 60, "green": 20}
stage, score, pv = 1, 0, 3
level = ""
cuboids = []

Ce sont (quasiment) toutes les variables globales utilisés dans le jeu, certaines sont plus intéressantes que d’autres, et nous nous attarderont ici uniquement sur celles que je juge donc intéressantes. Certaines variables respectent une certaine nomenclature (p_*: ce qui concerne la plateforme mobile, b_*: ce qui concerne la balle, br_*: ce qui concerne les briques, etc.).

  • p_size: C’est la taille de la plateforme mobile
  • p_speed: C’est sa vitesse de déplacement (si elle vaut 3, c’est qu’elle va se déplacer de 3 en 3 pixels (vers la gauche ou la droite))
  • p_x: C’est la position x, de la plateforme mobile, plus exactement, c’est la position du pixel au centre de la plateforme mobile. La position y est moins importante, étant donné que celle-ci est constante (c’est pourquoi une list n’a pas été utilisé pour stocker ces deux valeurs).

La position et la taille de la plateforme sont des éléments essentiels pour détecter une collision.
Ici, p_x est est représenté par le pixel rouge :

De la même manière.

  • b_size: C’est le diamètre (même si on parlera ici plutôt de côté) de la balle.
  • b_dir: C’est un vecteur, qui va donc déterminer où la balle va se diriger (si il vaut [1, 1], la balle ira en diagonale vers la droite et vers le bas (l’axe des ordonnées est inversé)). Plus la norme du vecteur est élevé, plus la balle sera rapide (si il vaut [2, 2], il ira aussi vers la droite et vers le bas, mais la balle sera plus rapide).
  • b_pos: C’est la position x, y de la balle.

Ces 3 variables (surtout la 1ère et la 3ème) sont essentiels pour détecter une collision.

Comme vous le voyez, le jeu a été conçu pour très facilement modulable. Pourquoi ? Car déjà c’est préférable lorsqu’on développe n’importe quelle application, et aussi pour intégrer des powers-up facilement. En effet, modifier la valeur de b_size aurait fait un power-up qui change la taille de la balle en plein jeu !

Des collisions

C’est sans aucun doute ce qui m’a mis le plus en difficulté, j’ai voulu faire un système non spécifique au jeu, qui serait donc facile d’intégrer dans un autre jeu, et c’est ce que j’ai fait.

Grosso modo, les collisions fixes (le système n’est pas optimisé pour gérer des entités mouvantes, celles de la plateforme mobile sont gérés autrement et sont spécifiques au jeu) sont stockées dans une list, enfin plutôt dans une list elle-même dans une list, enfin non, plutôt dans une list elle-même dans une list qui est elle-même dans une list. 🤯 😵‍💫
J’ai dit que les collisions étaient stockées, mais qu’est-ce que j’appelle une collision ? En réalité je stocke 2 valeurs entières qui vont former un cuboïde, ce dernier sera interprété comme un masque de collision (hitbox).

Ici le rectangle noir représenté un masque de collision, pour pouvoir le retrouver, nous avons besoin au minimum des coordonnées du point supérieur gauche et du point inférieur droit. Ainsi si la position de la balle se trouve dans la zone, elle doit rebondir. En réalité c’est un peu plus compliqué que ça, car selon le côté du cuboïde où la balle atterrit, la balle ne part pas au même endroit… Il faut donc aussi déterminer le côté du cuboïde où la balle est entré en collision. En bref, ce sont énormément de mathématiques.

[...]
def collision_manager(x, y):
    for i in range(len(cuboids)):
        if cuboids[i] and (i < cuboids[i][0][1]+10 or len(cuboids)-3 <= i <= len(cuboids)):
            h = b_size//2
            x_s, y_s, x_e, y_e = cuboids[i][0][0]-h, cuboids[i][0][1]-h, cuboids[i][1][0]+h, cuboids[i][1][1]+h
            if y_s-abs(b_dir[1]) <= y <= y_s+abs(b_dir[1]) and x_s <= x <= x_e:
                b_dir[1] = -b_dir[1]
                destroy_brick(i)
            elif x_s-abs(b_dir[0]) <= x <= x_s+abs(b_dir[0]) and y_s <= y <= y_e:
                b_dir[0] = -b_dir[0]
                destroy_brick(i)
            elif x_e-abs(b_dir[0]) <= x <= x_e+abs(b_dir[0]) and y_s <= y <= y_e:
                b_dir[0] = -b_dir[0]
                destroy_brick(i)
            elif y_e-abs(b_dir[1]) <= y <= y_e+abs(b_dir[1]) and x_s <= x <= x_e:
                b_dir[1] = -b_dir[1]
                destroy_brick(i)

Voici une démonstration du système (pendante la phase de développement) avec quelques informations de débogage affichés en haut à droite :

Les masques de collisions sont représentés par des rectangles rouges.

Génération de niveaux

Le système de générations de niveaux a été pensé par Vincent ROBERT (@nsi.xyz), ce système utilise également les mathématiques (comme tous les systèmes du jeu en fait).

[...]
def level_generator(stg):
    lvl = "".join([" " for _ in range(10)])+"*"*(stg//11+1)*10
    col = set()
    for i in (2, 3, 5, 7):
        if not stg % i:
            for j in range(i, 10, i):
                col.add(j)
    for i in range(stg // 11 + 2):
        for j in range(10):
            if j in col:
                lvl = lvl[:i * 10 + j] + " " + lvl[i * 10 + j + 1:]
    return lvl

Un niveau est stocké dans un str, à l’aide d’un « motif », le « motif » est composé de « * » et de  » « , lorsqu’il y a un « * », c’est que c’est l’emplacement d’une brique, sinon c’est qu’il y en a pas. Ainsi, générer un niveau consiste à générer une chaine de caractères avec des « * »,  » « . À noter, qu’ici, utiliser une chaine de caractère n’est pas très pertinent, il aurait été préférable d’utiliser un entier, et d’utiliser sa notation binaire (1: emplacement d’une brique, 0: emplacement vide). Peut-être que ce sera optimisé dans une version future du jeu (avec l’intégration des powers-up).

Ensuite, le niveau est construit :

[...]
def level_constructor():
    global level, cuboids
    rec(0, 0, 320, 222, dark_c)
    level = level_generator(stage)
    for i in range(len(level)):
        if level[i] == "*":
            rec(1 + (br_width + 2) * (i % 10), 1 + (br_height + 2) * (i // 10), br_width, br_height, set_color(i // 10))
    cuboids = [[[1+(br_width+2)*(i % 10), 1+(br_height+2)*(i//10)], [1 + (br_width + 2) * (i % 10) + br_width,
                1 + (br_height + 2) * (i // 10) + br_height]] if level[i] == "*" else [] for i in range(len(level))]
    cuboids += (((0, 0), (0, 222)), ((0, 0), (320, 0)), ((320, 0), (320, 222)))

Lorsqu’une brique est dessiné, ses collisions sont directement ajoutés dans la liste des collisions (cuboids).

Fonction mère

Enfin, les interactions du joueur sont directement interprétés dans la fonction engine(), Si on appuie sur la flèches gauche, on déplace la plateforme vers la gauche, et si c’est la flèche droite, on la déplace vers la droite. Lorsqu’il reste 3 collisions, c’est que toutes les briques sont cassés, et donc que le niveau est terminé ! Pourquoi 3 ? Car les collisions des bords de l’écran sont aussi stockés dans cuboids.

def engine():
    global p_x, pv, game_state, stage
    while game_state not in ("L_CLEAR", "G_OVER"):
        if keydown(0) and p_x > 10 + p_size // 2:
            p_x -= p_speed
            pad(p_size, 1)
        if keydown(3) and p_x < 310 - p_size // 2:
            p_x += p_speed
            pad(p_size, -1)
        ball_manager()
        if len(cuboids)-sum([1 if not i else 0 for i in cuboids]) == 3:
            game_state = "L_CLEAR"
            stage += 1
            pv += 1
        debug()
    pv -= 1 if game_state == "G_OVER" else 0
    menu()

Conclusion

Finalement, l’élaboration et le développement de ce projet ont été très enrichissantes. J’étais habitué jusqu’à ce projet à développer des applications « fixes » (avec un seul processus à gérer en même temps), développer des applications « dynamiques » (avec plusieurs processus simultanés (en l’occurrence, les mouvements de la balle, et celles de la plateforme)) est quelque chose d’un peu plus complexe, mais pas moins intéressant, au contraire ! Je suis assez satisfait du jeu final, même si je juge qu’il reste quelques défauts et qu’il manque des fonctionnalités que j’aurai voulu intégrer mais que je n’ai pas pu par manque de temps. Cependant, rien ne m’empêche de continuer le développement du jeu à l’avenir en y ajouter et corrigeant des choses !

À toi de jouer !

Ce jeu est disponible sur https://nsi.xyz/breakout !