TD → Labyrinthe

Structure hiérarchique d’un programme

La problématique

Lorsque l’on programme, nous nous concentrons trop souvent sur les micro-problèmes du moment : syntaxe, noms des variables, exactitude des tests logiques, mise en place des algorithmes… Bref, nous sommes trop souvent la tête dans le guidon et nous oublions de prendre du recul. La plupart du temps, cela ne posera aucun problème car nous écrivons des programmes courts, de quelques dizaines de lignes. Néanmoins, lorsque l’on se lance dans l’écriture d’un mini-jeu durant un projet, l’aventure peut nous amener à écrire autour de 200 à 500 lignes de code voire plus. Si l’on ne consacre pas un minimum d’énergie pour structurer le programme, cela va être dangereux : le programme va dysfonctionner et les développeurs vont finir par ne plus savoir comment gérer les erreurs.

Les programmes longs, sans structuration, souffrent de plusieurs problèmes :

  • Le code gérant des détails se trouvent au même niveau que du code gérant des aspects plus généraux du programme. Cela rend difficile la lecture et la compréhension.

  • Différents traitements s’entremêlent. Ainsi, lorsque l’on cherche l’endroit dans le programme gérant un point spécifique, on trouve des lignes de code éparpillées un peu partout. Cela n’est pas bon, car il devient très difficile d’identifier et d’isoler l’ensemble des traitements ce qui conduit à une mauvaise compréhension du fonctionnement du programme.

  • Des bugs sont présents et leur recherche s’avère ardue. En effet, généralement, si vous avez un bug concernant le déplacement de Pacman, vous allez naturellement vous placer au début de la fonction DeplacePacman() et vérifier que les paramètres de cette fonction contiennent des valeurs correctes. Puis, vous allez exécuter ligne à ligne les différents traitements pour détecter l’apparition d’une erreur. Sur trente lignes, la recherche est aisée. En revanche, si vous n’avez pas écrit une fonction spécifique pour cela, la première ligne et la dernière ligne du traitement peuvent couvrir une région de 200 lignes de code comprenant d’autres traitements ainsi que des appels de fonctions. Dans ce contexte, la recherche s’avère beaucoup plus difficile.

  • Lorsqu’il y a un problème, on peut être tenté de rajouter une ligne à un endroit précis et après cela, on constate que le problème a disparu. Cependant, en corrigeant un bug à un endroit, on crée parfois un autre bug un peu plus loin sans s’en rendre compte. Sans fonction, il n’y a pas de compartimentation du code. Ainsi, rien ne vous garantit qu’en retirant ou en modifiant une ligne de code à un endroit, vous ne cassiez pas quelque chose quelques lignes plus loin. Les fonctions permettent d’isoler un groupe de lignes de code du reste du programme. Une erreur présente dans le code d’une fonction n’est pas sensée se propager ailleurs, sauf bien sûr si l’on retourne un résultat erroné.

  • Certaines parties du code ont été dupliquées à plusieurs endroits du programme afin d’éviter de créer une fonction. Ainsi, lorsque l’on s’aperçoit d’un bug, il faudrait techniquement reporter la correction à tous les endroits correspondants, encore faut-il les retrouver…

  • Ce type de programme tolère très mal les ajouts de fonctionnalités. Les différentes parties du code sont tellement entremêlées les unes aux autres que modifier une partie peut mettre en danger une autre partie. D’expérience, les élèves qui structurent mal leur code ne pourront, sur un projet long, implémenter que 30% à 40% des fonctionnalités demandées. Ce n’est pas forcément parce que les fonctionnalités restantes sont complexes, mais c’est surtout parce que le programme devient difficile à maintenir.

  • Le travail à plusieurs personnes demande de savoir s’organiser pour que chaque nouvelle partie produite par un programmeur s’intègre correctement dans le programme actuel. Avec un programme mal structuré, cela devient un vrai challenge car chaque développeur intégrant son code ne sera pas trop comment le connecter au reste du projet.

En conclusion, les fonctions sont utiles pour la lisibilité, la structuration et pour garantir une certaine forme d’indépendance entre les différentes parties du programme. Faire sans est possible mais cela s’avère plus contraignant à gérer sur des programmes longs et rend difficile le travail en équipe.

Créer des fonctions ne vous garantit pas d’échapper aux problèmes de structuration. En effet, les fonctions doivent s’organiser entre elles en respectant certains principes que nous allons vous présenter ci-après.

Schéma d’une hiérarchie d’appels de fonctions

Les mauvais programmes ont un point commun : ils sont mal structurés. Ainsi les différentes étapes de traitement, au lieu d’être séparées, sont entremêlées ce qui complique toute correction ou toute évolution du code. En anglais, on appelle cela du « spaghetti coding » car le code source ressemble tristement à un plat de spaghetti.

Créer une structure hiérarchique dans votre programme consiste à passer d’un code spaghetti à une architecture correspondant à une hiérarchie des appels de fonctions. Basiquement, on pourrait schématiser la transformation ainsi :

../_images/spaghetti.png

