Course Website
Cours : Jeudi 8h15–9h00, CM 1 4
Exercices : Jeudi 9h15–11h, INF 3, BC 07-08
Pour cette première semaine, nous allons découvrir la bibliothèque Arcade et ses principaux concepts. Cette première série de tâches est fortement guidée, et peut difficilement être décomposée en parallèle. Si vous avez déjà votre binôme, je vous conseille de :
Si vous n’avez pas encore de binôme, cette semaine est encore une période de grâce. Il est parfaitement possible de la compléter individuellement, en attendant de trouver votre binôme. Vous pourrez alors partir de l’état de l’un ou de l’autre de vos projets respectifs.
Commencez par créer un nouveau projet pour votre… projet.
tl;dr: $ uv add arcade
Nous allons avoir besoin de la bibliothèque Arcade. Il nous faut donc l’installer dans notre projet. Heureusement, avec uv, cette tâche va se résumer à une ligne.
Comme avec toute bibliothèque, nous devons identifier quel est son nom de paquet (package).
La plupart des bibliothèques documentent comment les installer avec pip
, mais pas avec uv
.
Cependant, c’est le même nom de paquet dans les deux cas.
Nous allons donc à la section Using pip de la page d’installation d’Arcade, qui nous dit :
pip install arcade
(ne pas exécuter ceci avec uv !)
Le nom du paquet est donc arcade
.
Pour l’installer avec uv, nous exécutons donc :
$ uv add arcade
Resolved 17 packages in 907ms
Prepared 8 packages in 3.12s
Installed 8 packages in 554ms
+ arcade==3.0.1
+ attrs==25.1.0
+ cffi==1.17.1
+ pillow==11.0.0
+ pycparser==2.22
+ pyglet==2.1.2
+ pymunk==6.9.0
+ pytiled-parser==2.2.9
Ça y est ! Arcade est installée.
Nous pouvons remarquer que uv a installé une série d’autres bibliothèques. C’est parce qu’Arcade dépend elle-même de celles-ci. uv installe automatiquement toutes nos dépendences transitives. Par ailleurs, elles sont isolées dans ce projet. Elles ne viennent pas encombrer d’autres projets sur votre ordinateur (voire entre en conflit avec leurs dépendences respectives).
Il va nous falloir un peu de “boilerplate” pour démarrer notre jeu. Le boilerplate, c’est le code pas très intéressant qui fait “tourner la boutique”.
Commençons par donner le code, puis nous allons expliquer ses concepts :
import arcade
# Constants
WINDOW_WIDTH = 1280
WINDOW_HEIGHT = 720
WINDOW_TITLE = "Platformer"
class GameView(arcade.View):
"""Main in-game view."""
def __init__(self) -> None:
# Magical incantion: initialize the Arcade view
super().__init__()
# Choose a nice comfy background color
self.background_color = arcade.csscolor.CORNFLOWER_BLUE
# Setup our game
self.setup()
def setup(self) -> None:
"""Set up the game here."""
pass
def on_draw(self) -> None:
"""Render the screen."""
self.clear() # always start with self.clear()
def main() -> None:
"""Main function."""
# Create the (unique) Window, setup our GameView, and launch
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
game_view = GameView()
window.show_view(game_view)
arcade.run()
if __name__ == "__main__":
main()
Vous pouvez – exceptionnellement – copier-coller ce code directement dans votre main.py
.
Vous pouvez immédiatement vérifier que tout se passe bien avec uv run main.py
.
Détaillons maintenant ce que nous avons là, petit bout par petit bout.
On importe le module arcade
que nous fournit la bibliothèque :
import arcade
On définit quelques constantes pour leur donner des noms explicites.
# Constants
WINDOW_WIDTH = 1280 # largeur de la fenêtre en pixels
WINDOW_HEIGHT = 720 # hauteur de la fenêtre en pixels
WINDOW_TITLE = "Platformer" # titre de la fenêtre
On définit notre classe principale, GameView
.
On utilise une syntaxe particulière que nous n’avons pas encore vue, avec un qualificatif (arcade.View)
.
Pour celles et ceux qui ont plus d’expérience : c’est la syntaxe qui nous permet d’hériter de/étendre arcade.View
.
Pour les autres, acceptez pour l’instant que cette syntaxe est nécessaire pour que Arcade reconnaisse que notre classe représente notre jeu.
class GameView(arcade.View):
"""Main in-game view."""
On définit ensuite le constructeur de notre classe.
Là aussi, une incantation magique, avec super...
, se glisse dans le code.
Acceptez qu’elle est nécessaire en l’état, pour l’instant.
Après celle-ci, on initialise la couleur de fond d’écran.
Nous n’avons pas déclaré cet attribut.
C’est arcade.View
qui l’a déclaré pour nous (oui, je sais, encore un peu de magie).
Finalement, on appelle notre méthode setup()
.
def __init__(self) -> None:
# Magical incantion: initialize the Arcade view
super().__init__()
# Choose a nice comfy background color
self.background_color = arcade.csscolor.CORNFLOWER_BLUE
# Setup our game
self.setup()
La définition de cette méthode setup()
ne fait rien pour l’instant.
Dans les sections suivantes, c’est là où nous installerons les différents éléments du jeu.
def setup(self) -> None:
"""Set up the game here."""
pass
Finalement, nous définissons la méthode on_draw()
, qui est responsable de dessiner l’état du jeu à un instant donné.
def on_draw(self) -> None:
"""Render the screen."""
self.clear() # always start with self.clear()
La méthode clear()
nous est donnée par arcade.View
.
Elle va remplir l’écran de la couleur self.background_color
.
Notez que nous n’appelons pas on_draw
nous-mêmes.
C’est Arcade qui va appeler cette méthode environ 60 fois pas secondes.
Il ne nous reste plus que la fonction main()
.
Celle-ci crée une instance de la classe arcade.Window
, qui représente la fenêtre de l’application.
Le constructeur attend les largeur, hauteur et titre de la fenêtre en paramètres.
Ensuite, nous créons une instance de notre classe GameView
.
Nous passons cette instance en argument à la méthode window.show_view()
, ce qui va installer notre vue “dans la fenêtre”.
C’est grâce à cela que Arcade saura appeler notre méthode on_draw
!
Il ne reste plus qu’à lancer le “jeu” avec arcade.run()
.
def main() -> None:
"""Main function."""
# Create the (unique) Window, setup our GameView, and launch
window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
game_view = GameView()
window.show_view(game_view)
arcade.run()
if __name__ == "__main__":
main()
Notez que nous ne connaissons pas l’implémentation de arcade.Window
, pourtant nous pouvons l’utiliser correctement.
C’est le pouvoir des interfaces : nous n’avons pas besoin de connaître tous les détails de arcade.Window
.
Il peut cependant être déroutant de comprendre ce code sans avoir une idée de la structure de arcade.Window
.
Je vous propose donc, pour une fois une vue schématisée, imaginée et grandement simplifiée de ce que fait sans doute arcade.Window
(je ne le sais pas moi-même, en fait !) :
class Window:
view: GameView
def __init__(self, width: int, height: int, title: str) -> None:
... # toutes sortes d'initialisations
def show_view(self, view: GameView) -> None:
self.view = view
def arcade_run() -> None:
window.open() # open the window on the user's screen
while True:
window.view.on_draw()
wait_for(1 / 60) # wait for 1/60 seconds
Je vous suggère de créer un commit à ce moment. De manière générale, il serait judicieux d’en faire un à chaque fin de section.
(Changer de membre du binôme qui “a le contrôle” ici.)
Il est temps de dessiner des objets à l’écran : notre personnage, un peu d’herbe et quelques caisses. Dans un jeu, les objets ont un aspect graphique (dessin à l’écran) ainsi qu’un aspect physique (interactions). Ceci est modélisé par la notion de sprite. Chaque sprite représente un objet (ou parfois un morceau d’un objet) qui existe dans le monde 2D du jeu.
Avant d’aller plus loin, faisons un peu de refactoring.
Nous allons devoir étendre pas mal les responsabilités de GameView
, et nous devrons bientôt tester certains de ses comportements.
Cela n’est pas pratique si elle se trouve dans le fichier main.py
.
Créez donc un nouveau fichier gameview.py
et déplacez-y la définition de la classe GameView
.
Vous devrez insérer au début du main.py
restant l’import suivant :
from gameview import GameView
Commençons par dessiner notre joueuse (player). J’utiliserai le terme “joueuse” en français car les graphismes que nous utiliserons représentent une femme. Il faut bien alléger l’écriture à un moment.
Créons donc un sprite pour notre joueuse.
Nous allons souvent manipuler celui-ci, donc nous allons le stocker dans un attribut de GameView
.
Nous l’instancions dans setup()
.
À partir d’ici, je vous recommende de ne pas copier-coller les bouts de code. Prenez la peine des les écrire vous-mêmes, afin d’entraîner votre mémoire par la mécanisation des opérations. Vous pouvez bien sûr copier-coller les chaînes de caractères, surtout celles qui contiennent des chemins vers des ressources.
class GameView(arcade.View):
player_sprite: arcade.Sprite
def setup(self) -> None:
"""Set up the game here."""
self.player_sprite = arcade.Sprite(
":resources:images/animated_characters/female_adventurer/femaleAdventurer_idle.png",
center_x=64,
center_y=128
)
Un sprite est toujours caractérisé par :
Nous chargeons une des textures libres de droits (licence CC0) “livrée avec” Arcade. Vous pouvez trouver la liste complète des textures disponibles. Elles viennent toutes à l’origine du site kenney.nl, qui propose de nombreuses textures libres de droits pour créer des jeux vidéos. C’est une mine d’or !
Il ne nous reste plus qu’à dessiner notre joueuse dans la méthode on_draw
:
def on_draw(self) -> None:
"""Render the screen."""
self.clear() # always start with self.clear()
arcade.draw_sprite(self.player_sprite)
Si vous lancez le jeu à ce stade, vous devriez voir une image proche du coin inférieur gauche de l’écran.
Arcade utilise un système de coordonnées correspondant à ce que vous connaissez des mathématiques. Le point $(0, 0)$ est en bas à gauche de la fenêtre. Les $x$ positifs vont vers la droite, et les $y$ positifs vont vers le haut.
C’est peut-être contre-intuitif si vous avez déjà programmé des systèmes graphiques dans d’autres environnements. Souvent, en informatique, les $y$ positifs vont vers le bas.
Par contre, les angles de rotation sont traités par Arcade dans le sens des aiguilles d’une montre (clockwise). Ceci est l’opposé de ce qu’on utilise généralement en mathématiques.
Gardez cela en tête lorsque vous vous battrez avec des calculs de coordonnées plus tard dans le projet.
SpriteList
Nous avons maintenant notre joueuse, mais il lui manque un monde dans lequel évoluer. Nous allons ajouter un bon nombre de sprites pour représenter de l’herbe et des caisses.
Nous pourrions les stocker dans une list[arcade.Sprite]
de Python, puis faire une boucle for
pour appeler arcade.draw_sprite
sur chacun d’eux.
Cependant, cette approche poserait de sérieux soucis de performances.
À chaque appel à draw_sprite
, Arcade doit interagir avec le GPU (Graphical Processing Unit) de votre ordinateur.
Ce va-et-vient est coûteux.
Il est beaucoup mieux de rassembler un grand nombre de sprites et de les envoyer tous ensemble au GPU.
C’est pourquoi Arcade a son propre type de collection, arcade.SpriteList[T]
.
On peut appeler draw()
directement sur une SpriteList
, et cela envoie toute la collection d’un coup au GPU.
En fait, Arcade ne sait que utiliser SpriteList
.
Quand on appelle arcade.draw_sprite
, il crée une SpriteList
temporaire pour l’envoyer au GPU !
On va donc déjà stocker notre joueuse dans une SpriteList
dédiée, et l’utiliser pour la dessiner.
Ce qui suit est une vue de
git diff
. Les lignes qui commencent par@@
indiquent qu’on “saute” une section de code qui ne change pas. Par exemple, la création deself.player_sprite_list = arcade.SpriteList()
est bel et bien dans la méthodesetup()
, pas dans la méthode__init__()
. Référez-vous au contexte donné.
@@ -4,6 +4,7 @@ class GameView(arcade.View):
"""Main in-game view."""
player_sprite: arcade.Sprite
+ player_sprite_list: arcade.SpriteList[arcade.Sprite]
def __init__(self) -> None:
# Magical incantion: initialize the Arcade view
@@ -22,8 +23,10 @@ class GameView(arcade.View):
center_x=64,
center_y=128
)
+ self.player_sprite_list = arcade.SpriteList()
+ self.player_sprite_list.append(self.player_sprite)
def on_draw(self) -> None:
"""Render the screen."""
self.clear() # always start with self.clear()
- arcade.draw_sprite(self.player_sprite)
+ self.player_sprite_list.draw()
Maintenant, créons notre monde. L’herbe, comme les caisses, seront des obstacles qui ne bougeront jamais dans le monde. C’est ce qu’Arcade appelle des walls.
Il est temps de vous faire réllement travailler un peu, donc cette fois nous ne faisons plus que décrire les étapes à réaliser :
wall_list
qui sera aussi une SpriteList
.
Construisez celle-ci avec arcade.SpriteList(use_spatial_hash=True)
.
Le “spatial hashing” augmente les performances pour une SpriteList
qui ne contient que des sprites qui ne bougent (presque) jamais.setup()
, écrivez deux boucles pour placer les sprites suivants dans cette wall_list
:
":resources:images/tiles/grassMid.png"
.
Ajoutez aussi le paramètre scale=0.5
dans le constructeur de arcade.Sprite
.":resources:images/tiles/boxCrate_double.png"
, ainsi que scale=0.5
.on_draw()
, n’oubliez pas de dessiner wall_list
.Vous devriez obtenir le résultat suivant :
(Changer de membre du binôme qui “a le contrôle” ici.)
La prochaine étape est de pouvoir déplacer notre joueuse. Pour cela, il va falloir réagir aux appuis sur les touches du clavier.
De la même façon que Arcade appelle on_draw
pour dessiner l’état du jeu, elle appelle aussi deux méthodes supplémentaires pour le clavier :
on_key_press
est appelée lorsqu’une touche est enfoncée ;on_key_release
est appelée lorsqu’une touche est relâchée.Par exemple, à l’appui sur la touche flèche droite (→), on voudra démarrer un mouvement vers la droite. Et quand cette touche est relâchée, on veut arrêter le mouvement.
Cela pose la question du mouvement ! Comme vous le savez, le mouvement est une évolution de la position dans le temps, qui dépend de la vitesse. À son tour, la vitesse évolue en dépendant de l’accélération.
Pour un mouvement latéral à vitesse constante, nous n’allons pas modéliser l’accélération.
Nous allons directement changer la vitesse en fonction des appuis sur les touches gauche et droite.
Il se trouve que les Sprite
s d’Arcade possèdent déjà des attributs change_x
et change_y
qui représentent la vitesse, comme center_x
et center_y
représentent la position.
Nous allons donc en profiter.
Déclarons une constante pour la vitesse latérale de la joueuse :
PLAYER_MOVEMENT_SPEED = 5
"""Lateral speed of the player, in pixels per frame."""
puis ajoutons les méthodes suivantes dans GameView
:
def on_key_press(self, key: int, modifiers: int) -> None:
"""Called when the user presses a key on the keyboard."""
match key:
case arcade.key.RIGHT:
# start moving to the right
self.player_sprite.change_x = +PLAYER_MOVEMENT_SPEED
case arcade.key.LEFT:
# start moving to the left
self.player_sprite.change_x = -PLAYER_MOVEMENT_SPEED
def on_key_release(self, key: int, modifiers: int) -> None:
"""Called when the user releases a key on the keyboard."""
match key:
case arcade.key.RIGHT | arcade.key.LEFT:
# stop lateral movement
self.player_sprite.change_x = 0
Notez que nous avons défini PLAYER_MOVEMENT_SPEED
en “pixels per frame”.
Une frame est la période entre deux calculs de l’évolution du monde + affichage du nouveau monde.
C’est donc bien une unité de temps.
Le pixel étant une unité de distance, il est bien vrai que “pixels per frame” est une unité de vitesse.
Arcade suppose plus ou moins qu’il y aura toujours 60 frames par seconde (FPS), donc $1 \text{ frame} = 1/60 \text{ seconde}$, et
\[5 \text{ pixels par frame} = 5 \text{ pixels par ($1/60$ seconde)} = 5 \cdot 60 \text{ pixels par seconde} = 300 \text{ pixels par seconde}\]Mais alors quand modifions-nous la position ?
Idéalement, nous voulons le faire une fois par frame.
C’est exactement ce à quoi sert la méthode on_update
.
Arcade appelle on_update
une fois par frame, avant d’appeler on_draw
.
def on_update(self, delta_time: float) -> None:
"""Called once per frame, before drawing.
This is where in-world time "advances", or "ticks".
"""
self.player_sprite.center_x += self.player_sprite.change_x
Vous pouvez maintenant lancer le jeu et déplacer la joueuse latéralement avec les flèches gauche et droite du clavier.
D’un point de vue mathématico-physique, nous venons d’implémenter (une version rudimentaire) d’un intégrateur numérique. Les ajouts de
change_x
àcenter_x
par pas de temps (1/60 seconde) reviennent à intégrer numériquementchange_x
par rapport au temps qui passe. Les moteurs physiques plus évolués prennent en compte les forces pour calculer les accélérations, puis intègrent celles-ci pour obtenir les vitesses, et intègrent également celles-ci pour obtenir les positions. Les nouvelles positions et vitesses sont alors utilisées pour calculer les nouvelles forces, et on répète ce manège.
Vous aurez remarqué que notre joueuse n’est pas du tout arrêtée par les caisses, qui sont pourtant sensées être des obstacles. De plus, elle ne peut pas sauter, ce qui est gênant pour un jeu de plateformes. En effet, nous n’avons absolument pas tenu compte de la gravité ni des collisions dans notre mouvement !
La gravité en tant que telle est facile à gérer.
Il suffit d’intégrer l’accélération (constante g
en pixels par frame²) pour mettre à jour change_y
avant de mettre à jour position_y
.
En revanche, si on ne fait que ça, notre joueuse va tomber dans l’infini, puisqu’aucune force contraire ne la retiendra lorsqu’elle “posera les pieds” sur de l’herbe.
Gérer ces collisions est une autre paire de manches.
Heureusement, Arcade nous donne une fonctionnalité toute faite pour gérer ces aspects physiques.
Si ce n’était pas le cas, nous aurions arrêté le projet ici pour cette semaine, et nous aurions passé une semaine rien qu’à gérer la physique.
Utilisons donc un arcade.PhysicsEnginePlatformer
pour gérer la physique de notre joueuse.
Il faut le créer dans notre méthode setup()
, avec quelques paramètres : le sprite de la joueuse, une sprite list à considérer comme les murs (et/ou sols, caisses, etc., tout ce qui est solide et empêche la joueuse de passer), et une constante de gravité.
On peut ensuite utiliser sa propre méthode update
à la place de notre calcul.
@@ -3,6 +3,9 @@ import arcade
PLAYER_MOVEMENT_SPEED = 5
"""Lateral speed of the player, in pixels per frame."""
+PLAYER_GRAVITY = 1
+"""Gravity applied to the player, in pixels per frame²."""
+
class GameView(arcade.View):
"""Main in-game view."""
@@ -10,6 +13,8 @@ class GameView(arcade.View):
player_sprite_list: arcade.SpriteList[arcade.Sprite]
wall_list: arcade.SpriteList[arcade.Sprite]
+ physics_engine: arcade.PhysicsEnginePlatformer
+
def __init__(self) -> None:
# Magical incantion: initialize the Arcade view
super().__init__()
@@ -50,12 +55,18 @@ class GameView(arcade.View):
)
self.wall_list.append(wall)
+ self.physics_engine = arcade.PhysicsEnginePlatformer(
+ self.player_sprite,
+ walls=self.wall_list,
+ gravity_constant=PLAYER_GRAVITY
+ )
+
def on_update(self, delta_time: float) -> None:
"""Called once per frame, before drawing.
This where in-world time "advances", or "ticks".
"""
- self.player_sprite.center_x += self.player_sprite.change_x
+ self.physics_engine.update()
def on_draw(self) -> None:
"""Render the screen."""
La joueuse est désormais correctement bloquée par les caisses, mais elle ne peut toujours pas sauter au-dessus !
Pour cela, il faut donner une vitesse vers le haut lorsqu’on appuie sur la flèche du haut.
En revanche, on “n’arrêtera” pas le mouvement vertical quand on relâche.
C’est le moteur physique qui va appliquer la gravité pour diminuer progressivement la vitesse verticale, jusqu’à retomber sur un mur.
On modifie donc uniquement on_key_press
.
PLAYER_GRAVITY = 1
"""Gravity applied to the player, in pixels per frame²."""
+PLAYER_JUMP_SPEED = 18
+"""Instant vertical speed for jumping, in pixels per frame."""
+
class GameView(arcade.View):
"""Main in-game view."""
@@ -83,6 +86,9 @@ class GameView(arcade.View):
case arcade.key.LEFT:
# start moving to the left
self.player_sprite.change_x = -PLAYER_MOVEMENT_SPEED
+ case arcade.key.UP:
+ # jump by giving an initial vertical speed
+ self.player_sprite.change_y = PLAYER_JUMP_SPEED
def on_key_release(self, key: int, modifiers: int) -> None:
"""Called when the user releases a key on the keyboard."""
Ça y est ! Nous avons les bases d’un véritable jeu de plateformes.
Il est parfois utile, surtout pendant vos tests, de redémarrer le jeu depuis le départ, dans l’état initial.
C’est justement ce que fait la méthode setup()
!
Ajoutez un moyen de redémarrer depuis zéro lors de l’appui sur la touche Escape du clavier.
Consultez la documentation ou profitez de l’autocomplétion pour découvrir la bonne constante pour cette touche.
(Changer de membre du binôme qui “a le contrôle” ici.)
Notre joueuse peut maintenant se déplacer. Mais si elle s’aventure hors des bornes de l’écran, nous ne pourrons pas la suivre. Normalement, dans un jeu de plateformes, l’écran suit les déplacements de la joueuse.
Puisqu’on ne veut pas déplacer le monde entier à chaque frame, il est préférable d’introduire une caméra. C’est la caméra qui va bouger, et qui va déterminer quelle portion du monde est affichée à l’écran. Pour l’instant, nous allons nous contenter d’une caméra très simple qui est constamment centrée sur la joueuse.
Créons d’abord une arcade.camera.Camera2D
, et utilisons-là pour dessiner tous les sprites de notre monde.
@@ -18,6 +18,8 @@ class GameView(arcade.View):
physics_engine: arcade.PhysicsEnginePlatformer
+ camera: arcade.camera.Camera2D
+
def __init__(self) -> None:
# Magical incantion: initialize the Arcade view
super().__init__()
@@ -64,6 +66,8 @@ class GameView(arcade.View):
gravity_constant=PLAYER_GRAVITY
)
+ self.camera = arcade.camera.Camera2D()
+
def on_update(self, delta_time: float) -> None:
"""Called once per frame, before drawing.
@@ -74,8 +78,10 @@ class GameView(arcade.View):
def on_draw(self) -> None:
"""Render the screen."""
self.clear() # always start with self.clear()
- self.wall_list.draw()
- self.player_sprite_list.draw()
+
+ with self.camera.activate():
+ self.wall_list.draw()
+ self.player_sprite_list.draw()
def on_key_press(self, key: int, modifiers: int) -> None:
"""Called when the user presses a key on the keyboard."""
Pour l’instant, rien ne se passe, car nous ne faisons pas bouger la caméra.
On veut faire cela dans la méthode on_update
, après avoir appliqué la physique du jeu.
def on_update(self, delta_time: float) -> None:
"""Called once per frame, before drawing.
This where in-world time "advances", or "ticks".
"""
self.physics_engine.update()
+ self.camera.position = self.player_sprite.position
Aïe ! Mypy rapporte une erreur de type sur cette ligne :
Incompatible types in assignment (expression has type
"tuple[float | int, float | int]"
, variable has type"Vec2"
)
Néanmoins, Python étant ce qu’il est, nous pouvons quand même exécuter le programme sans erreur, et la caméra suit désormais la joueuse !
Pourquoi mypy se plaint-il ? Il s’avère que c’est un vieux bug dans mypy qui a été très récemment corrigé. Trop récemment. Le fix ne fait pas encore partie d’une release officielle de mypy. J’espérais qu’elle arriverait avant le début du semestre, mais c’est raté ; nous attendons toujours. On va donc faire ce qu’il est interdit de faire à moins de faire face à un bug de mypy : nous allons lui dire d’ignorer cette ligne !
# Waiting for a new version of mypy with https://github.com/python/mypy/pull/18510
self.camera.position = self.player_sprite.position # type: ignore
(Changer de membre du binôme qui “a le contrôle” ici.)
Nous arrivons au dernier concept important à connaître avant de pouvoir vous lâcher dans la nature d’Arcade : la détection de collisions. Certes, le moteur physique s’occupe des collisions entre la joueuse et les murs, dans le but de contrôler ses mouvements. Mais nous devrons aussi gérer les collisions pour d’autres aspects du jeu, notamment pour ramasser des objets. En effet, on ne veut pas empêcher la joueuse de se rendre sur un objet. Au contraire, on souhaite qu’elle le fasse, pour le ramasser et lui remettre.
Pour cela, nous allons utiliser des pièces à collectionner. Commencez par ajouter quelques pièces dans le monde :
coin_list
(les pièces étant fixes, activez le “spatial hashing” comme pour les murs).":resources:images/items/coinGold.png"
, ainsi que scale=0.5
.Puisque c’est une sprite list différente des murs, le moteur physique permet à la joueuse de passer dessus. Mais il faut encore les ramasser.
Pour cela, dans notre méthode on_update
, nous allons tester si la joueuse est “sur” une pièce.
Si c’est le cas, nous allons la retirer du monde, ce qui donnera l’illusion que la joueuse l’a ramassée.
Plus tard, vous pourriez compter les pièces ramassées dans un score, par exemple.
Pour tester si la joueuse est sur une pièce, nous devons en fait tester si le sprite de la joueuse est en collision avec le sprite d’une des pièces. C’est une opération fréquente, et coûteuse si on ne s’y prend pas bien. Heureusement, Arcade nous fournit exactement ce dont nous avons besoin : la fonction
arcade.check_for_collision_with_list(self.player_sprite, self.coin_list)
renvoie la liste de toutes les sprites dans self.coin_list
qui sont en collision avec self.player_sprite
.
En pratique, cette liste contiendra 0 ou 1 élément, mais pourquoi ne pas la gérer de manière générique ?
L’autre méthode dont avez besoin est
some_sprite.remove_from_sprite_lists()
qui retire some_sprite
de toutes les sprite lists dans lesquelles elle se trouve.
En pratique, dans notre cas, cela sert à la retirer de la liste self.coin_list
.
Utilisez ces deux méthodes dans on_update
pour permettre à la joueuse de ramasser les pièces qu’elle touche.
Il y a des algorithmes avancés derrière la fonction check_for_collision_with_list
, qui sortent du cadre de ce cours.
Cependant, il est bon de savoir qu’ils se basent sur la notion de “hit box”.
Idéalement, on voudrait que deux sprites entrent en collision si et seulement si il existe un pixel en commun dans leurs images.
En pratique, cela est trop coûteux.
Arcade calcule donc une “hit box” pour chaque sprite : un polygone, souvent un octogone irrégulier, qui circonscrit l’image tout en l’approchant le plus possible. L’algorithme de collisions n’a plus alors qu’à tester si les octogones se superposent, ce qui est beaucoup plus efficace que des formes arbitraires.
Vous pouvez demander à Arcade de dessiner les hit boxes des sprites d’une sprite list avec
some_sprite_list.draw_hit_boxes()
dans votre méthode on_draw
.
Essayez !
(Changer de membre du binôme qui “a le contrôle” ici.)
Les jeux vidéos sont, de notoriété publique, difficiles à tester. En effet, la “sortie” du programme n’est autre qu’un flux vidéo, dont il faudrait en théorie tester précisément certains pixels. C’est évidemment très fastidieux, et très fragile : de petits changements de traitements sans conséquence peuvent casser des tests pour rien.
On va donc plutôt choisir de tester certains états internes de notre jeu. Par exemple, au lieu de tester que le pixel $(300, 200)$ est de la bonne couleur, on va tester que la position de la joueuse est bien (proche de) $(300, 200)$. Pour cela, il faut avoir directement accès à cet état interne depuis nos tests. Si vous vous demandiez pourquoi nous avons défini tous nos attributs de manière publique, c’est pour ça.
Voyons donc comment tester que notre joueuse est capable de ramasser des pièces.
conftest.py
Arcade a une limitation majeure : il ne peut y avoir qu’une seule arcade.Window
dans le programme.
Il n’est même pas possible d’en “détruire” une pour pouvoir en créer une autre.
Il va donc falloir tricher dans nos tests pour que tous les tests réutilisent la même Window
.
Heureusement, chaque test pourra créer sa propre GameView
, donc ce ne sera pas trop gênant.
La partie délicate est de faire en sorte que les tests aient accès à une Window
globale, qui sera “remise à zéro” avant chaque test.
Cela étant vraiment compliqué à faire, on vous le donne.
Téléchargez le fichier conftest.py
et sauvegardez-le dans votre projet sous tests/conftest.py
.
Grâce à l’existence de ce fichier, vos tests pourront demander accès à la Window
globale avec un paramètre, sous la forme :
def test_something(window: arcade.Window) -> None:
# use the window in the test
On ne vous demande pas de comprendre le contenu de ce fichier. Je ne l’ai pas écrit moi-même. J’ai récupéré la mécanique utilisée par la bibliothèque Arcade elle-même, et l’ai simplifiée un tout petit peu.
Voici un test pour s’assurer que la joueuse peut ramasser des pièces :
import arcade
from gameview import GameView
INITIAL_COIN_COUNT = 5
def test_collect_coins(window: arcade.Window) -> None:
view = GameView()
window.show_view(view)
# Make sure we have the amount of coins we expect at the start
assert len(view.coin_list) == INITIAL_COIN_COUNT
# Start moving right
view.on_key_press(arcade.key.RIGHT, 0)
# Let the game run for 1 second
window.test(60)
# We should have collected the first coin
assert len(view.coin_list) == INITIAL_COIN_COUNT - 1
# Jump to get past the first crate
view.on_key_press(arcade.key.UP, 0)
view.on_key_release(arcade.key.UP, 0)
# Let the game run for 1 more second
window.test(60)
# We should have collected the second coin
assert len(view.coin_list) == INITIAL_COIN_COUNT - 2
La méthode window.test(frames)
nous est offerte par Arcade pour faciliter l’écriture de tests.
Elle génère les appels à on_update
et on_draw
pendant le nombre de frames spécifié en argument.
D’une certaine manière, on peut considérer qu’elle fait avancer le temps.
Les tests qui auront besoin d’une Window
vont suivre la formule suivante :
view: GameView
(potentiellement avec des paramètres, dans le future).window.show_view(view)
pour installer cette vue dans l’unique fenêtre de test.window.test(frames)
pour faire avancer le temps.view.on_key_press
, on_key_release
pour simuler les appuis de touches.Vous pourrez prendre exemple sur le test ci-dessus pour toutes vos fonctionnalités supplémentaires, en commençant par les améliorations de la prochaine section.
À partir d’ici, vous pouvez travailler séparément.
Nous vous proposons quelques améliorations à l’ergonomie de votre jeu, qui se basent sur les concepts que vous avez déjà en main.
Répartissez-vous le travail !
Avez-vous remarqué que votre joueuse s’arrête parfois alors que vous appuyez encore sur une touche gauche/droite ? Cela peut se produire avec la séquence suivante :
La joueuse s’arrête, alors qu’elle devrait continuer vers la gauche.
Identifiez le problème et corrigez-le.
Souvent dans un jeu de plateformes, la caméra ne reste pas exactement centrée sur la joueuse. La joueuse peut se déplacer dans les quatre directions sans que la caméra ne bouge. La caméra ne se met à bouger que lorsque la joueuse s’approche “trop près” des bords.
Adaptez la gestion de la caméra pour suivre ce nouveau modèle. Essayez différentes valeurs de “marges”, c’est-à-dire combien de pixels doivent être visibles autour de la joueuse au minimum.
Avez-vous essayé de sauter alors que vous étiez déjà dans les airs ? Le jeu vous laisse faire ! On se croirait dans Flappy Bird.
Identifiez le problème et corrigez-le.
Consultez la documentation de PhysicsEnginePlatformer
pour voir si un attribut ou une méthode pourrait vous aider dans cette tâche.
Un jeu sans bruitages, c’est un peu monotome. Ajoutez des sons lors des sauts et lors de la collecte des pièces.
Vous aurez besoin des fonctions suivantes :
arcade.load_sound
(en mode streaming=False
, par défaut)arcade.play_sound
(en mode loop=False
, par défaut)Il y a des sons disponibles ici. Nous suggérons :
":resources:sounds/coin1.wav"
pour ramasser une pièce":resources:sounds/jump1.wav"
pour sauterFaites en sorte de ne charger (load_sound
) chaque son qu’une seule fois, et non pas à chaque fois que vous voulez le jouer.
Vous pourriez aussi naviguer dans la documentation de Arcade pour voir s’il y a plus d’explications ou d’exemples sur l’utilisation des sons.
Remarque : il n’est pas possible de tester automatiquement les sons ; nous ne nous attendons donc pas à ce que vous le fassiez.
N’oubliez pas de compléter votre fichier LOG.md
.
Il n’y a pas de question pour ANSWERS.md
cette semaine.