ProPython
Projet Pygame - Flappy Bird
03 May, 2021
Moyen
Prérequis
Bases de Pygame

Projet Pygame - Flappy Bird

Avant de commencer cet exercice, assurez vous de bien avoir lu l'article Pygame - Créez votre premier jeu, ou bien d'avoir déjà des connaissances sur cette librairie !

Nous allons voir comment développer un petit jeu avec Pygame : le fameux Flappy Bird dont vous avez sûrement déjà entendu parler. Vous allez voir, ce n'est pas si difficile que ça, Pygame nous simplifie vraiment le travail et nous permet de faire toute sorte de jeu assez rapidement et facilement.

Enoncé

Vous contrôlez un personnage qui se déplace uniquement verticalement et qui doit éviter des obstacles sur son chemin. L'écran défile de sorte qu'on ait l'impression que le personnage avance : en fait il ne bouge pas horizontalement, ce sont les obstacles qui viennent progressivement à lui pour donner cette impression de défilement.

Le personnage peut uniquement sauter, et est constamment en chute libre. C'est à dire que si vous ne touchez à aucune commande, le personnage tombe. Par contre, appuyer régulièrement sur la touche pour le faire sauter permet d'éviter qu'il tombe.

La fenêtre est constituée de deux sections : le ciel et la mer. La mer est située en bas de la fenêtre et occupe une petite partie. Le ciel occupe le reste de la fenêtre. La partie est perdue lorsque le personnage touche la mer, ou entre en collision avec un obstacle.

On compte le score du joueur : il est initialement à 0, et pour chaque obstacle passé on l'incrémente de 1.

Voici à quoi doit ressembler votre jeu :

Conseils

  • Vous pouvez télécharger un pack d'images à utiliser pour votre jeu à utiliser en cliquant ici. Ce sont celles que j'ai utilisé !
  • Ne vous embêtez pas à animer le personnage.
  • Considérez les tuyaux comme un ensemble. Un ensemble est constitué d'un tuyau du bas, et d'un tuyau du haut.
  • Faites attention à gérer correctement les collisions. Une collision doit se déclencher dès qu'un bord de l'image de l'oiseau touche le bord de la mer, du haut de la fenêtre, ou le bord d'un tuyau, il faut donc bien gérer les collisions pour les 4 côtés de chaque image.
  • On peut diviser le programme en deux boucles : la première est celle qui gère l'écran d'accueil, tant que le joueur n'a appuyé nulle part. Cette boucle permet de lancer une fonction qui va contenir une boucle gérant le jeu. Vous pouvez tout gérer dans une unique boucle, mais c'est se compliquer la vie je pense, pensez donc à bien diviser votre code.

Correction

Ne consultez cette partie uniquement après avoir au moins essayé de réaliser le jeu, sinon ça n'a pas vraiment d'intérêt ! Le code utilisé pour la correction est inspiré de cet article. Je l'ai adapté et expliqué simplement, en distinguant bien les différentes étapes de développement du jeu !

Mise en place

Bon, pour bien démarrer on va organiser un tout petit peu le projet et voir les imports dont on aura besoin. On va aussi configurer notre fenêtre et charger nos images.

Une organisation simple pour ce projet est de rassembler les images dans un dossier. J'ai donc créé un dossier assets dans lequel j'ai mis toutes mes images.

Ensuite, on va importer les packages dont on aura besoin :

import random  # Pour la génération des tuyaux
import sys  # Pour arrêter le programme sous certaines conditions
import pygame
from pygame.locals import *

Maintenant, on peut définir les paramètres de notre fenêtre. On commence par définir une largeur et une hauteur. Attention, on ne choisit pas des valeurs aléatoires pour ces paramètres. Il faut les définir en fonction des dimensions de nos images. Dans mon cas, la largeur sera de 600, et la hauteur de 499.

window_width = 600
window_height = 499

On va définir quelques paramètres supplémentaires : les FPS et l'élévation. Les FPS correspondent au nombre d'images par seconde affichées par notre jeu. On prendra 32 dans mon cas. L'élévation est le hauteur à laquelle la mer doit être. Pour ma part, la mer occupera 20% de la fenêtre, ce qui donne ceci :