Comment se lit le schéma de droite ? Chaque bloc correspond à une fonction. En l’absence de fonction, il n’y a pas de hiérarchie et le code ressemble au schéma de gauche. La fonction principale, l’entrée du programme, est la fonction A(). Dans le code de la fonction A(), on trouve l’appel de trois autres fonctions B(), C() et D(). La fonction C() appelle les deux fonctions E() et F(). Les deux fonctions E() et F() n’appellent aucune autre fonction.

L’usage veut que la fonction principale soit représentée en haut, c’est le plus haut niveau de la hiérarchie. Nous présentons une configuration simple : il n’y a aucun cycle dans notre hiérarchie, ce serait le cas si par exemple, la fonction F() appelait la fonction C() ou si la fonction F() s’appelait elle-même. Ce cas peut se produire lors de la mise en place de fonctions récursives par exemple, mais cette situation étant assez rare, le schéma de droite permet de décrire la plupart de nos programmes.

Lorsqu’on lit de gauche à droite le schéma de droite, doit-on comprendre que les fonctions B(), C() et D() sont appelées successivement dans cet ordre ? Non, pas du tout : la fonction A() peut appeler la fonction B() puis la fonction C() puis à nouveau la fonction B(). De plus, entre deux lancements du programme, la fonction A() peut décider, suivant les données à traiter, d’appeler uniquement la fonction B() ou la fonction C(). Il est donc impossible de reproduire toutes les configurations d’appels possibles. Ainsi, le placement de gauche à droite des blocs ne présume en rien leur ordre d’appel. Par contre, en examinant le code de la fonction A(), nous connaissons les fonctions susceptibles d’être appelées et nous représentons leurs blocs dans la hiérarchie en dessous du bloc correspondant à la fonction A(). Le sens des flèches indique le sens d’appel.

Des fonctions différentes peuvent appeler une même fonction. Dans notre schéma, c’est le cas de la fonction B() et C() qui appellent toutes deux la fonction E(). Cela n’a rien d’exceptionnel, par exemple, la fonction math.max() peut être appelée à plusieurs endroits dans un programme.

La hiérarchie fait apparaître différents niveaux. En haut se trouve plutôt des fonctions générales qui exécutent des scénarios correspondant à des traitements longs : chercher une chaîne de texte dans un fichier, fusionner plusieurs PDF, répliquer plusieurs fichiers vers un serveur. En bas de la hiérarchie, on trouve plutôt des fonctions effectuant un traitement précis : dessiner un cercle, ouvrir un fichier, vider une liste, calculer une moyenne…

Avertissement

Dans une hiérarchie d’appel de fonctions, les fonctions du niveau inférieur ont pour objectifs d’effectuer une partie des traitements de la fonction appelante du niveau supérieur. Les tâches des fonctions supérieures sont donc plus générales que celles des fonctions en bas de la hiérarchie.

Identifier une hiérarchie d’appels de fonctions

Les mauvais critères

Quels peuvent être les critères pour construire une hiérarchie de fonctions ? Il existe de nombreuses fausses bonnes idées à ce sujet. Fort de ce constat, nous commençons par présenter les critères ne devant pas être retenus :

  • Fausse bonne idée n°1 : l’exactitude du traitement est un gage de bonne conception. Un ensemble de lignes de code est correct s’il construit un résultat correct. C’est un point important mais sans lien avec la construction d’une hiérarchie. On ne peut donc évoquer l’exactitude d’un traitement comme argument d’une bonne conception.

  • Fausse bonne idée n°2 : les fonctions les plus longues doivent être en haut de la hiérarchie. Une intuition, pourtant incorrecte, tend à nous faire penser que les traitements nécessitant le plus de lignes de code sont forcément en haut de la hiérarchie, pourtant il n’en est rien. Le nombre de lignes de code n’est pas un critère suffisamment précis pour concevoir une hiérarchie.

  • Fausse bonne idée n°3 : toutes les fonctions présentes dans un même niveau de la hiérarchie, même avec des blocs supérieurs différents, doivent comporter des fonctions de même périmètre/importance. Cette impression a tendance à apparaître lorsque l’on traite des cas simples. En effet, le faible nombre de fonctions a tendance à produire une hiérarchie type : la fonction principale se trouve au premier niveau, les fonctions de gestion au deuxième niveau et les fonctions basiques au troisième niveau. Cependant, dans les hiérarchies contenant beaucoup plus de blocs, les différentes branches de la hiérarchie n’ont pas forcément la même profondeur. Les branches les plus courtes peuvent se modéliser sur trois niveaux et les branches les plus complexes peuvent atteindre jusqu’à cinq niveaux de profondeur. On ne peut donc garantir que tous les blocs, à un même niveau de hiérarchie, aient un périmètre de traitement équivalent ; exception faite évidemment des hiérarchies de faible profondeur !

  • Fausse bonne idée n°4 : les fonctions isolées (fonctions n’appelant aucune autre fonction) se situent en bas de la hiérarchie. Cette idée est correcte mais mal formulée. Si l’on considère dans une hiérarchie la branche descendant de la fonction principale jusqu’à une fonction isolée, la fonction isolée se situe effectivement tout en bas de la branche. Cependant, si l’on considère toutes les branches de la hiérarchie, elles peuvent être de profondeurs différentes. Ainsi les fonctions isolées se situent habituellement dans les niveaux les plus bas : 3, 4 ou 5, mais rien n’oblige ces fonctions à se situer toutes au même niveau.

  • Fausse bonne idée n° 5 : pour un sujet donné, il existe une seule hiérarchie correcte. Construire une hiérarchie s’appuie sur le fait de regrouper des traitements par thématiques pour faire en sorte d’assembler de manière cohérente les différentes parties d’un programme. Cela reste une méthode pour aider à concevoir un programme le mieux possible. En conclusion, non, il n’existe pas une unique manière de concevoir une hiérarchie. Par exemple, les programmes mettant en place de nombreuses fonctionnalités requièrent de nombreuses fonctions et plus le nombre de fonctions est important, plus on a de façons d’organiser une hiérarchie.

  • Fausse bonne idée n°6 : il faut rechercher une hiérarchie parfaite. Certains programmeurs prennent très au sérieux la partie conception et vont refuser de publier leur programme car certaines parties sont encore à revoir. L’objectif de ce chapitre est de vous donner des repères pour essayer d’améliorer votre conception et d’éviter les principales erreurs. Sur le terrain, il faudra parfois accepter que certaines parties d’un programme soient codées sans réelle structuration, ceci pour diverses raisons : manque de temps, trop peu d’informations pour mettre une structuration en place ou encore portion de code réalisée par une personne non disponible.

