Skip to content

Projet Château

Ce projet de jeu type escape game va nous permettre d'aborder divers aspects de la programmation, en une application de taille raisonnable. Nous verrons la conception et l'implémentation orientée objet. L'interface graphique sera rudimentaire et utilisera le module Turtle.

Le jeu

Enfermé dans un château, représentant un labyrinthe de succession de pièces, un héros (vous) doit se frayer un chemin vers la sortie. Des portes verrouillées vous bloqueront souvent le passage et vous ne pourrez les ouvrir qu'en répondant correctement à des questions.

Heureusement, à divers endroits dans le château vous pourrez ramasser des indices, qui aident à répondre aux énigmes.

La mécanique

Elle est simple : le jeu se passe dans une fenêtre graphique Turtle, le héros est matérialisé par une petite balle rouge que vous dirigez via les flèches du clavier. Les questions sont déclenchées lorsqu'on passe à certains endroits (repérés en orange sur l'animation ci-dessous) et affichées par un système de popup du module Turtle.

jeu

Analyse

Une première analyse permet de mettre en avant les différents acteurs de notre futur programme. Ces acteurs deviendront des objets au sens de la POO.

En partant du jeu, nous pouvons identifier sans mal :

  • un château
  • un héros

En ayant un peu l'habitude des jeux d'aventure ou alors de la programmation objet, on sent qu'il va falloir :

  • un maître de jeu

C'est le maître de jeu qui fait l'interaction avec l'utilisateur. En règle général, on limite au strict minimum les endroits du programme qui ont des interactions avec l'utilisateur.

Les tortues du jeu

Lorsqu'on connaît un peu le module Turtle, on peut tout de suite décider du nombre de tortues qu'on utilisera : la tortue se déplace, et permet d'écrire à la position où elle se trouve. À chaque déplacement il faut donc calculer l'endroit. Moralité : moins une tortue se déplace mieux c'est. On constate que :

  • le héros bouge beaucoup, il sera plus simple de lui dédier une tortue ;
  • des messages sont régulièrement affichés en haut de l'écran, il sera donc plus simple d'y consacrer une tortue qui ne fera que ça, sans jamais bouger ;
  • les indices trouvés sont affichés à droite, là aussi plutôt que de déplacer régulièrement une tortue, on va en réserver une à cette tâche ; après l'affichage d'un indice, elle n'aura qu'à descendre de quelques pixels pour le suivant ;
  • ces 2 tortues seront créées et gérées par le maître de jeu qui s'occupera aussi de l'écran qui fera la gestion des événements et les interactions avec l'utilisateur (poser les questions et récupérer les réponses)
  • le château devra se dessiner et mettre à jour les cellules visitées, il aura aussi sa propre tortue.

Au total ce sont donc quatre tortues et un écran qui seront consacrés à l'interface graphique.

Le modèle du château

