Aller au contenu

Introduction au développement de jeu vidéo

Ce chapitre présente succintement quelques principes de développement de jeu vidéo en temps réel avec la bibliothèque Pygame.

Le temps réel veut dire que les entités du jeu évoluent en continue (ex : Minecraft, CS2, Fortnite, Mario). C'est l'opposé du tour par tour (ex : Dofus).

Principe

Le code d'un niveau de jeu a en général la structure suivante :

structure

L'état du niveau est initialisé au lancement du niveau.

Ensuite, le code du niveau simule l'évolution de l'état par petites intervalles de temps, appelés frames ou ticks:

A chaque frame, le code :

  • récupère les éventuelles intéractions faites par le joueur, par exemple un appui sur une touche.
  • Simule l'évolution de l'état du niveau sur la durée d'une frame, en prenant en compte les intéractions du joueur.
  • Affiche le nouvel état du niveau au joueur.

Installation de pygame et première fenêtre

Commencez par installer Pygame. Si vous n'y arrivez pas, utilisez la méthode dans le dépliant suivant :

Installation de Pygame par un script

Créez un fichier Python, copiez-collez y le script suivant, et executez-le avec votre outils de développement (par exemple, Thonny).

Attention

Il est peu recommandé d'exécuter des scripts qui ne proviennent pas d'une source fiable sans les comprendre, n'en faite pas une habitude !

import subprocess
import sys

def install(package):
    subprocess.check_call([sys.executable, "-m", "pip", "install", package])

install("pygame")

Vous devriez obtenir une sortie de cette forme :

Defaulting to user installation because normal site-packages is not writeable
Collecting pygame
Downloading pygame-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (13.7 MB)
    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 13.7/13.7 MB 2.8 MB/s eta 0:00:00
Installing collected packages: pygame
Successfully installed pygame-2.1.3

L'important est la dernière ligne, qui doit mentionner "Successfully installed".

Si vous pygame est déjà installé, vous aurez une sortie de la forme :

Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: pygame in ./.local/lib/python3.10/site-packages (2.1.3)

Qui mentionne "Requirement already satisfied : pygame".

Super. Voici le code de départ :

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"

def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran
    }


def entrees_joueur():

    quitter = False
    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True
    return {
        QUITTER : quitter
    }


def niveau():
    etat = initialiser_niveau()

    stop = False
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

niveau()

Si vous exécutez ce code, vous devriez voir apparaître une fenêtre toute noire :

fenetre noire

Que vous pouvez quitter en cliquant sur ECHAP.

Félicitation, vous avez créé votre première fenêtre !

Voyons plus en détail ce que fait le code :

import pygame
from pygame import *

# En python, les variables avec un nom en majuscules indiquent qu'elles ne doivent pas être modifiées
# C'est une convention, et non quelque chose que Python force. On les appelle des "constantes". 
# On va utiliser des dictonnaires pour stocker toutes nos valeurs et les organiser.
# Et les constantes vont nous servir de clé. Pourquoi ?
# Si on se trompe dans l'écriture d'une constante, Python affichera une erreur, alors qu'avec 
# directement une chaîne de caractère, Python ne dira rien en cas d'erreur.
ECRAN = "ECRAN" 
QUITTER = "QUITTER"

# Cette fonction initialise la totalité de l'état du niveau. Pour le moment, elle ne fait pas grand chose.
def initialiser_niveau():

    # On dit à Pygame de démarrer
    pygame.init()

    # On crée la fenêtre, de taille 1280x720.
    # set_mode retourne une *Surface*, c'est à dire une zone sur laquelle on peut dessiner
    ecran = pygame.display.set_mode([1280, 720])

    return {
        # Pour le moment, le seul état du niveau est... l'écran !
        ECRAN : ecran
    }

# Cette fonction récupère les intéractions du joueur. 
def entrees_joueur():

    quitter = False
    # pygame.event.get retourne la liste des évènements (touche appuyé, souris bougée, ...)
    # qui ont eu lieu depuis le dernier appel à pygame.event.get
    for evt in pygame.event.get():
        # On regarde le type de l'évènement. Ici, vrai si l'évènement corresponds à un appui sur une touche
        if evt.type == pygame.KEYDOWN:
            # On regarde sur quelle touche a appuyé le joueur. Ici, vrai si le joueur a appuyé sur ECHAP.
            if evt.key == pygame.K_ESCAPE:
                quitter = True

    # On retourne les entrées sous forme d'un dictionnaire qui récapitule les actions que le joueur
    # a effectuées cette frame
    return {
        QUITTER : echap
    }

# Cette fonction contient le code principal du niveau
def niveau():
    #création de l'état
    etat = initialiser_niveau()

    #Boucle infinie de la frame.
    stop = False

    #Stop sert à quitter si le joueur appuie sur échap
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

    # Ferme la fenetre
    pygame.quit()

# On appelle la fonction pour lancer le niveau
niveau()

Premier dessin

Pygame nous donne deux manières de réaliser l'affichage :

La première consiste à dessiner des formes à l'écran. La seconde à "coller" des images sur l'écran. Voyons la première.

Ajoutons un personnage et dessinons-le sous forme d'une cercle.

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"

#on se crée une fonction pour créer le personnage
# On va toujours passer par des fonction et pas directement par des dicts
# Parce que les fonctions permettent plus de vérifications. On aura l'occasion d'en reparler.
def creer_personnage():
    # Vector2 est un type fourni par Pygame, qui représente un ... vecteur en 2 d
    # et supporte toutes les opérations de base sur les vecteurs : normalisation, addition, soustraction, multiplication, ...
    return {
        POSITION : Vector2(0, 0)
    }


def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    #on ajoute le personnage au niveau
    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage()
    }


def entrees_joueur():

    quitter = False
    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True
    return {
        QUITTER : quitter
    }


# On crée une fonction pour afficher le niveau
def afficher_niveau(etat):
    # remplit l'écran de la couleur noire (code RGB 0, 0, 0)
    etat[ECRAN].fill((0, 0, 0))

    #pygame.draw.circle dessine un cercle sur une surface. Les paramètres sont les suivants : 
    # - la Surface sur laquelle dessiner (ici, l'écran)
    # - Le code RGB de la couleur, (196, 154, 214) corresponds à du rose/violet
    # - le centre du cercle. ici, ce sera 0, 0, la position que l'on a donné à notre personnage. 
    #   Elle corresponds à la position en pixels sur l'écran
    # - Le rayon du cercle, egalement en pixels
    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION],
        20
    )

    # pygame.display.flip permet de valider le dessin et de l'afficher à l'écran.
    pygame.display.flip()

def niveau():
    etat = initialiser_niveau()

    stop = False
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        #bien sûr, on appelle la fonction ici : 
        afficher_niveau(etat)

    pygame.quit()

niveau()

Normalement, en lançant le code, vous verrez apparaître le cercle dans le coin haut gauche de la fenêtre :

point rose

En HAUT à gauche ? mais comment ça ? Et bien tout simplement, le repère de l'écran est inversé : l'axe y croît vers le bas.

!!! example "Activité"*

=== "Enoncé, partie 1"
    Essayez de changer la position du personnage pour que le cercle apparaîsse en bas à gauche de la fenetre.

=== "Solution"

    Il faut changer la position du cercle avec y au maximum.

    La fenetre fait 720 pixels de haut, donc :

    ```python
    def creer_personnage():
        return {
            POSITION : Vector2(0, 720)
        }
    ```

===! "Enoncé, partie 2"
    Même chose, mais au milieu de la fenêtre

=== "Solution"
    On prends la moitié à chaque fois :

    ```python
    def creer_personnage():
        return {
            POSITION : Vector2(1280/2, 720/2)
        }
    ```

Déplacer le personnage

On va rendre le personnage réactif aux entrées de l'utilisateur, en le déplaçant avec les flèches.

On commence par UNE des directions.

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"


def creer_personnage():
    # On ajoute une vitesse et une direction au personnage
    # La direction est un vecteur indiquant vers où le perso va
    # la vitesse un nombre qui indique la vitesse du déplacement.
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 10
    }


def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage()
    }


def entrees_joueur():

    quitter = False

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

    # cette fonction renvoie l'état actuel de toutes les
    # touches du clavier. Une touche vaut True si elle est
    # enfoncée, False sinon
    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)
    # pygame.K_RIGHT est la touche fleche droite
    if touches[pygame.K_RIGHT]:
        direction.x += 1.0

    return {
        QUITTER : quitter,
        DIRECTION : direction
    }

# cette fonction modifie l'état du niveau en fonction
# des entrées du joueur
def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

# cette fonction déplace le personnage
# en additionant sa vitesse et direction à sa position

def deplacer_personnage(etat):
    etat[PERSONNAGE][POSITION] += etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION]

# cette fonction fait avancer l'état du jeu d'une frame
# on la découpe en sous-fonctions pour plus de clarté
# Chaque fonction représente une "règle" de fonctionnement
# de la simulation
def simuler_frame(etat):
    deplacer_personnage(etat)

def afficher_niveau(etat):
    etat[ECRAN].fill((0, 0, 0))

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION],
        20
    )

    pygame.display.flip()

def niveau():
    etat = initialiser_niveau()

    stop = False
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        #on applique les entrées à l'état
        appliquer_entrees_joueur(etat, entrees)

        #on avance la simulation
        simuler_frame(etat)

        afficher_niveau(etat)

    pygame.quit()