elevation = window_height * 0.8
fps = 32

On peut maintenant créer notre fenêtre :

window = pygame.display.set_mode((window_width, window_height))

Il nous reste à charger nos images. Dans mon cas, elles seront stockées dans un dictionnaire, donc je l'initialise en plus du chargement des images. Pour rappel, les images que j'ai utilisées sont téléchargeables en cliquant ici.

images = {}

# Chargement des images
images['flappybird'] = pygame.image.load('assets/bird.png').convert_alpha()
images['sea_level'] = pygame.image.load('assets/base.jfif').convert_alpha()
images['background'] = pygame.image.load('assets/background.jpg').convert_alpha()
images['pipeimage'] = (pygame.transform.rotate(pygame.image.load('assets/pipe.png')
                                                   .convert_alpha(),
                                                   180),
                           pygame.image.load('assets/pipe.png').convert_alpha())
images['scores'] = (
        pygame.image.load('assets/0.png').convert_alpha(),
        pygame.image.load('assets/1.png').convert_alpha(),
        pygame.image.load('assets/2.png').convert_alpha(),
        pygame.image.load('assets/3.png').convert_alpha(),
        pygame.image.load('assets/4.png').convert_alpha(),
        pygame.image.load('assets/5.png').convert_alpha(),
        pygame.image.load('assets/6.png').convert_alpha(),
        pygame.image.load('assets/7.png').convert_alpha(),
        pygame.image.load('assets/8.png').convert_alpha(),
        pygame.image.load('assets/9.png').convert_alpha()
    )

Quelques subtilités :

  • On stocke deux images de tuyau dans un tuple, car on a deux sens possibles pour les tuyaux, ils peuvent être orientés vers le bas ou vers le haut
  • On stocke les scores dans un tuple également

Ensuite, on va initialiser Pygame, l'horloge qui va nous servir pour gérer les FPS, on va donner un titre à la fenêtre, et on va afficher un message de bienvenue.

pygame.init()
fps_clock = pygame.time.Clock()
pygame.display.set_caption('Flappy Bird')

print("Bienvenue !")
print("Appuyez sur Espace ou Entrée pour lancer le jeu.")

Maintenant, on peut passer à la boucle principale !

La boucle principale

C'est celle qui va nous permettre de lancer le jeu et de contrôler le programme. En fait notre jeu va être constitué de deux boucles :

  • La boucle principale, qui gère l'exécution du programme dans sa globalité
  • La boucle du jeu, qui gère uniquement le jeu en lui-même

En gros, c'est comme si lorsqu'on lançait le programme, on avait un écran d'accueil, puis qu'ensuite on avait plusieurs onglets comme "Jouer", "Options", "Quitter", etc... La boucle principale gère tout. La boucle du jeu gère uniquement ce qu'il se passe lorsqu'on clique sur "Jouer".

Notre écran d'accueil est simplement une image figée sur le personnage. Ensuite, on rentrera dans le jeu en cliquant sur "Entrée". On doit donc définir ces évènements et dessiner dans la fenêtre afin d'afficher nos images.

On commence par définir la position initiale de notre personnage, ainsi que la position du sol :

while True:
    horizontal = int(window_width / 5)
    vertical = int((window_height - images['flappybird'].get_height()) / 2)

    ground = 0

Ces valeurs doivent être réinitialisées à chaque fois que le joueur termine une partie. Il faut donc créer une deuxième boucle qui correspondra à l'exécution d'une partie. Lorsque la partie sera terminée, on quittera cette boucle, ce qui réinitialisera nos variables, et on rentrera à nouveau immédiatement dans cette boucle.

Cette sous-boucle va nous permettre de gérer nos évènements :

        while True:
            for event in pygame.event.get():

On commence par un évènement pour quitter le programme :

if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
    pygame.quit()

    sys.exit()

Ensuite, il faut un évènement pour lancer le jeu si le joueur appuie sur espace :

elif event.type == KEYDOWN and (event.key == K_SPACE or event.key == K_UP):
flappygame()

On s'occupera de la fonction flappygame plus tard.