Principe 1 : les sous-fonctions traitent des sous-thématiques

Nous appelons sous-fonction une fonction appelée par une autre fonction. Dans le schéma des hiérarchies, son bloc se situe au-dessous d’un autre bloc. Les sous-fonctions d’une même fonction s’appellent des fonctions voisines. Ces fonctions voisines ont chacune pour objectif de traiter une partie de la problématique de la fonction appelante. Leur périmètre de traitement est ainsi inclus dans celui de la fonction appelante et nous pouvons schématiser ce principe d’inclusion dans le schéma suivant :

../_images/ens.png

Avertissement

Attention, à ne pas confondre l’inclusion des thématiques avec l’inclusion des traitements. L’inclusion des traitements est intrinsèque/automatique à la programmation fonctionnelle et elle ne nous intéresse pas lorsque l’on cherche à concevoir une hiérarchie.

En respectant ce principe, cela permet d’identifier si une fonction est correctement placée dans la hiérarchie. Si son traitement est sans rapport avec la thématique de la fonction appelante, alors cette fonction n’est pas correctement positionnée. Lorsque l’inclusion des thématiques n’est pas respectée, il faut essayer de modifier la hiérarchie en conséquence.

Par exemple, si vous trouvez un bloc gestion des déplacements sous un bloc de gestion des affichages, nous pouvons considérer que la conception n’est pas du tout adéquate à ce niveau.

Principe 2 : périmètres similaires pour des fonctions voisines

Nous appelons les sous-fonctions d’une même fonction des fonctions voisines. Respecter le principe numéro 1, les sous-fonctions traitent des sous-thématiques de la thématique de la fonction appelante, ne suffit pas à créer une hiérarchie correcte. Pour cela, il faut aussi tenir compte du principe numéro 2 : des fonctions voisines doivent avoir un périmètre similaire.

Ce principe précise que deux sous-fonctions doivent avoir un périmètre similaire. Par exemple, si en dessous du bloc de la gestion du personnage, vous trouvez les blocs : gestion des déplacements, gestion des tirs, gestion des collisions puis un bloc intitulé « gestion de la clef », vous devez détecter un problème. En effet, le bloc gestion des déplacements traite beaucoup d’aspects comme les touches du clavier, la collision avec les murs ou la montée des échelles et des escaliers. La gestion des collisions détecte le contact entre le héros et les monstres ou les pièges ou encore les bonus… Le bloc intitulé gestion de la clef s’avère avoir un périmètre très réduit par rapport à ses blocs voisins. Cela sous-entend que sa place est incorrecte. Le mauvais réflexe à ce niveau consiste à déplacer le bloc de la gestion de la clef sous un bloc préexistant. Ainsi, maladroitement, le bloc de la gestion de la clef se retrouvera probablement rattaché au bloc de la gestion des collisions car il existe en effet un point commun : il faut effectivement détecter si le héros passe à proximité de la clef. Cependant, cette solution est bancale car la thématique de la gestion de la clef va au-delà de la gestion des collisions et cette thématique s’inclut mal dans la thématique des collisions. En effet, il faut aussi traiter des questions comme : le héros a-t-il une clef ? la clef est-elle la bonne ? Combien de clefs a-t-il ? Son placement est donc maladroit. Lorsqu’une fonction de périmètre réduit se trouve voisine de fonctions plus générales dans la hiérarchie, parfois cela sous-entend qu’un bloc manque. Dans notre exemple, le bloc absent correspond à la gestion de l’inventaire du héros. Ce bloc apparaîtrait naturellement si dans le scénario du jeu on ajoutait d’autres objets à collecter comme des gourdes d’eau, des potions de soins ou des pièces d’argent. En créant ce nouveau bloc pour gérer l’inventaire, nous avons maintenant un bloc au périmètre similaire aux périmètres des blocs voisins. Ainsi, le bloc gestion de la clef se rattache correctement à ce nouveau bloc. Certes, l’intérieur de la fonction associée au bloc inventaire peut semblait vide, mais ne vous inquiétez pas, il existe plein d’idées pour le remplir !

