Projet : étape 3.3
Subsistance

Buts: Faire en sorte que nos hamsters identifient correctement ce qui leur sert de nourriture et puisse se nourrir.

Est-ce consommable ?

Il est souhaitable de pouvoir garantir qu'une entité simulée soit dotée ou non de la faculté de manger et, dans ce cas, ne dévore pas systématiquement tout ce qu'elle rencontre lors de ses déambulations. Nous nous intéressons donc ici à mettre en oeuvre une méthode telle que:

bool canConsume(Entity const* other)
retournant true si this peut consommer other.

La première question qui se pose est de savoir s'il est judicieux de programmer cette méthode au niveau des entités simulées, plutôt que plus bas dans la hiérarchie, au niveau des animaux ? Nous allons en fait opter pour cette option car il n'est pas incongru que toute entité simulée puisse s'interroger sur ce qu'elle peut faire ou non en cas de collision avec une autre entité.

On ne sait clairement pas définir la méthode canConsume au niveau d'abstraction des entités simulables.

Imaginons maintenant comment on pourrait redéfinir concrètement la méthode canConsume pour des hamster par exemple.

Dans la classe Hamster, l'idée la plus évidente serait d'écrire quelque chose comme

bool Hamster::canConsume(Entity const* other) {
  // si other est un tas de granulés  alors retourner true
  // si other est un hamster retourner false
  // etc.
}

hum... nous faisons là de vilains tests de type.

[Question Q3.14] Pourquoi tester le type des objets à l'exécution est-il potentiellement nocif  ? Répondez à cette question dans votre fichier REPONSES.

Il est possible de les éviter en "simulant" ce que l'on appelle le double dispatch : on contourne le fait que les méthodes en C++ ne soient pas polymorphiques sur les arguments, mais uniquement sur this, en utilisant la surcharge.

L'idée est la suivante. On définirait dans Entity des méthodes virtuelles pures telles que :

virtual bool canConsume(Entity const* entity) const = 0;
virtual bool consumableBy(Hamster  const* hamster) const = 0;
virtual bool consumableBy(Pellets const* Pellets) const = 0;

La méthode bool Hamster::canConsume(Entity const* entity) s'écrirait comme :

return entity->consumableBy(this);
Ce qui ce traduit par : le hamster peut consommer l'entité simulée, si l'entité en question peut se faire consommer par elle!! cette pirouette nous permet d'utiliser le polymorphisme et d'éviter les tests de types.

Avec bien sûr, Hamster::consumableBy(Hamster const*) retournant toujours faux et Pellets::consumableBy(Hamster const*) retournant vrai; ce qui peut être amené à changer si un tas de granulés trop désséché n'intéresse plus les hamsters, par exemple.

On procéderait de façon analogue pour toutes les sous-classes de Entity.

Supposons que l'on ait le code suivant :

vector<Entity*> entities({new Hamster(..), new Pellets(..)});
cout << v[0]->canConsume(v[1]) << endl;;

On veut tester si pour la hamster (v[0]), le tas de granulés (v[1]) est mangeable. C'est la méthode Hamster::canConsume qui va être invoquée et cette dernière va exécuter :

v[1]->consumableBy(this) // this étant v[0] : les granulés sont ils consommable par le hamster?

Si vous l'avez programmé proprement dans le classe Pellets cela devrait retourner vrai.

On peut ainsi tester pour n'importe quelle paire d'entités simulées si la première peut consommer la seconde, sans faire de test de type.

Test 16  : Consommables (double dispatch)

Le fichier de test Tests/UnitTests/ConsumableTest.cpp est fourni pour tester ces derniers développements. Ouvrez-le et examinez les scénarios de tests qui y sont prévus. La cible à lancer est consumableTest (à décommenter au préalable).

Vous devriez alors obtenir la trace d'exécution suivante :

Using ./build/../res/app.json for configuration.
===============================================================================
All tests passed (10 assertions in 2 test cases)

Maintenant que les hamsters savent que les granulés sont mangeables, nous allons modifier leur comportement pour qu'ils soit attirés par ces sources de nourriture. La méthode de déplacement va donc être impactée par ces changements, puisque les sources de nourriture exerceront une force d'attraction.

Attraction vers une cible

Il s'agit de faire en sorte que la méthode Animal::update mette en oeuvre un algorithme plus évolué, ressemblant à ceci:

  1. Analyser l'environnement pour trouver la source de nourriture la plus proche parmi celle de sa cage.
  2. S'il y en a une, et si l'animal n'est pas rassasié (voir plus bas), passer à l'état TARGETING_FOOD. Dans cet état, le mode de déplacement change car la cible exerce une force d'attraction sur l'animal qui va alors se diriger vers elle.
  3. S'il y a collision avec la cible (et que l'animal n'est pas rassasié), il passe à l'état FEEDING et mange. Le fait de manger se traduira par une diminution de l'«énergie» de la source de nourriture et augmentation de la sienne (voir plus bas).
  4. Si l'animal est rassasié, il se remet en mode "déambulation" (WANDERING) et déambule au hasard au moyen de la méthode move(sf::Time) précédemment codée.