niveau()

Si vous lancez ce code et maintenez la flèche droite enfoncée, vous devriez voir le personnage se déplacer... très vite.

On verra plus tard quelques réglages. Avant ça, activité :

Activité

Permettez au personnage de se déplacer vers la gauche.

On ajoutes simplement la direction dans la captation des entrées :

def entrees_joueur():
    #...

    direction = Vector2(0, 0)
    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0
    # utiliser des - et des + au lieu d'une valeur 
    # en dur permet d'annuler le déplacement
    # si des flèches opposées sont pressées en même temps

Ajoutez le déplacement vertical

Rappel : l'axe y va croissant vers le bas !

il faut modifier la coordonnée y du vecteur direction

Et ne pas oublier que l'axe est INVERSE

direction = Vector2(0, 0)
    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0
    if touches[pygame.K_UP]:
        direction.y += -1.0 #-1.0 parce que axe inversé
    if touches[pygame.K_DOWN]:
        direction.y -= -1.0

Temps réel et précision

Vous avez vu que le personnage se déplace très vite. Et si vous aviez plusieurs ordinateurs, vous verriez aussi que le personnage ne se déplace pas à la même vitesse sur toutes les machines.

C'est parce que pour le moment, on enchaîne les frames sans aucune attente, donc la vitesse de déplacement dépends du nombre de frames par secondes, et donc de la vitesse de la machine sur laquelle s'exécute le jeu.

Commençons par attendre à chaque frame pour essayer d'avoir exactement 60 frames par seconde.

def niveau():
    etat = initialiser_niveau()

    # l'utilitaire python.time.Clock crée une 
    # horloge capable de mesurer le temps et 
    # de faire des attentes
    horloge = pygame.time.Clock()

    stop = False
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)

        simuler_frame(etat)

        afficher_niveau(etat)

        # clock.tick(FPS) mesure le temps écoulé depuis le dernier. FPS = frame par seconde
        # appel, et attends de manière à ce que le temps écoulé
        # soit de au moins 1/FPS secondes
        horloge.tick(60)

    pygame.quit()

Remplacez la fonction niveau par celle ci-dessus dans le code, et lancez le jeu. Le personnage devrait se déplacer plus doucement.

Ok, mais... à quelle vitessé précisément ? à quoi correspond 10 dans la vitesse du personnage ?

VITESSE : 10

Des pixels par frame. Ok, mais c'est pas hyper compréhensible ça... On voudrait une unité plus humainement compréhensible. Par exemple, des pixels par seconde. C'est bien plus simple à manipuler : à 100 pixels par seconde, on sait que le personnage traverse l'écran verticalement en 7 secondes environ.

Idéalement, on veut connaître la durée de chaque frame. Et ça tombe bien, horloge.tick retourne la durée réelle mesurée pour la frame courante.

Récupérons sa valeur de retour et affichons là.

        #dt pour *delta temps* ou "temps écoulé depuis la dernière frame"
        dt = horloge.tick(60)

        print(dt)

On lance le jeu, et on regarde l'affichage en console :

16
16
17
16
16
18

C'est en milisecondes, et vous voyez que c'est pas totalement stable. C'est normal, c'est parce que tick ne peut que garantir que les frames ne durent pas moins d'un certain temps (16ms pour 60 FPS). tick ne peut rien faire si la frame dure plus de 16ms, par exemple à cause d'un lag. Et donc pour que le jeu soit le plus fluide possible, on doit tenir compte de la durée réelle de chaque frame dans la simulation.

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"


def creer_personnage():
    # On modifie la vitesse pour la valeur que l'on veut en 
    # pixels par seconde
    # j'ai pris 300 parce que ça semble raisonnable;
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300
    }


def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage()
    }


def entrees_joueur():

    quitter = False

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

    # cette fonction renvoie l'état actuel de toutes les
    # touches du clavier. Une touche vaut True si elle est
    # enfoncée, False sinon
    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)
    # pygame.K_RIGHT est la touche fleche droite
    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0
    if touches[pygame.K_UP]:
        direction.y += -1.0 #-1.0 parce que axe inversé
    if touches[pygame.K_DOWN]:
        direction.y -= -1.0

    return {
        QUITTER : quitter,
        DIRECTION : direction
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

# on s'attends maintenant à ce que dt soit en *secondes*
# (voir la fonction niveau pour l'explication)
def deplacer_personnage(etat, dt):
    #remarquez le *dt en bout de ligne
    etat[PERSONNAGE][POSITION] += etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION] * dt

# on s'attends maintenant à ce que dt soit en *secondes*
# (voir la fonction niveau pour l'explication)
def simuler_frame(etat, dt):
    deplacer_personnage(etat, dt)

def afficher_niveau(etat):
    etat[ECRAN].fill((0, 0, 0))

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION],
        20
    )

    pygame.display.flip()

def niveau():
    etat = initialiser_niveau()

    # l'utilitaire python.time.Clock crée une 
    # horloge capable de mesurer le temps et 
    # de faire des attentes
    horloge = pygame.time.Clock()

    stop = False
    #on considère la première frame comme de taille fixe
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)

        # on passe la durée de la frame à la simulation
        simuler_frame(etat, dt)

        afficher_niveau(etat)

        # ici, /1000 pour passer en secondes 
        # pour plus de lisibilité dans le reste du code
        dt = horloge.tick(60) / 1000 

        # ici on a retiré le print

    pygame.quit()

niveau()

Lancez le jeu et mesurez le temps que prends le personnage à traverser l'écran verticalement. Il devrait être de 2 secondes et demie environ. N'hésitez pas à changer la valeur de vitesse comme vous préférez.

Géométrie et précision

Activité

Lancez le jeu et déplacez le personnage, horizontalement, verticalement, et en diagonale. Remarquez-vous quelque chose ? Essayez d'expliquer pourquoi.

Et oui, le personnage se déplace plus vite en diagonale qu'horizontalement ou verticalement !

La raison, c'est qu'on fait :

POSITION += DIRECTION * VITESSE

Or, si on va disons à droite, direction vaut (1,0), donc de longueur 1. Si on va en diagonale bas-droite, direction vaut (1,1), de longueur racine de 2 (environ 1.4), et donc la vitesse en résultant va plus vite.

Voici comment résoudre ce problème :

def entrees_joueur():
    # ...


    direction = Vector2(0, 0)
    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0
    if touches[pygame.K_UP]:
        direction.y += -1.0
    if touches[pygame.K_DOWN]:
        direction.y -= -1.0

    # normalize retourne une copie normalisée du vecteur, 
    # c'est à dire avec sa longueur à 1 
    # et avec la même direction
    # on ne peut pas normaliser un vecteur de longueur 0
    # C'est pour ca qu'on vérifie avec un if avant.
    if direction.length() > 0:
        direction = direction.normalize()

    return {
        QUITTER : quitter,
        DIRECTION : direction
    }

Remplacez la fonction par celle-ci et lancez le jeu. Vous pouvez observer que le personnage se déplace maintenant à vitesse égale dans toutes les directions.

Physique de base : gravité et saut

On va petit à petit transformer le jeu en jeu de plateforme. Donc, le personnage pourra se déplacer horizontalement, et sauter/tomber sur l'axe vertical.

Pour ça, il faut ajouter de la physique de base. La physique conserve la vitesse, ou une partie de la vitesse, entre les frames : c'est l'inertie.

Modifions le code du jeu pour permettre au personnage de sauter et de tomber.

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"
INERTIE = "INERTIE"
SAUT = "SAUT"

# on ajoute au personnage une *inertie*. C'est un vecteur
def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0)
    }


def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage()
    }


def entrees_joueur():

    quitter = False
    saut = False 
    # le saut est géré par un évènement et non par get_pressed.
    # C'est plus précis pour les touches que le 
    # joueur va appuyer pendant peu de temps    
    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True
            # ici, on detecte le saut
            if evt.key == pygame.K_UP:
                saut = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0
    # On retire la direction verticale
    #qui était ici avant

    if direction.length() > 0:
        direction = direction.normalize()

    #on oublie pas de retourner la commande de saut
    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut
    }

# on traite la commande du saut. 
def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:
        # On modifie l'inertie du personnage pour avoir une vitesse 
        # verticale forte
        # La valeur est très élevée parce que la gravitée va continuer à s'appliquer 
        # pendant le saut.
        # Un changement d'inertie brutal s'appelle une *impulsion* dans le monde 
        # de la simulation physique
        # On oublie pas que l'axe y est inversé, d'où le -
        etat[PERSONNAGE][INERTIE] = Vector2(0, -500) 

# ici, on applique la gravité au personnage
def gravite_personnage(etat, dt):
    # on augmente simplement la vitesse vers le bas en fonction du temps passé
    # J'ai pas calculé proprement les valeurs ici, j'ai simplement lancé le jeu 
    # et testé avec plusieurs valeurs. Pareil pour l'impulsion du saut
    etat[PERSONNAGE][INERTIE] += Vector2(0, 1000) * dt

# On modifie l'application de la vitesse pour prendre en compte l'inertie
def deplacer_personnage(etat, dt):
    # L'inertie est une vitesse comme une autre
    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION] + etat[PERSONNAGE][INERTIE])  * dt

# on oublie pas d'appliquer la gravité
def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    deplacer_personnage(etat, dt)

