22 - Pygame

Razik Ikhlef

Ouverture hors programme. Pygame n’est pas au programme de NSI, mais c’est la bibliothèque la plus utilisée pour les projets de jeux. Tout ce qu’elle demande est au programme : variables, conditions, boucles, listes, fonctions. Les programmes de cette page s’exécutent dans Thonny (ou tout autre éditeur installé sur l’ordinateur), pas dans le navigateur.

Installer Pygame

Pygame ne fait pas partie de Python : il faut l’installer une fois sur l’ordinateur.

  • Dans Thonny : menu Outils, puis Gérer les paquets, chercher pygame et cliquer sur Installer.
  • Dans un terminal : pip install pygame (ou python3 -m pip install pygame).

Pour vérifier l’installation, on exécute import pygame : un message « Hello from the pygame community » s’affiche, sans erreur.

La boucle de jeu

Un jeu vidéo affiche des images successives, comme un dessin animé : 60 images par seconde dans nos exemples. Entre deux images, le programme fait toujours les mêmes quatre étapes, dans le même ordre. C’est la boucle de jeu :

La boucle de jeu (suite)

La boucle de jeu (suite)

  1. Lire les événements : touches enfoncées, clics, fermeture de la fenêtre.
  2. Mettre à jour l’état du jeu : positions, vitesses, score, collisions. Rien n’est dessiné à cette étape.
  3. Dessiner l’image complète, en partant du fond, puis l’afficher.
  4. Attendre pour ne pas dépasser 60 images par seconde.

Voici le programme minimal : une fenêtre bleu nuit qui se ferme quand on clique sur la croix.

La boucle de jeu (suite)

import pygame

pygame.init()                                    # démarrer Pygame
ecran = pygame.display.set_mode((600, 400))      # fenêtre de 600 x 400 pixels
pygame.display.set_caption("Ma première fenêtre")
horloge = pygame.time.Clock()

en_cours = True
while en_cours:
    # 1. Événements
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:        # clic sur la croix
            en_cours = False

    # 2. Mise à jour : rien pour l'instant

    # 3. Dessin
    ecran.fill((20, 30, 60))                     # tout repeindre en bleu nuit
    pygame.display.flip()                        # afficher l'image

    # 4. Attente
    horloge.tick(60)                             # 60 images par seconde au plus

pygame.quit()

La boucle de jeu (suite)

  • pygame.display.set_mode((600, 400)) crée la fenêtre et renvoie la surface sur laquelle on dessine, rangée dans ecran. Attention aux doubles parenthèses : la taille est un couple.
  • La boucle while en_cours tourne jusqu’à ce qu’on ferme la fenêtre ; c’est nous qui l’écrivons, contrairement à Tkinter.

La boucle de jeu (suite)

  • On dessine dans une image cachée ; pygame.display.flip() l’affiche d’un coup à l’écran. Sans cette ligne, la fenêtre reste noire.
  • horloge.tick(60) met le programme en pause juste ce qu’il faut pour ne pas dépasser 60 tours de boucle par seconde. Sans elle, le jeu irait aussi vite que le permet l’ordinateur, donc à une vitesse différente sur chaque machine.

Question. Que se passe-t-il si l’on supprime la boucle for evenement in pygame.event.get() ?

Réponse

La fenêtre ne répond plus : le système d’exploitation constate que le programme ne lit pas ses événements et l’indique (« Ne répond pas », curseur qui tourne). La croix ne ferme plus la fenêtre, puisque l’événement QUIT n’est jamais lu. Il faut appeler pygame.event.get() à chaque tour, même si l’on n’utilise aucun événement.

Le repère, les couleurs et les formes

Comme dans Tkinter, l’origine (0, 0) est le coin supérieur gauche de la fenêtre, l’axe des x va vers la droite et l’axe des y vers le bas. Dans une fenêtre de 600 × 400, les pixels ont des abscisses de 0 à 599 et des ordonnées de 0 à 399 : le pixel du coin inférieur droit a pour coordonnées (599, 399).

Le repère, les couleurs et les formes (suite)

Le repère, les couleurs et les formes (suite)

Une couleur est un triplet (rouge, vert, bleu), chaque composante allant de 0 à 255 : (255, 0, 0) est le rouge, (0, 0, 0) le noir, (255, 255, 255) le blanc, (255, 255, 0) le jaune. C’est le codage RVB (rouge, vert, bleu) qu’utilise aussi le CSS avec rgb(255, 0, 0). On range les couleurs utilisées dans des constantes, écrites en majuscules.

Le repère, les couleurs et les formes (suite)

import pygame

CIEL = (135, 206, 235)
HERBE = (60, 160, 70)
JAUNE = (255, 210, 0)
BRUN = (120, 70, 30)
VERT = (30, 120, 50)
BLANC = (255, 255, 255)

pygame.init()
ecran = pygame.display.set_mode((600, 400))
pygame.display.set_caption("Formes")
horloge = pygame.time.Clock()