Exercice 1

A l’intérieur d’une hiérarchie, on peut facilement remonter ou descendre le bloc d’une fonction sans pénaliser le fonctionnement du programme. Dans ce cas, comment identifier la place exacte de chaque fonction dans la hiérarchie ? C’est une question très difficile. Pour identifier correctement la place d’une fonction, une aide peut être d’utiliser le principe n°2 nous indiquant que des fonctions voisines doivent avoir des périmètres similaires. Examinons le périmètre des différentes fonctions voisines, si leurs périmètres semblent très différents, il faut peut-être repenser l’organisation. Prenons l’exemple d’un programme contenant les fonctions suivantes :

  • Gestion du freinage

  • Allumage du voyant réservoir vide

  • Gestion des vitesses

  • Gestion des airbags

  • Gestion des informations conducteur

  • Vérification de l’alimentation des airbags

Supposons que notre programmeur débutant, nommé Tom, construise cette première version :

../_images/ex1.png

Tom a mis en place une fonction principale, NavigationVoiture(), tenant le rôle de chef d’orchestre pour toutes les autres fonctions, c’est un bon début. Poursuivons l’analyse de ce schéma. Nous remarquons que toutes les autres fonctions se situent au même niveau dans la hiérarchie. Pourtant, les fonctions de gestion sont très générales avec des périmètres très larges (vitesse, airbags et freinage) alors que l’allumage du voyant réservoir est une action déjà très spécialisée. Le principe numéro 2, les fonctions voisines doivent avoir un périmètre similaire, n’est donc pas respectée. Pour les replacer correctement, nous utilisons le principe n°1 : la thématique d’une fonction doit s’inclure dans la thématique de la fonction appelante. Ainsi, l’appel de la fonction d’allumage du voyant réservoir devrait appartenir à la fonction de gestion des informations conducteur et son bloc devrait se situer sous le bloc de cette fonction. D’autre part, la fonction de vérification de l’alimentation de l’airbag semble être une action appartenant au périmètre de la gestion des airbags et sa place devrait se situer plutôt à l’intérieur de ce traitement.

Réfléchissez à comment corriger cette hiérarchie. Après cela, vous pouvez consulter un exemple de correction.

Exercice 2

Identifier une fonction consiste à identifier une tâche ou un traitement dans le programme. Identifier le placement d’une fonction dans une hiérarchie s’avère bien plus difficile. Nous allons traiter maintenant un cas pratique : prenons l’exemple d’un jeu de labyrinthe où un héros doit trouver un coffre au trésor en évitant des momies. Nous savons que dans ce jeu apparaît un écran Game Over et un écran d’accueil. Nous pouvons rapidement identifier des fonctions importantes à mettre en place :

  • Gestion de l’écran d’accueil

  • Gestion de l’écran de fin

  • Déplacement du héros

  • Déplacement des momies

  • Gestion des collisions entre le héros et les momies

  • Ouverture du coffre

  • Affichage du labyrinthe et des personnages

La structuration basique qui vient à l’esprit immédiatement est la suivante :

../_images/ex2.png

Cette structuration semble correcte car le jeu d’aventure va appeler l’ensemble de ces fonctions. Etant donné que nous n’avons qu’un niveau sous le bloc principal, le principe n°1 sur l’inclusion des thématiques est forcément respecté ! Le principe n°2 qui nous indique que des fonctions voisines doivent avoir des périmètres similaires semble plutôt respecté dans cette hiérarchie car nous avons des blocs avec des thèmes assez larges. Il faut donc repenser cette conception pour que le schéma corresponde à une hiérarchie occupant plusieurs niveaux. Ce qui nous amène à la question importante ? Quels blocs vont occuper le premier niveau ?

Examinons les thématiques des différents blocs. Le bloc du déplacement des momies, le bloc de déplacement du héros et le bloc de gestion du coffre ont des thématiques qui s’incluent dans la gestion de la partie. Le bloc écran d’accueil et le bloc Game Over ont des thématiques qui semblent s’inclure dans la gestion d’écrans permettant de donner des informations aux joueurs. Cette première analyse nous permet d’identifier deux groupes :

  • La gestion des écrans : écran d’accueil et écran de fin

  • La gestion de partie : déplacements, collisions et affichage

Cependant, nous n’arrivons pas à déterminer quelle est la thématique dans laquelle vont s’inclure les blocs du premier niveau. Essayons d’avoir une vision plus large : est-ce que tous les jeux ont des coffres ? Non. Est-ce que tous les jeux déplacent un personnage dans un labyrinthe : Non. Cependant, tous les jeux ont un écran d’accueil, un écran de fin, un écran de chargement de niveau, un écran de jeu principal (le labyrinthe dans notre cas) mais aussi des écrans de jeu bonus… Finalement le premier niveau de notre hiérarchie correspond aux traitements des différents écrans et de leurs transitions. Ainsi, nous avons trouvé une thématique dans laquelle s’inscrivent les thématiques des blocs du premier niveau de la hiérarchie. Ainsi, nous comprenons pourquoi nous n’arrivions pas à créer une hiérarchie valable : il nous manquait un bloc correspondant à la gestion de l’écran de jeu. A ce nouveau bloc, nous pouvons attacher les blocs associés à la gestion de partie.