Maintenant que nous savons qui va gérer les tortues, nous pouvons détailler le modèle du château. Il est constitué, on le voit, de petites cellules carrées de différents types :

  • des cellules vides (en blanc sur l'animation)
  • des murs (en gris)
  • des portes (en orange lorsqu'elles sont verrouillées et qui semblent disparaître ensuite)
  • des objets (en vert, eux aussi disparaissent une fois visités)
  • des cellules visitées qui sont en corail.

Nous avons donc identifié un quatrième objet : la cellule. Le chateau aura donc une liste de cellules. La cellule possèdera un type visiblement. Parmi les cellules on en a deux particulières : la Porte et l'Objet. Ces deux objets vont dériver de l'objet cellule et avoir des comportements un peu différents.

Les objets cellule n'auront pas d'interaction avec l'utilisateur et passeront par l'objet Chateau pour s'afficher.

Le schéma ci-dessous résume cette analyse :

structure

Le maître de jeu

C'est le point d'entrée de notre programme et l'interlocuteur de l'utilisateur. Notre programme principal ressemblera donc à :

escape_game = MaitreDejeu()
escape_game.setup()
escape_game.start()

Détaillons un peu le contenu de notre classe MaitreDejeu. Nous l'avons vu, il possède 2 tortues et 1 écran. Il va créer le Chateau et le Heros. Pour les propriétés ce sera tout.

class MaitreDeJeu:

    def __init__(self):
        self.chateau = Chateau(self)
        self.heros = Heros(self.chateau, self)
        self.controleur = Screen.Turtle()
        self.msg_view = turtle.Turtle()
        self.sac_view = turtle.Turtle()

setup

Concernant les actions, la première sera de procéder à quelques réglages :

  • récupérer les fichiers du jeu à savoir :
    • le plan du château
    • le dictionnaire des portes
    • le dictionnaire des objets
  • quelques réglages des tortues et les positionner
  • demander au chateau de faire ses propres réglages
  • demander au heros d'effectuer les siens.

Pour les fichiers, nous utilisons le module argparse qui permet de gérer les options passées à la ligne de commande. Il offre aussi gratuitement un -h qui permet de voir comment appeler le script.

Voici par exemple l'appel :

python3 chateau.py -h
produit cet affichage :

usage: chateau.py [-h] [-s SET]

optional arguments:
  -h, --help         show this help message and exit
  -s SET, --set SET  numéro du set de fichiers

Nous avons opté pour un set de fichiers (set au sens familier du terme, pas l'objet Python) ainsi nous avons par exemple :

  • plan_chateau_1.txt
  • dico_objets_1.txt
  • dico_portes_1.txt

Avec l'appel

python3 chateau.py -s 1

Nous lançons notre script avec le set 1. Le setup dans son intégralité : c'est la méthode la plus longue du script.

def setup(self):
    # Récupération des fichiers
    # ---
    parser = argparse.ArgumentParser()
    parser.add_argument('-s', '--set', \
        help='numéro du set de fichiers', type=int)

    args = parser.parse_args()
    set_fichier = int(args.set) if args.set else 1
    fichier_plan = f'fichiers/plan_chateau_{set_fichier}.txt'
    fichier_objets = f'fichiers/dico_objets_{set_fichier}.txt'
    fichier_portes = f'fichiers/dico_portes_{set_fichier}.txt'

    # Réglages des tortues messagères
    # ---
    self.msg_view.ht()
    aller(self.msg_view, *POINT_AFFICHAGE_ANNONCES)
    self.sac_view.ht()
    self.sac_view.up()
    self.sac_view.right(90)
    aller(self.sac_view, *POINT_AFFICHAGE_INVENTAIRE)
    self.note_inventaire('INVENTAIRE')

    # Setup du chateau et du heros
    # ---
    entree = self.chateau.setup(fichier_plan,\
                                fichier_objets,\
                                fichier_portes)
    self.heros.setup(entree, self.chateau.ech)

start

Une fois les réglages effectués, on peut lancer le jeu :

  1. faire une petite annonce à l'attention de l'utilisateur pour préciser que le jeu commence et que le but est de conduire le héros vers la sortie ;
  2. demander au chateau de se dessiner ;
  3. demander au heros de se placer à l'entrée (entrée qui a été initialisée au moment des réglages et passée au heros) ;
  4. faire les bind, c'est le coeur d'une application événementielle ;
  5. lancer l'écoute sur les event et la boucle principale de Turtle.
def start(self):
    self.annonce(MSG_DEBUT)
    self.chateau.init_view()
    self.heros.update_view()
    self.bind()
    self.controleur.listen()
    self.controleur.mainloop()

Les bind

En programmation événementielle, binder signifie associer des événements à des fonctions. Ici, on veut associer aux quatre flèches directionnelles les actions de mouvements du héros :

def bind(self):
    self.controleur.onkey(self.heros.move_up, KEY_UP)
    self.controleur.onkey(self.heros.move_down, KEY_DOWN)
    self.controleur.onkey(self.heros.move_left, KEY_LEFT)
    self.controleur.onkey(self.heros.move_right, KEY_RIGHT)

Le heros

Nous y sommes arrivés par le mdj (maître de jeu) qui a binder les fonctions de déplacement. Le heros a donc besoin :

  • de coordonnées x et y ;
  • d'une échelle ; pour passer du système de coordonnées du modèle qui est une grille discrète (19x27 sur l'exemple de l'animation) à des coordonnées de la fenêtre graphique de turtle ;
  • du chateau bien sûr ;
  • du mdj ;
  • et d'une tortue.
class Heros:

    def __init__(self, chateau, mdj):
        self.x = None
        self.y = None
        self.ech = 0
        self.chateau = chateau
        self.mdj = mdj
        self.view = turtle.Turtle()

À sa création, le heros n'a aucune position ; il l'initialisera lors de setup une fois que le chateau lui aura transmis l'entrée.

Mais laissons ces détails techniques pour nous intéresser au coeur du mécanisme : lorsque l'utilisateur presse la touche flèche BAS (par exemple), la méthode move_down() du heros est activée.

Le déplacement du heros

La façon dont turtle gère les événement oblige à passer une fonction sans paramètre lors du bind. Ainsi, move_down() ne prend aucun paramètre et appelle la vraie fonction de déplacement avec en paramètre la direction dans laquelle le heros est censé bouger.

def move_down(self):
    self.avancer(DOWN)

def avancer(self, direction):
    dx, dy = direction
    origine = self.chateau.cell(self.x, self.y)
    if self.chateau.inside(self.x + dx, self.y + dy):
        destination = self.chateau.cell(self.x + dx, self.y + dy)
        if destination.accessible():
            origine.seen()
            self.goto(dx, dy)
            self.update_view()
            if self.found_exit():
                self.mdj.end()
        else:
            destination.interaction()

Voici ce que fait cette méthode avancer qui est la méthode du jeu :

  1. on récupère le delta de déplacement correspondant à la direction ;
  2. on mémorise la cellule d'origine, celle où se trouve le heros ;
  3. si la future destination est bien dans les limites du chateau :
    • on mémorise la cellule destination ;
    • si la destination est accessible (si ce n'est pas un mur, ou une porte verrouillée ou un objet non encore ramassé) alors :
      • on marque la case d'origine comme déjà visitée,
      • on se déplace effectivement,
      • et on met à jour notre affichage,
      • on regarde si on a trouvé la sortie... auquel cas on dit au mdj que c'est fini,
  4. sinon il faut lancer une interaction avec la case destination.

Les Cell (les cases du chateau)

Le chateau est composé de cellules, qui possèdent une position, un type et un lien vers le chateau.

class Cell:

    def __init__(self, type_cell, x, y, chateau):
        self.x = x
        self.y = y
        self.type = type_cell
        self.chateau = chateau

Les types, sept au total, sont modélisés par de simples entiers :

  • VIDE = 0
  • MUR = 1
  • SORTIE = 2
  • PORTE = 3
  • OBJET = 4
  • ENTREE = 5
  • SEEN = 6

Les types VIDE, ENTREE, SORTIE et SEEN sont toujours accessibles et ne sont pas des cellules si particulières. Les Mur ne sont pas très particulières non plus et toujours inaccessibles. Notons quand même qu'une porte déverrouillée devient VIDE ainsi qu'un objet ramassé.

Une Cell générique ne fait pas grand chose ; elle peut changer de type et prévenir le chateau pour une mise à jour de l'affichage d'elle-même :

def change_type(self, type_cell):
    self.type = cell_type
    self.chateau.update_view(self)

Une Porte

C'est une spécialisation de Cell qui possède deux propriétés supplémentaires :

  • une question
  • une solution
class Porte(Cell):

    def __init__(self, *args):
        self.question = str()
        self.solution = str()
        Cell.__init__(self, PORTE, *args)

Notez le petit aspect technique : le constructeur de la Porte fait appel au constructeur de la classe mère en lui repassant tous les arguments et en précisant son type : PORTE.

Au moment de la création du chateau les portes sont créées (positions, type etc.) mais les questions et les solutions ne sont pas encore connues. Elles seront mises à jour dans la phase setup.

La partie la plus intéressante est l'interaction avec une porte :

Si la porte est verrouillée, elle demande au chateau de l'annoncer à l'utilisateur puis de lui poser la question. La réponse est transmise à la porte. Si c'est une bonne réponse, la porte se déverrouille et le chateau a ordre de l'annoncer ; sinon, il annonce que la porte va rester verrouillée. Le code objet est une traduction fidèle de ce comportement :

def interaction(self):
    if self.verrouillee():
        self.chateau.annonce(MSG_PORTE_VERROUILLEE)
        reponse = self.chateau.ask(self.question)
        if self.bonne_reponse(reponse):
            self.deverrouille()
            self.chateau.annonce(MSG_PORTE_DEVERROUILLEE)
        else:
            self.chateau.annonce(MSG_MAUVAISE_REPONSE)

Pour une Porte se déverrouiller consiste simplement à changer de type :

def deverrouille(self):
    self.change_type(VIDE)

L'autre Cell spéciale est l'Objet.

L'Objet

L'Objet possède aussi une propriété supplémentaire par rapport à une Cell lambda : son nom. Qui sera la chaîne de caractères affichée dans la fenêtre de jeu lorsque l'objet est ramassé (on aura compris qu'il n'y a pas réellement un objet).

def __init__(self, *args):
    self.nom = str()
    Cell.__init__(self, OBJET, *args)

On retrouve le même mécanisme d'appel au constructeur de la classe mère que pour la Porte.

L'Objet a aussi une interaction possible : le chateau annonce à l'utilisateur le nom de l'objet ; puis l'objet change de type.

def interaction(self):
    self.chateau.donner(self.nom)
    self.change_type(VIDE)    

Conclusion

Nous avons vu comment en partant du problème et des objets évidents, nous avons enrichi le modèle pour que les interactions entre les objets soient naturelles et celles avec l'utilisateur regroupées en un seul objet.

La description des objets en partant du principal, celui qui est aussi le point d'entrée du jeu conduit assez naturellement à passer en revue les différents objets et leurs principales méthodes.

Celles que nous avons tues sont juste des méthodes techniques sans grand intérêt comme le calcul de coordonnées (changement d'échelle entre le modèle et la vue) ou l'utilisation des méthodes de la tortue pour dessiner.