Finalement, si on n'a aucun évènement, on se contente d'afficher nos images et d'actualiser l'affichage :

                else:
                    window.blit(images['background'], (0, 0))
                    window.blit(images['flappybird'], (horizontal, vertical))
                    window.blit(images['sea_level'], (ground, elevation))

                    # On rafraîchit l'affichage
                    pygame.display.update()

                    # On attend
                    fps_clock.tick(fps)

Bon, maintenant on va pouvoir essayer de coder notre fonction pour lancer le jeu : flappygame. Mais avant, on va créer quelques fonctions utilitaires. Commençons par en créer une afin de générer des tuyaux de hauteur aléatoire.

Génération de tuyaux

On veut générer des tuyaux aléatoires. Mais attention, il y a quelques contraintes qui rendent cette partie pas si simple que ça. Par exemple, il ne faut pas que deux tuyaux puissent se superposer, que ce soit horizontalement ou verticalement. De même, il ne faut pas que deux tuyaux soient trop collés avec des hauteurs trop différentes, sinon le jeu sera bien trop dur.

Pour générer une hauteur de tuyau, on a besoin de connaître la hauteur de la fenêtre, la hauteur de la mer (puisqu'un tuyau ne doit pas dépasser la mer), et on va également utiliser une variable pour régler l'écart vertical entre le tuyau du bas et le tuyau du haut :

def createPipe():

# Décalage : plus cette valeur sera élevée, plus l'espace vertical entre deux tuyaux sera grand
    offset = window_height / 4

    # Générer une hauteur aléatoire pour le tuyau
    y2 = offset + random.randrange(
        0, int(window_height - images['sea_level'].get_height() - 1.2 * offset))

On a maintenant la hauteur du tuyau du haut. A partir de celle-ci et de notre décalage, on va pouvoir en déduire celle du tuyau du haut:

pipeHeight = images['pipeimage'][0].get_height()
y1 = pipeHeight - y2 + offset

Il ne reste plus qu'à définir la position horizontale de nos tuyaux. Un tuyau est généré au bout de la fenêtre à chaque fois. La position en x d'un nouveau tuyau générée est donc tout naturellement la largeur de la fenêtre :

pipeX = window_width + 10  # + 10 car on fait apparaître notre tuyau en dehors de la fenêtre, pour ne pas le faire apparaître brusquement devant le joueur

On rassemble nos tuyaux dans une liste qu'on renvoie :

pipe = [

        # Tuyau du haut
        {'x': pipeX, 'y': -y1},

        # Tuyau du bas
        {'x': pipeX, 'y': y2}
    ]
    return pipe

La fonction suivante à créer est une fonction pour vérifier s'il y a collision entre le personnage et un tuyau ou la mer.

Gestion des collisions

On va créer une fonction qui nous dit si la partie est terminée ou non, c'est à dire si l'oiseau est entré en collision avec un tuyau ou la mer. Cette fonction prend en paramètres la position de l'oiseau, ainsi que les tuyaux.

def isGameOver(horizontal, vertical, up_pipes, down_pipes):

On commence par vérifier si l'oiseau est sous l'eau ou s'il se cogne au plafond :

    if vertical > elevation - 25 or vertical < 0:
        return True

(On retire 25 car c'est la hauteur de l'image de l'oiseau).

Pour la gestion des collisions avec les tuyaux, on va distinguer le cas des tuyaux du haut et celui des tuyaux du bas. L'oiseau est en collision avec un tuyau du haut si sa position en x est comprise dans la largeur du tuyau, et si sa position en y est inférieure à la hauteur du tuyau. Et inversement pour un tuyau du bas. Ce qui se traduit comme ceci :

# On vérifie si l'oiseau est en collision avec le tuyau du haut
    for pipe in up_pipes:
        pipeHeight = images['pipeimage'][0].get_height()
        if (vertical < pipeHeight + pipe['y']
                and abs(horizontal - pipe['x']) < images['pipeimage'][0].get_width()):
            return True

    # On vérifie si l'oiseau est en collision avec le tuyau du bas
    for pipe in down_pipes:
        if (vertical + images['flappybird'].get_height() > pipe['y']) and abs(horizontal - pipe['x']) < images['pipeimage'][0].get_width():
            return True

Si on a rien retourné, alors il n'y a pas de collision, on renvoie False.

return False

Et maintenant, on peut nous attaquer à notre fonction flappygame.

La fonction flappygame

Cette fonction est en fait le coeur de notre jeu. C'est elle qui va gérer son exécution et ses évènements.

Les conditions initiales

On va commencer par déclarer certaines conditions initiales, par exemple la position de l'oiseau, du sol, des premiers tuyaux, etc... On pourrait penser que ces conditions initiales ont déjà été déclarées dans la boucle principale toute à l'heure, notamment la position de l'oiseau, mais en fait non, toute à l'heure ce n'était que pour l'affichage de la page d'accueil et non pour le jeu. Du coup pour avoir un affichage cohérent et ne pas voir l'oiseau se téléporter, il faut utiliser les mêmes conditions initiales que toute à l'heure. On va également rajouter à ces conditions le score initial du joueur, donc 0 logiquement :

def flappygame():
    your_score = 0
    horizontal = int(window_width / 5)
    vertical = int(window_width / 2)
    ground = 0

Ensuite il faut s'occuper des conditions initiales pour nos tuyaux, à savoir leur position initiale et leur hauteur :

    mytempheight = 100

    # On génère les deux premiers tuyaux
    first_pipe = createPipe()
    second_pipe = createPipe()

    # Cette liste contient tous les tuyaux du bas
    down_pipes = [
        {'x': window_width + 300 - mytempheight,
         'y': first_pipe[1]['y']},
        {'x': window_width + 300 - mytempheight + (window_width / 2),
         'y': second_pipe[1]['y']},
    ]

    # Celle-ci tous ceux du haut
    up_pipes = [
        {'x': window_width + 300 - mytempheight,
         'y': first_pipe[0]['y']},
        {'x': window_width + 200 - mytempheight + (window_width / 2),
         'y': second_pipe[0]['y']},
    ]

On génère simplement deux ensembles de tuyaux avec la fonction qu'on a créée toute à l'heure, et on les stocke dans des listes. On personnalise leur coordonnée selon x.

On va maintenant définir les vitesses de nos objets. Il faut pour cela penser aux différentes vitesses qu'on va avoir. Pour nos tuyaux, c'est simple, ils ne se déplacent que selon l'axe x, on a donc une unique vitesse selon cet axe :

pipeVelX = -4  # Vitesse des tuyaux selon x

Pour l'oiseau c'est un peu plus particulier. Il ne se déplace pas selon x. En effet, ce sont les tuyaux qui viennent à lui, et non l'inverse. Donc pas de vitesse selon x. Par contre selon y, on a une vitesse, et on aimerait établir un petit effet de lissage de sorte que le saut et la chute donnent une impression d'accélération. On va donc définir une vitesse minimale et une vitesse maximale en plus de la vitesse normale selon y. On a donc déjà 3 vitesses. En plus de cela, on va définir une vitesse de saut, qui sera la vitesse de l'oiseau lorsqu'on appuiera sur "Espace" pour le faire sauter. Puis finalement, on va définir une variable qui n'est pas vraiment une vitesse, mais qui va plutôt agir comme une résistance lors d'un saut de l'oiseau. Plus cette valeur sera élevée, moins l'oiseau sautera haut du coup.

    bird_velocity_y = -9  # Vitesse de l'oiseau
    bird_Max_Vel_Y = 10  # Vitesse max
    bird_Min_Vel_Y = -8  # Vitesse min
    birdAccY = 1  # Résistance

    # Vitesse de l'oiseau quand il saute
    bird_flap_velocity = -8

Finalement, notre dernière condition initiale est celle de savoir si l'oiseau est en train de sauter ou non. Par défaut on va dire que non :

bird_flapped = False

On peut maintenant passer à notre boucle principale, et la gestion des évènements.

Les évènements

On a simplement deux évènements : soit le joueur veut quitter le jeu et appuie sur la croix de la fenêtre, soit il appuie sur "Espace" et l'oiseau saute.

Le premier évènement est très simple à mettre en place. Pour le deuxième, on va simplement rendre la vitesse de l'oiseau selon y égale à celle de saut, et changer notre variable bird_flapped de sorte qu'on sache que l'oiseau a sauté :

        for event in pygame.event.get():
            if event.type == QUIT or (event.type == KEYDOWN and event.key == K_ESCAPE):
                pygame.quit()
                sys.exit()
            if event.type == KEYDOWN and (event.key == K_SPACE or event.key == K_UP):     
                    bird_velocity_y = bird_flap_velocity
                    bird_flapped = True

Voilà, nos évènements sont gérés !

Déroulement de la partie

On va maintenant gérer le déroulement d'une partie. La première chose à faire est de vérifier si l'oiseau n'est pas en collision, et donc si la partie n'est pas perdue. On utilise simplement notre fonction créée plus tôt :

        # On vérifie si l'oiseau est en collision
        game_over = isGameOver(horizontal, vertical, up_pipes, down_pipes)
        if game_over:
            return

En exécutant simplement return, cela nous permet de quitter la boucle de notre jeu et de revenir à l'écran principal pour relancer une partie.

Ensuite il faut gérer l'incrémentation du score. Pour cela on vérifie si le centre de l'image de l'oiseau a dépassé la coordonnée en x du centre de l'image d'un tuyau, et on incrémente le score à chaque fois qu'il se produit ceci :

        playerMidPos = horizontal + images['flappybird'].get_width() / 2
        for pipe in up_pipes:
            pipeMidPos = pipe['x'] + images['pipeimage'][0].get_width() / 2
            if pipeMidPos <= playerMidPos < pipeMidPos - pipeVelX:
                # On met à jouer le score
                your_score += 1
                print(f"Votre score est {your_score}")

Attention, il ne faut exécuter ceci qu'une seule fois par tuyau. C'est pour cela que notre if possède une borne supérieure, on s'assure que l'oiseau est assez près du tuyau avant d'incrémenter le score. Si jamais il est trop loins, c'est qu'il l'a dépassé et on n'incrémente plus le score.

Ensuite, on doit gérer la chute de l'oiseau. En effet, si le joueur n'appuie pas sur "Espace", l'oiseau ne saute pas et chute :

        if bird_velocity_y < bird_Max_Vel_Y and not bird_flapped:
            bird_velocity_y += birdAccY

On utilise ici notre variable de résistance, qui correspond en fait à un pas d'incrémentation de la vitesse.

Ensuite on peut réinitialiser notre variable bird_flapped si jamais l'oiseau a sauté, puisque sans réinitialisation, cela signifie qu'il saute à l'infini :

        if bird_flapped:
            bird_flapped = False

Ensuite, on modifie la position de l'oiseau dans la fenêtre en utilisant sa vitesse :

        playerHeight = images['flappybird'].get_height()
        vertical = vertical + min(bird_velocity_y, elevation - vertical - playerHeight)

On utilise ici elevation pour que la position de l'oiseau ne déborde pas sous l'eau si jamais l'oiseau entre en collision avec la mer. J'avoue que c'est du détail et que ce n'était pas forcément nécessaire, mais ce sont les détails qui permettent d'avoir finalement un beau jeu, bien réussi.

Ensuite, il faut gérer nos tuyaux. Déjà, il faut actualiser leur position à chaque tour de boucle :

        for upperPipe, lowerPipe in zip(up_pipes, down_pipes):
            upperPipe['x'] += pipeVelX
            lowerPipe['x'] += pipeVelX

Mais il faut également générer de nouveaux tuyaux lorsque les anciens sortent de l'écran :

        if 0 < up_pipes[0]['x'] < 5:
            newpipe = createPipe()
            up_pipes.append(newpipe[0])
            down_pipes.append(newpipe[1])

Puis il faut supprimer les tuyaux lorsque le joueur les a passés et qu'ils sortent de l'écran :

        if up_pipes[0]['x'] < - images['pipeimage'][0].get_width():
            up_pipes.pop(0)
            down_pipes.pop(0)

Il ne nous reste plus grand chose à faire maintenant. Seulement la gestion du score et des images. Commençons par coller nos images à leurs positions :

        window.blit(images['background'], (0, 0))
        for upperPipe, lowerPipe in zip(up_pipes, down_pipes):
            window.blit(images['pipeimage'][0],
                        (upperPipe['x'], upperPipe['y']))
            window.blit(images['pipeimage'][1],
                        (lowerPipe['x'], lowerPipe['y']))

        window.blit(images['sea_level'], (ground, elevation))
        window.blit(images['flappybird'], (horizontal, vertical))

Maintenant pour le score, puisque nous avons une image pour chaque numéro de 0 à 9, il sera affiché sous forme d'un ensemble d'images. Par exemple, 34 sera constitué d'une image 3, et d'une image 4. Or notre score est un entier actuellement. Il faut donc le formatter de sorte que ce soit une liste de numéros :

        numbers = [int(x) for x in list(str(your_score))]
        width = 0

Ensuite, on va chercher la largeur qu'occupera notre score dans la fenêtre en utilisant la largeur des images de numéros. On va également définir une position initiale pour coller notre score, qui s'incrémentera pour chaque numéro en plus contenu dans le score :

        for num in numbers:
            width += images['scores'][num].get_width()
        Xoffset = (window_width - width) / 1.1

On peut maintenant coller chaque numéro :

        for num in numbers:
            window.blit(images['scores'][num], (Xoffset, window_width * 0.02))
            Xoffset += images['scores'][num].get_width()

Maintenant il faut actualiser notre affichage, et faire patienter notre programme en fonction des FPS qu'on a défini :

pygame.display.update()
fps_clock.tick(fps)

Et voilà, c'est tout pour notre fonction flappygame. D'apparence, elle paraît compliquée, mais en fait en distinguant bien chaque étape, ce n'est pas si dur !

Code final

Bon, maintenant nous avons toutes nos fonctions. Il ne nous reste plus qu'à tout assembler pour tester notre jeu.

Voici donc le code final, commenté :

Voir

Améliorations

Notre jeu est déjà pas mal et bien fonctionnel comme ça. En revanche, si vous souhaitez vous entraîner, il peut être utile de lui rajouter des fonctionnalités. Voici quelques idées si vous manquez d'inspiration :

  • Rajouter un système de difficulté : soit le joueur choisit une difficulté initiale sur l'écran d'accueil, soit la difficulté augmente au fil du temps, par exemple les tuyaux deviennent plus rapides, l'écart entre les tuyaux devient plus petit, etc...
  • Rajouter des obstacles en plus : soit fixes, par exemple des boules de piques qui apparaissent aléatoirement n'importe où sur l'écran, soit en mouvement, par exemple des mouettes qui se déplacent entre les tuyaux plus rapidement.
  • Enregistrer les meilleurs scores dans un fichier.
  • Ajouter une fonctionnalité pour mettre en pause une partie.
  • Sauvegarder une partie dans un fichier pour y revenir plus tard.
  • etc... tout est possible, il faut juste un peu d'imagination !

Le mot de la fin

Voilà ! Vous avez pu voir comment Pygame nous permet de créer des jeux simplement et de façon plutôt intuitive ! Par contre, on est restreint aux jeux 2D, mais c'est déjà pas mal.

Si l'article vous a plu, que vous avez des questions, ou s'il y a des choses que vous n'avez pas compris, n'hésitez pas à laisser un commentaire, ou à me contacter, par mail ou via le site, je me ferai un plaisir de vous répondre !

On se retrouve prochainement pour le développement d'autres petits jeux !

Laisser un commentaire

Premium - 15€/mois

L'accès à des articles inédits, à une multitude de ressources, à de nouveaux projets, mais également à des vidéos explicatives, découvrez ici pourquoi passer premium.

Articles liés

Catégories

Ressources

Retrouvez une collection de ressources (des scripts, des fiches résumé, des images...) liées aux articles du blog ou au Python.
Voir

Contact

contact@propython.fr
Se connecter pour envoyer un message directement depuis le site.

Navigation

AccueilSe connecterCréer un compteRessourcesPremium

Catégories

Pages légales

Politique de confidentialitéMentions légalesConditions générales de vente