Réfléchissez à comment corriger cette hiérarchie. Après cela, vous pouvez consulter un exemple de correction.

Le jeu du labyrinthe et des momies

Nous présentons les étapes d’analyse d’un jeu de labyrinthe par un programmeur débutant que nous appellerons Tom. Nous allons découvrir ses choix de conception, ses analyses, les tests et les déceptions de notre nouvel ami. Le but de ce chapitre est de montrer qu’une réflexion sur la conception d’un programme est un point important et qu’elle se construit par phases successives d’essais et de recherche. Ce cas pratique permet ainsi de mettre en œuvre les principes introduits en début de chapitre.

Tom notre ami débutant programmeur

Si vous mettez en place un projet important en juxtaposant des morceaux de code et en espérant que cela se passe bien, malheureusement, cela ne va pas bien se passer. En effet, cette approche induit un manque d’organisation et une absence de structure dans votre projet. Ce dernier finira par ressembler à une pieuvre tentaculaire, les bugs que vous rencontrerez seront très difficiles à corriger et faire évoluer votre programme vous semblera une tâche insurmontable.

Tom va essayer de mettre en place des choix de conception, de découper son projet en parties indépendantes, de représenter les données de la manière la plus adéquate possible et de ne pas mélanger les différents niveaux de hiérarchie dans son programme. Tom aura beaucoup de bonnes idées, mais il s’apercevra que beaucoup de zones d’ombre restent présentes. Il devra donc repenser son modèle de conception, essayer de l’améliorer sans pour autant choisir une solution trop complexe pour son niveau.

A certains moments, Tom devra faire appel à son ami Benjamin pour des problèmes qui le dépassent. En effet, parfois, il est très difficile de trouver l’origine d’une erreur, même si le code semble simple et écrit correctement. Un avis externe lui sera nécessaire.

Ce chapitre présente la construction d’un jeu par un programmeur débutant. Beaucoup de débutants croient qu’il faut avoir la bonne intuition dès le premier instant. Dans le cas contraire, ils concluent que l’on n’est pas fait pour devenir un bon développeur. D’autres pensent qu’il faut s’attacher à tout prix à leur idée de départ, même si celle-ci s’avère peu concluante. En bref, ces comportements sont trop extrêmes. En suivant la progression de Tom, vous allez comprendre qu’on ne peut pas connaître tous les pièges avant d’avoir commencé. Pourtant, dans beaucoup de formation IT, on vous explique qu’il faut rédiger un descriptif complet de l’application et après la programmer, comme ferait un architecte pour une maison. Que vous soyez débutant ou expérimenté, si vous vous attaquez à un programme sortant de vos habitudes, alors, il faut accepter l’idée que des étapes d’essais, de recherche et de reformulation soient nécessaires.

Vous allez donc pouvoir assister aux coulisses de la vie d’un programmeur qui mène son projet de bout en bout. En espérant que cette expérience puisse vous guider dans vos choix de conception, vous décomplexer de vos défauts et de faire aboutir vos projets !

Le scénario

Présentation

Voici le scénario du jeu. Attention, il est relativement court mais de nombreuses difficultés se cachent dans ce sujet :

Un aventurier parcourt un labyrinthe à la recherche d’un trésor. Il doit trouver dans ce labyrinthe une clef servant à ouvrir le coffre aux trésors 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.

Compléments d’informations

Cette description, plutôt claire, laisse encore beaucoup de liberté quant à l’interprétation. Voici donc quelques précisions :

  • Un écran d’accueil permet de lancer la partie. Il pourrait permettre à terme de régler les différentes options comme le niveau de la musique ou la difficulté du jeu qui affecte directement le nombre de momies présentes dans le jeu.

  • 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é. 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 ses 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 clef 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 une difficulté certaine. Nous verrons plus tard quelle approche choisir.

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

Organisation du programme

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

Les données du jeu

Représenter le labyrinthe

Comme le sujet précise que le labyrinthe correspond à une grille 2D, Tom décide de représenter le labyrinthe dans une string. La lettre « M » code un mur et l’espace une case vide. Voici le code créant le labyrinthe :

string plan =    " M           "
                                 " M M MMM MMM "
                                 "   M       M "
                                 "MM M M MMM M "
                                 "   M M     M "
                                 " MMM MMM MMMM"
                                 "   M  M      "
                                 " M M  M M MM "
                                 " M M  M M M  "
                                 " M M MM M MMM"
                                 " M M    M    "
                                 " M M MMMMMMM "
                                 " M      M    ";

Tom ne sait pas trop si son choix est judicieux. Mais par contre, il sait que s’il doit changer la manière dont est représenté le labyrinthe dans son code plus tard, alors il risque de devoir modifier la moitié des lignes de son programme et cela lui déplaît. Il demande donc à son ami Benjamin ce qu’il pense de son choix. Benjamin regarde le code rapidement et donne son avis :

<< Le tableau 2 dimensions est un choix plutôt satisfaisant. Par contre, de ce que je vois, tu vas avoir du boulot en plus ! >>

Tom ne voit pas du tout pourquoi Benjamin dit cela et il lui demande d’expliquer ses pensées :