en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False

    ecran.fill(CIEL)
    pygame.draw.rect(ecran, HERBE, (0, 300, 600, 100))           # rectangle : x, y, largeur, hauteur
    pygame.draw.circle(ecran, JAUNE, (500, 80), 40)              # cercle : centre, rayon
    pygame.draw.rect(ecran, BRUN, (140, 200, 30, 100))           # tronc
    pygame.draw.circle(ecran, VERT, (155, 180), 60)              # feuillage
    pygame.draw.ellipse(ecran, BLANC, (250, 60, 120, 50))        # nuage : rectangle englobant
    pygame.draw.line(ecran, BRUN, (300, 300), (450, 250), 5)     # ligne : départ, arrivée, épaisseur
    pygame.draw.polygon(ecran, JAUNE, [(400, 300), (440, 260), (480, 300)])  # triangle
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Le repère, les couleurs et les formes (suite)

Un paysage : ciel bleu, soleil jaune, nuage blanc, arbre au tronc brun et au feuillage vert, herbe verte, un trait brun et un triangle jaune.

Un paysage fait de sept formes.

Le repère, les couleurs et les formes (suite)

Fonction Paramètres après la surface et la couleur
pygame.draw.rect (x, y, largeur, hauteur) ; ajouter une épaisseur pour un contour seul : pygame.draw.rect(ecran, c, r, 3)
pygame.draw.circle (cx, cy) le centre, puis le rayon
pygame.draw.ellipse le rectangle qui contient l’ellipse
pygame.draw.line départ, arrivée, épaisseur
pygame.draw.polygon liste des sommets

L’ordre compte : chaque forme est peinte par-dessus les précédentes. Le feuillage est dessiné après le tronc, donc il le recouvre en partie.

Le repère, les couleurs et les formes (suite)

Question. Que verrait-on si l’on dessinait l’herbe en dernier ?

Réponse

La même image, sauf ce qui se trouve dans la bande y de 300 à 400 : l’herbe recouvrirait la base du trait brun et du triangle, qui touchent la ligne y = 300. Les formes au-dessus de cette ligne ne changent pas, puisqu’elles ne sont pas sous l’herbe.

Animer : modifier une position à chaque image

Pour qu’un objet bouge, on range sa position dans des variables, et on la modifie un peu à chaque tour de boucle, à l’étape de mise à jour. La vitesse est le nombre de pixels parcourus à chaque image.

Animer : modifier une position à chaque image (suite)

import pygame

LARGEUR, HAUTEUR = 600, 400
RAYON = 20

pygame.init()
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
pygame.display.set_caption("Rebonds")
horloge = pygame.time.Clock()

x, y = 100, 100      # position du centre de la balle
vx, vy = 5, 3        # vitesse, en pixels par image

en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False

    # Mise à jour de la position
    x = x + vx
    y = y + vy
    # Rebonds : on inverse la vitesse quand la balle touche un bord
    if x - RAYON < 0 or x + RAYON > LARGEUR:
        vx = -vx
    if y - RAYON < 0 or y + RAYON > HAUTEUR:
        vy = -vy

    ecran.fill((20, 30, 60))
    pygame.draw.circle(ecran, (255, 140, 0), (x, y), RAYON)
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Animer : modifier une position à chaque image (suite)

Une balle orange sur fond bleu nuit, au milieu de sa trajectoire.

La balle à la 50e image.

Le tableau suit la balle pendant les premières images.

Animer : modifier une position à chaque image (suite)

Image x y vx vy
départ 100 100 5 3
1 105 103 5 3
2 110 106 5 3
3 115 109 5 3

À 60 images par seconde, la balle parcourt 5 × 60 = 300 pixels par seconde horizontalement : elle traverse la fenêtre en deux secondes.

Question. On supprime la ligne ecran.fill((20, 30, 60)). Qu’observe-t-on ?

Réponse

Une traînée orange : chaque image dessine la balle à sa nouvelle position sans effacer les précédentes, qui restent à l’écran. C’est pourquoi chaque image commence par repeindre tout le fond.

Le clavier : deux façons de lire les touches

Pygame offre deux façons de savoir ce que fait le joueur au clavier, qui servent à des choses différentes.

Le clavier : deux façons de lire les touches (suite)

L’événement KEYDOWN pygame.key.get_pressed()
Question posée « une touche vient d’être enfoncée ? » « quelles touches sont enfoncées en ce moment ? »
Réaction une seule fois par appui à chaque image, tant que la touche est maintenue
Où l’écrire dans la boucle des événements à l’étape de mise à jour
Usage sauter, tirer, mettre en pause, valider un menu se déplacer en continu

Le clavier : deux façons de lire les touches (suite)

Le programme suivant utilise les deux : les flèches déplacent le carré en continu, et la barre d’espace change sa couleur, une fois par appui.

Le clavier : deux façons de lire les touches (suite)

import pygame