Nous allons dans ce qui suit commencer par développer quelques outils utiles à la mise en oeuvre de cet algorithme avant d'y revenir.

Pour connaître l'objet Lab simulé, vous pouvez recourir à la méthode getAppEnv de la classe Application.

Déplacement accéléré vers une cible

Le point 2 de l'algorithme ci-dessus implique que la méthode de déplacement utilisée à ce moment n'est plus celle utilisée en jusqu'ici mode WANDERING. Il s'agira plutôt d'une méthode move(const Vec2d& force, sf::Time dt), conditionnée par une force d'attraction, force.

L'algorithme que mettra en oeuvre cette méthode sera le suivant :

  1. acceleration = force / masse de l'animal ;
  2. nouvelle_vitesse = vitesse_courante + acceleration * dt
  3. nouvelle_direction = nouvelle_vitesse normalisée
  4. la nouvelle vitesse doit être plafonnée à la vitesse maximale de l'animal, en l'occurence getMaxSpeed(): si la norme de nouvelle_vitesse est supérieure à la vitesse maximale, nouvelle_vitesse se ramène à nouvelle_direction * vitesse maximale;
  5. nouvelle_position = position_courante + nouvelle_vitesse * dt

La masse des hamsters est un paramètre configurable. Elle sera, par simplification, identique pour toutes les instances et donnée par getAppConfig().hamster_mass.

[Question Q3.15] Pourquoi selon vous cela n'est pas une bonne idée de stocker la masse du hamster dans un attribut ? (en quoi cela fait-il perdre un avantage offert par les fichiers de configuration ?) Répondez à cette question dans votre fichier REPONSES (et pensez-y à chaque fois que vous souhaiterez utiliser un attribut pour une donnée paramétrable).

La force d'attraction exercée par une cible peut-être calculée comme étant le vecteur :

où v⃗ est la vitesse courante de l'automate, et v⃗target la vitesse qu'il souhaite avoir vers la cible. Cette dernière peut se calculer comme suit :

avec :

x⃗target est la position de la cible, x⃗ celle de l'animal et maxSpeed la vitesse maximale de ce dernier.

La force d'attraction est ainsi proportionnelle à la distance séparant l'animal de la cible.

deceleration est une constante permettant de moduler la décélération vers la cible. Cette constante permet de faire en sorte que plus l'animal est proche de la cible, plus il est lent. Le but pour lui étant de ne pas la dépasser (par excès de zèle!). Vous pourrez prendre 0.3 comme valeur.

Indications pour le codage de move(const Vec2d& force, sf::Time dt) :

Codez la méthode move selon les indications précédentes.

Seuils de satiété

Vous considérerez qu'un animal à deux seuils de satiété, un minimal et un maximal. Un animal n'est pas rassasié s'il est en dessous de son seuil de satiété minimal ou s'il est en train de se nourrir mais qu'il n'a pas encore atteint son seuil de satiété maximal.

Définir ces deux seuils permet d'éviter un basculement discret de l'état rassasié à l'état non rassasié (qui ferait qu'un animal pourrait faire des basculements brutaux et incessants vers une source de nourriture par exemple).