<< Je ne connais pas ton jeu dans les détails, mais tu vas devoir déterminer si ton héros peut se déplacer à gauche ou à droite en vérifiant la présence d’un mur. Cependant, regarde bien les bords de ton labyrinthe. Beaucoup de couloirs sont présents. Si ton héros se trouve tout à droite et que le joueur appuie sur la flèche droite, tu vas devoir vérifier si un mur se trouve sur la case de droite, mais cette case n’existe pas ! Tu vas sortir du tableau et tu obtiendras une erreur. Tu peux éviter ce problème en ajoutant un test à l’intérieur de chaque test, du type : si mon héros ne se trouve pas tout à droite et qu’il veut aller à droite… Mais cela ajoute pas mal de code à ton programme. En plus, je crois que tu vas devoir recommencer pour les momies. >>

Tom acquiesce mais il ne voit pas du tout comment contourner ce problème. Benjamin explique alors son idée :

<< Si tu ajoutes un mur, qui entoure la totalité de ton labyrinthe, comme une enceinte, cela ne change rien à ton jeu. Si cela te gêne à l’affichage, tu peux toujours faire en sorte de ne pas afficher ce bord. Cependant, en ajoutant cette enceinte, ni le héros, ni les momies ne peuvent se trouver au bord du terrain de jeu. Ainsi, chaque fois que tu vas analyser les cases autour de tes personnages, ces cases seront à l’intérieur du terrain de jeu et plus besoin d’ajouter de tests pour éviter ce problème. Cette solution est un peu inhabituelle, mais crois moi, elle va simplifier ton programme et en plus elle est d’une grande simplicité. >>

Tom trouve effectivement l’idée séduisante. Cette légère modification permet apparemment de retirer une bonne vingtaine de tests, autant l’appliquer directement. Voici le nouveau code de Tom :

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";

Avertissement

Un programme traite des données et ces données génèrent des cas particuliers qui nécessitent chacun un traitement spécifique. Si le cas général requiert 10 lignes de code et que 3 cas particuliers existent et requièrent eux aussi 10 lignes de code chacun, nous arrivons à un total de 40 lignes soit 4 fois plus que pour le cas général. Si on étend cet exemple à un programme de 4000 lignes, cela sous-entendrait que 3000 lignes existent uniquement pour gérer les cas particuliers. Si en créant des données fictives (comme notre enceinte de murs), on peut faire disparaître des cas particuliers, surtout, ne vous privez pas de ce tour de magie !

Pour que la visualisation de la string dans le code source corresponde au labyrinthe à l’écran, Tom rajoute une petite fonction utilitaire :

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

Elle lui permet pour une case de coordonnées (x,y) de trouver la lettre correspondante dans la chaîne de caractères et ainsi de vérifier s’il s’agit d’un mur.

Représenter les éléments du jeu

Chaque case du labyrinthe correspond carré d’une certaine largeur. correspondant soit à un mur soit à un couloir. Cependant les personnages se déplacent à l’intérieur de se labyrinthe. Tom veut que les personnages se déplacent de manière fluide, pas de case en case comme dans un jeu d’échec. Tom décide ainsi de stoker la position du joueur et des momies en utilisant des coordonnées pixels. De quelles informations Tom a-t-il besoin pour gérer les momies ? La position est primordiale, mais quoi d’autre ? Tom hésite, car si seulement la position est utile, il ne créera pas de structure. Cependant, il pense qu’il faut aussi conserver d’autres informations que la position. En réfléchissant, Tom se rappelle que les momies ont une IA de déplacement et sûrement faudra-t-il conserver leur direction courante pour savoir vers où elles avancent. Ok, Tom pense finalement qu’il vaut mieux créer une structure pour ses momies :

struct Momie
{
   V2 PosPixels;
   V2 DirDeplacement;
   ...
};

Il code ensuite les trois momies :

Momie Momie1 = ... // Momie numéro 1
Momie Momie2 = ... // Momie numéro 2
Momie Momie3 = ... // Momie numéro 3

Tom est satisfait par son approche. Il n’arrive pas à identifier de problèmes potentiels. Il demande alors son avis à Benjamin :

<< Ton choix de conception est satisfaisant. Cependant, il reste valable tant que tu as un faible nombre de momies dans le jeu. Si tu dois gérer beaucoup plus de momies, tu va devoir faire un traitement pour chaque momie en dupliquant le code. Utilise plutôt un tableau ou une liste dynamique. >>

A ce niveau, Tom a 2 options, car il ne connaît pas les listes dynamiques :

  • Conserver une variable par momie.

  • Créer un tableau de momies. Une boucle permet de gérer chaque momie. Une variable NbMomies stockera le nombre de momies présentes.

Tom doit maintenant lister les informations du héros, il trouve les informations suivantes :

  • La position en pixels

  • Le nombre de vies restantes

  • S’il possède la clef ou pas

  • Les pièces d’or ramassées

Tom décide alors qu’une structure sera utile pour regrouper toutes ces informations.

Note

Comme dans le projet Flipper, vous devrez utilisez une seule variable globale G stockant l’ensemble des informations de partie.

Gestion du jeu

Gestion de partie

