TD → Labyrinthe

Le scénario

Voici le scénario du jeu: un aventurier parcourt un labyrinthe à la recherche d’un trésor. Il doit trouver dans ce labyrinthe une clé servant à ouvrir le coffre au trésor tout en évitant certains pièges disposés sur le sol. Pour compliquer sa tâche, trois momies protègent le trésor. Elles errent dans les couloirs du labyrinthe et si l’aventurier a le malheur de les croiser, il sera désintégré instantanément.

Voici quelques précisions supplémentaires:

  • Le monde est représenté comme une grille 2D vue par-dessus. Chaque case représente soit une case libre où le héros peut se déplacer soit un mur de forme carrée. Les déplacements se font dans les quatre directions : haut, bas, gauche, droite.

  • Les pièges au sol sont des trappes, si le héros passe sur une de ces cases, il tombe à l’intérieur et meurt. Cela consomme une vie.

  • Pour qu’une momie puisse désintégrer le héros, il faut que ce dernier se trouve à proximité. Si c’est le cas, cela tue le héros et une vie est consommée.

  • Lorsque le joueur perd une vie, il recommence au départ du labyrinthe. Il conserve les objets collectés comme la clé par exemple. Le joueur dispose de trois vies. Lorsqu’il les a toutes utilisées, un écran GAME OVER apparaît.

  • Les momies doivent donc avoir une IA. Nous devons donc écrire du code pour gérer leurs déplacements. Donner une IA aux momies présente un réel défi. Nous verrons plus tard quelle approche choisir.

  • Après deux à trois secondes d’affichage du Game Over, on retourne à l’écran d’accueil.

Représenter le labyrinthe

Pour faciliter la lecture et la modification, on représente le labyrinthe dans le code en utilisant une string:

  • La lettre « M » représente un mur

  • L’espace une case vide

Voici un exemple:

string Map =
                              "MMMMMMMMMMMMMMM"
                              "M M           M"
                              "M M M MMM MMM M"
                              "M   M       M M"
                              "MMM M M MMM M M"
                              "M   M M     M M"
                              "M MMM MMM MMMMM"
                              "M   M  M      M"
                              "M M M  M M MM M"
                              "M M M  M M M  M"
                              "M M M MM M MMMM"
                              "M M M    M    M"
                              "M M M MMMMMMM M"
                              "M M      M    M"
                              "MMMMMMMMMMMMMMM";

Note

Les bords de la carte correspondent à des murs. Cette astuce évite de traiter les cas où le héros ou une momie se retrouve à proximité d’un bord.

Pour faire en sorte que l’origine (0,0) soit positionnée en bas à gauche dans ce labyrinthe, nous utilisons cette fonction:

bool Mur(int x, int y) { return Map[(15 - y - 1)*15+x] == 'M'; }

Cette fonction retourne true si la case de coordonnées (x,y) est un mur.

Les données du jeu

Coordonnées

On veut que les personnages se déplacent à l’intérieur de ce labyrinthe de manière fluide et non de case en case comme aux échecs. Ainsi, on stocke la position du joueur et des momies en utilisant des coordonnées entières en pixels. Pour les momies, il faudra aussi qu’elles aient un vecteur de déplacement :

struct Momie
{
   V2 PosPixels;
   V2 DirDeplacement;
   ...
};
Momie Momie1 = ... // Momie numéro 1
Momie Momie2 = ... // Momie numéro 2
Momie Momie3 = ... // Momie numéro 3

Cependant, cette approche reste valable pour un faible nombre de momies. A terme, il vaut mieux utiliser un tableau ou, mieux encore, une liste dynamique, surtout s’il existe un spawner de monstres.

Pour le héros, on doit stocker les informations suivantes :

  • La position en pixels

  • Le nombre de vies restantes

  • S’il possède la clé ou pas

  • Les pièces d’or ramassées

Une structure sera utile pour regrouper toutes ces informations.

Avertissement

Comme dans le projet Flipper, vous devez utiliser uniquement la variable globale G (gamedata) stockant l’ensemble des informations de partie.

Gestion du jeu

Gestion de partie