LARGEUR, HAUTEUR = 600, 400
COTE = 40
COULEURS = [(250, 200, 40), (230, 60, 60), (60, 200, 120)]

pygame.init()
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
pygame.display.set_caption("Flèches et espace")
horloge = pygame.time.Clock()

x, y = 280, 180
vitesse = 4
numero_couleur = 0

en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False
        elif evenement.type == pygame.KEYDOWN:          # une touche vient d'être enfoncée
            if evenement.key == pygame.K_SPACE:
                numero_couleur = (numero_couleur + 1) % len(COULEURS)

    touches = pygame.key.get_pressed()                  # état actuel du clavier
    if touches[pygame.K_LEFT]:
        x = x - vitesse
    if touches[pygame.K_RIGHT]:
        x = x + vitesse
    if touches[pygame.K_UP]:
        y = y - vitesse
    if touches[pygame.K_DOWN]:
        y = y + vitesse
    x = max(0, min(x, LARGEUR - COTE))                  # rester dans la fenêtre
    y = max(0, min(y, HAUTEUR - COTE))

    ecran.fill((30, 30, 60))
    pygame.draw.rect(ecran, COULEURS[numero_couleur], (x, y, COTE, COTE))
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Le clavier : deux façons de lire les touches (suite)

Un carré rouge sur fond bleu sombre, décalé vers la droite et vers le haut.

Après un appui sur espace, puis une demi-seconde avec les flèches droite et haut enfoncées.

Le clavier : deux façons de lire les touches (suite)

  • La flèche du haut diminue y, à cause du repère tourné vers le bas.
  • max(0, min(x, LARGEUR - COTE)) maintient x entre 0 et LARGEUR - COTE : si x devient négatif, max le ramène à 0 ; s’il dépasse LARGEUR - COTE, min le ramène à cette valeur.
  • On ne lit evenement.key qu’après avoir vérifié que evenement.type == pygame.KEYDOWN : un clic ou un déplacement de la souris n’a pas d’attribut key.
  • Les touches s’appellent pygame.K_LEFT, pygame.K_SPACE, pygame.K_RETURN (Entrée), pygame.K_ESCAPE, pygame.K_a … pygame.K_z, pygame.K_0 … pygame.K_9.

Le clavier : deux façons de lire les touches (suite)

Question. On déplace le carré en testant pygame.K_RIGHT dans la boucle des événements KEYDOWN, avec x = x + vitesse. Que se passe-t-il quand on maintient la flèche droite ?

Réponse

Le carré avance de 4 pixels une seule fois : l’événement KEYDOWN ne se produit qu’au moment où la touche est enfoncée. Pour un déplacement continu, il faut pygame.key.get_pressed().

La souris

Un clic produit un événement MOUSEBUTTONDOWN, dont l’attribut pos donne la position (x, y) du clic ; l’attribut button vaut 1 pour le bouton gauche, 3 pour le droit. À tout moment, pygame.mouse.get_pos() donne la position actuelle du pointeur.

La souris (suite)

import random
import pygame

LARGEUR, HAUTEUR = 600, 400
RAYON = 25

pygame.init()
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
pygame.display.set_caption("Attraper la cible")
horloge = pygame.time.Clock()
police = pygame.font.SysFont("Arial", 24)


def nouvelle_position():
    """Renvoie un centre au hasard, la cible restant entière dans la fenêtre."""
    return (random.randint(RAYON, LARGEUR - RAYON),
            random.randint(RAYON + 40, HAUTEUR - RAYON))


centre = nouvelle_position()
score = 0