Tom se concentre maintenant sur la fonction gérant la partie en cours. Il cherche à maintenir une hiérarchie de fonctions organisée. Comme cette partie du code est haut niveau : elle déclenche séquentiellement toutes les actions de gestion du jeu, on devrait y trouver essentiellement des appels de fonctions. A son premier essai, Tom obtient la fonction suivante :

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

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

   GestionClef();
   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. Le séquencement est correct : d’abord on déplace le héros et ensuite on vérifie si ce dernier se trouve au niveau de la clef. Raisonnement identique pour le trésor ou les momies… Lors des premiers tests tout se passe correctement. Fier de son code, Tom va le montrer à Benjamin qui lui pose la question suivante :

<< A quoi servent les deux lignes : G.Hero.PosPixels et G.Hero.NbVies>>

Tom ne comprend pas pourquoi Benjamin ne saisit pas le sens de ces deux lignes. La réponse est pourtant simple. Toutes les lignes sont construites avec des appels de fonctions avec des noms très explicites, et à cet endroit des variables sont modifiées, plusieurs d’ailleurs sans que l’on sache pourquoi. Tom répond alors à Benjamin : <<ce sont les traitements lorsque le héros meurt.>> Ce à quoi Benjamin répond aussitôt :

<<Met ces lignes dans une fonction, comme cela, si tu veux rajouter d’autres traitements, tu sauras directement où les mettre>>.

En effet, Tom a un doute, il hésite car il trouve que si le héros garde la clef en mourant, le jeu est trop facile. Peut-être vaut-il mieux créer cette fonction et y reporter tous les traitements relatifs à la mort du héros.

Déplacement du héros

Tom 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

La partie difficile consiste à gérer la collision du héros avec un bloc de mur. Le dessin du héros peut se modéliser par un rectangle. Chaque case représentant un mur se modélise par un carré. Pour tester si la nouvelle position du héros est correcte, on peut tester l’ensemble des cases murs avec le rectangle englobant le héros. Si aucune intersection n’est détectée, alors la nouvelle position du héros est valide.

bool DetectCollisionMomies(V2 P)
{
        ...
        for (int x = 0; x < 15 ; x++)
        for (int y = 0; y < 15 ; y++)
                if ( G.Mur[x][y] == 1 )
                        if ( CollisionRectRect(...,...) )
                                return false;
        return true;

}

Pour savoir comment gérer la collision entre deux rectangles aux bords verticaux/horizontaux, Tom consulte la page sur les détections de collision.

Note

La taille du dessin du héros dépendra de ce que vous choisirez. Il serait donc utile de rajouter sa largeur et sa longueur en pixels dans les paramètres du héros.

Pour la collision avec les pièges et les objets, on peut reprendre exactement le même principe.

L’IA des momies

Faire des aller-retours

Tom n’a jamais programmé d’IA, il ne sait pas vraiment comment s’y prendre. Il se dit qu’il pourrait essayer de programmer un déplacement de gauche à droite, ou de haut en bas. Tom met en place la première version de sa fonction DeplacementMomies().

Tom a remarqué que le déplacement de la momie nécessitait de tester la collision avec les murs du labyrinthe comme lors du déplacement du héros. Il a donc pris le code gérant les collisions du héros pour créer une fonction commune : PositionValide(…). Tom teste sa méthode. Il est très fier car il voit pour la première fois ses momies bouger à l’écran.

Voici son premier essai :

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);
}

Note

Si vous trouvez que le héros ou les momies se déplacent trop vite, il faut utiliser des valeurs faibles pour le vecteur Direction comme 0.5 ou 0.1 ou 0.05.

Tom savoure sa victoire : il a appliqué cette fonction aux trois momies et ils les voient maintenant se déplacer à l’écran. Mais au bout de quelques minutes de tests, Tom se rend compte que les momies n’explorent pas la totalité du labyrinthe et ne font que rebondir de droite à gauche contre les murs. Finalement, elles ne représentent que peu de danger pour le héros ou elles bloquent totalement un passage. Il va falloir changer cela rapidement.

Déplacement aléatoire

Tom ne voit pas trop comment faire pour créer une IA pour les trois momies. Il faudrait qu’elles se déplacent vers le héros, mais le labyrinthe ne permet pas de choisir facilement une direction qui rapprochent les momies de l’aventurier. Tom en discute avec Benjamin car il pense qu’il ne peut pas mettre en place une IA digne de ce nom. Benjamin qui a un peu plus d’expérience dans le monde du jeu est beaucoup plus pragmatique dans son approche. Il explique à Tom :

<< Tu sais ce n’est pas grave si les momies ne se comportent pas comme de vrais chasseurs qui poursuivent ton héros à la trace. Mets en place un déplacement aléatoire, cela suffira largement. Le premier avantage est que les momies vont enfin se déplacer dans tout le labyrinthe. Ensuite, elles vont sûrement se retrouver à côté du héros à un moment ou à un autre. Le joueur croira que c’est une forme d’IA alors qu’en fait c’est le hasard ! Dans tous les cas, l’aléatoire a deux avantages : il est simple à mettre en place et il empêche les joueurs de s’habituer à une forme de routine mécanique qu’auraient les momies avec un algorithme plus avancé. L’aléatoire empêche de produire un comportement répétitif. Du coup, les joueurs penseront que ton IA est très évoluée. >>