def afficher_niveau(etat):
    etat[ECRAN].fill((0, 0, 0))

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION],
        20
    )

    pygame.display.flip()

def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()

niveau()

Lancez le jeu. Vous devriez voir le personnage tomber. Vous devriez aussi pouvoir sauter avec flèche haut.

Activité

Modifiez la physique du jeu pour qu'on ai l'impression que la gravité est moins forte.

Il faut diminuer la valeur de la gravité :

etat[PERSONNAGE][INERTIE] += Vector2(0, 1000) * dt

Et éventuellement augmenter la valeur de l'impulsion du saut :

etat[PERSONNAGE][INERTIE] = Vector2(0, -5000) 

Perso je vais garder les valeurs initiales de 1000 pour la gravité et -5000 pour le saut

Collision avec le sol

Si vous lancez le jeu et ne faites rien ... Le personnage disparaît sous le sol !

# On détecte quand le personnage sort de l'écran
def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > 720:
        #et on corrige
        etat[PERSONNAGE][POSITION].y = 720
        #On annule l'inertie verticale du personnage.
        etat[PERSONNAGE][INERTIE].y = 0

# on gère les collisions APRES avoir déplacé le personnage
def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_sol(etat, dt)

Activité

Vous avez sans doute remarqué que le cercle du personnage s'enfonce à moitié dans le sol.

Expliquez le problème puis résolvez-le. Il y a au moins deux solutions. L'une est bien meilleure que l'autre.

La position du personnage correspond au centre du cercle, et donc comme la position du personnage bloque au niveau du sol, le cercle, lui, s'enfonce plus.

La première solution, c'est de "décaler" le sol du rayon du cercle personnage (20) vers le haut

if etat[PERSONNAGE][POSITION].y > (720 - 20):
    #et on corrige
    etat[PERSONNAGE][POSITION].y = 720 - 20

Cette solution marche, mais elle risque de poser problème par la suite : on va devoir décaler toutes les intéractions physiques du rayon du personnage ! Ca peut vite devenir très compliqué...

La seconde solution, c'est de simplement dessiner le cercle "décalé" vers le haut de son rayon. Comme ça, la position du personnage correspondra à la position de ses "pieds"

def afficher_niveau(etat):
    etat[ECRAN].fill((0, 0, 0))

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20), #se retrouve ici
        20 #cette taille
    )

    pygame.display.flip()

Plateforme : dessin

Dans un jeu de plateformes, il faut... des plateformes.

Une plateforme est simplement une ligne horizontale. Le joueur peut traverser une plateforme par dessous, mais pas par dessus.

On va procéder par étapes :

  • D'abord, on crée UNE plateforme et on la dessine sans qu'elle interagisse avec le joueur
  • Ensuite, on gère la collision entre le joueur et la plateforme
  • Finalement, on généralise le code à un nombre variable de plateformes.

Bon, commençons par créer une seule plateforme:

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"
INERTIE = "INERTIE"
SAUT = "SAUT"
DEBUT = "DEBUT"
FIN = "FIN"
PLATEFORMES = "PLATEFORMES"

def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0)
    }

# Fonction pour créer une plateforme
# Les paramètres permettent de ne rien oublier, par rapport à un dictionaire
# debut et fin sont des coordonnées x
# hauteur est la coordonnée y
def creer_plateforme(debut, fin, hauteur):
    return {
        DEBUT : Vector2(debut, hauteur),
        FIN : Vector2(fin, hauteur)
    }

# On ajoute la plateforme au niveau. 
# Pour le moment sous forme d'une valeur simple
def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage(),
        PLATEFORMES : creer_plateforme(100, 300, 500)
    }


def entrees_joueur():

    quitter = False
    saut = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:

        etat[PERSONNAGE][INERTIE] = Vector2(0, -500) 


def gravite_personnage(etat, dt):

    etat[PERSONNAGE][INERTIE] += Vector2(0, 1000) * dt


def deplacer_personnage(etat, dt):

    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION] + etat[PERSONNAGE][INERTIE])  * dt


def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][INERTIE].y = 0


def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_sol(etat, dt)

# On déplace le code d'affichage du personnage dans un autre
# fonction pour plus de clarté
def afficher_personnage(etat):

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20),
        20 
    )

# Et on définit une fonction pour afficher la plateforme
def afficher_plateformes(etat):
    #pygame.draw.rect permet de dessiner un rectangle à l'écran : 
    # surface
    # couleur
    # Rectangle : Rect(gauche, haut, largeur, hauteur)
    pygame.draw.rect(
        etat[ECRAN], 
        (128, 128, 128), #C'est du gris 
        Rect(
            etat[PLATEFORMES][DEBUT].x, 
            etat[PLATEFORMES][DEBUT].y,
            etat[PLATEFORMES][FIN].x - etat[PLATEFORMES][DEBUT].x,
            10)
    )
    # on dessine la ligne comme un rectangle, dont le bord haut
    # est aligné avec la ligne et de quelques pixels d'epaisseur (ici, 10)

def afficher_niveau(etat):

    etat[ECRAN].fill((0, 0, 0))

    # On affiche la plateforme AVANT d'afficher le personnage, de cette manière en cas de superposition
    # c'est le personnage qui sera devant
    afficher_plateformes(etat)

    afficher_personnage(etat)


    pygame.display.flip()  


def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()

niveau()

Si vous lancez le jeu, vous verrez apparaître une plateforme :

plateforme

Plateforme : collision

Maintenant, il faut détecter une collision entre le joueur et la plateforme.

Là, pas le choix, c'est DES MATHS.

C'est un peu plus compliqué que les collisions avec le sol, parce que la plateforme n'est pas infinie, dans les deux directions :

  • Le joueur ne peut pas aller "dans le sol" donc à partir du moment où il est sous le sol, on sait qu'il y a collision
  • Le sol est infini horizontalement, donc la vérification est très simple.

Pour la plateforme, on va devoir vérifier si le segment qu'a parcouru le personnage dans son déplacement dans la frame a une intersection avec le segment de la plateforme.

segment collision

Il y a plusieurs manières de connaître l'existence d'une intersection entre deux segments dont un des deux est toujours horizontal. Je vous présente ici UNE méthode. Ce n'est pas forcément la meilleure, mais elle a le mérite d'être plus visuelle et donc plus simple à expliquer graphiquement

On va réaliser des projections des deux segments sur différentes droites.

Vocabulaire : Projection

projection ortho

Ici, \(v'\) est la projection orthogonale, ou simplement projection de \(v\) sur \(u\).

Précisément 3 :

  • L'axe y
  • L'axe x
  • une droite perpendiculaire au segment du mouvement

Si les projections des deux segments se superposent sur TOUTES ces droites, alors il y a intersection, sinon, il n'y a pas intersection.

SAT

En Python, ça donne ça :

# Effectue la projection du segment avec début et fin sur axe, un vecteur normalisé
# Retourne un couple de valeur d,f avec d <= f, qui corresponds à la position de début
# et fin sur l'axe 
# On retrouve les projections de debut et fin en faisant
# axe * d et axe * f
def projection_segment(axe, debut, fin):
    # v1.project(v2) retourne la projection orthogonale de v1 sur v2.
    debut_proj = debut.project(axe)
    # On convertis la projection en une coordonnée sur l'axe
    d = debut_proj.x / axe.x if axe.x != 0 else debut_proj.y / axe.y
    fin_proj = fin.project(axe)
    f = fin_proj.x / axe.x if axe.x != 0 else fin_proj.y / axe.y
    # on retourne les deux dans le bon ordre
    return (      
        min(d, f),
        max(d, f)
    )

# pour deux segments (debut1, fin1), (debut2, fin2) des couples de vecteurs et un axe, retourne True si 
# les projections de ces segments se superposent sur l'axe
def proj_superposees(axe, debut1, fin1, debut2, fin2):
    p1 = projection_segment(axe, debut1, fin1)
    p2 = projection_segment(axe, debut2, fin2)

    # pas de superposition si la fin de l'un est plus grande que le début de l'autre
    return not (p1[1] < p2[0] or p2[1] < p1[0])

# a partir d'un vecteur non nul vec, retourne un vecteur non nul orthognal (perpendiculaire) à vec, normalisé
def vec_ortho(vec):
    return vec.rotate(90).normalize()

# A partir d'une forme (debut_p, fin_p) et d'un déplacement entre debut_m et fin_m  des vecteurs,
# Retourne True si le déplacement entre en collision avec la plateforme
def collision_plateforme(debut_p, fin_p, debut_m, fin_m):

    # pas de collision si déplacement nul
    if (debut_m - fin_m).length() == 0:
        return False

    #sinon, on teste les 3 axes : x, y, orthogonal au déplacement
    deplacement = fin_m - debut_m
    axes = [Vector2(0, 1), Vector2(1, 0), vec_ortho(deplacement)]
    for axe in axes:
        # si un axe n'a pas de superposition, pas d'intersection
        if not proj_superposees(axe, debut_p, fin_p, debut_m, fin_m):
            return False 
    # si tous superposent, intersection
    return True

On peut maintenant ajouter la détection de collision pour la plateforme:

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"
INERTIE = "INERTIE"
SAUT = "SAUT"
DEBUT = "DEBUT"
FIN = "FIN"
PLATEFORMES = "PLATEFORMES"
POSITION_DEBUT_FRAME = "POSITION_DEBUT_FRAME"


# On ajoute une manière de se rappeler de la position du perso en début de frame 
# On en aura besoin pour retracer le déplacement du personnage dans la frame
def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0)
    }