en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False
        elif evenement.type == pygame.MOUSEBUTTONDOWN:
            dx = evenement.pos[0] - centre[0]
            dy = evenement.pos[1] - centre[1]
            if dx * dx + dy * dy <= RAYON * RAYON:      # clic dans le disque
                score = score + 1
                centre = nouvelle_position()

    ecran.fill((255, 255, 255))
    pygame.draw.circle(ecran, (220, 40, 40), centre, RAYON)
    pygame.draw.circle(ecran, (255, 255, 255), centre, RAYON // 2)
    pygame.draw.circle(ecran, (220, 40, 40), centre, RAYON // 4)
    texte = police.render(f"Score : {score}", True, (0, 0, 0))
    ecran.blit(texte, (10, 10))
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

La souris (suite)

Une cible rouge et blanche sur fond blanc, avec le texte « Score : 4 » en haut à gauche.

Après quatre cibles atteintes.

La souris (suite)

Le clic est dans le disque quand sa distance au centre est au plus égale au rayon. D’après le théorème de Pythagore, cette distance vaut la racine carrée de dx² + dy² ; comparer les carrés des deux longueurs évite de calculer une racine.

Les rectangles Rect et les collisions

Presque tout, dans un jeu, se ramène à des rectangles : le joueur, les ennemis, les murs, les boutons. Pygame fournit un type pygame.Rect, qui range la position et la taille d’un rectangle, et calcule tout seul ses bords et son centre.

joueur = pygame.Rect(100, 300, 50, 40)   # x, y, largeur, hauteur

Les rectangles Rect et les collisions (suite)

Les rectangles Rect et les collisions (suite)

Attribut Signification Pour pygame.Rect(100, 300, 50, 40)
x, y (ou left, top) coin supérieur gauche 100, 300
width, height largeur, hauteur 50, 40
right, bottom bord droit, bord bas 150, 340
centerx, centery, center centre 125, 320, (125, 320)

On peut modifier n’importe lequel de ces attributs, et le rectangle se déplace en conséquence : joueur.x = joueur.x + 5 le décale de 5 pixels vers la droite, joueur.center = (300, 200) le centre au point (300, 200), joueur.bottom = 400 le pose sur le bas de la fenêtre.

Les rectangles Rect et les collisions (suite)

Deux méthodes détectent les collisions :

  • r1.colliderect(r2) renvoie True si les rectangles r1 et r2 se chevauchent ;
  • r.collidepoint(pos) renvoie True si le point pos est dans le rectangle r : c’est le test d’un clic sur un bouton.

Question. Les rectangles a = pygame.Rect(0, 0, 50, 50) et b = pygame.Rect(40, 30, 50, 50) se chevauchent-ils ? Et a et c = pygame.Rect(50, 0, 50, 50) ?

Réponse

a va de x = 0 à x = 50 et de y = 0 à y = 50 ; b commence en (40, 30), donc à l’intérieur de a : ils se chevauchent, a.colliderect(b) vaut True.

c commence exactement en x = 50, là où a s’arrête : les deux rectangles se touchent sans se chevaucher, et a.colliderect(c) vaut False. Le bord droit a.right = 50 n’appartient pas au rectangle a, qui couvre les colonnes de pixels 0 à 49.

Écrire du texte

Pygame ne sait pas écrire directement sur l’écran : il fabrique une image du texte avec une police, puis on colle cette image sur l’écran avec blit.

police = pygame.font.SysFont("Arial", 32)           # créée une seule fois, avant la boucle
image = police.render("Score : 12", True, (255, 255, 255))   # texte, lissage, couleur
ecran.blit(image, (10, 10))                          # coller l'image, coin supérieur gauche en (10, 10)

Écrire du texte (suite)

  • pygame.font.SysFont("Arial", 32) cherche la police parmi celles de l’ordinateur ; pygame.font.Font(None, 32) utilise la police fournie avec Pygame, présente partout.
  • render renvoie une surface ; le deuxième argument, True, lisse les bords des lettres.
  • Pour centrer un texte, on demande le rectangle de l’image, placé autour d’un centre : ecran.blit(image, image.get_rect(center=(300, 200))).

On crée la police une seule fois, avant la boucle : la créer à chaque image ralentit nettement le jeu.

Un bouton cliquable

Un bouton, dans Pygame, n’est qu’un rectangle dessiné, un texte centré dessus, et un test collidepoint au moment du clic. Une fonction évite de répéter ce code pour chaque bouton ; la couleur change quand la souris survole le bouton.

Un bouton cliquable (suite)

import pygame

pygame.init()
ecran = pygame.display.set_mode((600, 400))
pygame.display.set_caption("Choix de la difficulté")
horloge = pygame.time.Clock()
police = pygame.font.SysFont("Arial", 28)

boutons = {
    "Facile": pygame.Rect(200, 90, 200, 60),
    "Moyen": pygame.Rect(200, 170, 200, 60),
    "Difficile": pygame.Rect(200, 250, 200, 60),
}
choix = "aucun"


def dessiner_bouton(texte, rect):
    """Dessine un bouton, plus clair quand la souris le survole."""
    if rect.collidepoint(pygame.mouse.get_pos()):
        couleur = (90, 140, 230)
    else:
        couleur = (50, 90, 170)
    pygame.draw.rect(ecran, couleur, rect, border_radius=12)
    image = police.render(texte, True, (255, 255, 255))
    ecran.blit(image, image.get_rect(center=rect.center))


en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False
        elif evenement.type == pygame.MOUSEBUTTONDOWN and evenement.button == 1:
            for texte in boutons:
                if boutons[texte].collidepoint(evenement.pos):
                    choix = texte

    ecran.fill((25, 25, 40))
    for texte in boutons:
        dessiner_bouton(texte, boutons[texte])
    info = police.render(f"Difficulté choisie : {choix}", True, (230, 230, 230))
    ecran.blit(info, info.get_rect(center=(300, 360)))
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Un bouton cliquable (suite)

Trois boutons bleus arrondis « Facile », « Moyen », « Difficile » ; le bouton « Moyen » est plus clair car survolé ; en bas, le texte « Difficulté choisie : Moyen ».

Après un clic sur « Moyen », la souris étant restée dessus.

Un bouton cliquable (suite)

Les boutons sont rangés dans un dictionnaire : la clé est le texte du bouton, la valeur son rectangle. Une boucle sur les clés dessine tous les boutons, et une autre teste lequel a été cliqué : ajouter un bouton ne demande qu’une ligne.

Des images

Où placer les fichiers ?

Les images et les sons sont rangés dans des dossiers à côté du programme, par exemple :

mon_jeu/
├── main.py
├── images/
│   ├── fond.png
│   └── vaisseau.png
└── sons/
    └── tir.wav

Où placer les fichiers ? (suite)

Écrire pygame.image.load("images/vaisseau.png") fonctionne quand le programme est lancé depuis le dossier mon_jeu, mais provoque FileNotFoundError s’il est lancé depuis un autre dossier : un chemin relatif part du dossier courant, qui n’est pas forcément celui du programme. Un chemin absolu comme C:/Users/Lea/Bureau/mon_jeu/images/vaisseau.png ne fonctionne que sur l’ordinateur de Léa.

Où placer les fichiers ? (suite)

La solution sûre construit le chemin à partir de l’emplacement du programme, donné par la variable spéciale __file__ :

import os

DOSSIER = os.path.dirname(os.path.abspath(__file__))     # dossier du programme
IMAGES = os.path.join(DOSSIER, "images")

vaisseau = pygame.image.load(os.path.join(IMAGES, "vaisseau.png"))

os.path.join assemble les morceaux avec le bon séparateur (/ ou \, selon le système) : le jeu fonctionne sur tous les ordinateurs.

Charger, transformer et afficher

import os
import pygame

DOSSIER = os.path.dirname(os.path.abspath(__file__))

pygame.init()
ecran = pygame.display.set_mode((600, 400))
pygame.display.set_caption("Images")
horloge = pygame.time.Clock()

# Chargement : une seule fois, avant la boucle
vaisseau = pygame.image.load(os.path.join(DOSSIER, "images", "vaisseau.png")).convert_alpha()
vaisseau = pygame.transform.scale(vaisseau, (80, 80))       # redimensionner
petit = pygame.transform.scale(vaisseau, (40, 40))
tourne = pygame.transform.rotate(vaisseau, 45)              # tourner de 45 degrés
miroir = pygame.transform.flip(vaisseau, False, True)       # retourner verticalement

position = vaisseau.get_rect(center=(300, 300))             # rectangle de l'image

en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False

    ecran.fill((10, 10, 30))
    ecran.blit(vaisseau, position)          # coller l'image à la place de son rectangle
    ecran.blit(petit, (60, 60))
    ecran.blit(tourne, (240, 40))
    ecran.blit(miroir, (460, 60))
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Charger, transformer et afficher (suite)

Sur fond spatial sombre, quatre copies d'un vaisseau : une grande en bas au centre, une petite en haut à gauche, une tournée de 45 degrés et une retournée tête en bas.

Une image chargée une fois, puis redimensionnée, tournée et retournée.

Charger, transformer et afficher (suite)

  • pygame.image.load lit un fichier PNG ou JPEG et renvoie une surface. On l’appelle avant la boucle : lire un fichier sur le disque 60 fois par seconde ralentit énormément le jeu.
  • .convert_alpha() convertit l’image au format de l’écran en gardant la transparence du PNG : l’affichage est beaucoup plus rapide. Elle s’appelle après set_mode.

Charger, transformer et afficher (suite)

  • image.get_rect(center=...) renvoie un Rect de la taille de l’image, placé où l’on veut : on déplace ce rectangle, on teste ses collisions, et on colle l’image à sa place avec blit.
  • pygame.transform.flip(image, True, False) retourne horizontalement : c’est ainsi qu’un personnage regarde à gauche ou à droite avec une seule image.

Des sons

Le module pygame.mixer joue des sons courts (bruitages) et une musique de fond.

son_tir = pygame.mixer.Sound(os.path.join(DOSSIER, "sons", "tir.wav"))   # avant la boucle
son_tir.play()                                                           # au moment du tir

pygame.mixer.music.load(os.path.join(DOSSIER, "sons", "musique.ogg"))
pygame.mixer.music.play(-1)        # -1 : jouer en boucle
pygame.mixer.music.stop()

Les formats les plus sûrs sont WAV et OGG. Un son se charge une seule fois, avant la boucle, comme une image.

Le temps

pygame.time.get_ticks() renvoie le nombre de millisecondes écoulées depuis pygame.init(). En gardant l’instant d’un événement dans une variable, on mesure des durées :

debut = pygame.time.get_ticks()            # au début de la partie
...
ecoule = (pygame.time.get_ticks() - debut) // 1000      # secondes écoulées
restant = 30 - ecoule                                   # compte à rebours de 30 secondes

Le temps (suite)

Le même principe déclenche une action à intervalle régulier, par exemple faire apparaître un ennemi toutes les secondes :

dernier = pygame.time.get_ticks()
...
maintenant = pygame.time.get_ticks()
if maintenant - dernier >= 1000:           # une seconde depuis la dernière apparition
    ennemis.append(nouvel_ennemi())
    dernier = maintenant

On n’utilise jamais time.sleep dans un jeu : pendant l’attente, la fenêtre ne répond plus et rien ne bouge.

Plusieurs objets : une liste

Un jeu contient souvent beaucoup d’objets du même genre : ennemis, projectiles, pièces à ramasser. On les range dans une liste, et chaque étape de la boucle les traite tous avec une boucle for.

Plusieurs objets : une liste (suite)

fruits = []                                   # liste de Rect

# Mise à jour : faire tomber chaque fruit
for fruit in fruits:
    fruit.y = fruit.y + 4

# Garder seulement les fruits encore dans la fenêtre et pas attrapés
restants = []
for fruit in fruits:
    if fruit.colliderect(panier):
        score = score + 1
    elif fruit.top < HAUTEUR:
        restants.append(fruit)
fruits = restants

# Dessin
for fruit in fruits:
    pygame.draw.ellipse(ecran, ROUGE, fruit)

Plusieurs objets : une liste (suite)

Question. Pourquoi construire une nouvelle liste restants plutôt que d’écrire fruits.remove(fruit) dans la boucle for fruit in fruits ?

Réponse

Supprimer un élément d’une liste pendant qu’on la parcourt décale les éléments suivants : la boucle saute alors l’élément qui suit chaque suppression. Construire une nouvelle liste avec les éléments à garder évite ce problème.

Les états du jeu : menu, partie, fin

Un jeu passe par plusieurs écrans : l’accueil, la partie, l’écran de fin. Plutôt que plusieurs variables booléennes (accueil = True, partie = False, fin = False), qu’il faut toutes tenir à jour et qui peuvent se contredire, on utilise une seule variable etat, qui vaut "menu", "jeu" ou "fin". Dans la boucle, une condition choisit ce qu’on traite et ce qu’on dessine selon l’état.

Les états du jeu : menu, partie, fin (suite)

Les états du jeu : menu, partie, fin (suite)

etat = "menu"

while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False
        elif evenement.type == pygame.KEYDOWN and evenement.key == pygame.K_RETURN:
            if etat == "menu" or etat == "fin":
                nouvelle_partie()
                etat = "jeu"

    if etat == "jeu":
        mettre_a_jour()            # déplacements, collisions, score
        if partie_finie():
            etat = "fin"

    if etat == "menu":
        dessiner_menu()
    elif etat == "jeu":
        dessiner_jeu()
    else:
        dessiner_fin()
    pygame.display.flip()
    horloge.tick(60)

Chaque écran a ses fonctions de mise à jour et de dessin : la boucle principale reste courte et lisible.

Un projet complet : attraper les fruits

Ce jeu réunit tout ce qui précède. Le joueur déplace un panier avec les flèches ; des fruits tombent du haut ; chaque fruit attrapé rapporte un point, chaque fruit raté en fait perdre un. La partie dure 30 secondes. Il n’utilise aucun fichier d’image ni de son : il fonctionne tel quel.

Un projet complet : attraper les fruits (suite)

import random
import pygame

LARGEUR, HAUTEUR = 600, 400
DUREE = 30                     # durée d'une partie, en secondes
FOND = (25, 35, 60)
BLANC = (255, 255, 255)
JAUNE = (255, 210, 0)
COULEURS_FRUITS = [(230, 50, 50), (255, 150, 0), (120, 200, 60)]

pygame.init()
ecran = pygame.display.set_mode((LARGEUR, HAUTEUR))
pygame.display.set_caption("Attraper les fruits")
horloge = pygame.time.Clock()
grande = pygame.font.SysFont("Arial", 48)
petite = pygame.font.SysFont("Arial", 24)

# État de la partie
etat = "menu"
panier = pygame.Rect(260, 360, 80, 20)
fruits = []                    # liste de fruits, chacun une liste [rectangle, couleur]
score = 0
debut = 0
derniere_chute = 0


def ecrire(texte, police, couleur, centre):
    """Écrit un texte centré sur le point centre."""
    image = police.render(texte, True, couleur)
    ecran.blit(image, image.get_rect(center=centre))


def nouvelle_partie():
    """Remet à zéro tout l'état de la partie."""
    global fruits, score, debut, derniere_chute
    panier.centerx = LARGEUR // 2
    fruits = []
    score = 0
    debut = pygame.time.get_ticks()
    derniere_chute = debut


def mettre_a_jour():
    """Déplace le panier et les fruits, gère les collisions et le score."""
    global fruits, score, derniere_chute
    touches = pygame.key.get_pressed()
    if touches[pygame.K_LEFT]:
        panier.x = panier.x - 7
    if touches[pygame.K_RIGHT]:
        panier.x = panier.x + 7
    panier.x = max(0, min(panier.x, LARGEUR - panier.width))

    maintenant = pygame.time.get_ticks()
    if maintenant - derniere_chute >= 600:          # un nouveau fruit toutes les 0,6 s
        x = random.randint(0, LARGEUR - 30)
        fruits.append([pygame.Rect(x, -30, 30, 30), random.choice(COULEURS_FRUITS)])
        derniere_chute = maintenant

    restants = []
    for fruit in fruits:
        rect = fruit[0]
        rect.y = rect.y + 4
        if rect.colliderect(panier):
            score = score + 1
        elif rect.top > HAUTEUR:
            score = score - 1
        else:
            restants.append(fruit)
    fruits = restants


def temps_restant():
    return DUREE - (pygame.time.get_ticks() - debut) // 1000


def dessiner_jeu():
    ecran.fill(FOND)
    for fruit in fruits:
        pygame.draw.ellipse(ecran, fruit[1], fruit[0])
    pygame.draw.rect(ecran, JAUNE, panier, border_radius=6)
    ecran.blit(petite.render(f"Score : {score}", True, BLANC), (10, 10))
    ecran.blit(petite.render(f"Temps : {temps_restant()} s", True, BLANC), (460, 10))


def dessiner_menu():
    ecran.fill(FOND)
    ecrire("Attraper les fruits", grande, JAUNE, (LARGEUR // 2, 150))
    ecrire("Flèches gauche et droite pour déplacer le panier", petite, BLANC, (LARGEUR // 2, 230))
    ecrire("Entrée pour commencer", petite, BLANC, (LARGEUR // 2, 280))


def dessiner_fin():
    ecran.fill(FOND)
    ecrire("Partie terminée", grande, JAUNE, (LARGEUR // 2, 150))
    ecrire(f"Score : {score}", grande, BLANC, (LARGEUR // 2, 220))
    ecrire("Entrée pour rejouer", petite, BLANC, (LARGEUR // 2, 290))


en_cours = True
while en_cours:
    for evenement in pygame.event.get():
        if evenement.type == pygame.QUIT:
            en_cours = False
        elif evenement.type == pygame.KEYDOWN and evenement.key == pygame.K_RETURN:
            if etat != "jeu":
                nouvelle_partie()
                etat = "jeu"

    if etat == "jeu":
        mettre_a_jour()
        if temps_restant() <= 0:
            etat = "fin"

    if etat == "menu":
        dessiner_menu()
    elif etat == "jeu":
        dessiner_jeu()
    else:
        dessiner_fin()
    pygame.display.flip()
    horloge.tick(60)

pygame.quit()

Un projet complet : attraper les fruits (suite)

L'écran d'accueil : titre jaune « Attraper les fruits » et deux lignes d'instructions blanches.

L’état « menu ».

Un projet complet : attraper les fruits (suite)

La partie : fruits rouges, orange et verts qui tombent vers un panier jaune, avec le score en haut à gauche et le temps restant en haut à droite.

L’état « jeu ».

Quelques choix méritent d’être remarqués.

Un projet complet : attraper les fruits (suite)

  • Tout l’état de la partie tient dans quelques variables globales : etat, panier, fruits, score, debut, derniere_chute. Les fonctions qui les réaffectent les déclarent avec global ; panier.x = ... modifie le rectangle sans réaffecter la variable panier, d’où l’absence de global panier.
  • nouvelle_partie() remet tout à zéro : rejouer ne demande pas de relancer le programme.
  • Chaque fruit est une liste [rectangle, couleur] : fruit[0] est son Rect, fruit[1] sa couleur.
  • Les constantes en majuscules (LARGEUR, DUREE…) rassemblent les réglages en haut du fichier : pour rendre le jeu plus difficile, on change un seul nombre.

Un projet complet : attraper les fruits (suite)

Question. Comment faire accélérer les fruits au fil de la partie ?

Réponse

Remplacer la vitesse fixe 4 par une valeur qui dépend du temps écoulé, par exemple rect.y = rect.y + 4 + (DUREE - temps_restant()) // 10 : la vitesse passe à 5 après 10 secondes, puis à 6 après 20 secondes.

Organiser un projet plus grand

  • Une fonction par tâche : mettre_a_jour, dessiner_jeu, dessiner_menu… Une boucle principale de vingt lignes se relit facilement ; une boucle de trois cents lignes, non.
  • Des constantes en majuscules pour les tailles, vitesses et couleurs, plutôt que des nombres répétés dans tout le code.
  • Des données plutôt que du code répété : les boutons dans un dictionnaire, les niveaux ou les dialogues dans des listes, chaque élément traité par la même boucle.

Organiser un projet plus grand (suite)

  • Éviter from pygame import * : on ne sait plus d’où vient chaque nom, et un nom du programme peut en écraser un de Pygame. On écrit import pygame, puis pygame.QUIT.
  • Pygame propose aussi des classes (pygame.sprite.Sprite) pour représenter chaque objet du jeu avec ses données et ses méthodes. La programmation orientée objet est au programme de terminale ; tout ce qui précède s’écrit sans elle.

Erreurs fréquentes

Symptôme Cause Correction
la fenêtre ne répond pas, la croix ne ferme rien pas de pygame.event.get() dans la boucle lire les événements à chaque tour
la fenêtre reste noire pygame.display.flip() oublié l’appeler à la fin de chaque tour
l’objet laisse une traînée le fond n’est pas repeint ecran.fill(...) au début du dessin
le jeu va trop vite, ou à des vitesses différentes selon l’ordinateur pas de horloge.tick(60) l’ajouter à la fin de la boucle

Erreurs fréquentes (suite)

Symptôme Cause Correction
le jeu rame pygame.image.load ou pygame.font.SysFont dans la boucle charger images, sons et polices avant la boucle
FileNotFoundError: No file 'images/fond.png' found in working directory chemin relatif au dossier courant, ou chemin absolu d’un autre ordinateur os.path.join(DOSSIER, "images", "fond.png")
pygame.error: video system not initialized pygame.init() oublié, ou dessin après pygame.quit() pygame.init() au début, pygame.quit() après la boucle seulement

Erreurs fréquentes (suite)

Symptôme Cause Correction
pygame.error: font not initialized police créée avant pygame.init() créer les polices après pygame.init()
AttributeError: 'pygame.event.Event' object has no attribute 'key' evenement.key lu sans vérifier le type de l’événement tester d’abord evenement.type == pygame.KEYDOWN
TypeError: size must be two numbers set_mode(600, 400) sans les doubles parenthèses set_mode((600, 400))

Erreurs fréquentes (suite)

Symptôme Cause Correction
l’objet ne bouge que d’un cran quand on maintient la touche déplacement placé dans KEYDOWN pygame.key.get_pressed() pour les déplacements
la boucle ne s’arrête jamais, ou NameError: name 'En_cours' is not defined deux noms différents pour la même variable (en_cours et En_cours) Python distingue les majuscules : un seul nom
l’objet monte au lieu de descendre y diminué au lieu d’augmenté l’axe des y va vers le bas

Pour aller plus loin

  • Ajouter au jeu des fruits des « bombes » noires qui font perdre trois points, et un meilleur score gardé d’une partie à l’autre.
  • Ajouter un bruitage quand un fruit est attrapé, avec pygame.mixer.Sound.
  • Remplacer le panier et les fruits par des images, en gardant les mêmes Rect pour les collisions.
  • La documentation de Pygame (en anglais) décrit toutes les fonctions et propose des tutoriels.

À retenir

  1. Un jeu est une boucle qui, à chaque image, lit les événements, met à jour l’état, dessine tout, puis attend avec horloge.tick(60).
  2. pygame.event.get() à chaque tour, ecran.fill(...) avant de dessiner, pygame.display.flip() après : sans eux, la fenêtre se fige, laisse des traînées ou reste noire.
  3. L’origine est en haut à gauche, y augmente vers le bas ; une couleur est un triplet (rouge, vert, bleu).
  4. Une animation modifie une position d’une vitesse à chaque image ; un rebond change le signe de la vitesse.

À retenir (suite)

  1. KEYDOWN réagit une fois par appui ; pygame.key.get_pressed() donne les touches maintenues.
  2. pygame.Rect range position et taille ; colliderect et collidepoint détectent les collisions et les clics.
  3. Images, sons et polices se chargent une seule fois, avant la boucle, avec des chemins construits depuis le dossier du programme.
  4. Une variable etat ("menu", "jeu", "fin") organise les écrans ; des listes rangent les objets nombreux.

Résumé

Concept Syntaxe Exemple
Démarrer pygame.init(), set_mode((l, h)) ecran = pygame.display.set_mode((600, 400))
Événements for e in pygame.event.get(): if e.type == pygame.QUIT:
Touche enfoncée e.type == pygame.KEYDOWN, e.key e.key == pygame.K_SPACE

Résumé (suite)

Concept Syntaxe Exemple
Touches maintenues pygame.key.get_pressed() touches[pygame.K_LEFT]
Clic e.type == pygame.MOUSEBUTTONDOWN, e.pos bouton.collidepoint(e.pos)
Fond ecran.fill(couleur) ecran.fill((0, 0, 0))
Formes pygame.draw.rect, circle, line, ellipse, polygon pygame.draw.circle(ecran, c, (x, y), 20)
Afficher pygame.display.flip() fin de chaque tour
Cadence horloge.tick(60) 60 images par seconde

Résumé (suite)

Concept Syntaxe Exemple
Rectangle pygame.Rect(x, y, l, h) joueur.colliderect(ennemi)
Texte police.render(texte, True, c) puis blit ecran.blit(image, (10, 10))
Image pygame.image.load(chemin).convert_alpha() avant la boucle
Chemin os.path.join(DOSSIER, "images", "a.png") DOSSIER tiré de __file__
Son pygame.mixer.Sound(chemin).play() bruitage
Temps pygame.time.get_ticks() durée en millisecondes