On s’intéresse maintenant à la fonction gérant la partie en cours. Cette fonction est de haut niveau car elle déclenche séquentiellement les actions principales du jeu. On devrait y trouver essentiellement des appels de fonctions.

int gestion_jeu()
{
   // le héros
   GestionDeplacementHeros();;    // lecture du clavier / test mur

   // les dangers
   if  ( DetectCollisionPiege() || DetectCollisionMomies() )
          G.Hero.PosPixels = ...;
          G.Hero.NbVies -= 1

   GestionClé();
   GestionTresor();

   // les momies
   DeplacementMomies();

   // détection si partie terminée
   if (G.Hero.NbVies > 0 ) return  ECRAN_JEU;
   else                    return  ECRAN_GAMEOVER;
}

Les différentes étapes de gestion du jeu semblent correctement s’enchaîner:

  • Déplacement du héros

  • Interactions avec le décor: piège, momie, clé, trésor

  • Déplacement des momies

A quoi servent les deux lignes : G.Hero.PosPixels et G.Hero.NbVies ?? Effectivement, nous avons vu que placer des traitements spécifiques au milieu de traitements plus généraux n’est pas un signe de bonne structuration du code. Il est donc nécessaire de lire attentivement ces deux lignes pour en comprendre le rôle. Dans l’idéal, il serait préférable de créer une fonction dédiée, avec un nom explicite tel que GestionMortHeros(), afin d’y regrouper toutes les actions à effectuer lors de cet évènement. Dans le cas présent, seules deux lignes sont concernées, et la tentation de ne pas créer de fonction est grande…

Déplacement du héros

On s’intéresse maintenant à la fonction GestionDeplacementHeros() permettant de gérer les déplacements du joueur. Cette fonction effectue un traitement en trois étapes :

  • Lire les touches activées par le joueur

  • Calculer la nouvelle position

  • Vérifier que le héros ne rentre pas dans un mur et, dans ce cas, le déplacer

Comment détecter la collision du héros avec un bloc de mur ?

On peut délimiter l’image du héros à l’écran par un rectangle (technique bounding box). Chaque case du mur correspond à un carré. Pour tester si la nouvelle position du héros est correcte, il faut déterminer si sa bounding box collisionne avec un mur:

bool DetectCollisionMomies(...)
{
        ...
        for (int x = 0; x < largeurLaby ; x++)
        for (int y = 0; y < hauteurLaby ; y++)
                if ( G.Mur[x][y] == 1 )
                        if ( CollisionRectRect(...,...) )  // test collision BoundingBox Mur
                                return true;
        return false;       // aucune collision trouvée

}

Collision Rect/Rect

Méthode

On étudie la collision entre deux rectangles. Pour chaque rectangle, on donne les coordonnées de son sommet bas/gauche ainsi que sa largeur et sa hauteur:

bool collisionRectangles(V2 r1,int l1, int h1, V2 r2, int l2, int h2)
{
        if (r1.x + l1 < r2.x ) return false;  // R1 à gauche de R2
        if (r1.x > r2.x + l2 ) return false;  // R1 à droite de R2

        if ( r1.y + h1 < r2.y ) return false; // R1 en dessous de R2
        if ( r1.y > r2.y + h2 ) return false; // R1 au dessus de R2

        return true; // sinon il y a forcément une intersection entre les deux rectangles
}

Les repères

Il existe deux repères dans le jeu:

  • Le repère écran en pixels

  • Le repère du labyrinthe correspondant à chaque case du décor

Lorsque vous testez l’intersection entre les deux rectangles, il faudra être dans le même repère. Si l’on choisit le repère écran, cela veut dire qu’il faudra transformer les coordonnées d’un bloc de mur en coordonnées dans le repère écran en multipliant chaque composante par la largeur d’une case (en pixels).

L’IA des momies

Faire des aller-retours

Nous allons coder une première version:

  • La momie avance dans une direction donnée

  • Si elle rencontre un obstacle, elle fait demi-tour

Pour cela, il faut tester la collision d’une momie avec les murs du labyrinthe, comme on l’a déjà fait lors de la gestion du déplacement du héros. Il est donc judicieux de réutiliser les fonctions déjà programmées à cet effet.

