2. Jumper (platformer)

Dans ce tutoriel, nous allons développer un jeu de plateforme où le personnage peut se déplacer sur des blocs pour attraper des objets et combattre des ennemis. Nous allons également devoir mettre en place un système de gravité pour permettre au joueur de sauter et de tomber des blocs.

../_images/platformer.gif

1. Faire apparaître des blocs

Dans un jeu de plateforme, il faut des blocs (tile en anglais) ! Heureusement pour vous, je vous ai préparé un acteur qui va prendre ce rôle, l’acteur Plateform.
Commençons par initialiser notre jeu et faire apparaître un bloc au centre de notre fenêtre.

from pgzhelper import *

TITLE = 'Platformer'
WIDTH = 800
HEIGHT = 600

platform = Platform('grass_tile', (WIDTH/2, HEIGHT/2))

def draw():
    screen.fill('sky blue') # Ajout d'un background bleu pour faire le ciel
    platform.draw()

def update():
    pass # Notre acteur platform n'a pas de fonction update à appeler, il doit juste s'afficher, donc on ne fait rien ici
../_images/platformer_bloc.png

2. Faire apparaître notre joueur

Faisons à présent apparaître notre personnage au-dessus de ce bloc. Comme pour le jeu précédent, nous allons implémenter une classe Player qui va représenter notre joueur.

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        pass # Ne fait rien pour le moment

Nous allons ensuite pouvoir le créer dans le programme principal et appeler sa méthode draw et update dans les fonctions principales du même nom. On place notre joueur de telle sorte à ce qu’il se trouve juste un peu au dessus de notre bloc.

player = Player('alien_walk1', (WIDTH/2, HEIGHT/2-100))

def draw():
    screen.fill('sky blue')
    platform.draw()
    player.draw()

def update():
    player.update() # Ne fait rien pour le moment
../_images/platformer_bloc_joueur.png

3. Déplacer le joueur horiontalement

Commençons de manière simple par implémenter les déplacements horizontaux de notre personnage avec les touches a et d.
Comme pour le jeu précédent, nous allons définir une vitesse pour notre personnage (ex: speed = 3).

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.speed = 3

    def update(self):
        pass # Ne fait rien pour le moment

La logique du déplacement doit être codée dans la méthode update. Il suffit de vérifier si la touche a ou d est enfoncée et si c’est le cas, mettre à jour la position x du joueur en tenant compte de sa vitesse. On en profite également pour retourner l’image si le déplacement se fait vers la gauche.

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.speed = 3

    def update(self):
        if keyboard.a:
            self.x -= self.speed
            self.flip_x = True
        if keyboard.d:
            self.x += self.speed
            self.flip_x = False

Et voilà ! Vous devriez voir votre personnage flotter de gauche à droite de l’écran au-dessus du bloc.

4. Ajouter la gravité

L’heure est grave, notre personnage flotte sans jamais tomber ! Repensons à notre ami Newton… la gravité est une accélération vers le bas. La vitesse verticale de notre personnage doit donc augmenter avec le temps.

La première chose à faire est de fixer notre constante de gravité à l’aide d’une variable. Elle vaut 9.81 m/s^2 sur Terre mais cela serait trop pour notre petit jeu. Fixons-la à 0.3 pour le moment. Il nous faut aussi déclarer une vitesse verticale vy pour notre personnage, qui est nulle par défaut.

GRAVITY = 0.3 # Une constante s'écrit en majuscule par convention

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.vy = 0 # vitesse verticale du personnage
        self.speed = 3

Bien, à présent il nous faut ajouter cette accélération à la vitesse verticale vy de notre personnage à chaque tick du jeu et faire évoluer sa position verticale y. Tout ceci se passe dans la méthode update de la classe Player.

def update(self):
    self.vy += GRAVITY # Augmente la vitesse verticale du joueur

    if keyboard.a:
        self.x -= self.speed
        self.flip_x = True
    elif keyboard.d:
        self.x += self.speed
        self.flip_x = False

    self.y += self.vy # Met à jour la position verticale du joueur

Vous devriez à présent pouvoir voir votre personnage chuter dès le début du jeu (et tomber à travers notre platforme).