def creer_plateforme(debut, fin, hauteur):
    return {
        DEBUT : Vector2(debut, hauteur),
        FIN : Vector2(fin, hauteur)
    }

def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage(),
        PLATEFORMES : creer_plateforme(100, 300, 500)
    }


def entrees_joueur():

    quitter = False
    saut = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:

        etat[PERSONNAGE][INERTIE] = Vector2(0, -500) 


def gravite_personnage(etat, dt):

    etat[PERSONNAGE][INERTIE] += Vector2(0, 1000) * dt


# On se rappelle de la position du personnage en début de frame
def deplacer_personnage(etat, dt):

    # On fait une copie du vecteur, sinon il sera modifié quand on modifiera la position
    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])
    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION] + etat[PERSONNAGE][INERTIE])  * dt


def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][INERTIE].y = 0

def projection_segment(axe, debut, fin):
    debut_proj = debut.project(axe)
    d = debut_proj.x / axe.x if axe.x != 0 else debut_proj.y / axe.y
    fin_proj = fin.project(axe)
    f = fin_proj.x / axe.x if axe.x != 0 else fin_proj.y / axe.y
    return (      
        min(d, f),
        max(d, f)
    )


def proj_superposees(axe, debut1, fin1, debut2, fin2):
    p1 = projection_segment(axe, debut1, fin1)
    p2 = projection_segment(axe, debut2, fin2)

    return not (p1[1] < p2[0] or p2[1] < p1[0])

def vec_ortho(vec):
    return vec.rotate(90).normalize()

def collision_plateforme(debut_p, fin_p, debut_m, fin_m):

    if (debut_m - fin_m).length() == 0:
        return False

    deplacement = fin_m - debut_m
    axes = [Vector2(0, 1), Vector2(1, 0), vec_ortho(deplacement)]
    for axe in axes:
        if not proj_superposees(axe, debut_p, fin_p, debut_m, fin_m):
            return False 
    return True

# On vérifie la collision avec la plateforme et on réagit si besoin
# Il ne peut avoir une collision que si le joueur DESCENDS
def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][INERTIE].y > 0:

        if collision_plateforme(

            etat[PLATEFORMES][DEBUT],
            etat[PLATEFORMES][FIN],
            etat[PERSONNAGE][POSITION_DEBUT_FRAME],
            etat[PERSONNAGE][POSITION]
         ) :
            etat[PERSONNAGE][POSITION].y = etat[PLATEFORMES][DEBUT].y
            #On remets l'inertie verticale du personnage à 0
            etat[PERSONNAGE][INERTIE].y = 0

# on vérifie la collision de la plateforme AVANT le sol
def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

def afficher_personnage(etat):

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20),
        20 
    )

def afficher_plateformes(etat):

    pygame.draw.rect(
        etat[ECRAN], 
        (128, 128, 128), 
        Rect(
            etat[PLATEFORMES][DEBUT].x, 
            etat[PLATEFORMES][DEBUT].y,
            etat[PLATEFORMES][FIN].x - etat[PLATEFORMES][DEBUT].x,
            10)
    )


def afficher_niveau(etat):

    etat[ECRAN].fill((0, 0, 0))

    afficher_plateformes(etat)

    afficher_personnage(etat)

    pygame.display.flip()  


def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()

niveau()

Plateforme : plusieurs plateformes

Activité

Modifiez le code du jeu pour ajouter plusieurs plateformes, qui vont toutes donner au joueur

Il faut transformer la plateforme en une liste de plateformes, et adapter le code de collision_plateformes et de dessiner_plateformes en conséquences.

import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE = "VITESSE"
INERTIE = "INERTIE"
SAUT = "SAUT"
DEBUT = "DEBUT"
FIN = "FIN"
PLATEFORMES = "PLATEFORMES"
POSITION_DEBUT_FRAME = "POSITION_DEBUT_FRAME"


# On ajoute une manière de se rappeler de la position du perso en début de frame 
# On en aura besoin pour retracer le déplacement du personnage dans la frame
def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0)
    }


def creer_plateforme(debut, fin, hauteur):
    return {
        DEBUT : Vector2(debut, hauteur),
        FIN : Vector2(fin, hauteur)
    }

# On transforme la plateforme en une liste de plateformes
def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage(),
        PLATEFORMES : [
            creer_plateforme(100, 300, 500),
            creer_plateforme(100, 300, 200),
            creer_plateforme(800, 900, 500),
            creer_plateforme(1000, 1100, 400),
            creer_plateforme(500, 700, 600),
        ]
    }


def entrees_joueur():

    quitter = False
    saut = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:

        etat[PERSONNAGE][INERTIE] = Vector2(0, -500) 


def gravite_personnage(etat, dt):

    etat[PERSONNAGE][INERTIE] += Vector2(0, 1000) * dt


# On se rappelle de la position du personnage en début de frame
def deplacer_personnage(etat, dt):

    # On fait une copie du vecteur, sinon il sera modifié quand on modifiera la position
    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])
    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE] * etat[PERSONNAGE][DIRECTION] + etat[PERSONNAGE][INERTIE])  * dt


def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][INERTIE].y = 0

def projection_segment(axe, debut, fin):
    debut_proj = debut.project(axe)
    d = debut_proj.x / axe.x if axe.x != 0 else debut_proj.y / axe.y
    fin_proj = fin.project(axe)
    f = fin_proj.x / axe.x if axe.x != 0 else fin_proj.y / axe.y
    return (      
        min(d, f),
        max(d, f)
    )


def proj_superposees(axe, debut1, fin1, debut2, fin2):
    p1 = projection_segment(axe, debut1, fin1)
    p2 = projection_segment(axe, debut2, fin2)

    return not (p1[1] < p2[0] or p2[1] < p1[0])

def vec_ortho(vec):
    return vec.rotate(90).normalize()

def collision_plateforme(debut_p, fin_p, debut_m, fin_m):

    if (debut_m - fin_m).length() == 0:
        return False

    deplacement = fin_m - debut_m
    axes = [Vector2(0, 1), Vector2(1, 0), vec_ortho(deplacement)]
    for axe in axes:
        if not proj_superposees(axe, debut_p, fin_p, debut_m, fin_m):
            return False 
    return True

# On teste maintenant la collision avec chaque plateforme
def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][INERTIE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(

                plateforme[DEBUT],
                plateforme[FIN],
                etat[PERSONNAGE][POSITION_DEBUT_FRAME],
                etat[PERSONNAGE][POSITION]
            ) :
                etat[PERSONNAGE][POSITION].y = plateforme[DEBUT].y
                etat[PERSONNAGE][INERTIE].y = 0


def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

def afficher_personnage(etat):

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20),
        20 
    )

# On affiche toutes les plateformes
def afficher_plateformes(etat):

    for plateforme in etat[PLATEFORMES]:
        pygame.draw.rect(
            etat[ECRAN], 
            (128, 128, 128), 
            Rect(
                plateforme[DEBUT].x, 
                plateforme[DEBUT].y,
                plateforme[FIN].x - plateforme[DEBUT].x,
                10)
        )


def afficher_niveau(etat):

    etat[ECRAN].fill((0, 0, 0))

    afficher_plateformes(etat)

    afficher_personnage(etat)

    pygame.display.flip()  


def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()

niveau()

Game design

Un illustre concepteur de jeu vidéo, Nolan Bushnell, a un jour dit un truc du genre :

Un bon jeu vidéo est facile à apprendre et difficile à maîtriser.

L'idée, c'est qu'un bon jeu se base sur des principes relativement simple et/ou guide les joueurs et joueuses dans leurs apprentissage, et propose ensuite des défi pour tous les niveaux de maîtrise de ces principes.

Relativité

Idéalement, un jeu doit être accessible à tous et toutes, mais...

Ce principe n'est pas absolu, mais relatif en fonction du public cible.

En particulier, ce qui est "facile à apprendre" dépends beaucoup des joueurs et joueuses qui sont la cible principale du jeu.

Par exemple, beaucoup de mécaniques que l'on trouve dans les MMORPGs sont compliquées : guildes, raides, instances, crafting, compétences, ... Mais comme elles sont dans presque TOUS les MMORPGs, les joueurs et joueuses de MMO y sont habitués, et donc il leur est facile de les apprendre.

Un bon exemple d'un jeu respectant un tel principe est le jeu de rythme osu!. Il consiste simplement à cliquer en rythme sur des cercles qui apparaissent à l'écran alors qu'une musique joue en arrière plan. C'est simple et intuitif. Mais la difficulté augmente plus ou moins infiniement jusqu'à ce genre de non-sens que seulement quelques personnes dans le monde sont capable de réussir.

Bon, notre but c'est de faire un jeu. Il nous faut donc:

  • Un objectif
  • Des mécaniques pour y arriver
  • Des moyens de régler la difficulté et de proposer du défi à tous les niveaux de maîtrise des mécaniques

Le choix de ces trois aspects est influencé par ce que l'on veut faire, mais aussi par ce que l'on peut faire.

Comme ce cours veut rester relativement simple, ces aspects vont rester relativement simples.

L'objectif pour le joueur sera d'atteindre un point du niveau situé en hauteur.

