Buts: Il s'agit lors de cette étape :
Avant de nous pencher sur le codage de cette étape, un petit détour sur la question de comment représenter les paramètres caractéristiques d'une simulation s'impose.
Il peut en effet y en avoir de très nombreux. Par exemple, quelles dimensions donner aux différents composants graphiques, quelles textures utiliser pour dessiner les animaux, etc.
Jusqu'ici, nous avons procédé de façon très ad hoc : quelques constantes codées dans le fichiers Constants.hpp sont utilisées par exemple pour configurer la texture à associer à un animal ou encore les caractéristiques de son champ de vision.
L'inconvénient de procéder ainsi est qu'à chaque fois que l'on veut modifier ces valeurs, il faut recompiler et relancer le programme.
Pour contourner cette limitation, nous allons désormais tirer pleinement parti de la classe fournie Config. Cette dernière contient la définition des constantes utiles au programme qu'elle va lire dans un fichier de configuration au format JSON (voir ci-dessous) . Le noyau de simulation fourni (Application) est doté d'un attribut de type Config et d'une méthode getAppConfig qui permet d'aller puiser des valeurs dans le fichier de configuration. Pour changer une valeur en cours de simulation, sans avoir à recompiler le code, il suffit d'éditer le fichier JSON utilisé, le sauvegarder puis d'aller dans la fenêtre de simulation et y recharger ces modifications au moyen de la touche 'C' ('C' pour "Configuration").
Ceci nous permettra de faire varier à souhait les exécutions en modifiant simplement le fichier de configuration et donc d'expérimenter facilement de nombreuses situations différentes sans avoir à recompiler et relancer le programme.
Il existe plusieurs formats standard pour la modélisation de données (XML, JSON, YAML etc), qui d'ailleurs ne sont pas uniquement utilisés dans le contexte de la programmation.
Pour ce projet, nous avons pris le parti d'utiliser le format JSON pour répertorier les paramètres caractéristiques de la simulation.
Les fichiers de configuration du projet sont les fichiers avec l'extension .json dans le dossier res/.
Le répertoire src/JSON contient toutes les fonctionnalités nécessaires à la récupération des données depuis un tel fichier. Ce sont ces fonctionnalités qu'utilise la classe Config pour aller lires des données dans les fichiers .json.
Dans les fichiers .json, les paramètres sont étiquetés de manière hiérarchique au moyen de chaîne de caractères qui permettent de les décrire.
Par exemple :
{ "simulation" : { ... "animal" : { ... }, "scorpion":{ "mass":0.7, "size":100, "longevity":80000, "max speed":100, ... "texture": "scorpion.png" ... }, ... }se lit comme :
La classe utilitaire fournie Config permet d'accéder à la valeur d'un paramètre donné (en l'associant à une constante publiquement accessible). Par exemple la constante scorpion_texture permettra d'accéder à la valeur du paramètres étiqueté ["simulation"]["animal"]["scorpion"]["texture"] dans le fichier .json.
Comme dit plus haut, la classe Application dispose d'un attribut de type Config, qu'elle d'initialise au moyen du bon fichier .json, et c'est par ce biais que vous pourrez accéder aux paramètres par une tournure de ce type :
getAppConfig().scorpion_texture;
Chaque test que vous lancerez, graphique ou pas, pourra être paramétré par le fichier de configuration souhaité, par exemple, en ligne de commande :
./ppsTest app.jsonpermettra de lancer la cible ppsTest en utilisant app.json comme fichier de configuration.
Le noyau de simulation fourni prévoit que certains éléments essentiels soient contrôlables par des touches, typiquement:
Si vous ouvrez le fichier fourni Application.hpp, vous constaterez (ligne 146) qu'un type énuméré modélise ces contrôles. La méthode Application::handleEvent (ligne 486 de Application.cpp) est programmée de sorte à ce que la touche Q permette de basculer d'un contrôle à l'autre et une fois un contrôle donné choisi, les touches PgUp et PgDn (ou 9 et 8) permettent de faire varier les valeurs du paramètre contrôlé. Ici, si le paramètre contrôlé est la température, les touches PgUp/9 et PgDn/8 appeleront respectivement des méthodes Environment::increaseTemperature et Environment::decreaseTemperature pour faire varier le contrôle «température».
Pour que le code soit compilable ces méthodes (et quelques autres) doivent être complétées dans votrs classe Environment.
Commencez par modéliser le fait que la température devienne un élément caractéristique de l'environnement. Dotez ensuite votre environnement de méthodes publiques permettant d'initialiser de consulter ou de modifier cette température:
Jusqu'ici, un environnement était constitué d'un ensemble d'animaux et de cibles, aucun des animaux ne pouvant être assimilé à une cible pour un autre.
Nous allons, à cette étape, plutôt considérer que l'environnement est constitué d'un ensemble d'êtres vivants/entités organiques, dont certains sont « consommables » par d'autres (et peuvent par conséquent devenir des cibles).
Dans le répertoire Environment/, créez une nouvelle classe OrganicEntity dont héritera votre classe Animal : désormais, un animal est une entité organique de l'environnement. Il en sera de même pour les cactus que mangeront nos lézards.
Il doit être possible de faire des tests de collision avec toute entité organique car le fait d'en rencontrer une sur son chemin peut avoir une incidence sur le cours de la simulation. Par conséquent, le lien d'héritage liant jusqu'à présent un Animal avec un Collider devra être déplacé un cran plus haut. La hiérarchie à laquelle nous voulons aboutir est en effet la suivante :
La classe OrganicEntity aura pour le moment uniquement un attribut pour son niveau d'énergie (un double) . Nous anticipons ici le fait que toute entité organique est mortelle (elle meurt notamment si elle a un niveau d'énergie trop bas).
Dotez la classe OrganicEntity d'un constructeur prenant en paramètre une position, une taille et un niveau d'énergie (dans cet ordre). Le paramètre relatif à la taille servira à initialiser le rayon de l'entité en tant que Collider (la taille fournie en paramètre sera considérée comme étant le double du rayon).
Adaptez ensuite votre constructeur de la classe Animal de sorte à prendre en compte ces modifications. Ce dernier prendra désormais en paramètre une position, une taille et un niveau d'énergie.
Dans la foulée et puisque les animaux vont se reproduire d'ici à la fin de cette étape, on peut aussi doter les animaux d'un attribut supplémentaire : un booléen indiquant s'il s'agit d'une femelle ou pas. Cet attribut sera aussi initialisé au moyen d'une valeur passée comme dernier paramètre du constructeur.
Nous sommes maintenant prêts à créer animaux spécifiques : des lézards (classe Lizard) et des scorpions (classe Scorpion). Pour l'instant ces classes n'ont rien de particulier en terme d'attributs. Elles seront codées dans le répertoire Animal/.
Notre classe Animal doit par contre subir quelques retouches:
Nous nous attaquerons un peu plus loin à la méthode update. Commentez son corps pour le moment.
Les getters peuvent, par contre, trouver des définitions concrètes dans les classes Lizard et Scorpion :
[Question Q3.1] Quelles méthodes avez-vous décidé de déclarer comme virtuelles pures dans la classe Animal ? A quels endroits l'utilisation du mot clé override vous semble t-il pertinent ? Répondez à ces questions, en justifiant vos choix, dans votre fichier REPONSES.
Vous doterez les classes Lizard et Scorpion de deux constructeurs chacune : l'un prenant en paramètre la position de départ de l'animal (un Vec2d), son niveau d'énergie et un booléen indiquant s'il s'agit d'une femelle ou pas. L'autre, prenant en paramètre uniquement une position et initialisant le niveau d'énergie avec un niveau par défaut dont la valeur est configurable (getAppConfig().lizard_energy_initial et getAppConfig().scorpion_energy_initial). Le sexe de l'animal sera tiré au hasard par ce constructeur.
[Question Q3.2] : que doit-on retoucher dans le fichier .json si l'on souhaite par exemple modifier en cours de simulation le niveau d'énergie initial par défaut des lézards? Répondez à cette question, dans votre fichier REPONSES.
Pour le scorpion, le nom du fichier de texture est paramétrable dans les fichiers .json sous l'étiquette "texture" de "scorpion" et est accessible via getAppConfig().scorpion_texture.
Pour la lézard, vous utiliserez getAppConfig().lizard_texture_female si c'est une femelle
et getAppConfig().lizard_texture_male sinon.
Les fichiers de configuration .json contiennent une étiquette "debug"
{ "debug":true, ... }Il est possible de l'exploiter pour avoir des affichages différenciés selon que l'on est en mode « debugging » ou pas.
La fonction isDebugOn(), définie dans les fichiers Application.[hpp][cpp], retourne true si la valeur associée à l'étiquette "debug" dans le fichier .json qui a servi à lancer le programme vaut true et false sinon.
Faites en sorte que le champ de vision et la cible virtuelle utilisées pour le déplacement aléatoires ne soient plus visibles que lorsque le mode «debugging» est activé (isDebugOn() retourne true).
Voilà, nous disposons, en principe, à ce stade d'une petite hiérarchie d'animaux concrets. Il nous reste à apporter les adaptations adéquates à l'environnement pour prendre en compte les changements de conception opérés jusqu'ici.
Jusqu'ici, la classe Environment recensait la liste des animaux présents et un ensemble de cibles, arbitrairement définies, à atteindre par ces animaux. Ce second attribut n'a plus vraiment raison d'être puisque les cibles seront à trouver parmi les entités organiques de l'environnement.
Pour adapter l'environnement à nos récents développements, une façon de procéder consiste à :
[Question Q3.3] : coder le type de retour de getEntitiesInSightForAnimal de façon analogue à celui de getTargetInSightForAnimal n'est pas une bonne solution du point de vue de l'encapsulation. Expliquez pourquoi dans votre fichier REPONSES et proposer une alternative de codage assurant une meilleure encapsulation.
La méthode clean doit désormais vider le monde de toutes ses entités organiques (vous pourrez ainsi plus tard, repeupler l'environnement à votre guise!). Vous prendrez soin de gérer la mémoire de façon adéquate.
Si vous avez correctement mis en place les modifications suggérées jusqu'ici vous devriez pouvoir tester l'affichage polymorphique des animaux de l'environnement.
Le test graphique Tests/GraphicalTests/PPSTest.[hpp][cpp] est fourni à cet effet. Il va servir de base à tous les tests graphiques de l'étape 3 («PPS» pour «PredatorPreySimulation»). Pour l'exécuter, décommentez la cible ppsTest dans votre fichier CMakeLists.txt (comme d'habitude, 4 lignes sont à décommenter).
Ouvrez le fichier Tests/GraphicalTests/PPSTest.cpp et examinez la méthode onEvent. Vous constaterez qu'elle est programmée de sorte que l'appui sur la touche 'S' ajoute un scorpion dans votre collections hétérogène d'entités organiques, et la touche 'L' y ajoute un lézard (la touche 'R' qui provoque l'invocation de la redoutable clean pourra les faire disparaître). N'oubliez pas de décommenter les appels à addEntity dans ce fichier lorsque vos classes Scorpion et Lizard sont prêtes à être testées.
Lancez le test fourni. vous devriez voir s'afficher un scorpion (idem pour les lézards avec la touche 'L') (notez que vous pouvez zoomer/dezoomer avrec la molette de la souris) :
![]() |
![]() |
Activez ensuite le mode «debugging» (en appuyant sur la touche 'D', puis en appuyant sur la touche 'C' dans la fenêtre. Vous devriez voir s'afficher des caractéristiques distinctes pour le champ de vision (entre la lézard et le scorpion).
Vérifiez que la touche 'R' vide bien l'environnement de tout contenu.
Changez différentes valeurs (paramètre du champs de vision par exemple) dans votre fichier app3.json et vérifiez que cela se répercute proprement dans la simulation sans que vous ayez besoin de recompiler le code (ceci est valable pour tous les paramètres sauf ceux dimensionnant le monde et la fenêtre graphique).
si vous souhaitez que les lézards se dessinent d'une façon moins figée, une façon simple de procéDer est de faire en sorte que leur texture s'alternent aléatoirement entre deux images possibles. Vous disposez de textures additionnelles pour gérer cet aspect (getAppConfig().lizard_texture_male_down et getAppConfig().lizard_texture_female_down).
Sur la base de ce que vous avez fait jusqu'ici, ajoutez à votre conception une classe Cactus (dans le répertoire Environment/) matérialisant les cactus dont nos lézards pourront se nourrir.
Vous considérerez qu'un objet de type Cactus est-un OrganicEntity (mais pas un animal !). Le niveau d'énergie est assimilié à la "valeur nutritive" qu'il apporte.
La position initiale d'une Cactus sera transmise au constructeur et vous pourrez utiliser la valeur de getAppConfig().food_energy pour donner un niveau d'énergie initial de départ. Pour un cactus, la taille et le niveau d'énergies sont identiques.
Pour le dessin, vous procéderez de façon analogue aux animaux en utilisant getAppConfig().food_texture en guise de texture.
Vous pouvez considérer à ce stade qu'un Cactus évolue ... en ne faisant rien (mais il peut être amené à croitre/vieillir/se déseccher dans le futur). Vous pouvez ajouter un commentaire dans sa méthode update
// TODO : Evolution du cactus à programmer
Pour tester votre codage, utilisez à nouveau le test PPSTest.cpp.
Vous prendrez soin au préalable d'ouvrir le fichier Tests/GraphicalTests/PPSTest.cpp et d'y décommenter l'ajout de nourriture au moyen de la touche 'G' (pour "Grove", la touche 'C' étant prise :-/).
Voici l'affichage que vous devriez obtenir en lançant le programme et en appuyant sur la touche 'G' :
Vérifiez que la touche 'R' fait aussi disparaître correctement les Cactus.
Vous considérerez qu'un nuage est un Collider dont la position est la taille sont données à la construction. Il évolue au cours du temps selon la modalité suivante: si la température excède la valeur getAppConfig().cloud_evaporation_temperature, il perd getAppConfig().cloud_evaporation_rate de son rayon (rayon = rayon - taux d'évaporation * rayon). Lorsque son rayon devient inférieur à getAppConfig().cloud_evaporation_size il disparaît. Un nuage se dessine au moyen de la texture désignée par getAppConfig().cloud_texture.
Les nuages doivent apparaître aléatoirement dans l'environnement lorsque certaines conditions sont remplies (voir plus bas).
Pour permettre la génération aléatoire de nuages, complétez la classe CloudGenerator (dans le répertoire Environment/). Cette dernière est caractérisée par un compteur de type sf::Time, mesurant le temps écoulé depuis la précédente génération de nuage. Le constructeur par défaut initialisera ce temps à sf::Time::Zero.
Pour faire évoluer ce compteur au cours du temps, il faut que la classe CloudGenerator dispose d'une méthode update(sf::Time dt).
La méthode update va mettre en oeuvre l'algorithme suivant :
Pour finir, ajoutez à votre classe Environment :
Vous considérerez que l'environnement à des périodes de sécheresse de durée sf::seconds(getAppConfig().environment_drought_duration). Le générateur de nuages ne doit entrer en action que s'il a fait sec pendant une telle durée et que la température est inférieure au seuil getAppConfig().environment_rain_temperature.
Vous veillerez enfin à ce que le nombre de nuages ne puisse dépasser la valeur getAppConfig().environment_max_clouds. Enfin, la touche R doit désormais aussi supprimer les nuages.
Ouvrez le test PPSTest.cpp et décommentez l'appel à addGenerator dans la méthode onSimulationStart.Cette méthode, qui exécute les traitements nécessaires au démarrage de l'application, ajoute un générateur de nuages à la collection de générateurs de l'environnement.
Lancez à nouveau PPSTest vous devriez voir apparaître spontanément des nuages dans l'environnement lorsque la température seuil est atteinte, et les voir cesser d'être générés et s'évaporer quand la température remonte, comme dans la vidéo ci-dessous:
[Video : Génération automatique des nuages + évaporation à la chaleur] |
Vous pouvez jouer sur les constantes getAppConfig().simulation_time_factor et sur getAppConfig().cloud_generator_delta pour accélérer ou décélérer la génération automatique de nuages et sur la constante getAppConfig().environment_drought_duration pour augmenter ou diminuer la durée des période de sécheresse. Vérifiez que les nuages cessent d'etre générés lorsque leur nombre maximal est atteint ou lorsque vous faites remonter la température.
if (bernoulli(getAppConfig().environment_raining_probability)...)pour tester la probabilité d'occurence d'un épisode pluvieux.
pointerCollection.erase(std::remove(pointerCollection.begin(), pointerCollection.end(), nullptr), pointerCollection.end());(revoir le cours du semester passé sur la STL) Faites attentions à inclure le fichier d'entête <algorithm> pour pouvoir utiliser la fonction remove.
Les nuages doivent désormais aussi s'évaporer pendant les épisodes pluvieux. Pour pouvoir repérer visuellement les épisodes pluvieux, vous ferez en sorte que pendant qu'ils ont lieu, la position des nuages s'incrémente de Vec2d(uniform
Pour terminer cette partie, il vous est demandé de modéliser le fait que lorsqu'un épisode pluvieux s'achève, le sol reste humide pendant une durée de getAppConfig().environment_humidity_duration. Pendant cette période d'humidité, le niveau d'énergie est multiplié par getAppConfig().food_growth_rate à chaque pas de simulation. Ce niveau ne peut toutefois dépasser la valeur getAppConfig().food_max_energy.
Lancez à nouveau PPSTest. Placez quelques cactus et faites baisser la température. Vous devriez observer des phénomènes pluvieux cyclique qui ont un impact sur la croissance des cactus:
[Video : Épisodes pluvieux (en accéléré)] |
Le répertoire fourni Interface/ fournit deux entêtes de classe abstraites Updatable et Drawable.
Pour plus de clarté dans la conception on peut faire hériter tout objet dessinable de Drawable et tout objet qui évolue au cours du temps de Updatable.
Nous allons le voir en détail un peu plus tard dans le semestre, mais en C++ il est possible qu'une classe hérite de plusieurs super-classes. Le principe quant à l'héritage lui-même reste fondamentalement le même. Pour spécifier un héritage multiple (ici une classe d'objets dessinable et évoluant au cours du temps), il suffit de lister les héritages en les séparant par des virgules :
class MaClasse : public Drawable, public Updatable(la classe MaClasse hérite de Drawable et de Updatable). Il n'est bien sûr pas indispensable d'hériter systématiquement des deux.
[Question Q3.4] Comment proposez-vous d'utiliser les classes Updatable et Drawable dans votre conception (on peut considérer que toute entité organique évolue au cours du temps, par exemple les cactus évoluent en poussant). Répondez à ces questions, dans votre fichier REPONSES et adaptez votre code en conséquence.
Vous avez appris récemment que dans une hiérarchie polymorphique il était conseillé de programmer les destructeurs comme vituels... pensez-y!