5. Se poser sur une plateforme

Il faut maintenant s’occuper de faire tenir notre personnage sur le bloc afin de l’empêcher de passer au travers. En temps normal, c’est une implémentation difficile car il y a beaucoup de petits détails à prendre en compte mais je vous ai un peu facilité le travail. Notre acteur Plateform contient une méthode qui permet de gérer les collisions avec les autres acteurs: check_collision_with(actor).
Il nous suffit de l’appeler dans la méthode update de notre joueur pour l’empêcher de passer au travers.

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.vy = 0
        self.speed = 3

    def update(self):
        self.vy += GRAVITY
        
        if keyboard.a:
            self.x -= self.speed
            self.flip_x = True
        elif keyboard.d:
            self.x += self.speed
            self.flip_x = False
            
        platform.check_collision_with_actor(self) # S'occupe automatiquement de gérer la collision
            
        self.y += self.vy

Si vous êtes curieux de comprendre comment la collision est gérée, je vous invite à aller jeter un oeil à l’implémentation de cette méthode dans le fichier pgzhelper.py.

6. Permettre au personnage de sauter

Un jeu de plateforme n’en est pas un si notre personnage ne peut pas sauter ! Nous allons le faire avec la touche espace (space) du clavier.
Quand on y pense, faire sauter notre personnage revient à modifier sa vitesse verticale. En la rendant négative, le personnage se déplace vers le haut jusqu’à ce que la gravité vienne compenser l’action du saut et le fasse tomber à nouveau.

Il suffit donc d’ajouter 2 lignes de code à la méthode update où l’on vérifie si la touche space est enfoncée. Si c’est le cas, on modifie la vitesse verticale vy de notre personnage (ex: vy = -10).

Pas de saut dans le vide !

Attention, il faut également que le personnage se trouve sur une plateforme pour pouvoir sauter ! Cela se vérifie si la vitesse verticale de notre personange est nulle au moment du saut ! (vy == 0)

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.vy = 0
        self.speed = 3

    def update(self):
        self.vy += GRAVITY
        
        if keyboard.a:
            self.x -= self.speed
            self.flip_x = True
        elif keyboard.d:
            self.x += self.speed
            self.flip_x = False

        platform.check_collision_with_actor(self)
            
        # Il est important que ce test se fasse après le contrôle de collision avec la platforme
        if keyboard.space and self.vy == 0:
            self.vy = -10 # On met une vitesse verticale négative pour sauter
            
        self.y += self.vy

7. Créer un niveau rempli de plateformes

Bon notre jeu est un peu triste avec une unique plateforme. Il est temps de créer un niveau entier.
Il existe évidemment plein de manières de s’y prendre. Une solution courante et répendue est de représenter le niveau sous la forme d’un tableau en 2 dimensions où chaque élément du tableau spécifie si une plateforme s’y trouve ou non.

../_images/platformer_grid.png

Pour cela, nous allons définir une variable constante WORLD_MAP qui contient une liste de listes (donc un tableau 2D). Un 1 signifie que l’on y place un bloc tandis qu’un 0 signifie que l’on y met rien. Je vous propose de reprendre le tableau ci-dessous.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,1,0,0,0,1,1,1,0,0,1,1,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

Nous devons aussi connaître combien de lignes et de colonnes contient notre tableau. La fonction len() en python retourne la longueur d’une liste. On peut l’utiliser pour calculer les constantes ROWS et COLS qui stockeront respectivement le nombre de lignes et de colonnes.
Enfin, définissons également la taille que prendra chacun de nos blocs avec la constante TILE_SIZE. Cela nous permettra d’ajuter la taille de notre fenêtre de jeu en multipliant TILE_SIZE avec ROWS et COLS.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,1,0,0,0,1,1,1,0,0,1,1,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

TILE_SIZE = 70 # Taille d'un bloc en pixels
ROWS = len(WORLD_MAP) # Nombre de lignes
COLS = len(WORLD_MAP[0]) # Nombre de colonnes

WIDTH = COLS*TILE_SIZE # Largeur de la fenêtre de jeu
HEIGHT = ROWS*TILE_SIZE # Hauteur