Les mécaniques seront :

  • Le saut
  • Le dash, un mouvement horizontal rapide
  • Le boost, Une sorte de second saut, qui annule les déplacements horizontaux mais pas verticaux du personnage.

La difficulté sera réglée :

  • La disposition des plateformes (taille, position, nombre)
  • Une vitesse minimale d'ascension requise pour le joueur (par exemple, de la lave qui monte)
  • Eventuellement, des obstacles que le joueur doit éviter

Bien sûr, à la fin du chapitre, vous pourrez modifier le jeu comme vous le souhaitez...

Dash

Le dash est un déplacement horizontal rapide.

Il y a des tas de manières d'implémenter un dash :

  • Téléporter le personnage dans la direction où il se déplace
  • Augmenter la vitesse de "course/marche" du personnage pendant un court laps de temps
  • Donner une impulsion horizontale au personnage, comme un "saut" horizontal.
  • ...

C'est l'impulsion qui va nous intéresser ici, parce que jouer avec la physique va permettre par la suite de faire des combos avec le boost.

Activité : impulsion naive

Ajoutez un dash que le joueur peut utiliser en appuyant sur la touche MAJ GAUCHE (de code pygame.K_LSHIFT).

Ne vous préoccupez pas des détails, ajoutez simplement une impulsion horizontale.

Je ne montre ici que les fonctions qui a ont été modifiés

# On ajoute une clé dash
DASH = "DASH"



def entrees_joueur():

    quitter = False
    saut = False 
    dash = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

            # On détecte un appui sur la touche de dash
            if evt.key == pygame.K_LSHIFT:
                dash = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut,
        DASH : dash #on rends compte de l'appui
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:

        etat[PERSONNAGE][INERTIE] = Vector2(0, -500) 

    # Si le personnage dash, on fait une impulsion horitontale dans sa direction de déplacement

    if entrees[DASH]:

        etat[PERSONNAGE][INERTIE] = etat[PERSONNAGE][DIRECTION] * 500

Ca marche... si on veut Uu

En particulier, vous avez sans doute remarqué 3 choses :

  • Le saut interrompt le dash et vice-versa. C'est pas très combo tout ça !
  • Le dash est affecté par la gravité. Ca rends le dash un peu bizarre, parce que le personnage "tombe".
  • Si on dash une fois et qu'on laisse aller, le personnage se déplace continuellement dans la direction. Ca rends le dash très bizarre, parce qu'on voudrait que le personnage s'arrête au bout d'un temps.

Il va falloir résoudre ces problèmes, un par un.

Combos

Activité

Modifiez la physique du jeu (saut, gravité, dash, ...) pour que les mouvements du dash et du saut s'additionnent au lieu de s'annuler.

def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    if entrees[SAUT]:
        # le saut annule l'inertie verticale, mais pas l'inertie horizontale :
        etat[PERSONNAGE][INERTIE] = Vector2(etat[PERSONNAGE][INERTIE].x, -500) 

    if entrees[DASH]:
        # Le dash annule l'inertie horizontale, mais pas l'inertie verticale :
        etat[PERSONNAGE][INERTIE] = Vector2( etat[PERSONNAGE][DIRECTION].x * 500, etat[PERSONNAGE][INERTIE].y)

Frottements

Pour que l'impulsion du dash disparaisse au bout d'un temps, il y a deux manières :

  • Annuler l'impulsion ou appliquer une impulsion inverse après un certain temps.
  • Réduire continuellement l'intertie du personnage, sous forme de "frottements".

Activité

Annuler l'impulsion (ou appliquer une impulsion inverse) après un certain temps peut poser problème si on veut ajouter d'autres intéraction physiques par la suite. Essayez de trouver un scénario exemple.

Imaginons que l'on ajoute une autre compétence qui modifie l'inertie horizontale du personnage. On pourrait avoir le scénario suivant :

  • Le personnage a une vitesse horizontale de 0.
  • Il dash, sa vitesse devient 500.
  • Il active la compétence de frein, sa vitesse devient 250.
  • On applique l'impulsion inverse au dash, sa vitesse est maintenant -250. C'est incohérent.

On peut avoir aussi :

  • Le personnage a une vitesse horizontale de 0.
  • Il dash, sa vitesse devient 500
  • Il applique un boost, sa vitesse devient 750.
  • On annule sa vitesse horizontale, qui devient 0 : le boost a été perdu dans le processus.

On va donc plutôt appliquer un frottement, comme dans la vraie vie.

Le frottement est une diminution de la vitesse proportionnelle à la vitesse : plus on va vite, plus les frottements sont forts et donc plus la vitesse diminue vite.

Pour ça, on donne au personnage un coefficient de frottement. C'est une valeur comprise entre 0 et 1, qui exprime la force générale des frottements. 0 corresponds à aucun frottement, et 1 à une "accroche" totale, qui annule instantanément la vitesse.

La formule de diminution de la vitesse que l'on utilise est la suivante :

\[ V_f = V_i \times (1 - C_f) ^ {t} \]

Avec :

  • V_f la vitesse après application des frottements
  • V_i la vitesse initiale
  • C_f le coefficient de frottement
  • t l'intervalle de temps sur lequel les frottements sont appliqués.

En python, ça donne :

def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        #Valeur un peu arbitraire, on la réglera plus tard
        COEF_FROTTEMENT : 0.3

    }


def frottements_personnage(etat, dt):

    # On ne gère que l'axe horizontal pour le moment
    etat[PERSONNAGE][INERTIE].x = etat[PERSONNAGE][INERTIE].x * (1 - etat[PERSONNAGE][COEF_FROTTEMENT]) ** dt

    # si le personnage a beaucoup ralenti horizontalement, on annule sa vitesse
    # abs est la valeur absolue
    if abs(etat[PERSONNAGE][INERTIE].x) < 1:
        etat[PERSONNAGE][INERTIE].x = 0


def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    # on oublie pas d'appeler la fonction !
    frottements_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

Modifiez la valeur de l'impulsion du dash et du coefficient de frottement jusqu'à arriver à un comportement qui vous convient.

Personnellement, j'ai monté le dash à 1000 et le frottement à 0.9.

Vous avez peut-être remarqué quelque-chose un peu étrange avec le dash :

  • Dashez vers la gauche
  • Déplacez-vous un coup vers la droite très rapidement
  • puis relachez le déplacement assez rapidement aussi

Le personnage se remet à aller vers la gauche.

C'est parce que le déplacement du personnage n'affecte pas son inertie, et vice versa.

Pour résoudre ça, on doit gérer les déplacements du personnage directement avec la physique.

Moteur

Au lieu d'avoir une vitesse "séparée", notre personnage va se déplacer à l'aide d'impulsions.

def deplacer_personnage(etat, dt):

    #On modifie l'inertie du personnage en appliquant une impulsion
    etat[PERSONNAGE][INERTIE] += etat[PERSONNAGE][DIRECTION] * etat[PERSONNAGE][VITESSE]

    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])

    #Il n'y a plus que l'inertie qui compte, on retire la vitesse
    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][INERTIE])  * dt

Si on essaie le code ici, on remarque un problème : le personnage accélère encore et encore quand on essaie de le déplacer.

Il faut donc que les déplacements du personnage se comportent comme un moteur :

  • On fixe une force maximale que le moteur peut appliquer
  • On donne une vitesse cible au moteur
  • Le moteur va appliquer sa force sur le personnage pour modifier son inertie et essayer d'atteindre la vitesse cible, sans dépasser la valeur maximale.
#nouvelle clé
FORCE_MOTEUR = "FORCE_MOTEUR"


def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE : 300,
        INERTIE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        COEF_FROTTEMENT : 0.9,
        #La force du moteur
        FORCE_MOTEUR : 100,

    }


def moteur_personnage(etat, dt):
    #On calcule l'impulsion max a partir de la force. Une impulsion est une force * du temps
    #donc dans l'intervalle dt, on peut avoir une impulsion de au max :
    impulsion_max = etat[PERSONNAGE][FORCE_MOTEUR] * dt


    #on calcule la vitesse cible :
    vitesse_cible = etat[PERSONNAGE][DIRECTION].x * etat[PERSONNAGE][VITESSE]


    #On calcule maintenant l'impulsion idéale pour atteindre cette vitesse : 
    impulsion_ideale = vitesse_cible - etat[PERSONNAGE][INERTIE].x

    #si l'impulsion idéale est inférieure à l'impulsion max, on l'applique directement :
    if abs(impulsion_ideale) <= impulsion_max:
        etat[PERSONNAGE][INERTIE].x += impulsion_ideale
    else :
        # sinon, on applique l'impulsion max :
        # que l'on met dans le bon sens évidemment
        if impulsion_ideale < 0 :
            impulsion_max = -impulsion_max
        etat[PERSONNAGE][INERTIE].x += impulsion_max


def deplacer_personnage(etat, dt):

    #L'impulsion est maintenant dans le moteur
    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])

    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][INERTIE])  * dt

def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    frottements_personnage(etat, dt)
    #on oublie pas d'appeler le moteur
    moteur_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

On essaie, et c'est LEEEEEENT. Parce qu'une telle force est beaucoup trop petite dans l'idée.

Il faut donc jouer avec les différentes valeurs : - Force du moteur - Coefficient frottement - Impulsion du dash

Pour arriver à une valeur correcte.

Personnellement, j'ai mis 3000 dans l'impulsion du dash, et 10000 en force du moteur, c'est NERVEUX mdrr

