Aller au contenu

Un exemple de projet adapté en \(1^{re}\) NSI⚓︎

Problématique⚓︎

Si on met de côté les problèmes de temps pour faire faire un projet aux élèves, une des difficultés réside dans le réglage entre un projet trop guidé où l'élève n'aura pas grand chose à faire et un projet trop peu guidé avec le risque d'un échec.

La première étape est le choix du sujet avec la question : qui choisit le projet ? Là, on peut imaginer un petit ensemble de sujets proposés par l'enseignant•e et laisser aussi aux élèves la possibilité de proposer leur propre projet. Dans ce deuxième cas, il faudra quand même veiller à ce que le projet ne soit pas déraisonnable.

La deuxième étape est le choix de l'interface : avec ou sans interface graphique ? Dans une très très grande majorité des cas les élèves sont plus attirés vers des interfaces graphiques, même pour des projets qui pourraient très bien s'en passer, simplement parce qu'iels estiment le projet plus abouti ainsi.

Puissance 4 : un jeu simple⚓︎

Dans cet article nous décrivons en détail la réalisation guidée d'un jeu simple, ultra connu qui peut être codé en utilisant une interface graphique même avec des élèves de \(1^{re}\).

Nous sommes partis du projet réalisé par des élèves à l'aide du gui pygame1. Cette outil est très complet, fait pour développer des jeux de toute sorte. Mais, il peut s'avérer trop compliqué pour des débutants. Certain•e•s enseignant•e•s lui préfère d'autres solutions : le module turtle, pysimplegui plutôt réservée à des applications autres que des jeux ou encore le tout nouveau pyxel.

Néanmoins, si le jeu n'est pas trop compliqué graphiquement (peu ou pas d'éléments mobiles, pas de lois de physique à modéliser, de collisions diverses etc.) alors pygame est parfaitement utilisable, même en \(1^{re}\), même sans utiliser de POO. La partie algorithmique la plus difficile est la recherche d'un alignement de \(4\) jetons de même couleur. Mais nous verrons cela plus en détail dans la suite. En réalité le plus difficile est surtout de modéliser correctement le problème en veillant à bien séparer la partie modèle (celle qui fait les calculs comme trouver si les jetons sont alignés, si la grille est pleine, si une colonne est pleine etc.) de la partie qui génère les affichages et de celle qui orchestre tout ça et fait la liaison avec l'utilisateur. Pour la partie graphique, nous avons récupéré certains éléments (la plupart des images) du projet élève qui a inspiré cet article.

Séparer les choses : la clé pour ne pas avoir un code spaghetti⚓︎

Sans formaliser le concept de design pattern, de MVC etc. il est important dès les premiers projets en \(1^{re}\) d'apprendre à distinguer les trois aspects d'un programme :

  • la partie affichage : son rôle est clair
  • le modèle du problème : qui gère les structures pour modéliser le problème et qui code les différentes actions du jeu
  • le chef d'orchestre ou contrôleur : qui traite les interactions de l'utilisateur, et donne des ordre aux 2 autres composantes ; en général pour un projet de petite taille, il s'agit du programme principal, et de la boucle principale de jeu

mvc

Au-delà de l'aspect design pattern qui, nous le répétons, n'est pas le plus important pour des projets réalisés en classe de Première ou même de Terminale, découper le projet de cette façon va permettre de préciser les parties où les élèves vont intervenir principalement.

Nous allons détailler chacune des parties, en commençant par le modèle, là où il y aura, pour les élèves, le plus de travail demandé.

Un modèle pour le Puissance 4⚓︎

Une première question pourrait être de faire trouver par les élèves comment modéliser le jeu. Ainsi, prenons cette configuration, rencontrée après quelques coups joués :

Une configuration de Puissance 4

visuel puissance 4

Quelle(s) structure(s) de données utiliser en Python ? Il faudra rapidement valider ses structures. Une solution possible est une matrice d'entiers associées à un tableau. En reprenant la configuration de l'exemple précédent, voici notre modélisation :

Modélisation de l'exemple

La matrice :

[[0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 1, 0, 0], 
 [0, 0, 0, 0, 1, 0, 0], 
 [0, 0, 0, 1, 2, 2, 0]]

Le tableau :

[5, 5, 5, 4, 2, 4, 5]