Bien, il ne reste plus qu’à faire apparaître nos blocs selon les positions données par WORLD_MAP. Comme nous aurons plein de plateformes, nous allons les stocker dans une liste plateformes. Nous allons ensuite parcourir notre tableau, calculer les positions, et faire apparaître un bloc au bon endroit si on trouve un 1 en l’ajoutant à notre liste.

player = Player('alien_walk1', (WIDTH/2, HEIGHT/2-100))

platforms = [] #On définit notre liste vide de plateformes
for row in range(ROWS): # On parcourt les lignes du tableau
    for col in range(COLS): # On parcourt les colonnes du tableau
        # Calcul de la position (x, y) du bloc en fonction de la ligne et de la colone sur laquelle on est
        pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
        if WORLD_MAP[row][col] == 1:
            platform = Platform('grass_tile', pos, width=TILE_SIZE)
            platforms.append(platform)

Notez que l’on utilise l’argument width de l’acteur Platform pour lui donner la taille que l’on a définit avec notre constante TILE_SIZE.

Il ne reste à présent plus qu’à modifier notre code pour afficher toutes les plateformes de notre liste ainsi que faire le contrôle de collision de notre joueur avec toutes les plateformes également.

class Player(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.vy = 0
        self.speed = 3

    def update(self):
        self.vy += GRAVITY
        
        if keyboard.a:
            self.x -= self.speed
            self.flip_x = True
        elif keyboard.d:
            self.x += self.speed
            self.flip_x = False
            
        for platform in platforms: # On teste les collisions avec toutes nos plateformes
            platform.check_collision_with_actor(self)
            
        if keyboard.space and self.vy == 0:
            self.vy = -10
            
        self.y += self.vy

# ...

def draw():
    screen.fill('sky blue')
    for platform in platforms: # On dessine toutes nos plateformes
        platform.draw()
    player.draw()

Vous devriez pouvoir à présent librement déplacer votre personnage dans le niveau et sauter sur les blocs.

8. Différents types de blocs

Pour l’instant, nous avons uniquement des blocs qui servent de plateformes mais que l’on peut traverser librement (par exemple pour sauter dessus depuis dessous). Il peut être utile d’ajouter 2 autres types de blocs:

  • Des blocs solides que l’on ne peut pas traverser.

  • Des blocs solides que l’on ne peut pas traverser mais auquels le personnage peut s’accrocher, pour escalader par exemple.

Pour cela, l’acteur Platform qui représente un bloc prend 2 arguments logiques supplémentaires: solid et sticky.
Un bloc avec solid = True ne pourra pas être traversé.
Un bloc avec sticky = True pourra être escaladé.

L’unique différence entre ces blocs se fait au niveau de la méthode check_collisions_with(actor) et vous n’avez pas à vous préoccuper de comment c’est fait (sauf si vous êtes curieux !).

Voici comment définir un bloc solide:

platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)

Et un bloc solide que l’on peut escalader:

platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)

Jouez avec ces 2 nouveaux types de blocs en remplaçant vos blocs d’origine.

9. Remplir le niveau avec différents types de blocs

Voyons à présent une méthode permettant de disposer comme on le souhaite ces différents types de bloc dans notre niveau. Le plus simple est de modifier notre constante WORLD_MAP. Nous allons simplement ajouter des 2 pour signifier des blocs solides et des 3 pour signifier des blocs que l’on peut escalader.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,3,0,0,0,1,1,1,0,0,2,2,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

Comment prendre en compte ces changements ? Lorsque nous parcourons ce tableau pour créer nos objets Platform, nous ajoutons 2 tests de condition pour savoir si l’élément parcouru est 1, 2 ou 3 et créons le bon bloc en conséquence.

platforms = []
for row in range(ROWS):
    for col in range(COLS):
        pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
        if WORLD_MAP[row][col] == 1: # Bloc classique traversable
            platform = Platform('grass_tile', pos, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 2: # Bloc solide mais que l'on ne peut pas escalader
            platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 3: # Bloc solide que l'on peut escalader
            platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)
            platforms.append(platform)

Lancez le jeu pour tester que les bons types de blocs se trouvent bien au bon endroit.

