[TD] Un petit jeu très simple

L’auteur de ce contenu recherche un repreneur. N’hésitez pas à le contacter par MP pour proposer votre aide !

Et nous voici à notre premier TD !

Tout au long de cet exercice, on vous guidera pour réaliser ce petit jeu, puis à la fin, on vous proposera une liste d’améliorations possibles :)

Le cahier des charges

Notre cahier des charges va être simple :

  • avoir une raquette

  • qu’il y ait une balle qui soit perpétuellement en mouvement dans le jeu, et qu’elle ne puisse pas sortir de l’écran

et enfin :

  • faire un jeu qui marche

Vous l’aurez compris, nous allons coder le très célèbre Pong !

Et voici à quoi il ressemblera :

Notre jeu !

Voyons un peu comment nous allons agencer tout ça !

Étant fanatiques de la programmation orientée objet, on va en utiliser pas mal dans ce TD. Donc petit conseil, soyez à l’aise avec avant de continuer. :)

L’architecture que l’on a choisie est la suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TD
|-->images
|---|---balle.png
|---|---raquette.png
|---|---menu.png
|---main.py
|---game.py
|---balle.py
|---raquette.py
|---constantes.py

Un menu et des constantes

Le menu

Le menu va devoir charger Pygame, lancer une boucle infinie qui affichera le fond du menu, et attendra que l’utilisateur veuille bien rentrer dans le jeu :)

En gros, on aura ceci :

1
2
3
4
5
6
menu
|---boucle
|---|---appuie sur une touche
|---|---|---lancement du jeu
|---|---|---|---appuie sur echap, on quitte la boucle du jeu
|---on se retrouve ici, dans la boucle du menu

Sans plus attendre, voici le code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import pygame
from pygame import locals as const
from game import Game


def main():
    print("Appuyez sur n'importe quelle touche pour lancer la partie !")

    pygame.init()

    ecran = pygame.display.set_mode((640, 480))
    fond = pygame.image.load("images/menu.png").convert_alpha()

    continuer = True
    jeu = Game(ecran)  # Game() est une class qui va se charger ... du jeu :)

    while continuer:
        for event in pygame.event.get():
            if event.type == const.QUIT or (event.type == const.KEYDOWN and event.key == const.K_ESCAPE):
                # de manière à pouvoir quitter le menu avec echap ou la croix
                continuer = 0
            if event.type == const.KEYDOWN:
                # start() sera une méthode de la class Game(), et s'occupera de lancer le jeu
                jeu.start()

        ecran.blit(fond, (0, 0))

        pygame.display.flip()

    pygame.quit()


if __name__ == '__main__':
    main()

Les constantes

Dans la plupart des jeux, il y a des constantes. Il faut dire que ça simplifie vraiment la vie quand on souhaite modifier telle ou telle chose sans avoir à relire tout son code ^^

Ici on va avoir besoin de peu de constantes, mais on créera quand même un fichier pour les stocker, histoire de bien séparer le vrai code de nos constantes.

On va donc avoir besoin des constantes suivantes :

  • RIEN, pour indiquer que la raquette ne bouge pas

  • HAUT, pour indiquer que la raquette monte

  • BAS, pour indiquer … que la raquette descend

  • VITESSE_RAQUETTE, la vitesse de déplacement de notre raquette en pixels par seconde

  • VITESSE_BALLE, la vitesse de déplacement de notre balle en pixels par seconde

RIEN, HAUT, et BAS auront des valeurs symboliques comme -1, 0, et 1.

Pour la vitesse de la raquette, on a choisi 20 pixels par secondes, et pour celle de la balle, 4 pixels par secondes.

La gestion de la raquette

Alors voilà ! Nous y sommes. :)

Notre raquette devra pouvoir faire plein de choses, dont :

  • s’afficher, bien entendu

  • se déplacer de haut en bas tout en restant dans la fenêtre hein !

  • fournir une méthode pour que la balle puisse regarder si elle entre en collision avec notre raquette

  • faire des triples back flip de la mort :diable:

Allez hop, à vous de coder cette partie !

Une petite aide pour commencer … ?