La grille est donc modélisée par un tableau de \(6\) tableaux en Python. Chaque tableau contient un entier parmi les trois valeurs possibles : \(0, 1\) ou \(2\).

Pourquoi une matrice d'entiers ?⚓︎

On pourrait être tenté de modéliser des jetons rouge et jaune par des chaines de caractères : 'rouge', 'jaune' ou même 'R' et 'J'. Il n'y a aucune raison de procéder ainsi : les chaines de caractères sont, en générales, plus lourdes et moins polyvalentes que les entiers. Par exemple, les entiers peuvent servir d'indices de tableau.

Si on se sert de ces entiers pour le numéro du joueur alors le changement de joueur se fait par une simple soustraction : joueur = 3 - joueur permet de passer de \(1\) à \(2\) et vice versa.

Et un tableau pour les sommets⚓︎

La matrice d'entier n'est pas le seul élément de la modélisation d'une grille de puissance 4. En effet, la méthode remplissage de cette grille est un peu particulière : en réalité ce sont les colonnes vues comme des piles qui nous intéresse. Ainsi, étant donné un numéro de colonne, il faudra empiler la valeur c'est-à-dire déterminer sur quelle ligne on va mettre le \(1\) ou le \(2\).

Un remplissage particulier

Si nous reprenons la grille de notre exemple précédent :

[[0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 0, 0, 0], 
 [0, 0, 0, 0, 1, 0, 0], 
 [0, 0, 0, 0, 1, 0, 0], 
 [0, 0, 0, 1, 2, 2, 0]]

Si jaune (\(2\)) joue sur la colonne \(6\) qui est la dernière, encore vide (attention la première colonne porte le numéro \(0\)) alors la ligne à remplir est la dernière soit la \(5\). Par contre en choisissant la colonne \(4\), c'est la ligne \(2\) qui sera concernée.

En plus de la matrice, un tableau de \(7\) entiers va permettre de mémoriser pour chacune des colonnes la prochaine ligne à utiliser. Ce sont en quelques sorte les indices des sommets de piles qu'on initialisera ainsi : sommets = [5] * 7. Et dans notre exemple, les colonnes \(3\), \(4\) et \(5\) contiennent déjà des jetons et les numéros de lignes jouables sont respectivement \(4\), \(2\) et \(4\).

Les premières questions⚓︎

À partir de ce modèle définit complètement au niveau des structures de données, on peut guider l'élève vers le travail à faire pour compléter ce modèle des fonctions nécessaires :

  1. Écrire un début de programme principal qui crée une grille vide et un tableau sommets initialisé correctement.
  2. Écrire une fonction est_valide qui prend un numéro de colonne en paramètre et renvoie True s'il s'agit d'une colonne jouable compte tenu des informations de grille et sommets.
  3. Écrire une fonction joue qui prend un numéro de colonne valide, un numéro de joueur, qui joue le coup correspondant et renvoie le numéro de la ligne qui a été concernée
  4. Écrire une fonction grille_pleine qui renvoie True si grille est pleine, False sinon

La partie algorithmique du projet⚓︎

La fonction qui demande un peu de réflexion est celle qui va tester l'alignement de quatre jetons. Une méthode consiste à parcourir les cases de la grille de haut en bas et de gauche à droite pour tester si dans une des quatre directions Est, Sud-Est, Sud et Sud-Ouest on trouve quatre valeurs \(1\) ou \(2\).

Le graphique ci-dessous montre les quatre directions à partir d'une case. Et pourquoi on n'a besoin de considérer que quatre directions. En effet, si on considère la case verte, on constate que considérer la direction OUEST n'a pas de sens puisque si jamais un alignement de quatre existe alors, cet alignement aura été découvert lors que parcours de la case bleue dans la direction EST.

directions

Avant de pouvoir coder les fonctions, il nous faut modéliser cette notion de direction. Comme nous l'avons vu sur le schéma précédent, lorsqu'on a un case \((i, j)\) la case \((i, j + 1)\) qui se trouve donc sur la même ligne mais une colonne plus loin, plus à l'Est. Les directions peuvent donc être modélisées par un vecteur unité, un couple de la forme \((u, v)\)\(u\) et \(v\) valent \(-1, 0\) ou \(1\).