10. Ajouter un ennemi qui se balade

Notre personnage se sent un peu seul dans son niveau. Pourquoi ne pas ajouter un slime qui se balade de gauche à droite sur les blocs ?

../_images/platformer_slime.png

Commençons par créer une classe Slime pour notre ami. Il aura également besoin d’une vitesse de déplacement (speed) ainsi qu’une vitesse verticale vy si l’on veut qu’il soit soumis à la gravité. On en profite pour également lui donner des images pour son animation (images).

class Slime(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)
        self.images = ['slime_green1', 'slime_green2']
        self.vy = 0
        self.speed = 2

    def update(self):
        pass # Ne fait rien pour l'instant

Pour son emplacement, je vous propose de l’ajouter à notre constante WORLD_MAP avec le numéro 4.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,4,0,0,0,0,0],
    [0,0,0,3,0,0,0,1,1,1,0,0,2,2,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

Nous devons donc créer une liste slimes = [] et la remplir comme pour les plateformes.
N’oublions finalement pas de mettre à jour les fonctions principale draw et update.

platforms = []
slimes = [] # Création de la liste vide
for row in range(ROWS):
    for col in range(COLS):
        pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
        if WORLD_MAP[row][col] == 1:
            platform = Platform('grass_tile', pos, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 2:
            platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 3:
            platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 4:
            slime = Slime('slime_green1', pos, max_distance_x=300)
            slimes.append(slime)

def draw():
    screen.fill('sky blue')
    player.draw()
    for slime in slimes:
        slime.draw()
    for platform in platforms:
        platform.draw()

def update():
    player.update()
    for slime in slimes:
        slime.update() # Ne fait rien pour le moment

Occupons nous à présent de la méthode update de notre ami afin qu’il puisse se déplacer. Nous allons simplement le faire se déplacer vers la droite selon sa vitesse (speed). Nous allons également lui appliquer la gravité et contrôler sa collision avec les blocs de la même manière qu’on l’a fait pour notre joueur.

def update(self):
    self.vy += GRAVITY # On augmente sa vitesse verticale avec la gravité

    for platform in platforms: # On teste sa collision avec chaque bloc
        platform.check_collision_with_actor(self)

    self.x += self.speed # On met à jour sa position en x
    self.y += self.vy # On met à jour sa position en y

Testez votre jeu ! Le slime devrait à présent tomber de sa position initiale puis se déplacer vers la droite à l’infini.
Il serait intéressant de faire en sorte que notre slime reste dans une zone choisie et qu’il se retourne lorsqu’il dépasse cette zone.

On peut ajouter ce comportement en passant à son constructeur une distance horizontale maximale à parcourir avant de repartir dans l’autre sens. Nous allons ajouter l’argument max_distance_x à son constructeur. Nous devons également déclarer une nouvelle variable distance_x qui nous servira à connaître la distance parcourure par notre slime depuis son lieu d’origine.

class Slime(Actor):
    def __init__(self, image, pos, max_distance_x, **kwargs): # Ajout de l'argument max_distance_x
        super().__init__(image, pos, **kwargs)
        self.images = ['slime_green1', 'slime_green2']
        self.vy = 0
        self.speed = 2
        self.max_distance_x = max_distance_x
        self.distance_x = 0

Enfin, il ne reste plus qu’à mettre à jour la méthode update pour augmenter distance_x à chaque déplacement et contrôler si 0 <= distance_x <= max_distance_x pour savoir si notre ami est toujours dans sa zone. Si ce n’est pas le cas, on inverse sa vitesse speed pour qu’il reparte dans l’autre sens et on change l’orientation de son sprite (flip_x).

def update(self):
    self.vy += GRAVITY

    for platform in platforms:
        platform.check_collision_with_actor(self)

    self.x += self.speed
    self.y += self.vy
    self.distance_x += self.speed # On met à jour sa distance parcourue

    if not (0 <= self.distance_x <= self.max_distance_x): # S'il sort de sa zone
        self.flip_x = not self.flip_x # On inverse l'orientation de son sprite
        self.speed = -self.speed # On inverse sa vitesse horizontale

Et voilà ! Votre ami le slime devrait à présent se cantonner à sa zone. Nous ne nous préoccupons pas de sa collision avec le joueur dans ce tutoriel, revoyez le tutoriel précédent pour voir comment ajouter ce comportement.

11. Ajouter des objets à ramasser

Nous n’avons aucun objectif dans le jeu ! Il serait bien d’ajouter des objets à ramasser pour le joueur. Pourquoi pas des pièces ? C’est très classique mais ça fonctionne toujours.

../_images/platformer_coin.png

Comme d’habitude, commençons par créer une classe pour notre nouvel acteur Coin. Les pièces n’auront rien d’autre à faire que de s’afficher et de disparaître au contact du joueur, son implémentation sera donc relativement simple.

class Coin(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        pass # Ne fait rien pour l'instant

Pour choisir l’emplacement de nos pièces, le plus simple est de passer par notre constante WOLRD_MAP. Nous pouvons utiliser la valeur 5 pour représenter une pièce.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,5,0,0,0,0,0,4,0,0,0,5,0],
    [0,0,0,3,0,0,0,1,1,1,0,0,2,2,0],
    [0,0,0,0,0,0,0,0,0,0,5,5,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

Modifions à présent notre parcours du tableau pour créer les acteurs Coin au bon endroit. Nous devons aussi créer une liste coins pour nos pièces, comme nous l’avons fait pour nos plateformes. Finalement, n’oublions pas d’adapter les fonctions principales draw et update pour afficher les pièces de notre liste.

platforms = []
slimes = []
coins = [] # Création de la liste vide
for row in range(ROWS):
    for col in range(COLS):
        pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
        if WORLD_MAP[row][col] == 1:
            platform = Platform('grass_tile', pos, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 2:
            platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 3:
            platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 4:
            slime = Slime('slime_green1', pos, max_distance_x=300)
            slimes.append(slime)
        elif WORLD_MAP[row][col] == 5: # Création d'une pièce
            coin = Coin('coin', pos)
            coins.append(coin)

def draw():
    screen.fill('sky blue')
    player.draw()
    for slime in slimes:
        slime.draw()
    for platform in platforms:
        platform.draw()
    for coin in coins:
        coin.draw()

def update():
    player.update()
    for slime in slimes:
        slime.update()
    for coin in coins:
        coin.update() # Ne fait rien pour l'instant

Reste à savoir de quelle manière de gérer les collisions avec le joueur. Il y a 2 possibilités:

  • Ajouter le test de collision avec une pièce dans le update du Player.

  • Ajouter le test de collision avec le joueur dans le update des Coin.

Je vous propose la 2ème solution, qui a pour avantage de ne pas ajouter plus de code à la classe Player qui est déjà complexe.
Il suffit alos de tester s’il y a collision avec le player et, le cas échéant, marquer le coin pour qu’il soit supprimé.

class Coin(Actor):
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        if player.collides_with(self): # Test de collision avec le joueur
            self.to_remove = True # On marque la pièce en question pour la supprimer

# ...

def update():
    player.update()
    for slime in slimes:
        slime.update()
    for coin in coins:
        coin.update() # Chaque pièce teste si elle est en collision avec le player

    remove_actors(coins) # On supprime du jeu les pièces marquées pour être supprimées

Testez le jeu et ramassez les pièces pour voir si tout fonctionne. Pour le moment, ramasser des pièces n’apporte rien au joueur, vous serez libre d’ajouter cette fonctionnalité par vous-même.

12. Changer de niveau

Notre jeu ne comporte qu’un seul niveau, il est temps que cela change ! Nous allons ajouter un portail permettant de passer à un second niveau.

Commençons par créer la classe Portal correspondant au portail et ajoutons sa position dans la constante WORLD_MAP. Je propose d’utiliser la numéro 6 pour le représenter. Afin de garder la même structure que pour les blocs et les pièces, je vous propose d’ajouter notre portail à une liste de portails. Cela permettrait éventuellement à l’avenir de disposer plusieurs portails menant à des niveaux différents.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,5,0,0,0,0,6,4,0,0,0,5,0],
    [0,0,0,3,0,0,0,1,1,1,0,0,2,2,0],
    [0,0,0,0,0,0,0,0,0,0,5,5,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

# ...

class Portal(Actor): # Création de la classe Portal
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        pass # Ne fait rien pour le moment

# ...

platforms = []
slimes = []
coins = []
portals = [] # On crée la liste vide pour les portails
for row in range(ROWS):
    for col in range(COLS):
        pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
        if WORLD_MAP[row][col] == 1:
            platform = Platform('grass_tile', pos, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 2:
            platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 3:
            platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)
            platforms.append(platform)
        elif WORLD_MAP[row][col] == 4:
            slime = Slime('slime_green1', pos, max_distance_x=300)
            slimes.append(slime)
        elif WORLD_MAP[row][col] == 5:
            coin = Coin('coin', pos)
            coins.append(coin)
        elif WORLD_MAP[row][col] == 6: # On crée un portail et on l'ajoute à la liste
            portal = Portal('portal', pos, width=TILE_SIZE)
            portals.append(portal)

def draw():
    screen.fill('sky blue')
    player.draw()
    for slime in slimes:
        slime.draw()
    for platform in platforms:
        platform.draw()
    for coin in coins:
        coin.draw()
    for portal in portals:
        portal.draw()

def update():
    player.update()
    for slime in slimes:
        slime.update()
    for coin in coins:
        coin.update()
    for portal in portals:
        portal.update() # Ne fait rien pour l'instant

    remove_actors(coins)

Vous devriez voir le portail apparaître à l’endroit indiqué. Voyons à présent comment modifier le niveau lorsque le joueur est en contact avec le portail et qu’il appuie sur la touche e. On va commencer par modifier la méthode update de Portal pour qu’il puisse détecter cet évenement. Pour le moment, affichons simplement l’activation du niveau 2 sous forme de texte dans la console avec print.

class Portal(Actor): # Création de la classe Portal
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        if player.collides_with(self) and keyboard.space:
            print('Niveau 2 !')

Une manière de changer de niveau est de simplement créer une autre constante WORLD_MAP_2 et recréer toutes les plateformes en conséquence.

WORLD_MAP = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,5,0,0,0,0,6,4,0,0,0,5,0],
    [0,0,0,3,0,0,0,1,1,1,0,0,2,2,0],
    [0,0,0,0,0,0,0,0,0,0,5,5,0,0,0],
    [1,1,1,1,0,0,1,1,1,1,1,1,1,1,1],
]

WORLD_MAP_2 = [
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,5,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,1,1,0,0,0,0],
    [0,0,0,0,0,1,1,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,1,1,0,0,0,0,0,0,0,0,0,0,0],
    [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
    [0,4,0,0,0,1,1,5,1,0,0,0,0,0,0],
    [5,5,5,0,0,0,0,0,0,4,0,0,0,5,0],
    [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
]

Définissons ensuite une fonction load_level(world_map) qui va s’occuper de créer toutes les objets du jeu en fonction de la world_map qu’on lui passe et qui les retourne au programme principal. Donc l’idée est simplement de déplacer tout notre code qui remplit les listes d’objets dans une fonction. On part du principe que tous les niveaux vont contenir autant de lignes et de colonnes.

def load_level(world_map):
    platforms = []
    slimes = []
    coins = []
    portals = []
    for row in range(ROWS):
        for col in range(COLS):
            pos = (col*TILE_SIZE+TILE_SIZE/2, row*TILE_SIZE+TILE_SIZE/2)
            if world_map[row][col] == 1:
                platform = Platform('grass_tile', pos, width=TILE_SIZE)
                platforms.append(platform)
            elif world_map[row][col] == 2:
                platform = Platform('grass_tile', pos, solid=True, width=TILE_SIZE)
                platforms.append(platform)
            elif world_map[row][col] == 3:
                platform = Platform('grass_tile', pos, solid=True, sticky=True, width=TILE_SIZE)
                platforms.append(platform)
            elif world_map[row][col] == 4:
                slime = Slime('slime_green1', pos, max_distance_x=300)
                slimes.append(slime)
            elif world_map[row][col] == 5:
                coin = Coin('coin', pos)
                coins.append(coin)
            elif world_map[row][col] == 6:
                portal = Portal('portal', pos, width=TILE_SIZE)
                portals.append(portal)

    return platforms, slimes, coins, portals

player = Player('alien_walk1', (100, 100))

# Appel de notre fonction pour remplir toutes les listes ! On lui passe WORLD_MAP au début du programme car c'est le niveau 1
platforms, slimes, coins, portals = load_level(WORLD_MAP)

Si tout a fonctionné, vous ne devriez voir aucun changement par rapport à tout à l’heure, on a simplement déplacé du code, mais il fait la même chose.
Il ne reste plus à présent d’appeler la fonction load_level lorsque l’on passe le portail mais en lui passant WORLD_MAP_2 ! Cependant, comme nous souhaitons ici modifier les listes des objets (platforms, coins, etc) depuis une fonction, nous devons déclarer ces variables global avant de pouvoir les toucher.

class Portal(Actor): # Création de la classe Portal
    def __init__(self, image, pos, **kwargs):
        super().__init__(image, pos, **kwargs)

    def update(self):
        if player.collides_with(self) and keyboard.e:
            print('Niveau 2 !')
            global platforms # On déclare les variables externes que l'on veut modifier comme globales
            global slimes
            global coins
            global portals
            platforms, slimes, coins, portals = load_level(WORLD_MAP_2) # On met à jour les listes grâce à notre fonction

Et voilà ! Testez si tout fonctionne comme souhaité !
Vous avez remarqué ? Notre portail ressemble plutôt à une serrure… Et si vous faisiez en sorte qu’il faille d’abord ramasser une clé cachée dans le niveau afin de pouvoir traverser le portail ?

13. Idées d’améliorations

Voici plusieurs idées d’amélioration du jeu.
Vous pouvez bien sûr me proposer d’autres idées et je vous dirai leur difficulté.

Totalement dans vos cordes (facile):

  • Ajouter une animation au joueur quand il se déplace.

  • Ajouter une musique de fond.

  • Ajouter des bruitages (par exemple pour le saut ou les pièces).

  • Empêcher le joueur de sortir de l’écran à gauche et droite.

  • Faire en sorte que le joueur perde de la vie à chaque contact avec un ennemi.

  • Créer un game over sous les conditions de votre choix (par exemple au contact d’un ennemi ou après un certain temps). Cela affiche par exemple un écran noir avec écrit “Game Over” en grand.

  • Ajouter un compteur de pièces pour le score.

  • Ajouter un 3ème niveau.

  • Permettre de mettre le jeu en pause avec la touche espace.

Un peu plus complexe (moyen):

  • Ajouter une clé dans le niveau nécessaire au déblocage du portail vers le prochain niveau.

  • Permettre au joueur de tirer des projectiles soumis à la gravité pour détruire les slimes (ex: flèches).

  • Permettre au joueur de sauter sur les ennemis pour les détruire.

  • Permettre au joueur de faire des doubles sauts.

  • Ajouter un nouveau type d’ennemi (ex: qui peut sauter).

  • Faire en sorte que les ennemis suivent le joueur s’il est sufisamment proche au lieu de se balader aléatoirement.

  • Faire en sorte qu’en cas de contact avec un ennemi, le joueur perd de la vie et l’ennemi meurt. La vie du joueur est affichée sous le score.

  • Créer un nouveau type de bloc qui peut tuer le joueur au contact (ex: lave).

Challenging (difficile):

  • Permettre au joueur de poser des bombes pour détruire certains blocs.

  • Ajouter un système d’interupteur à activer pour détruire des blocs et donc ouvrir des passages.

  • Ajouter un second joueur pour pouvoir faire une course aux pièces (celui qui en ramasse le plus gagne).

  • Ajouter un item qui permet au joueur de voler pendant un temps limité.

  • Permettre la création de niveaux bien plus grands que la fenêtre de jeu principale qui défileront ensuite avec le déplacement du joueur. Indice: déplacez tous les acteurs du jeu dans la direction inverse au déplacement du joueur.

  • Créer un menu avant le début du jeu qui permet de choisir la difficulté ou d’autres options.