C6 - Sprites et groupes
Dans ce chapitre, nous apprendrons à manipuler la classe Sprite
. Mais avant cela, des connaissances sur la programmation orientée objet avec Python sont nécessaires.
1 - Rappels sur la POO
La POO (Programmation Orientée Objet) consiste à programmer ses propres objets. Un objet est un ensemble de variables (nommées attributs) et de fonctions (nommées méthodes). Par exemple, Rect
(un rectangle) est un objet qui possède un attribut center
et une méthode colliderect
. On accède aux méthodes et aux attributs d'un objet de la même façon qu'on accède au contenu d'un module : grâce à un .
, rappelons-nous comment nous avons écrit cela auparavant :
voiture = pygame.image.load("dossier_images/taxi.png")
voiture_rect = voiture.get_rect(topleft=(0, 0)) # méthode get_rect de voiture
voiture_rect.center = (screen_width / 2, screen_height / 2) # Attribut center
Jusque-là, nous avons utilisé des objets déjà programmés. Or, nous pouvons créer nos propres objets. Un objet se construit d'une manière similaire aux fonctions. Il faut d'abord définir l'objet grâce au mot-clé class
. Puis on peut appeler cet objet autant de fois que l'on souhaite grâce à des parenthèses ()
. Voiçi par exemple l'objet le plus simple, l'objet vide :
class Objet:
pass
objet_1 = Objet()
objet_2 = Objet()
Info
Dans le langage Python, le mot clé pass
est l'instruction qui ne fait rien. Elle sert simplement à combler le vide lorsqu'au minimum une instruction est recquise dans certaines parties du code (par exemple à l'intérier de boucles for
et while
ou dans les définitions de fonction)
Mais la définition d'un objet sera très rarement vide. Tout d'abord, dans la plupart des objets que nous programmerons, nous définirons une méthode __init__
. Toute instruction à l'intérieur de cette méthode sera éxécutées à l'appel de l'objet. Par conséquent, quand on appelle un objet, on appelle également la méthode __init__
associée à celui-ci. Ainsi en éxécutant le code suivant :
class Objet:
def __init__(self):
print("hello world")
objet_1 = Objet()
objet_2 = Objet()
On remarque que le texte "hello world" est affiché deux fois. Le paramètre self
est un paramètre spécial et il sera toujours le premier paramètre d'une méthode, peu importe l'objet créé. self
(signifiant "soi-même" en anglais) est ce qui permet de désigner l'objet en lui-même. self
permet alors d'accéder à tous les attributs et méthodes d'un objet à l'intérieur de la définition de celui-ci, étant donné que ceux-ci appartiennent à l'objet. Nous utiliserons également self
lorsque nous voudrons créer de nouveaux attributs. Pour illustrer nos propos, créons un objet Feuille
, possèdant une méthode plier
et deplier
ainsi qu'un attribut epaisseur
:
class Feuille:
def __init__(self):
self.epaisseur = 1
def plier(self, n=1):
for _ in range(n):
self.epaisseur *= 2
def deplier(self, n=1):
for _ in range(n):
if self.epaisseur == 1:
return
else:
self.epaisseur /= 2
Lorsqu'on initialise l'objet, on commence par créer un nouvel attribut epaisseur
identifiable par le self
qui le précède. Dans chacune des méthodes plier
et deplier
, le premier paramètre est self
puisque self
devra toujours être le premier paramètre d'une méthode. Chaque méthode accède à l'attribut epaisseur
grâce à self
et peuvent même modifier la valeur de l'attribut ! En effet, contrairement aux variables normales, la valeur des attributs peuvent être modifiées à l'intérieur de fonctions. Dans cette fonction que nous avons programmmé plus tôt par exemple :
def swap_values(vector:pygame.math.Vector2):
vector.x, vector.y = vector.y, vector.x
Nous affectons clairement une nouvelle valeur aux attributs x
et y
de l'objet vector
.
De plus, chaque attribut existe individuellement. Signifiant que la valeur d'un même attribut de deux objets différents créés à partir de la même classe peut être différente d'un objet à l'autre. Afin d'illustrer cela, créons deux objets à partir de la classe Feuille
:
feuille_simple = Feuille()
feuille_pliee = Feuille()
feuille_pliee.plier(3)
print(feuille_simple.epaisseur)
print(feuille_pliee.epaisseur)
Nous remarquons que bien que nous ayons modifié la valeur d'epaisseur
de feuille_pliee
, la valeur d'epaisseur
de feuille_simple
n'a pas été modifiée.
Enfin, notons qu'une classe peut en hériter une autre. C'est-à-dire que nous pouvons construire une classe à partir d'une autre. Et c'est justement cet aspect là que nous exploiterons pour programmer nos propres sprites.
2 - Classe Sprite
Définition
Avant toute chose, définissons le terme sprite. Pour la librairie Pygame, un sprite est la combinaison d'une image et d'un rectangle. Tout sprite possède une méthode update
définissant comment le sprite se comporte et évolue. Pour Python, un sprite est ainsi tout objet qui possède un attribut image
et rect
ainsi qu'une méthode update
. Un sprite appartient généralement à un groupe de sprites.
Héritage et initialisation
On peut construire des sprites personnalisés à partir de la classe pygame.sprite.Sprite
. Supposons que nous souhaitons créer une classe Player
représentant le joueur et qu'il s'agisse d'un sprite. Nous devrons alors hériter la classe Sprite
dans la classe Player
, ce qui peut être réalisé grâce à l'instruction super()
:
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
Une telle écriture appelle la méthode __init__
de la classe passée en argument (ici Sprite
), étant donné que super()
représente cette dernière classe. Mais au lieu de créer un nouvel objet Sprite
, on dit à l'ordinateur de considérer Player
comme un objet Sprite
. Ce qui permet à Player
de revevoir tous les attributs de la classe héritée. Player
reçoit ainsi un attribut image
et rect
ainsi qu'une méthode update
parmi d'autres. Pour plus de personnalisation, nous pouvons redéfinir ces attributs et méthodes à nos souhaits. C'est d'ailleurs ce que nous allons généralement faire. Commencer par donner cette image à notre sprite :
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.image.load("dossier_images/player_walk.png").convert_alpha()
Il faut ensuite associer un rectangle à notre sprite. Pour cela, définissons son attribut rect
:
class Player(pygame.sprite.Sprite):
def __init__(self, position):
super().__init__()
self.image = pygame.image.load("dossier_images/player_walk.png").convert_alpha()
self.rect = self.image.get_rect(center=position)
Distinguons le paramètre position
dans la méthode __init__
. En effet cette méthode peut prendre plusieurs arguments. Et nous devrons donner une valeur à ces arguments à chaque appel de la classe, similairement aux fonctions. En supposant qu'on souhaite créer 2 objets Player
par exemple :
joueur_centre = Player((screen_width / 2, screen_height / 2))
autre_joueur = Player((356, 49))
print(autre_joueur.rect.center)
Comme nous pouvons le remarquer, le tuple (356, 49)
passé en argument définit la position du point central du rectangle de notre sprite et cela est grâce à la ligne :
self.rect = self.image.get_rect(center=position)
Où nous ordonnons clairement à notre rectangle de prendre la position passée en argument.
3 - Groupes de sprites
Mais comment afficher les deux sprites Player
que nous avons créé auparavant ? Nous pouvons très bien écrire :
screen.blit(joueur_centre.image, joueur_centre.rect)
screen.blit(autre_joueur.image, autre_joueur.rect)
Mais cela est long à écrire. En plus de cela, si nous n'avions pas 2 mais 34 sprites à afficher, le code deviendrait répétitif. Nous pourrions donc utiliser des listes, mais Pygame offre un outil mieux adapté : les groupes.
Créer un groupe et y ajouter des sprites
L'objet Group
se situe dans le module sprite, ainsi pour créer un groupe groupe_joueurs
:
groupe_joueurs = pygame.sprite.Group()
Il existe deux principales manières d'ajouter des sprites dans un groupe. Soit, on passe en argument tous les sprites à ajouter dans le groupe à l'initialisation de celui-ci comme cela :
joueur_centre = Player((screen_width / 2, screen_height / 2))
autre_joueur = Player((356, 49))
groupe_joueurs = pygame.sprite.Group(joueur_centre, autre_joueur)
Soit, on ajoute les sprites après l'initialisation du groupe grâce à la méthode add
:
groupe_joueurs = pygame.sprite.Group()
joueur_centre = Player((screen_width / 2, screen_height / 2))
autre_joueur = Player((356, 49))
groupe_joueurs.add(joueur_centre, autre_joueur)
Nous pouvons afficher l'intégralité des sprites contenus dans un groupe grâce à la méthode draw
:
while running:
# Boucle évènementielle
screen.blit(background, (0, 0)) # On affiche toujours l'arrière-plan en premier
groupe_joueurs.draw(screen)
# Update et tick
Warning
Attention la méthode draw
prend un argument ! Il s'agit de la surface sur laquelle afficher les sprites. Vous l'aurez sans doute deviné, 99% du temps ce paramètre aura simplement pour valeur notre écran.
Méthode update
Comme nous l'avons dit précédemment, chaque sprite possède une méthode update
. Cette méthode définit le comportement du sprite, elle doit être définie dans la classe :
class Player(pygame.sprite.Sprite):
def __init__(self, position):
super().__init__()
self.image = pygame.image.load("dossier_images/player_walk.png").convert_alpha()
self.rect = self.image.get_rect(center=position)
self.vecteur = pygame.math.Vector2(2, 5)
def update(self):
self.rect.topleft += self.vecteur
self.vecteur.x, self.vecteur.y = -self.vecteur.y, -self.vecteur.x
Cette méthode sera appelée dans la boucle principale de notre jeu. Mais les groupes rendent cette tâche plus facile. En effet, nous pouvons appeler toutes les méthodes update
des sprites contenus dans un groupe de sprites en une seule fois. Il suffit alors d'appeler la méthode update
du groupe (et non pas du sprite individuellement) :
while running:
# Boucle évènementielle
screen.blit(background, (0, 0)) # On affiche toujours l'arrière-plan en premier
groupe_joueurs.draw(screen)
groupe_joueurs.update()
# Update et tick
update
peut prendre le nombre d'arguments que vous souhaitez. Mais attention! Lorsque nous appellons la méthode update
d'un groupe, les arguments passés à cette méthode seront également passés à chaque méthode update
de chaque sprite dans le groupe. Il faut donc bien vérifier si les arguments coïncident bien avec chaque sprite. Voici un exemple où les arguments passés ne coïncident pas :
class Player(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.surface.Surface((50, 50))
self.image.fill((0, 0, 255))
self.rect = self.image.get_rect(center=(screen_width / 2 + 100, screen_height / 2))
self.hp = 3
def update(self, deplacement):
self.rect.topleft += deplacement
class Ennemy(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = pygame.surface.Surface((30, 30))
self.image.fill((255, 0, 0))
self.rect = self.image.get_rect(center=(screen_width / 2 - 100, screen_height / 2))
def update(self):
self.rect.x += 1
joueur = Player()
ennemi = Ennemy()
groupe_sprites = pygame.sprite.Group(joueur, ennemi)
while running:
# Boucle évènementielle
screen.blit(background, (0, 0))
groupe_sprites.draw(screen)
random_vect = pygame.math.Vector2(randint(1, 5), randint(1, 5))
groupe_sprites.update(random_vect) # Erreur
# Tick et display.update()
Dans le code çi-dessus, le méthode update
du sprite Ennemy
ne prend pas d'argument, alors que celle de la classe Player
prend un argument deplacement
! Nous devons donc en conclure qu'un sprite Player
ne doit pas être dans le même groupe qu'un sprite Player
. Pour que cela fonctionne bien, nous devons créer deux groupes distingués :
joueur = Player()
ennemi = Ennemy()
groupe_joueur = pygame.sprite.GroupSingle(joueur)
groupe_ennemis = pygame.sprite.Group(ennemi)
while running:
# Boucle évènementielle
screen.blit(background, (0, 0))
groupe_joueur.draw(screen)
groupe_ennemis.draw(screen)
random_vect = pygame.math.Vector2(randint(1, 5), randint(1, 5))
groupe_joueur.update(random_vect)
groupe_ennemis.update()
# Tick et display.update()
Info
Dans le code çi-dessus, nous sommes certains que groupe_joueur
contiendra un seul et unique sprite
. Pour ce genre de situation, il existe un groupe spécial : GroupSingle
. Il fonctionne comme les groupes habituels, à l'exception qu'il ne peut contenir qu'un seul objet.
4 - Autres méthodes
Kill et empty
La méthode kill
de la classe Sprite
supprime le sprite associé de tous les groupes dont il appartient. Ce sprite est ainsi considéré comme "mort", il n'est plus affiché par la méthode draw
en conséquence. En reprenant l'exemple de l'accident de voiture du chapitre C5, nous pouvons simplement faire disparaitre les deux voitures grâce à la méthode kill
au lieu de les faire exploser :
class Voiture(pygame.sprite.Sprite):
def __init__(self, fichier_img, position, direction):
super().__init__()
self.image = pygame.image.load(fichier_img)
self.rect = self.image.get_rect(center=position)
self.speed = 6 * direction
def flip_image(self):
self.image = pygame.transform.rotate(self.image, 180)
def update(self):
self.rect.y += self.speed
voiture_orange = Voiture("dossier_images/Car.png", (screen_width / 2, 80), 1)
voiture_orange.flip_image()
taxi = Voiture("dossier_images/taxi.png", (screen_width / 2, screen_height - 80), -1)
voitures = pygame.sprite.Group(voiture_orange, taxi)
while running:
# Boucle évènementielle
voitures.draw(screen)
if voiture_orange.rect.colliderect(taxi.rect):
voiture_orange.kill()
taxi.kill()
Nous pouvons même simplifier le programme grâce à la méthode empty
qui permet de vider tout un groupe de son contenu. Ainsi au lieu d'écrire :
voiture_orange.kill()
taxi.kill()
Nous pouvons tout à fait écrire :
voitures.empty()
Evidemment, il ne faudrait pas qu'il y ait d'autres sprites que voiture_orange
et taxi
dans le groupe voitures
lorsqu'on utilise empty
. Sinon, nous supprimerons involontairement d'autres sprites.
Parcourir un groupe
Nous pouvons obtenir une représentation sous forme de liste d'un groupe grâce à la méthode sprites()
(ou simplement l'attribut sprite
pour GroupSingle
). Ce qui nous permet de parcourir le contenu du groupe. En supposant par exemple qu'on souhaite programmer un jeu avec plusieurs cibles sur lequel le joueur doit tirer, nous écririons l'algorithme suivant pour détecter si le joueur a atteint une cible :
Si le joueur clique:
on affecte la position du curseur à la variable pos
Pour chaque cible dans le groupe:
Si le rectangle de la cible sélectionnée est en collision avec point pos:
La cible disparait
Ce qui peut être traduit en Python par le code suivant (en supposant que groupe_cibles
a déjà été défini) :
# Dans la boucle évènementielle
if event.type == pygame.MOUSEBUTTONDOWN:
pos_curseur = pygame.mouse.get_pos()
for cible in groupe_cibles.sprites():
if cible.rect.collidepoint(pos_curseur):
cible.kill()
Challenge Final
En parlant d'un jeu de tir, pourquoi ne pas en programmer un ? Votre mission, si vous l'acceptez sera de programmer un jeu de tir qui ressemble à ça :
Ce jeu devra respecter les critères suivants :
- Le curseur doit être votre arme
- Les cibles sont définies par une classe héritant de la classe
Sprite
. A vous de construire cette classe - Le curseur que vous contrôlez pour viser peut aussi être défini par une classe héritant de la classe
Sprite
mais cela est facultatif - Les cibles sont contenues dans un groupe
- Le joueur doit pouvoir tirer et éliminer des cibles uniquement si il en touche une lors du tir
- Il doit y avoir une part d'aléatoire dans la position et le déplacement des cibles
Toutes les ressources nécessaires à la programmation du jeu çi-dessus sont téléchargeables ici. Elles proviennent du site opengameart.org. De plus, vous avez la possibilité de télécharger le code source en cas de besoin.