void DeplacementMomies(Momie & M,...)
{
        ...

        // calcul de la nouvelle position
        V2 newPos = M.Pos + M.Dir;

        // test pour savoir si on a une collision avec un mur
        if ( PositionValide(...) )
                // pas de collision => on déplace la momie
                M.Pos = newPos;
        else
                // collision détectée, on ne bouge pas mais on change de direction
                M.Dir = V2(-M.Dir.x,-M.Dir.y);
}

Maintenant, les trois momies se déplacent à l’écran. Cependant, elles n’explorent pas la totalité du labyrinthe et ne font que des allers-retours dans le même couloir. Nous allons mettre en place une version améliorée.

Déplacement aléatoire

Pour mettre en place une IA pour les momies, nous n’allons pas adopter un comportement de chasseur, où chaque momie « foncerait » vers le héros. D’une part, cela rendrait le jeu injouable, et d’autre part, cela ne correspond pas à l’image que l’on se fait d’une momie : une créature qui erre lentement et sans but précis dans un labyrinthe. Nous allons donc opter pour un déplacement aléatoire, plus cohérent avec ce type de personnage.

Note

L’aléatoire présente plusieurs avantages : il est simple à implémenter, il évite que les momies adoptent un comportement trop répétitif, et, avec un peu de chance, il peut même donner l’illusion que certaines de leurs décisions sont calculées, alors qu’en réalité, tout repose sur le hasard !

...
// collision détectée, on ne bouge pas mais on choisit une nouvelle direction

int rd = .. // tirage d'une valeur aléatoire entre 0 et 3

// les 4 directions possibles : en haut, à droite, en bas, à gauche
V2 Dir[4] = { V2(0,1), V2(1,0), V2(0,-1), V2(-1,0) };

M.Dir = Dir[rd];

Voici le principe:

  • Lorsqu’une momie arrive face à un mur

    • on choisit aléatoirement une nouvelle direction de déplacement

    • on n’effectue pas de déplacement

Au prochain appel de la fonction DeplacementMomies() soit la direction tirée aléatoirement est correcte et, dans ce cas, la momie se déplace ; soit la direction est invalide et alors on retire une nouvelle direction aléatoirement. Cette nouvelle version permet aux momies d’explorer la totalité du décor.

Mise en place du projet Labyrinthe

Le projet

En premier lieu, téléchargez la librairie G2D sur la page de La librairie G2D. Remplacez le fichier Eleve.cpp se trouvant dans le projet par celui-ci.

Téléchargez Le fichier du projet.

Avertissement

La quasi-totalité de votre code doit être écrit dans le fichier Eleve.cpp.

Les textures

Une texture est une image qui se comporte comme du papier peint. Une texture, quelque soit sa dimension originale, sera étirée pour recouvrir le rectangle en entier.

La librairie G2D fournit une fonction permettant de convertir une chaîne de caractères, où chaque lettre représente la couleur d’un pixel, en une texture affichable à l’écran. Ainsi, même si vous ne maîtrisez aucun logiciel graphique et que vous ne vous sentez pas à l’aise en dessin, cette méthode vous permet de créer des objets visuels de façon simple et ludique, dans un style 8 bits rétro gaming. Voici un exemple de chaîne de caractères représentant l’image du héros :

string texture =
        "[RRR  ]"
        "[RRWR ]"
        "[RRR  ]"
        "[YY   ]"
        "[YYY  ]"
        "[YY YG]"
        "[GG   ]"
        "[CC   ]"
        "[CC   ]"
        "[C C  ]"
        "[C C  ]";

Remarquez la présence des caractères [ et ] qui délimitent le bord. Voici la texture associée :

../_images/sprite.png

Pour générer la texture, la fonction G2D::initSprite() est appelée dans la fonction AssetInit() au tout début du programme.

int initTextureFromString(V2 & Size, const std::string & Sprite);

L’objet V2 passé en référence va être initialisé pour stocker la taille originale de la texture. Cette fonction retourne un numéro d’identification unique qui devra être transmis à la fonction DrawRectWithTexture() pour que cette texture soit utilisée pour le dessin.

Le clavier