Et on termine les questions du modèle :

  1. Écrire une fonction quatre_alignes qui prend un couple (i, j) et qui renvoie True si dans l'une des quatre direction Est, Sud-Est, Sud et Sud-Ouest on trouve quatre valeurs identiques non nulles à partir de \((i, j)\).
  2. Écrire pour cela une fonction quatre_alignes_en_direction qui prend un couple \((i, j)\) et une direction \(d\) et renvoie True quatre valeurs identiques et non nulles sont alignées dans la direction \(d\).
  3. Écrire une fonction un_gagnant qui renvoie True si la grille possède un alignement de quatre valeurs identiques non nulles.

La partie graphique⚓︎

Elle repose, pour ce jeu, essentiellement sur des affichages d'images dans la fenêtre graphique. Le module pygame s'installe via pip install pygame. Puis il faut charger le module et l'initialiser, ainsi que les sous-modules :

Charger pygame
import pygame

pygame.init()

Création de la fenêtre graphique⚓︎

La fenêtre de jeu

Là, les élèves ne peuvent pas inventer les choses, en fonction du temps qu'ils auront à consacrer au projet, on peut leur donner l'essentiel. Par exemple, la création de la fenêtre graphique dans laquelle tout va se dérouler :

fenetre = pygame.display.set_mode((LARGEUR, HAUTEUR))

LARGEUR et HAUTEUR sont des constantes que nous conseillons de définir dans un fichier à part (il faudra il mettre toutes les constantes et importer ce fichier via l'instruction from constantes import *):

constantes.py
LARGEUR = 700
HAUTEUR = 600

Chargement de toutes les images⚓︎

Voici les images du jeu :

menu

Ces deux boutons seront placés au bons endroits sur l'image précédente

menu

menu

menu

menu menu

Ces images, il faut les charger via pygame avant de pouvoir les coller dans la fenêtre graphique. Cette action peut se faire au début du programme principal, une fois pour toute, pour ensuite utiliser les images chargées dans le reste du code.

Voici, avec l'image du menu d'accueil, le code pour charger l'image :

Charger une image avec pygame
IMG_ACCUEIL = pygame.image.load('images/accueil.png').convert()

Le convert peut être remplacé par un convert_alpha lorsque l'image contient des zones transparentes. C'est le cas des images des pions. Voici le code pour charger les \(2\) images pions en utilisant un dictionnaire (avec comme clé les identifiants joueur \(1\) et \(2\)) :

IMG_PAWN = {RED_ID: pygame.image.load('images/pion_rouge.png').convert_alpha(),
            YELLOW_ID: pygame.image.load('images/pion_jaune.png').convert_alpha()}

Et on peut poser une nouvelle question. Sachant, que les images restantes sont :

  • les boutons 'images/bouton_quitter.png', 'images/bouton_jouer.png'
  • la grille vierges 'images/grille.png'
  • les textes de fin de partie : 'images/texte_partie_nulle.png', 'images/texte_rouge_gagne.png', 'images/texte_jaune_gagne.png'

Écrire les premières instructions du programme principal pour charger toutes les images.

Une fois les images chargées, il faudra, le moment voulu les plaquer dans la fenêtre :

Coller une image avec pygame
fenetre.blit(IMG_ACCUEIL, (0, 0))
pygame.display.flip()

Dans ce code, fenetre, souvenez-vous est notre fenêtre graphique, la méthode blit va donc peindre notre image dans la fenêtre. Le couple en deuxième argument est la position du coin supérieur gauche de l'image dans la fenêtre. Ensuite, il faut rafraichir l'affichage, c'est le rôle de la deuxième instruction.

Pour les boutons jouer et quitter il faut comprendre que blit renvoie un objet de type Surface qui sera exploitée plus tard pour détecter un clic utilisateur :

Le cas des boutons
btn_play = screen.blit(IMG_PLAY, POSITION_PLAY)
btn_quit = screen.blit(IMG_QUIT, POSITION_QUIT)
pygame.display.flip()

POSITION_PLAY et POSITION_QUIT sont les couples de coordonnées pour positionner correctement les deux boutons. Ces couples seront donnés et valent \((90, 145)\) et \((93, 347)\) respectivement.

Le maitre d'orchestre ou contrôleur⚓︎

Le contrôleur c'est le programme principal et la fonction qui va traiter les interactions (qu'on appelle souvent loop). Voici un squelette de contrôleur :

