Principe et outils
On veut déplacer le personnage en utilisant les touches directionnelles du clavier.
On va en profiter pour donner à notre code sa structure finale et voir quelques outils.
Temps réel
Créer un jeu vidéo, c'est simuler un monde virtuel (éventuellement très simple), et permettre au joueur d'influencer cette simulation, par exemple par le biais d'un personnage qu'il contrôle.
Pour simuler le monde, on va partir d'un état initial, l'état \(0\). Par exemple, une boule avec une position \(p_0 = (0,0)\) et une vitesse \(v_0 = (50,0)\).
On va ensuite calculer le nouvel état (état \(1\)) du monde après un petit interval de temps \(t\), le timestep. Par exemple, \(0.5\) seconde.
On a donc :
En répétant ça, on peut calculer l'avancement de la simulation par étapes.
Etat | Position (px) | Vitesse (px/s) | Temps Ecoulé (s) |
---|---|---|---|
0 | \((0,0)\) | \((50,0)\) | \(0.5\) |
1 | \((25,0)\) | \((50,0)\) | \(1.0\) |
2 | \((50,0)\) | \((50,0)\) | \(1.5\) |
3 | \((75,0)\) | \((50,0)\) | \(2.0\) |
... | ... | ... | ... |
Si on affiche chaque nouvel état du monde avec le même intervalle de temps \(t\) que l'on a choisi pour la simulation, et que \(t\) est suffisamment petit, alors le joueur aura l'impression d'un monde qui évolue en continu de manière fluide. C'est ce qu'on appelle un jeu en temps réel. En général, on choisit pour \(t\) une valeur inférieure à 17ms, ce qui corresponds à au moins 60 affichages par seconde.
Remarques
On appelle chaque affichage une frame (ou image). Pour les étapes de la simulation, on parle de tick ou de step.
Plutôt que d'exprimer l'intervalle \(t\), on parlera souvent plutôt en FPS (Frame Per Second), ou en ticks (sous entendu "par seconde").
Dans notre cas, les ticks de simulation seront parfaitement synchronisés avec les frames. Ce n'est pas toujours le cas dans tous les jeux vidéos.
TODO (prof) ajouter des exos/exemples !
Classes et objets
Pour décrire un monde virtuel entier, l'état de notre simulation, il va nous falloir énormément de valeurs.
Par exemple, notre personnage aura a minima une position et une vitesse, ce qui fait au moins 4 nombres (2 vecteurs en deux dimensions).
Chaque projectile aura également une position et une vitesse, voir d'autres caractéristiques.
On risque vite de s'y perdre si on ne structure pas tout ça avec rigueur.
Pour ça, on va utiliser un des outils de la syntaxe de Python : les objets et les classes.
Un objet est une valeur qui contient des valeurs et des opérations nommées, respectivement appelées attributs et méthodes. On peut voir un objet comme un groupe de valeurs qui ont un role. Par exemple, un objet vecteur peut avoir deux coordonnées x et y.
Une classe définit la structure d'un objet, c'est son type.
Simplifications
Dans ce projet, les classes et objets seront majoritairement utilisés pour structurer les valeurs que l'on va manipuler.
Les classes et objets permettent bien plus que de la simple structuration de valeurs, mais leur présentation complète dépasse le cadre de ce projet. Ces concepts seront vus, en partie, en terminale.
On peut définir une classe avec la syntaxe suivante :
Par exemple :
La ligne
Donne le nom de la classe. Ici, Vec
pour vecteur. En Python, il convient d'écrire le nom des classes tout attaché avec une majuscule à chaque mot : NomDeClasse
.
Le bloc
Est une méthode particulière appelée initialiseur. Elle permet de définir les attribut qu'auront les objets de la classe. Dans les paramètres de cette méthodes, self
représente l'objet de la classe qui est créé.
Les objets de la classe Vec
auront donc deux attributs, x
et y
. Remarquez que self.x
est l'attribut, et x
est l'argument : ce sont des valeurs qui peuvent être différentes !
Pour créer un objet d'une classe, on appelle le nom de la classe comme une fonction. Les arguments à passer sont ceux attendus par la méthode __init__
, sauf l'argument self
, qui est implicite.
Par exemple, on peut créer un objet de la classe Vec
avec le code suivant :
On accède ensuite aux attributs de l'objet en utilisant l'opérateur .
:
Les objets sont des valeurs mutables, comme les list
et les dict
.
v = Vec(33, 44)
u = v
#ici, u et v correspondent au même objet de type Vec
v.x = 22
print(u.x) #affiche 22
On va beaucoup utiliser cette propriété, mais il nous faudra rester prudents parce que la mutabilité peut être source d'erreur, en particulier si on fait des copies d'un objet.
Entrainement
Définissez, à l'aide de la classe Vec, une classe Personnage qui a pour attributs :
- Une position, dont la valeur initiale est passée en paramètre à la méthode
__init__
- Une vitesse, dont la valeur initiale est
(0,0)
La position et la vitesse peuvent être représentées par un vecteur.
Créez un objet personnage en position (10, -3)
puis changez sa vitesse en (60, -10)
.
Pour créer l'objet, on fait appel à l'initialiseur de Personnage, et on passe un Vec de position en paramètre.
Pour modifier la vitesse, on peut le faire de deux manières :
Boucle principale
Après avoir créé et initialisé l'état de notre jeu (gamestate en anglais), on va répéter en boucle ces opérations, jusqu'à que le joueur quitte le jeu.
- Traiter les actions du joueur (appui sur une touche, ...)
- Mettre a jour l'état du monde en fonction du temps et des actions du joueur
- Faire le rendu du nouvel état et l'afficher
- Attendre un certain temps pour compléter \(t\), l'intervalle.
C'est ce qu'on appelle la boucle principale du jeu. Elle est présente dans tous les jeux, mais elle parfois est bien plus compliquée ou sous une forme différente.
On peut voir chacune des trois opérations et les données qui leurs sont associées comme un système.
- Les actions du joueur sont traitées par le contrôleur
- L'état du monde est géré par la simulation
- L'affichage est géré par le moteur de rendu
L'idée générale est qu'on va essayer de séparer le plus possible ces trois systèmes, pour que le code soit plus lisible et plus simple à faire évoluer. On groupera aussi les classes et fonctions de chaque système au même endroit dans le fichier.
TODO schema ici
L'état de notre jeu va donc être une classe comportant trois attributs :
class GameState :
def __init__(self):
self.controles = None # on met a None parce que ces attributs seront initialisés plus tard
self.simulation = None
self.rendu = None
Un système donné ne modifiera que l'attribut lui correspondant. En revanche, un système peut aller regarder l'attribut des autres composants. Par exemple, le moteur de rendu observe l'état de la simulation pour l'afficher.
TODO schema
Chaque système est chargé d'initialiser sa partie, avec une fonction :
def initialiser_controles(etat):
pass
def initialiser_simulation(etat):
pass
def initialiser_rendu(etat):
pass
Ensuite, chaque système a une fonction correspondant aux opérations décrites plus haut:
Notre boucle principale va donc ressembler, grosso modo, à ça :
pygame.init()
etat = GameState()
initialiser_controles(etat)
initialiser_simulation(etat)
initialiser_rendu(etat)
while not etat.controles.quitter:
traiter_controles(etat)
avancer_simulation(etat)
afficher_rendu(etat)
pygame.quit()
Reste à attendre pour compléter le timestep.
Pour ça, le module time de pygame nous founit la classe Clock.
Le principe est simple, on va créer un objet Clock, puis appeler la méthode tick
dans notre boucle en lui donnant un nombre de ticks par secondes. Cette méthode va calculer l'interval \(t\) correspondant, puis attendre avant de retourner, de manière à ce que au moins \(t\) ou plus de temps se soient écoulés depuis le dernier appel.
Par exemple, le programme suivant affiche "tick" dix fois à 500ms d'intervalle (donc 2 fois par secondes).
Essayez de changer la valeur du paramètre de tick
en 1 et en 10 pour voir la différence !
On va donc rajouter une structure similaire dans notre boucle principale :
FPS = 60 # Constante, beaucoup plus haut dans le fichier
pygame.init()
etat = GameState()
initialiser_controles(etat)
initialiser_simulation(etat)
initialiser_rendu(etat)
clock = pygame.time.Clock()
while not etat.controles.quitter:
clock.tick(FPS)
traiter_controles(etat)
avancer_simulation(etat)
afficher_rendu(etat)
pygame.quit()
Le dépliant ci dessous vous propose une version presque complète du code, dans laquelle a été ajoutée une fonction et un bloc main, ainsi que les import nécessaires. C'est à partir de cette version que nous allons créer le jeu, en ajoutant et modifiant le code.
Fichier complet
Code non fonctionnel
Le code ci dessous ne fonctionne pas encore, il est incomplet !
import pygame
####################
#### CONFIG ####
####################
## Général
FPS = 60
## Controles
## Simulation
## Rendu
####################
#### GENERAL ####
####################
class GameState :
def __init__(self):
self.controles = None # on met a None parce que ces attributs seront initialisés plus tard
self.simulation = None
self.rendu = None
####################
#### CONTROLEUR ####
####################
def initialiser_controles(etat):
pass
def traiter_controles(etat):
pass
####################
#### SIMULATION ####
####################
def initialiser_simulation(etat):
pass
def avancer_simulation(etat):
pass
####################
#### RENDU ####
####################
def initialiser_rendu(etat):
pass
def afficher_rendu(etat):
pass
####################
#### MAIN ####
####################
def main():
pygame.init()
etat = GameState()
initialiser_controles(etat)
initialiser_simulation(etat)
initialiser_rendu(etat)
clock = pygame.time.Clock()
while not etat.controles.quitter:
clock.tick(FPS)
traiter_controles(etat)
avancer_simulation(etat)
afficher_rendu(etat)
pygame.quit()
if __name__ == "__main__":
main()