Si vous appuyez sur les flèches sur le clavier, le sprite du héros va se déplacer à l’écran, et comme vous pourrez le remarquer, il traverse les murs !

Travail à effectuer

L’ensemble des tâches a été décrites dans cette page. Nous les résumons rapidement :

  • Respect de la hiérarchie des appels de fonctions

  • Collision avec les murs pour gérer les déplacements du héros.

  • Création d’un coffre, d’une clé, pour ouvrir le coffre il faut la clé !

  • La partie se termine lorsque le coffre est ouvert => affichage d’un WIN à l’écran

  • Création de trois momies

  • Mise en place de l’IA des momies avec un déplacement aléatoire

  • Piège au sol non demandé

  • Animation de la marche du héros

Pour l’animation de marche du héros, vous devez créer une deuxième texture avec une position de pieds différente. Il faudra ainsi alterner les deux textures pour donner l’impression que le héros marche dans le labyrinthe. On peut alterner chaque texture une fois par seconde ou toutes les n frames d’affichage.

Pour aller plus loin

Voici quelques informations supplémentaires pour ceux qui veulent développer le jeu Labyrinthe comme projet.

Continuer en projet étendu

Si vous avez fini ce projet et qu’il vous reste du temps, vous pouvez rajouter des éléments pour étendre ce projet :

  • Ramasser un pistolet, personne n’a dit que les balles étaient fournies avec !

  • Utiliser cette arme pour tuer une momie.

  • Positionner des des trappes au sol.

  • Ajouter des détecteurs qui déclenchent un piège comme une flèche tirée depuis un mur.

  • Des diamants à ramasser !

  • Un score enfin !

  • Un Spawner de momies.

  • Des portes qui s’ouvrent et qui se referment !

  • Et bien d’autres options !

Structuration

Nous vous proposons un nouveau binôme Tom qui va travailler avec vous sur ce projet. En lisant le sujet, il a conclu qu’il y avait trois étapes dans le jeu :

  • L’écran d’accueil

  • Le jeu à proprement parlé

  • L’écran Game Over.

Tom programme un code reflétant ces trois étapes :

if ( ecran == ECRAN_ACCEUIL )
        bool fini = gestion_ecran_accueil()
        if (fini) ecran = ECRAN_JEU

if ( ecran == ECRAN_JEU )
        bool fini = gestion_ecran_jeu()
        if (fini) ecran = ECRAN_GAME_OVER

if ( ecran == ECRAN_GAME_OVER )
        bool fini = gestion_ecran_game_over()
        if (fini) ecran = ECRAN_ACCEUIL

Tom a correctement analysé ce qui relevait du niveau principal dans notre programme : la gestion des transitions entre les différents écrans. En effet, c’est la première étape du jeu : afficher l’écran d’accueil. Une fois l’écran d’accueil passé, une partie se déroule et en fin de partie un nouvel écran Game Over est affiché. Tom a géré chaque écran de la même manière. Il utilise une variable ecran indiquant l’écran actif. Ensuite, il appelle la fonction de gestion de cet écran qui retourne true si cette écran se termine. Nous rappelons les conditions de transition d’un écran à l’autre :

  • Pour l’écran d’accueil : l’appui sur les touches +/- règle le niveau de la musique, le clic avec la souris sur le bouton <<difficile>> augmente le nombre de momies.

  • Pour le jeu, la fonction de gestion va traiter les touches du clavier pour déplacer le joueur, elle déplace les momies et détecte si le joueur est mort.

  • Pour l’écran Game Over, la fonction de gestion attend trois secondes avant de désactiver cet écran.

Le code fournit par Tom est plutôt bien organisé car il a choisi dans ce niveau du programme de ne traiter qu’un aspect : les transitions d’un écran à un autre. C’est tout à son honneur. Cependant, son choix de développement souffre d’un petit défaut. En effet, il a séquencé son code en trois étapes : l’écran d’accueil, puis le jeu puis l’écran de fin suivi de l’arrêt du programme. Pour un premier jet, c’est déjà bien. Cependant il a un doute et demande son avis à son ami Benjamin. Ce dernier lui indique une méthode pour trouver la solution par lui-même :