Petit refactoring

Quand on programme, il est crucial de garder un code propre, c'est à dire facile à comprendre.

On peut faire deux actions pour ça :

  • Réfléchir avant d'écrire le code pour qu'il soit propre. C'est la conception. Mais il y a toujours des défauts qu'on ne peut pas anticiper.
  • Faire le ménage dans le code régulièrement, pour corriger les défauts qu'on a repéré au fur et à mesure, c'est le refactoring.

Le code du jeu comporte a minima deux gros défauts qui peuvent être sources de confusion :

def creer_personnage():
    return {
        # ...
        VITESSE : 300
        INERTIE : Vector2(0, 0),
        # ...
    }

Activité : imaginons

Imaginons que quelqu'un qui n'a pas encore participé au projet lit votre code :

  • Quelqu'un a qui vous avez demandé de l'aide
  • Ou même le "vous" de dans quelques semaines quand vous reprendrez le code après une pause

Que comprendra cette personne en lisant les termes VITESSE et INERTIE ?

Ces définitions correspondent à l'usage qui en est fait dans le code ?

Allons chercher des définitions usuelles de vitesse et inertie. Wikipédia est une plutôt bonne source pour ça.

vitesse : la vitesse est une grandeur qui mesure, pour un mouvement, le rapport de la distance parcourue au temps écoulé.

inertie : En physique, l'inertie d'un corps est sa tendance à conserver sa vitesse.

Aie. En fait, ce qu'on a appelé inertie dans le code, c'est la vitesse du personnage. Et l'inertie se manifeste dans le fait que cette vitesse est conservée entre les frames.

Et ce qu'on a appelé vitesse dans le code, c'est pas exactement la vitesse du personnage, c'est sa vitesse de course.

On va donc changer ces termes dans le code

On va faire VITESSE -> VITESSE_COURSE, puis INERTIE -> VITESSE.

Activité

A votre avis, pourquoi c'est beaucoup plus facile de faire le changement dans cet ordre que dans l'autre ?

Si on change Inertie par Vitesse, on n'arrivera plus à différencier la vitesse (ex inertie) et la vitesse (qui doit devenir vitesse course)

En général votre éditeur a des outils qui aident au refactoring. Dans VSCode, vous pouvez faire :

  • Selectionner un mot
  • Clic droit sur la selection
  • Cliquer sur "Changer toutes les occurrences"
  • Selectionner les critères de restriction qui apparaissent en haut à droite de la fenêtre (mots uniquement et sensibilité à la casse)
  • Réécrire le mot que vous voulez changer.

Voici le code complet après refactoring:

CODE
import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE_COURSE = "VITESSE_COURSE"
VITESSE = "VITESSE"
SAUT = "SAUT"
DEBUT = "DEBUT"
FIN = "FIN"
PLATEFORMES = "PLATEFORMES"
POSITION_DEBUT_FRAME = "POSITION_DEBUT_FRAME"
DASH = "DASH"
COEF_FROTTEMENT = "COEF_FROTTEMENT"
FORCE_MOTEUR = "FORCE_MOTEUR"


def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE_COURSE : 300,
        VITESSE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        COEF_FROTTEMENT : 0.9,
        #La force du moteur
        FORCE_MOTEUR : 10000,
    }


def creer_plateforme(debut, fin, hauteur):
    return {
        DEBUT : Vector2(debut, hauteur),
        FIN : Vector2(fin, hauteur)
    }

def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage(),
        PLATEFORMES : [
            creer_plateforme(100, 300, 500),
            creer_plateforme(100, 300, 200),
            creer_plateforme(800, 900, 500),
            creer_plateforme(1000, 1100, 400),
            creer_plateforme(500, 700, 600),
        ]
    }


def entrees_joueur():

    quitter = False
    saut = False 
    dash = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

            if evt.key == pygame.K_LSHIFT:
                dash = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut,
        DASH : dash
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]


    if entrees[SAUT]:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][VITESSE].x, -500) 

    if entrees[DASH]:
        etat[PERSONNAGE][VITESSE] = Vector2( etat[PERSONNAGE][DIRECTION].x * 3000, etat[PERSONNAGE][VITESSE].y)


def gravite_personnage(etat, dt):

    etat[PERSONNAGE][VITESSE] += Vector2(0, 1000) * dt


def frottements_personnage(etat, dt):

    etat[PERSONNAGE][VITESSE].x = etat[PERSONNAGE][VITESSE].x * (1 - etat[PERSONNAGE][COEF_FROTTEMENT]) ** dt

    if abs(etat[PERSONNAGE][VITESSE].x) < 1 :
        etat[PERSONNAGE][VITESSE].x = 0


def moteur_personnage(etat, dt):

    impulsion_max = etat[PERSONNAGE][FORCE_MOTEUR] * dt


    vitesse_cible = etat[PERSONNAGE][DIRECTION].x * etat[PERSONNAGE][VITESSE_COURSE]


    impulsion_ideale = vitesse_cible - etat[PERSONNAGE][VITESSE].x

    if abs(impulsion_ideale) <= impulsion_max:
        etat[PERSONNAGE][VITESSE].x += impulsion_ideale
    else :
        if impulsion_ideale < 0 :
            impulsion_max = -impulsion_max
        etat[PERSONNAGE][VITESSE].x += impulsion_max

def deplacer_personnage(etat, dt):

    #L'impulsion est maintenant dans le moteur
    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])

    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE])  * dt


def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][VITESSE].y = 0

def projection_segment(axe, debut, fin):
    debut_proj = debut.project(axe)
    d = debut_proj.x / axe.x if axe.x != 0 else debut_proj.y / axe.y
    fin_proj = fin.project(axe)
    f = fin_proj.x / axe.x if axe.x != 0 else fin_proj.y / axe.y
    return (      
        min(d, f),
        max(d, f)
    )


def proj_superposees(axe, debut1, fin1, debut2, fin2):
    p1 = projection_segment(axe, debut1, fin1)
    p2 = projection_segment(axe, debut2, fin2)

    return not (p1[1] < p2[0] or p2[1] < p1[0])

def vec_ortho(vec):
    return vec.rotate(90).normalize()

def collision_plateforme(debut_p, fin_p, debut_m, fin_m):

    if (debut_m - fin_m).length() == 0:
        return False

    deplacement = fin_m - debut_m
    axes = [Vector2(0, 1), Vector2(1, 0), vec_ortho(deplacement)]
    for axe in axes:
        if not proj_superposees(axe, debut_p, fin_p, debut_m, fin_m):
            return False 
    return True

def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][VITESSE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(

                plateforme[DEBUT],
                plateforme[FIN],
                etat[PERSONNAGE][POSITION_DEBUT_FRAME],
                etat[PERSONNAGE][POSITION]
            ) :
                etat[PERSONNAGE][POSITION].y = plateforme[DEBUT].y
                etat[PERSONNAGE][VITESSE].y = 0


def simuler_frame(etat, dt):
    gravite_personnage(etat, dt)
    frottements_personnage(etat, dt)
    moteur_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

def afficher_personnage(etat):

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20),
        20 
    )

def afficher_plateformes(etat):

    for plateforme in etat[PLATEFORMES]:
        pygame.draw.rect(
            etat[ECRAN], 
            (128, 128, 128), 
            Rect(
                plateforme[DEBUT].x, 
                plateforme[DEBUT].y,
                plateforme[FIN].x - plateforme[DEBUT].x,
                10)
        )


def afficher_niveau(etat):

    etat[ECRAN].fill((0, 0, 0))

    afficher_plateformes(etat)

    afficher_personnage(etat)

    pygame.display.flip()  


def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()
niveau()

Boost

Maintenant que le dash est implémenté, reste le boost.

Le boost est simplement un autre saut qui annule la vitesse horizontale du personnage.

Activité

Ajoutez le boost dans le jeu, déclenché avec la touche espace, de code pygame.K_SPACE.

Il faut modifier :

  • La fonction de captage des entrées
  • la fonction d'application des entrées
BOOST = "BOOST" #on ajoute une clé "boost"

def entrees_joueur():

    quitter = False
    saut = False 
    dash = False 
    # On ajoute une valeur pour le boost
    boost = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

            if evt.key == pygame.K_LSHIFT:
                dash = True

            #Déclenché avec espace
            if evt.key == pygame.K_SPACE:
                boost = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut,
        DASH : dash,
        BOOST : boost #on retourne simplement
    }

def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]


    if entrees[SAUT]:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][VITESSE].x, -500) 

    if entrees[DASH]:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][DIRECTION].x * 3000, etat[PERSONNAGE][VITESSE].y)

    # Le boost annule la vitesse horizontale, mais pas la vitesse verticale
    # Et donne une impulsion vers le haut (rappel : l'axe y est inversé !)
    # La valeur ici est un peu arbitraire, j'ai choisi une valeur qui donne, en gros, la hauteur de 2 sauts.
    if entrees[BOOST]:
        etat[PERSONNAGE][VITESSE] = Vector2(0, etat[PERSONNAGE][VITESSE].y - 700)

Limites d'usage

Le but d'un jeu est de créer du défi. Or, pour le moment, il est trop simple, puisque le personnage peut sauter/dasher/booster à l'infini.

On va donc contraindre un peu l'utilisation du dash, du saut et du boost :

  • Le personnage peut sauter deux fois et dasher une fois. Il regagne ses sauts/dashs quand il touche le sol (ou une plateforme).
  • Le personnage peut booster une fois toutes les 6 secondes.