Mais bien sûr ! (Pour ceux qui souhaitent tout coder sans indice, n’ouvrez pas le spoiler !)

On avait dit quoi :D ?

  • À l’initialisation, notre raquette doit avoir une référence vers l’écran, avoir une position de base, et charger son image.

  • Quand elle se déplace, on doit être sûre qu’elle ne sort pas de l’écran (jouez avec la méthode get_size() de l’image de votre raquette), et la replacer en conséquent.

  • Pour les collisions avec la balle, soit vous avez pris de l’avance et lu le chapitre sur les Rect, soit vous regardez si la balle est dans la raquette (donc fournissez la position de la balle, et sa taille), méthode un peu plus longue. :)

  • Pour ce qui est de la méthode de rendu, c’est super simple, on ne vous en dit pas plus. ;)

N’oubliez pas que notre raquette sera une classe !

Correction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import pygame
from constantes import *


class Raquette:
    def __init__(self, ecran: pygame.Surface):
        self.ecran = ecran
        self.ecran_large = self.ecran.get_width()  # ca vous nous servir pour centrer la raquette et regarder
                                                   # si la raquette sort de l'écran ou non
        self.ecran_haut = self.ecran.get_height()  # idem
        self.image = pygame.image.load("images/raquette.png").convert_alpha()
        self.pos = [20, (self.ecran_haut - self.image.get_height()) // 2]  # on centre notre raquette à droite
    
    def render(self):
        self.ecran.blit(self.image, self.pos)
    
    def move(self, dir: int=RIEN):
        # on test toutes les collisions possibles
        # et voici l'utilité de nos constantes !
        if dir == HAUT:
            if self.pos[1] - VITESSE_RAQUETTE >= 0:
                self.pos[1] -= VITESSE_RAQUETTE
            else:
                self.pos[1] = 0
        elif dir == BAS:
            if self.pos[1] + VITESSE_RAQUETTE <= self.ecran_haut - self.image.get_height():
                self.pos[1] += VITESSE_RAQUETTE
            else:
                self.pos[1] = self.ecran_haut - self.image.get_height()
    
    def collide_with_me(self, pos_objet: tuple, taille_objet: tuple):
        # utile pour savoir si la balle collide avec la raquette
        if self.pos[0] <= pos_objet[0] <= self.pos[0] + self.image.get_width() and \
                self.pos[1] <= pos_objet[1] <= self.pos[1] + self.image.get_height():
            # le coté gauche de la balle est dans la raquette
            return True
        elif self.pos[0] <= pos_objet[0] + taille_objet[0] <= self.pos[0] + self.image.get_width() and \
                self.pos[1] <= pos_objet[1] + taille_objet[1] <= self.pos[1] + self.image.get_height():
            # le coté droit de la balle est dans la raquette
            return True
        # enfin, on a pas eu de collision, donc on return False
        return False

Gestion de la balle

Voyons maintenant comment on va gérer notre balle.

Tout d’abord :

  • il faut que la balle stocke un lien vers son image

  • il faut qu’elle connaisse sa position

  • il faut qu’elle ait en mémoire son vecteur directeur1 (et oui ! on fait encore des maths ;) )

  • il lui faut une méthode pour qu’elle se déplace et qu’elle change de direction au contact d’un objet

Et voilà ! Vous avez tout pour coder cette classe !

Hein O_o ? Ça suffit pour coder la classe Ball ?

Oui. Le plus compliqué sera sûrement la méthode de déplacement, qui gérera aussi les collisions. ;)

Pour le changement de direction, vous embêtez pas, et utilisez le même vecteur directeur, en changement simplement son sens et en ajoutant un peu d’aléatoire sur x (ou y) à chaque collision (sinon la balle suivra le même parcours ^^ ).

Allez, hop hop hop ! Au boulot !

Et voici notre version de cette classe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import pygame
from constantes import *
from random import randint