<< Améliorer la structure d’un programme est difficile si tu envisages uniquement les cas traités actuellement par ton programme. Il faut parfois étendre ta vision à des fonctionnalités supplémentaires qui permettent d’envisager une version étendue de ton programme. Il ne faut pas penser à toutes les possibilités envisageables car elles sont infinies. Mais concentre-toi sur celles qui te semblent les plus probables si tu devais améliorer ton programme. >>

Fort de ce conseil, Tom réfléchit. Ce qui le gêne le plus, c’est son menu d’accueil. On ne gère pas habituellement les options de jeu dans le menu d’accueil mais plutôt dans un menu spécifique. Il faudrait le rajouter pense-t-il. Tom imagine ce que pourrait être son jeu idéal : il voudrait un menu supplémentaire pour choisir l’univers graphique dans lequel se déroule le jeu : pyramide avec des momies ou musée avec gardiens ou encore désert avec des scorpions. Cela l’amuse beaucoup même s’il sait que tout cela est un peu inatteignable. Ainsi, Tom remarque qu’il aurait besoin de deux menus supplémentaires : réglage des options et aussi choix du monde. A ce moment précis, il se rend compte que sa structure actuelle du programme ne lui permet pas de passer d’un menu à l’autre. Sa structure actuelle est linéaire : écran d’accueil puis écran de jeu et enfin écran de Game over, toujours dans cet ordre. Comment faire ?

Finalement, Tom arrive à la conclusion que seul l’écran courant peut déterminer s’il y a ou non une transition vers un nouvel écran. Ainsi, Tom modifie ses fonctions de gestion d’écran pour qu’elle retourne l’écran actif après leur exécution. Tom voit qu’avec cette nouvelle structure il est très facile de rajouter plusieurs autres écrans à son jeu. Il pense pouvoir en gérer facilement une dizaine, cela est largement suffisant pour son objectif actuel.

Tom est face à un nouveau problème. Lorsque l’on commence une nouvelle partie, le nombre de vies du joueur doit être mis à 3 et les objets du décors doivent être mis à leur position initiale. Où peut-on coder ces initialisations ? Il est tentant de les mettre quelque part, discrètement, comme à la fin de la gestion de l’écran d’accueil. Une autre option consiste à le mettre dans la gestion du jeu avec un booléen qui repère si c’est le démarrage de la partie et donc que l’on doit faire ces initialisations. Cependant, cela décale le problème car il faudra réinitialiser ce booléen quelque part. Dans tous les cas, ces options sont bancals au niveau de la conception car on gère une initialisation dans l’écran d’accueil ou l’écran de jeu, ce qui n’est pas vraiment leur job.

Finalement, à force de réfléchir, Tom arrive à la conclusion que l’initialisation est une étape du jeu, comme un écran à part entière. Ce choix lui permet d’être sûr que cette initialisation se déclenche une seule fois et ceci juste avant le lancement du jeu. Tom se demande si ce choix est judicieux car les initialisations ne correspondent pas vraiment à une étape du jeu comme les autres écrans. Tom demande son avis à Benjamin qui lui fait une réponse inattendue :

<< Tu sais, quand tu joues à un jeu 3D, lorsque tu lances une partie, tu as souvent une phase d’attente où l’ordinateur charge les graphismes et la map. D’ailleurs ils mettent souvent une jolie image pour te faire patienter ou une barre de progression pour indiquer l’avancement. Ton idée me semble tout à fait dans cette logique. Rajoute une jolie image, si tu tiens vraiment à avoir un écran à gérer dans ton jeu ! >>

Tom sourit et conclut que son choix de conception est plutôt censé. Il met ainsi en place cette solution qui lui semble très satisfaisante.

if (ecran == ECRAN_ACCEUIL )
        ecran = gestion_ecran_accueil();

if (ecran == ECRAN_OPTIONS)
        ecran = gestion_ecran_options();

if (ecran ==  INIT_PARTIE)
        ecran = InitPartie();

if (ecran == ECRAN_JEU)
        ecran = gestion_jeu();

if (ecran == ECRAN_GAME_OVER)
        ecran = gestion_GameOver();