Aller au contenu

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 :

\[ \begin{align} p_1 &= p_0 + v_0 / t\\ &= (0,0) + (50, 0) * 0.5 &= (0,0) + (25, 0) &= (25, 0) \end{align} \]

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 :

1
2
3
4
5
def Nom: 
    def __init__(self, parametres):
        self.attribut1 = valeur1
        self.attribut2 = valeur2 
        ...

Par exemple :

1
2
3
4
def Vec:
    def __init__(self, x, y):
        self.x = x
        self.y = y

La ligne

def Vec:

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

2
3
4
    def __init__(self, x, y):
        self.x = x 
        self.y = y

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 :

v = Vec(1.0, 2.0)

On accède ensuite aux attributs de l'objet en utilisant l'opérateur . :

print(v.x) # affiche 1.0
print(v.y) # affiche 2.0
v.y = 22 
print(v.y) #affiche 22

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.

class Personnage :

    def __init__(self, position):
        self.position = position
        self.vitesse = Vec(0,0)

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.

perso = Personnage(Vec(10, -3))

Pour modifier la vitesse, on peut le faire de deux manières :

## Manière 1 : on crée un nouveau Vec 
perso.vitesse = Vec(60, -10)

## Manière 2 : on modifie directement les attributs :
perso.vitesse.x = 60
perso.vitesse.y = -10

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:

def traiter_controles(etat):
    pass

def avancer_simulation(etat):
    pass

def afficher_rendu(etat):
    pass

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).

import pygame

clock = pygame.time.Clock()

for i in range(10):
    print("tick")
    clock.tick(2)

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()