Le contrôleur
# -- LES FONCTIONS DU CONTROLEUR
# --

def loop(mode):
    # à compléter plus tard


# -- PROGRAMME PRINCIPAL
# -- 

# Initialisation de la fenêtre graphique
#
pygame.init()
fenetre = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption(TITLE_GAME)

# Chargement des images du jeu (voir le chapitre sur l'interface graphique)
#
IMG_ACCUEIL = pygame.image.load('images/accueil.png').convert()

# ici à compléter avec les réponses aux questions de la partie graphique


# les boutons jouer et quitter
# (blit une image va créer une surface qu'on exploitera ici pour savoir si le bouton a été cliqué)
#
btn_play = screen.blit(IMG_PLAY, POSITION_PLAY)
btn_quit = screen.blit(IMG_QUIT, POSITION_QUIT)

# le modèle de grille pour le puissance 4
# 
grid = [[NOBODY] * 7 for _ in range(6)]
tops = [5] * 7

# -- Affichage du menu et Lancement du jeu 
menu_mode()
loop(MENU_MODE)

La boucle principale⚓︎

Il s'agit de la fonction loop qui, fondamentalement, réalise la boucle suivante :

Tant que le jeu n'est pas fini, faire...

Et, ce qu'il y a à faire va dépendre de où on en est dans le jeu. Généralement, on distingue a minima \(3\) phases ou modes :

  1. écran d'accueil ou de réglages : c'est là qu'on peut avoir un menu pour choisir par exemple qui joue les rouges, ou le niveau de difficulté, si on joue seul ou contre un deuxième joueur humain etc. ; on peut identifier ce mode par une simple contante entière : MENU_MODE = 0 ; ce mode présente aussi le bouton pour pouvoir quitter le jeu ;
  2. mode partie : la grille est affichée, une partie est en cours ; PLAY_MODE = 1 ;
  3. enfin la partie est terminée, on est en !#py ENDGAME_MODE = 2, l'écran présente les résultats, à la suite de ce mode on repassera probablement en MENU_MODE.

Cette boucle doit évidemment récupérer les interactions de l'utilisateur et là, il faut fournir le code aux élèves qui ne peuvent pas l'inventer.

Récupérer les clics utilisateurs

Pour récupérer les évènements avec pygame, on utilise une boucle for :

for event in pygame.event.get():
    # ici le traitement

L'objet event possède notamment un type qui permet de savoir s'il s'agit d'un clic souris, d'un clic-droit, d'une touche clavier enfoncée, ou relâchée etc. La constante du module pygame qui correspond à l'évènement clic souris est : pygame.MOUSEBUTTONDOWN. L'évènement qui correspond au clic sur le bouton de fermeture de la fenêtre graphique est pygame.QUIT. Enfin, pygame.quit() permet de quitter l'application graphique. Voici le squelette du traitement :

for event in pygame.event.get():
    if event.type == pygame.MOUSEBUTTONDOWN:
        # ici le traitement 

    elif event.type == pygame.QUIT:
        # ici le traitement avant de quitter
    pygame.quit()

Les trois modes⚓︎

Le menu⚓︎

La partie⚓︎

Les résultats⚓︎

Voici donc un squelette de la fonction loop :

Squelette de la fonction loop
def loop(mode):
    """Boucle de jeu : 3 modes possibles ;
    - menu mode : on ne peut que lancer une partie ou quitter
    - play mode : une partie en cours, on clique pour jouer son pion dans une colonne
    - endgame mode : partie terminée, on voit le résultat ; tout clic ramène en menu mode
    """
    player = RED_ID  # Rouge commence
    quit = False
    while not quit:
        for event in pygame.event.get():
            if event.type == pygame.MOUSEBUTTONDOWN:
                if mode == MENU_MODE:
                    # ici le code pour faire afficher l'écran d'accueil

                elif mode == PLAY_MODE:
                    # ici les instructions pour le déroulement d'une partie

                elif mode == ENDGAME_MODE:
                    # ici les instructions pour afficher les résultats

            elif event.type == pygame.QUIT:
                mode = ENDGAME_MODE
                quit = True
    pygame.quit()

  1. Le site officiel propose une doc mais nous conseillons d'utiliser plutôt celle de devdocs.io très sobre et ergonomique.