Tom est assez d’accord, s’il tentait de mettre en place un algorithme complexe trouvé sur le net, il y aurait de grandes chances qu’il n’arrive pas à le faire fonctionner correctement. Si par chance il y arrivait, les momies risqueraient de se comporter toujours suivant le même schéma. De plus si l’algorithme est vraiment efficace, les momies vont fondre sur le héros et il n’aura aucune chance. L’aléatoire lui laisse plus de chance. Tom est convaincue et il pense que cette approche est à sa portée. Tom modifie donc la fonction DeplacementMomies() pour intégrer de l’aléatoire. Son idée de départ est la suivante :

...
// 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];

Lorsqu’une momie arrive face à un mur et qu’elle ne peut plus avancer, on choisit une nouvelle direction parmi les 4 directions existantes. Il n’y a pas de déplacement, juste un changement de direction. Au prochain passage, soit on a tiré une direction amenant vers une position correcte et la momie se déplacera, soit on a tiré une direction qui envoyait dans un mur et dans ce cas là la momie n’avance pas et on retire une direction au hasard.

Tom teste cette nouvelle version et enfin il voit les momies explorer la totalité du décor. C’est très satisfaisant. Changer uniquement de direction lorsque les momies arrivent au bout d’un couloir produit un effet d’errance de ces monstres à l’intérieur du labyrinthe ce qui correspond parfaitement à ce que Tom attendait des momies !

Bilan du projet

Hiérarchie actuelle

Un fois le projet terminé, Tom dessine la hiérarchie des fonctions de son programme :

../_images/bilan.png

Comme nous l’avons déjà vu, le premier niveau de la hiérarchie correspond à la boucle principale de l’application qui aiguille le programme pour gérer/afficher tel ou tel écran du jeu. Au second niveau, nous trouvons les fonctions qui gèrent les cinq écrans principaux. Au troisième niveau, se trouve les fonctions en lien avec la gestion de la partie en cours. Nous y trouvons que des traitements correspondant au scénario du jeu. Le quatrième niveau contient des fonctions effectuant un traitement très précis : détection de la collision, tirage aléatoire.

Pourquoi présenter la mise en place d’un projet ? Pour être à l’aise à programmation et en conception, il faut se confronter à des projets longs de plusieurs centaines de lignes. Mais il semble peu judicieux de se frotter à un projet important avec peu d’expérience. En accompagnant Tom, vous avez pu suivre le projet en étant dans les coulisses, pas de strass, pas de paillettes, vous avez pu voir Tom hésiter, tenter d’appliquer les principes généraux pour construire un programme le plus judicieusement possible, constater ses oublis et ses erreurs, essayer à nouveau, retester, être déçu de son idée qu’il pensait géniale et être enfin satisfait de son travail. Mais maintenant c’est à votre tour !

Avertissement

Il ne faut pas oublier dans un jeu que nous avons une fonction Logic() pour gérer la logique et une fonction Render() pour gérer l’affichage. Nous nous sommes essentiellement concentré sur la fonction Logic() dans notre présentation. La fonction Render() n’ayant pas une mécanique complexe à mettre en place.

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. Elle va nous servir pour remplir l’intérieur de nos objets, notamment les rectangles. Une texture est sans dimension, même si elle correspond à une image de pixels avec une largeur et une hauteur, elle sera toujours étirée pour recouvrir le rectangle en entier.

La librairie G2D fournit une fonction très pratique permettant de convertir une string stockant les couleurs de chaque pixel dans une texture. Ainsi, même si vous ne maîtrisez aucun logiciel graphique et que vous n’êtes pas sûr de vous en dessin, normalement, vous pouvez à partir de cette méthode dessiner des objets de manière assez ludique ceci dans un style 8bits retrogaming. Voici l’exemple de la chaîne de caractères stockant 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. Elle prend en paramètres une référence sur un objet V2 qui va être initialisé pour stocker la taille de la texture ainsi qu’une chaîne de caractères qui contient les données couleur. Cette fonction retourne un numéro d’identification unique de cette texture dans la carte graphique. Ce numéro devra être transmis à la fonction DrawRectWithTexture() pour que la texture soit utilisée.

Quelle différence entre un sprite et une texture ? Question difficile car ces deux notions sont très proches. La texture, c’est l’image originale en pixels. La texture est stockée dans la carte graphique (GPU). Le sprite, c’est un élément graphique du jeu qui utilise cette texture. Le sprite a une position, une largeur et une hauteur et un identifiant de texture. Le sprite c’est le contenant/la bouteille et la texture c’est le contenu/l’eau dans la bouteille.

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

Comme vous l’avez sûrement compris, Tom n’est qu’un ami virtuel. Il va donc falloir travailler pour mettre en place le projet labyrinthe.

L’ensemble des tâches a été décrit 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 clef, pour ouvrir le coffre il faut la clef !

  • La partie se termine lorsque le coffre est ouvert => WIN => 3 secondes plus tard retour à l’écran d’accueil.

  • 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é.

Il vous sera demandé de créer une deuxième texture pour le héros 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. Dans le cas contraire, on a l’impression qu’il flotte au dessus du décor. Pour le rythme, on peut alterner une fois par seconde ou toutes les n frames d’affichage.

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 !