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.
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 :
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 !
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 :
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 !
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 :
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.
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.
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
.
Cette fonction est en fait le coeur de notre jeu. C'est elle qui va gérer son exécution et ses évènements.
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.
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 !
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 !
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é :
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 :
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 !