Ces valeurs sont pour le moment un peu arbitraires, vous pourrez les adapter par la suite.

Activité

Limitez le nombre de sauts à 2 et de dash à 1 entre deux contacts avec une plateforme/le sol.

Commencez par limiter seulement le dash et/ou le saut.

Ajoutez des compteurs dans les données de Personnage. Décrémentez-les quand le personnage fait une action, et redonnez-leur leurs valeurs de base quand il entre en contact avec le sol ou avec une plateforme.

Il vous faudra modifier :

  • Ajouter des clés en haut du fichier
  • creer_personnage
  • collision_sol
  • collision_plateformes
  • appliquer_entrees_joueur
# Deux nouvelles clés
SAUTS_RESTANTS = "SAUTS_RESTANTS"
DASHS_RESTANTS = "DASH_RESTANTS"


def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE_COURSE : 300,
        VITESSE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        COEF_FROTTEMENT : 0.9,
        FORCE_MOTEUR : 10000,
        # On donne les valeurs de base
        DASHS_RESTANTS : 1,
        SAUTS_RESTANTS : 2
    }

def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]

    # On saute seulement si on a des sauts restants 
    if entrees[SAUT] and etat[PERSONNAGE][SAUTS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][VITESSE].x, -500) 
        etat[PERSONNAGE][SAUTS_RESTANTS] -= 1 #on diminue le nombre de sauts restants

    # On dash seulement si on a des dash restants
    if entrees[DASH] and etat[PERSONNAGE][DASHS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][DIRECTION].x * 3000, etat[PERSONNAGE][VITESSE].y)
        etat[PERSONNAGE][DASHS_RESTANTS] -= 1 # on diminue le nombre de dashs restants

    if entrees[BOOST]:
        etat[PERSONNAGE][VITESSE] = Vector2(0, etat[PERSONNAGE][VITESSE].y - 700)

def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][VITESSE].y = 0

        # En cas de collision avec le sol, on réinitialise le dash et le saut
        etat[PERSONNAGE][SAUTS_RESTANTS] = 2
        etat[PERSONNAGE][DASHS_RESTANTS] = 1


def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][VITESSE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(

                plateforme[DEBUT],
                plateforme[FIN],
                etat[PERSONNAGE][POSITION_DEBUT_FRAME],
                etat[PERSONNAGE][POSITION]
            ) :
                etat[PERSONNAGE][POSITION].y = plateforme[DEBUT].y
                etat[PERSONNAGE][VITESSE].y = 0

                # En cas de collision avec une plateforme, on réinitialise le dash et le saut
                etat[PERSONNAGE][SAUTS_RESTANTS] = 2
                etat[PERSONNAGE][DASHS_RESTANTS] = 1

Maintenant ajoutons un délais minimum entre deux utilisations du boost. Dans le monde du jeu vidéo, on appelle ça un temps de relance ou cooldown.

Activité

Ajoutez un cooldown de 6 secondes pour le boost.

Le principe est la suivant :

Ajoutez une valeur COOLDOWN_BOOST aux données du personnage

De base, sa valeur est 0. Quand la valeur est à 0, le personnage peut utiliser le boost.

Si le personnage utilise le boost, la valeur du cooldown passe à 6.

Chaque frame : - Si le cooldown est supérieur à 0, il est décrémenté du dt de la frame. Si il passe sous 0, il est fixé à 0. - si le cooldown est 0, on ne fait rien.

Je vous conseille de créer une nouvelle fonction

def cooldown_personnage(etat, dt):
Qui sera appelée par simuler_frame.

#nouvelle clé
COOLDOWN_BOOST = "COOLDOWN_BOOST"

# On l'ajoute au personnage
def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE_COURSE : 300,
        VITESSE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        COEF_FROTTEMENT : 0.9,
        FORCE_MOTEUR : 10000,
        DASHS_RESTANTS : 1,
        SAUTS_RESTANTS : 2,
        # Ici, valeur de base 0
        COOLDOWN_BOOST : 0
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]


    if entrees[SAUT] and etat[PERSONNAGE][SAUTS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][VITESSE].x, -500) 
        etat[PERSONNAGE][SAUTS_RESTANTS] -= 1

    if entrees[DASH] and etat[PERSONNAGE][DASHS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][DIRECTION].x * 3000, etat[PERSONNAGE][VITESSE].y)
        etat[PERSONNAGE][DASHS_RESTANTS] -= 1

    # On peut booster que si le cooldown est inférieur
    # ou égal a 0 (le inférieur n'est nécessaire a priori
    # mais tant qu'a faire autant le rajouter)
    if entrees[BOOST] and etat[PERSONNAGE][COOLDOWN_BOOST] <= 0:
        etat[PERSONNAGE][VITESSE] = Vector2(0, etat[PERSONNAGE][VITESSE].y - 700)
        # Quand on booste, on met le cooldown a 6
        etat[PERSONNAGE][COOLDOWN_BOOST] = 6.0

#fonction pour gérer le cooldown
def cooldowns_personnage(etat, dt):
    # si le cooldown est sup a 0, on diminue
    if etat[PERSONNAGE][COOLDOWN_BOOST] > 0:
        etat[PERSONNAGE][COOLDOWN_BOOST] -= dt
    # si il est inférieur on le met a 0
    # c'est pas un elif mais un autre if,
    # parce qu'il doit vérifier après avoir 
    # décrémenté
    if etat[PERSONNAGE][COOLDOWN_BOOST] < 0:
        etat[PERSONNAGE][COOLDOWN_BOOST] = 0.0

def simuler_frame(etat, dt):
    # On met a jour le cooldown a chaque frame
    cooldowns_personnage(etat, dt)
    gravite_personnage(etat, dt)
    frottements_personnage(etat, dt)
    moteur_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

Au cas où, le code complet du jeu se trouve dans le dépliant ci-dessous :

Code Complet
import pygame
from pygame import *

ECRAN = "ECRAN"
QUITTER = "QUITTER"
PERSONNAGE = "PERSONNAGE"
POSITION = "POSITION"
DIRECTION = "DIRECTION"
VITESSE_COURSE = "VITESSE_COURSE"
VITESSE = "VITESSE"
SAUT = "SAUT"
DEBUT = "DEBUT"
FIN = "FIN"
PLATEFORMES = "PLATEFORMES"
POSITION_DEBUT_FRAME = "POSITION_DEBUT_FRAME"
DASH = "DASH"
COEF_FROTTEMENT = "COEF_FROTTEMENT"
FORCE_MOTEUR = "FORCE_MOTEUR"
BOOST = "BOOST"
SAUTS_RESTANTS = "SAUTS_RESTANTS"
DASHS_RESTANTS = "DASH_RESTANTS"
COOLDOWN_BOOST = "COOLDOWN_BOOST"


def creer_personnage():
    return {
        POSITION : Vector2(1280/2, 720/2),
        DIRECTION : Vector2(0, 0),
        VITESSE_COURSE : 300,
        VITESSE : Vector2(0, 0),
        POSITION_DEBUT_FRAME : Vector2(0,0),
        COEF_FROTTEMENT : 0.9,
        FORCE_MOTEUR : 10000,
        DASHS_RESTANTS : 1,
        SAUTS_RESTANTS : 2,
        COOLDOWN_BOOST : 0
    }


def creer_plateforme(debut, fin, hauteur):
    return {
        DEBUT : Vector2(debut, hauteur),
        FIN : Vector2(fin, hauteur)
    }

def initialiser_niveau():

    pygame.init()

    ecran = pygame.display.set_mode([1280, 720])

    return {
        ECRAN : ecran, 
        PERSONNAGE : creer_personnage(),
        PLATEFORMES : [
            creer_plateforme(100, 300, 500),
            creer_plateforme(100, 300, 200),
            creer_plateforme(800, 900, 500),
            creer_plateforme(1000, 1100, 400),
            creer_plateforme(500, 700, 600),
        ]
    }


def entrees_joueur():

    quitter = False
    saut = False 
    dash = False 
    boost = False 

    for evt in pygame.event.get():
        if evt.type == pygame.KEYDOWN:
            if evt.key == pygame.K_ESCAPE:
                quitter = True

            if evt.key == pygame.K_UP:
                saut = True

            if evt.key == pygame.K_LSHIFT:
                dash = True

            if evt.key == pygame.K_SPACE:
                boost = True

    touches = pygame.key.get_pressed()

    direction = Vector2(0, 0)

    if touches[pygame.K_RIGHT]:
        direction.x += 1.0
    if touches[pygame.K_LEFT]:
        direction.x -= 1.0

    if direction.length() > 0:
        direction = direction.normalize()


    return {
        QUITTER : quitter,
        DIRECTION : direction,
        SAUT : saut,
        DASH : dash,
        BOOST : boost
    }


def appliquer_entrees_joueur(etat, entrees):
    etat[PERSONNAGE][DIRECTION] = entrees[DIRECTION]


    if entrees[SAUT] and etat[PERSONNAGE][SAUTS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][VITESSE].x, -500) 
        etat[PERSONNAGE][SAUTS_RESTANTS] -= 1

    if entrees[DASH] and etat[PERSONNAGE][DASHS_RESTANTS] > 0:
        etat[PERSONNAGE][VITESSE] = Vector2(etat[PERSONNAGE][DIRECTION].x * 3000, etat[PERSONNAGE][VITESSE].y)
        etat[PERSONNAGE][DASHS_RESTANTS] -= 1

    if entrees[BOOST] and etat[PERSONNAGE][COOLDOWN_BOOST] <= 0:
        etat[PERSONNAGE][VITESSE] = Vector2(0, etat[PERSONNAGE][VITESSE].y - 700)
        etat[PERSONNAGE][COOLDOWN_BOOST] = 6.0