class Balle:
    def __init__(self, ecran: pygame.Surface):
        self.ecran = ecran
        self.vect_dir = [-VITESSE_BALLE, VITESSE_BALLE]
        self.image = pygame.image.load("images/balle.png").convert_alpha()
        self.b_large = self.image.get_width()  # la taille de notre balle en x
        self.b_haut = self.image.get_height()  # et la taille en y
        self.pos = [(self.ecran.get_width() - self.b_large) // 2, (self.ecran.get_height() - self.b_haut) // 2]
    
    def move(self, raquette):
        tmp = self.pos[0] + self.vect_dir[0] * VITESSE_BALLE, self.pos[1] + self.vect_dir[1] * VITESSE_BALLE
        collision = raquette.collide_with_me(tmp, (self.b_large, self.b_haut))
        if collision or tmp[0] <= 0 or tmp[0] + self.b_large >= self.ecran.get_width():
            self.vect_dir[0] = - self.vect_dir[0] + randint(100, 225) / 1000
            self.vect_dir[1] += randint(100, 225) / 1000
        if tmp[1] <= 0 or tmp[1] + self.b_haut >= self.ecran.get_height():
            self.vect_dir[0] += randint(100, 225) / 1000
            self.vect_dir[1] = - self.vect_dir[1] + randint(100, 225) / 1000
        
        # dans tous les cas, on déplace la balle !
        self.pos[0] += self.vect_dir[0]
        self.pos[1] += self.vect_dir[1]
    
    def render(self):
        self.ecran.blit(self.image, self.pos)

  1. Un vecteur directeur est un déplacement en x et en y. Donc c’est juste un tuple/une liste qui indique de combien doit on déplacer tel objet, en abscisse et ordonnée. 

Et enfin, le cœur de la maison, la cuisine !

Non non, bien sûr, on parlait du cœur du jeu. :D

Pour ce qui est du jeu, c’est lui qui va créer la raquette, le terrain (graphiquement parlant hein), et la balle. On aura différentes méthodes, dont :

  • prepare, c’est une méthode que SuperFola aime bien ajouter à chaque fois dans ses classe Game, car c’est elle qui va se charger de tout remettre à 0 s’il le faut, de re-régler la vitesse de répétition des touches etc … Et oui ! Imaginez un peu si on lance 2 fois le jeu (en supposant qu’après le 1er lancement, on avait quitté le jeu et que l’on soit revenu au menu pour le relancer) ! Au 2ème lancement, le score sera le même qu’à la fin de la 1ère partie, et tout ce qui va avec !

  • update_screen, qui sera chargée d’appeler toutes les méthodes d’affichage (de la raquette, de la balle) et de créer le terrain (graphiquement parlant).

  • process_event, qui prendra en paramètre un événement unique (envoyé depuis une boucle for dans la méthode start) et qui se chargera d’effectuer les actions liées au-dit événement.

  • start, qui va lancer le jeu (donc boucle while) et appeler toutes les autres méthodes, dans cet ordre :

    • prepare

    • boucle while :

      • boucle for des événements :

        • appel à process_event(...)
      • update_screen

      • déplacement de la balle

      • pygame.display.flip

Pour ceux qui veulent s’entrainer, pas de problèmes, le code est sous spoiler !

Correction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import pygame
from pygame import locals as const
from constantes import *
from raquette import Raquette
from balle import Balle


class Game:
    def __init__(self, ecran: pygame.Surface):
        self.ecran = ecran
        self.raquette = Raquette(self.ecran)
        self.balle = Balle(self.ecran)
        self.continuer = True
        self.controles = {
            HAUT: const.K_UP,
            BAS: const.K_DOWN
        }
    
    def prepare(self):
        pygame.key.set_repeat(200, 50)
        self.continuer = True
        self.raquette = Raquette(self.ecran)
        self.balle = Balle(self.ecran)
    
    def update_screen(self):
        pygame.draw.rect(self.ecran, (0, 0, 0), (0, 0) + self.ecran.get_size())  # on dessine le fond
        pygame.draw.rect(self.ecran, (255, 255, 255), (self.ecran.get_width() // 2 - 1, 0, 2, self.ecran.get_height()))  # la séparation au milieu du terrain
        self.raquette.render()  # on dessine notre raquette
        self.balle.render()  # on dessine notre balle
    
    def process_event(self, event: pygame.event):
        if event.type == const.KEYDOWN:
            # et revoici l'utilité de nos constantes, utilisées comme clé de dictionnaire :)
            # comme ça on peut plus facilement changer les controles ;)
            if event.key == self.controles[HAUT]:
                self.raquette.move(HAUT)
            if event.key == self.controles[BAS]:
                self.raquette.move(BAS)
        if event.type == const.QUIT:
            self.continuer = False
    
    def start(self):
        self.prepare()
        
        while self.continuer:
            for event in pygame.event.get():
                self.process_event(event)
            
            self.update_screen()
            
            self.balle.move(self.raquette)  # on déplace notre balle
            
            pygame.display.flip()

Et voilà ! Ce premier TD est fini.

C’était assez court, mais ça vous a donné du fil à retordre non :) ?

Vous avez dû remarquer que notre balle n’entre pas en collision correctement avec la raquette ou les murs non ? Et si on allait voir la méthode de déplacement de notre balle ?

La voici :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
    def move(self, raquette):
        tmp = self.pos[0] + self.vect_dir[0] * VITESSE_BALLE, self.pos[1] + self.vect_dir[1] * VITESSE_BALLE
        collision = raquette.collide_with_me(tmp, (self.b_large, self.b_haut))
        if collision or tmp[0] <= 0 or tmp[0] + self.b_large >= self.ecran.get_width():
            self.vect_dir[0] = - self.vect_dir[0] + randint(100, 225) / 1000
            self.vect_dir[1] += randint(100, 225) / 1000
        if tmp[1] <= 0 or tmp[1] + self.b_haut >= self.ecran.get_height():
            self.vect_dir[0] += randint(100, 225) / 1000
            self.vect_dir[1] = - self.vect_dir[1] + randint(100, 225) / 1000

        # dans tous les cas, on déplace la balle !
        self.pos[0] += self.vect_dir[0]
        self.pos[1] += self.vect_dir[1]

La ligne à incriminer est celle-ci, c’est certain !

1
tmp = self.pos[0] + self.vect_dir[0] * VITESSE_BALLE, self.pos[1] + self.vect_dir[1] * VITESSE_BALLE

En effet, en regardant bien, on voit que l’on n’applique pas la position calculée dans tmp si on est en collision avec un mur ou la raquette à cette nouvelle position. Or on se déplace de VITESSE_BALLE, qui ne vaut pas 1 ! Donc si la position actuel + 4 est dans la raquette, notre position actuelle ne sera pas forcément dans la raquette elle même, mais on applique quand même notre changement de direction.

Maintenant que l’on sait cela, corrigeons le !

Il faudrait donc avancer la balle même si elle entre en collision et changer en même temps sa direction (elle se déplace assez vite pour qu’on ne puisse pas voir que la balle entre dans la raquette ou dans un mur).

Voici donc une possible correction :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    def move(self, raquette):
        self.pos[0] += self.vect_dir[0] * VITESSE_BALLE
        self.pos[1] += self.vect_dir[1] * VITESSE_BALLE

        collision = raquette.collide_with_me(self.pos, (self.b_large, self.b_haut))
        if collision or self.pos[0] <= 0 or self.pos[0] + self.b_large >= self.ecran.get_width():
            self.vect_dir[0] = - self.vect_dir[0] + randint(100, 225) / 1000
            self.vect_dir[1] = randint(100, 225) / 100
        if self.pos[1] <= 0 or self.pos[1] + self.b_haut >= self.ecran.get_height():
            self.vect_dir[0] = randint(100, 225) / 100
            self.vect_dir[1] = - self.vect_dir[1] + randint(100, 225) / 1000

Liste d’améliorations possibles :

  • avoir des bonus au fur et à mesure que le temps passe

  • que la balle accélère plus le temps de jeu augmente

  • que des briques apparaissent sur le terrain, et fassent changer la balle de trajectoire en la percutant, un peu à la manière du Casse briques ;)

C’est une petite liste d’améliorations possibles, mais cela va vous occuper un petit de bout de temps je pense ^^