Les seuils de satiété sont paramétrables (associés à ["animal"]["satiety"]["min"] ou ["animal"]["satiety"]["max"] et donc à getAppConfig().animal_satiety_min et getAppConfig().animal_satiety_max

Vous pouvez modifiez un peu les affichages en mode «debugging» pour ajouter une information sur l'état de satiété de l'animal.

Indication: codez une méthode vous permettant de tester si un animal est rassasié.

Consommation de nourriture

A chaque pas de simulation, l'animal prélève une certaine quantité de nourriture. Vous pourrez par exemple coder une méthode polymorphique déterminant combien l'animal mange à chaque bouchée. Pour un hamster cette méthode retournerait getAppConfig().hamster_energy_bite.

La source de nourriture consommée voit alors son énergie baisser de cette quantité. L'animal qui mange voit de son côté son énergie augmenter de cette quantité multipliée par getAppConfig().animal_meal_retention.

[Question Q3.16] Quelle(s) méthode(s) proposez-vous de mettre en place et dans quelle classe pour permettre de simuler la consommation de nourriture par un Animal? Répondez à cette question dans votre fichier REPONSES et codez le matériel ainsi suggéré.

Vous disposez maintenant des indications nécessaires à la mise en oeuvre de la nouvelle version de Animal::update suggérée ci-desssus.

[Question Q3.17] Comment proposez-vous de mettre en oeuvre le pas 1 de l'algorithme de Animal::update sans donner un accès publique à toutes les entités simulées depuis Lab : quelles méthodes/attributs ajoutez vous et à quelles classes et au moyen de quelle(s) méthodes déjà codée(s).

[Question Q3.18] Quel nouvel algorithme proposez-vous pour updateState afin de mettre en oeuvre proprement les transitions d'états suggérées par l'algorithme ci-dessus?

Répondez à ces questions dans votre fichier REPONSES et codez la nouvelle version de Animal::update.

Faites enfin en sorte que l'état de l'animal s'affiche en mode «debug», afin de vérifier que les transitions d'états se font de façon appropriée.

Test 19 : Consommation de nourriture

Comme pour les tests de l'étape précédente créez des situations où vous pouvez observer la consommation de nourriture.

Par exemple, mettez la simulation en mode pause, créer un hamster (suffisamment affamé) et placez un tas de granulés dans son champs de vision. Réactivez la simulation. Vous devriez la voir se diriger vers lui et en consommer (le tas de granulés «rétrécit»):

[Video : Attraction vers une source de nourriture + consommation]

Vérifiez que 

Jouez avec les paramètres, notamment ceux liés à la satiété, pour créer des conditions favorables aux comportements que vous voulez observer.

Temps de pause (bonus)

Pour faire plus réaliste, vous pouvez faire en sorte que l'état IDLE prévu soit exploité. L'idée est que l'animal qui déambule au hasard peut de temps en temps se mettre en pause (en mode IDLE) et arrêter de bouger pendant un certain temps. Vous pouvez utiliser la fonction bernoulli de Random.hpp pour décider de quand il bascule dans le mode IDLE.

Amélioration du code existant

Il est souvent nécessaire en programmation de remettre en question des choix antérieurs et d'apporter des modifications utiles au code existant ("code refactoring"). C'est d'autant plus nécessaire dans le cadre de ce projet, que vous ne connaissez pas forcément toutes les notions utiles au moment d'aborder telle ou telle partie. Maintenant qu'a été introduire la notion d'héritage multiple, nous allons revoir un peu la conception existante pour l'améliorer.

Objets «dessinables et «simulables»

Il peut être intéressant de modéliser explicitement dans la conception quels types d'objets évoluent au cours du temps et quels types d'objets sont dessinables. Pour un type d'objet qui aurait les deux caractéristiques (attention ce ne sera pas forcément le cas dans le projet), on pourrait le modéliser explicitement au moyen de l'héritage multiple :

class MaClasse : public Updatable, public Drawable
(la classe MaClasse hérite de Drawable et de Updatable)

[Question Q3.19 ] : les classes fournies dans le répertoire Interface, à savoir Drawable et Updatable, fournissent deux méthodes polymorphiques drawOn et update. Quelles classes de votre conception actuelle serait-il bon de faire hériter de ces sous-classes ? Quel plus cela amène t-il à la conception ? Répondez à ces questions dans votre fichier REPONSES.

Afin que le code soit compilable, vous définirez un type énuméré DrawingPriority, dans un fichier Env/DrawingPriority.hpp:

enum class DrawingPriority
{
     FOOD_PRIORITY,
     ANIMAL_PRIORITY,
     DEFAULT_PRIORITY
}

Faites les modifications ainsi suggérées.

Pour le reste de votre conception, vous resterez attentif à faire hériter de Drawable et de Updatable, toutes les classes pour lesquelles il est pertinent de le faire. Il n'est bien sûr pas indispensable d'hériter systématiquement des deux.

Notez enfin que si vous êtes dérangés par le fait que les hamsters se dessinent parfois sous les tas de granulés, la classe Drawable offre une méthode getDepth() qui permet de définir l'ordre de priorité du dessin et donc de résoudre ce souci.

Pour tirer profit de cela, les animaux en tant qu'objets dessinables devrait avoir une redéfinition de getDepth qui retourne leur niveau de priorité dans le dessin (DrawingPriority::ANIMAL_PRIORITY) et les sources de nourriture de même (avec la valeur DrawingPriority::FOOD_PRIORITY). Quand la méthode de dessin de l'environnement dessine les Entity, elle peut désormais le faire en dessinant les objets dans l'ordre de priorité voulu:

    // entities est le vector des Entity du laboratoire,on en crée une copie dans une liste:
      list<Entity*> sorted( entities.begin(), entities.end());
     // on définit une relation d'ordre sur la base de getDepth(): 
    auto comp([](Entity* a, Entity* b)->bool{ return int(a->getDepth()) < int(b->getDepth()); }); 
   // on trie l'ensemble sur cette base
    sorted.sort (comp); 
   // il faut ensuite dessiner l'ensemble trié sorted et non plus entities
    

Destructeurs virtuels

Vous avez appris récemment que dans une hiérarchie polymorphique il était conseillé de programmer les destructeurs comme vituels... pensez-y!


Retour à l'énoncé du projet (partie 3)