def gravite_personnage(etat, dt):

    etat[PERSONNAGE][VITESSE] += Vector2(0, 1000) * dt


def frottements_personnage(etat, dt):

    etat[PERSONNAGE][VITESSE].x = etat[PERSONNAGE][VITESSE].x * (1 - etat[PERSONNAGE][COEF_FROTTEMENT]) ** dt

    if abs(etat[PERSONNAGE][VITESSE].x) < 1 :
        etat[PERSONNAGE][VITESSE].x = 0


def moteur_personnage(etat, dt):

    impulsion_max = etat[PERSONNAGE][FORCE_MOTEUR] * dt


    vitesse_cible = etat[PERSONNAGE][DIRECTION].x * etat[PERSONNAGE][VITESSE_COURSE]


    impulsion_ideale = vitesse_cible - etat[PERSONNAGE][VITESSE].x

    if abs(impulsion_ideale) <= impulsion_max:
        etat[PERSONNAGE][VITESSE].x += impulsion_ideale
    else :
        if impulsion_ideale < 0 :
            impulsion_max = -impulsion_max
        etat[PERSONNAGE][VITESSE].x += impulsion_max

def deplacer_personnage(etat, dt):

    #L'impulsion est maintenant dans le moteur
    etat[PERSONNAGE][POSITION_DEBUT_FRAME] = Vector2(etat[PERSONNAGE][POSITION])

    etat[PERSONNAGE][POSITION] += (etat[PERSONNAGE][VITESSE])  * dt


def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):

        etat[PERSONNAGE][POSITION].y = 720
        etat[PERSONNAGE][VITESSE].y = 0

        etat[PERSONNAGE][SAUTS_RESTANTS] = 2
        etat[PERSONNAGE][DASHS_RESTANTS] = 1

def projection_segment(axe, debut, fin):
    debut_proj = debut.project(axe)
    d = debut_proj.x / axe.x if axe.x != 0 else debut_proj.y / axe.y
    fin_proj = fin.project(axe)
    f = fin_proj.x / axe.x if axe.x != 0 else fin_proj.y / axe.y
    return (      
        min(d, f),
        max(d, f)
    )


def proj_superposees(axe, debut1, fin1, debut2, fin2):
    p1 = projection_segment(axe, debut1, fin1)
    p2 = projection_segment(axe, debut2, fin2)

    return not (p1[1] < p2[0] or p2[1] < p1[0])

def vec_ortho(vec):
    return vec.rotate(90).normalize()

def collision_plateforme(debut_p, fin_p, debut_m, fin_m):

    if (debut_m - fin_m).length() == 0:
        return False

    deplacement = fin_m - debut_m
    axes = [Vector2(0, 1), Vector2(1, 0), vec_ortho(deplacement)]
    for axe in axes:
        if not proj_superposees(axe, debut_p, fin_p, debut_m, fin_m):
            return False 
    return True

def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][VITESSE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(

                plateforme[DEBUT],
                plateforme[FIN],
                etat[PERSONNAGE][POSITION_DEBUT_FRAME],
                etat[PERSONNAGE][POSITION]
            ) :
                etat[PERSONNAGE][POSITION].y = plateforme[DEBUT].y
                etat[PERSONNAGE][VITESSE].y = 0

                etat[PERSONNAGE][SAUTS_RESTANTS] = 2
                etat[PERSONNAGE][DASHS_RESTANTS] = 1

def cooldowns_personnage(etat, dt):
    if etat[PERSONNAGE][COOLDOWN_BOOST] > 0:
        etat[PERSONNAGE][COOLDOWN_BOOST] -= dt

    if etat[PERSONNAGE][COOLDOWN_BOOST] < 0:
        etat[PERSONNAGE][COOLDOWN_BOOST] = 0.0

def simuler_frame(etat, dt):
    cooldowns_personnage(etat, dt)
    gravite_personnage(etat, dt)
    frottements_personnage(etat, dt)
    moteur_personnage(etat, dt)
    deplacer_personnage(etat, dt)
    collision_plateformes(etat, dt)
    collision_sol(etat, dt)

def afficher_personnage(etat):

    pygame.draw.circle(
        etat[ECRAN],
        (196, 154, 214),
        etat[PERSONNAGE][POSITION] + Vector2(0, -20),
        20 
    )

def afficher_plateformes(etat):

    for plateforme in etat[PLATEFORMES]:
        pygame.draw.rect(
            etat[ECRAN], 
            (128, 128, 128), 
            Rect(
                plateforme[DEBUT].x, 
                plateforme[DEBUT].y,
                plateforme[FIN].x - plateforme[DEBUT].x,
                10)
        )


def afficher_niveau(etat):

    etat[ECRAN].fill((0, 0, 0))

    afficher_plateformes(etat)

    afficher_personnage(etat)

    pygame.display.flip()  


def niveau():
    etat = initialiser_niveau()

    horloge = pygame.time.Clock()

    stop = False
    dt = 0.016
    while not stop:
        entrees = entrees_joueur()
        if entrees[QUITTER] :
            stop = True

        appliquer_entrees_joueur(etat, entrees)


        simuler_frame(etat, dt)

        afficher_niveau(etat)

        dt = horloge.tick(60) / 1000 

    pygame.quit()
niveau()

Il ne nous reste plus beaucoup de changements à faire avant d'avoir un jeu jouable :

  • Donner un peu plus d'information au joueur/joueuse sur le cooldown et le nombre de sauts etc
  • Permettre des niveaux verticalement plus grands
  • Charger un niveau depuis un fichier.

Mais avant ça, il faut faire un peu de ménage !

Refactoring et redondance et magie

Globalement, le code a un défaut que l'on se traine depuis un moment, et qui risque de poser problème par la suite : la redondance.

La redondance dans le code, c'est quand plusieurs bouts de code remplissent un même rôle.

Activité

Essayez de repérer dans le code du jeu des éléments redondants :

  • Deux fois la même valeur
  • Deux bouts de code qui font la même chose
  • ...

Et expliquez pourquoi ça pose problème.

Par exemple, dans la gestion du nombre de sauts. Pas le concept, la valeur 2 du nombre de sauts :

Elle apparaît à plusieurs endroits :

def creer_personnage():
    return {
        # ...
        SAUTS_RESTANTS : 2,
    }

def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):
        #...
        etat[PERSONNAGE][SAUTS_RESTANTS] = 2

def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][VITESSE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(
                # ...
            ) :
                # ...
                etat[PERSONNAGE][SAUTS_RESTANTS] = 2

Ici, la valeur est redondante, elle apparaît à plusieurs endroits.

Le problème que ce genre de redondance pose, c'est que si on veut modifier le nombre de sauts, on doit le modifier a plein d'endroits différents, et on a des risques d'oublier un endroit.

Proposez une manière de résoudre le problème présenté en solution de la partie 1 : on veut que la valeur du nombre de sauts soit à un seul endroit dans le code.

Faites le changement.

On a globalement deux choix :

Soit on stocke la valeur dans une variable (a vrai dire une constante) en haut du fichier.

Soit on stocke la valeur dans l'état du personnage.

Personellement, je choisis pour l'instant la première option, parce que ça laisse le code un peu plus libre.

# Je fais précéder de `CFG` pour "ConFiGuration"
CFG_MAX_SAUTS = 2

def creer_personnage():
    return {
        # ...
        SAUTS_RESTANTS : CFG_MAX_SAUTS,
    }

def collision_sol(etat, dt):
    if etat[PERSONNAGE][POSITION].y > (720):
        #...
        etat[PERSONNAGE][SAUTS_RESTANTS] = CFG_MAX_SAUTS

def collision_plateformes(etat, dt):


    if etat[PERSONNAGE][VITESSE].y > 0:

        for plateforme in etat[PLATEFORMES]:
            if collision_plateforme(
                # ...
            ) :
                # ...
                etat[PERSONNAGE][SAUTS_RESTANTS] = CFG_MAX_SAUTS

D'une manière générale, c'est une bonne pratique de TOUJOURS ou presque mettre préférer utiliser des constantes ou variables plutôt que des valeurs littérales (c'est à dire écrites directement dans le code)

Ca permet d'éviter la redondance, mais aussi la création de valeurs magiques. Une valeur magique, c'est une valeur littérale qui a un sens obscur. Par exemple que représente 720 dans le code suivant ?

if etat[PERSONNAGE][POSITION].y > (720):
Réponse

la hauteur de la fenêtre en pixels !

C'était pas évident du tout à se rappeler.

A la place, on voudrait plutôt avoir :

if etat[PERSONNAGE][POSITION].y > (hauteur_fenetre):

# ou 

if etat[PERSONNAGE][POSITION].y > (CFG_HAUTEUR_FENETRE):

Activité : attrapez les tous

Essayez de lister toutes les valeurs redondantes ou magiques dans le code, et faites en des constantes.

TODO CONTINUER ICI

HUD

Caméra et défilement

Deboggage