IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)
Voir le flux RSS

Katian

Jeu de plateforme POO : Problème de design

Noter ce billet
par , 18/01/2021 à 21h18 (366 Affichages)
Citation Envoyé par koala01 Voir le message
Citation Envoyé par seba110298 Voir le message
Corrige-moi si je me trompe mais une agrégation est, comme une composition, représenté par un attribut de classe, avec la différence que l'agrégation peut-être liée à plusieurs instances (de classes différentes ou non) et ne dépend donc pas de la durée de vie de l'instance sur laquelle il est lié.
C'est exactement cela (même si les termes choisis sont un peu "exotiques" )

Si on reprend ton schéma, toutes les instances de la classe Sprite pourraient se partager pourraient se partager "différentes instances" de la classe Texture, car, après tout, il n'y aurait rien d'étonnant à avoir deux sprites différents (metttons : un en haut à gauche du plateau et l'autre en bas à droite) qui seraient rendus à l'écran de la même manière
La notion de composition se représente en C++ à l'aide de références ou de pointeurs :
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
class Sprite{
public:
    /* il faut une texture pour représenter le sprite à l'écran */
    Sprite(Texture const & tex):m_texture{tex}{
    }
private:
    Texture const & m_texture; // <== Agregation en UML
};
La composition implique que l'instance "contenue" ne peut exister que ... tant que l'instance de la classe "contenant" existe:
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
class Positionable{
public:
    Posiionnable(int posX, int posY):m_position{posX,posY}{
    }
private:
    Position m_position; // <== Composition en UML
};
Les flèches représentent donc une relation d'héritage.
<...>

Ce diagramme a été généré sur le code que j'ai fait et donc toutes les flèches représentent une relation d'héritage
C'est bien ce que je craignais

Car la relation d'héritage est la relation la plus forte que l'on puisse trouver entre deux classes, car elle implique de pouvoir transmettre une instance de la classe dérivée à n'importe quelle fonction s'attendant à recevoir instance de la classe de base comme paramètre). On parle de substituabilité.

En d'autre termes, si on dit souvent (pour la facilité) que l'héritage représente une relation EST-UN, ce n'est pas tout "à fait juste", dans le sens où on devrait dire que l'héritage représente une relation "EST-SUBSTITUABLE-A"

C'est pour cela que je te disais que Sprite peut hériter de Transformable (un sprite est un élément qui peut être substitué à un élément transformable), mais qu'il ne peut pas hériter de SpriteRenderer

Ce sont Character et Item qui les redéfinissent.
Si l'héritage fonctionne (aussi bien pour Character que pour Item) avec Movable (car les éléments et les personnages doivent pouvoir se déplacer), il ne fonctionne pas pour Sprite, parce que ni les objets ni les personnages ne sont, à proprement parler, des sprites : ils sont mis en relation (sans doute par agrégation) avec un sprite qui pourra être utilisé pour l'affichage à l'écran, mais, pour le reste, on n'a absolument aucun besoin de savoir ce genre de chose pour les manipuler.

Cela se remarque très fort quand on place tout cela dans un contexte MVC (Model View Controler, ou Modèle Vue Contrôleur, si tu préfères en anglais): les classes Item et Character font partie du modèle : ce sont les "données métier" que l'on manipule tout au long du jeu.

La classe Sprite, par contre, n'est là que ... pour nous permettre d'afficher les différents éléments à l'écran. Elle fait donc clairement dans la partie Vue du MVC.

Or, si dépendance il doit y avoir entre le modèle et la vue, ce seront les éléments de la vue (la classe Sprite, en l'occurrence) qui doivent interroger les éléments du modèle (l'interface Movable, en l'occurrence) pour pouvoir se mettre à jour (par exemple : mettre à jour les matrices de translation et / ou de transformation), et non l'inverse

(oui, c'est sûrement pas beau à voir toutes ces mauvaises relations ).
Non, vraiment pas

À vrai dire, la seule différence que je fais entre un Item et un Character (autre que le mouvement) est qu'un Character possède plusieurs images (animé) et que l'Item n'en contient qu'une. Et à la construction d'un Sprite, je lui donne le nombre d'images, à savoir 1 pour l'Item et le nombre d'images que contient la texture pour Character. Pourquoi ne puis-je donc pas dire qu'un Item est Sprite avec seulement 1 image ?
Avec les explications que je viens de donner concernant le MVC, ce sera peut-être plus clair

Le sprite, il s'en fout pas mal de savoir à quoi correspond ce qu'il affiche : tout ce qu'il doit savoir, c'est:
  1. le nombre d'images qui seront affichées et
  2. la position (sur l'écran) d'un des coins (le coin supérieur gauche, par exemple) à laquelle il doit s'afficher

Qu'il affiche un personnage, un banc, un coffre, un arbre ou la lune, pour lui, ca ne fera absolument aucune différence

Pour la classe SpriteRenderer, l'idée que j'avais était que toutes les instances de SpriteRenderer possèdent les mêmes ressources (à l'aide de static)
Heu, oui, mais non ...

Le problème est double :

Primo et sauf erreur de ma part, on peut aisément craindre que le nombre de float par tableau, le nombre d'indices par tableau, et, surtout, les valeurs représentées dans ces différents tableaux diffèrent d'un élément à l'autre. Tu ne peux donc pas déclarer ces données comme étant statiques parce que... le simple fait de modifier ces informations pour une instance donnée de SpriteRenderer les modifierait ... pour toutes les instances existantes

Ce qui nous mène tout droit au deuxième problème, qui est bien plus conceptuel :

secundo (et je suis sur de mon fait, sur ce coup), ta classe SpriteRenderer déroge au SRP (Single Responsability Principle, ou, si tu préfères : le Principe de la Responsabilité Unique) qui nous dit que chaque type de donnée, chaque donnée, chaque fonction ne doit s'occuper que d'une seule et unique chose.

Il manque donc deux notions importantes dans ton code, à savoir :
  • Une notion (appelons la RenderingData) qui regroupera les différentes donnée (VAO, VBO, EBO, shader, datas et indices) nécessaires à l'affichage et
  • Une notion (appelons la RenderingDataHolder) qui permettra de regrouper les différents éléments de type RenderingDataafin de les maintenir en mémoire, et de permettre au SpriteRenderer de les utiliser

Cela te permettrait d'avoir un et un seul SpriteRenderer qui irait chercher les données dont il a besoin (regroupées au sein de la structure RenderingData) auprès du RenderDataHoler en fonction des informations reçues par le Sprite

Je sais que cela a l'air un peu tiré par les cheveux, mais c'est sans doute ce qui t'offrira le plus de souplesse à l'utilisation

étant donné que toutes les instances de Sprite possèdent les mêmes ressources de SpriteRenderer, j'aurais pu mettre ces ressources communes directement dans Sprite.
Non, parce que cela n'aurait fait que déplacer le problème de respect du SRP
Le problème c'est que ces ressources permettent d'afficher un Sprite et donc là, je mélange la logique métier de son affichage... Et ça, c'est une mauvaise chose, non ?
Tout à fait

L'un dans l'autre, même si la classe Sprite fait partie des notions qui entre dans la partie Vue du MVC, elle n'est là que pour aider la classe SpriteRenderer à sélectionner les informations qu'il doit utiliser pour effectuer l'affichage (surtout si tu adopte le point de vue que je viens d'exposer).

Ceci étant dit, on pourrait même envisager que la notion de RenderingDataHolder ne soit effectivement là que pour maintenir en mémoire les différentes instances de RenderingData, et de n'y faire appel que ... lorsque l'on veut effectivement créer un nouveau sprite, en créant une agrégation au niveau de la classe Sprite.

On modifie donc un tout petit peu le code que je donnais (et on en profite pour appliquer DIP pour l'affichage du sprite) plus haut pour lui donner la forme de
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Sprite{
public:
    /* il faut une texture pour représenter le sprite à l'écran,
     * et les informations permettant l'affichage à l'écran
     */
    Sprite(Texture const & tex, RenderingData rend):m_texture{tex},m_rendering{rend}{
    }
    void draw(SpriteRenderer /* const */ & renderer){
        renerer.draw(m_rendering); /* tu auras compris qu'il faut adapter le prototype de la fonction draw
                                    * de la classe SpriteRenderer
                                    */
    }
private:
    Texture const & m_texture; // <== Agregation en UML
     RenderingData const & m_rendering;
};
La meilleure solution est donc, comme tu l'as dit, de créer une relation d'agrégation en instanciant la classe SpriteRenderer une fois puis en la liant à tous les Sprite à l'aide d'une référence ?
Ou, mieux encore : d'appliquer, comme je viens de le faire, le =>DIP<= (le cinquième principe SOLID : Dependancies Inversion Principle ou si tu préfères, le principe des inversion des dépendances)


Je pense que je me suis mal exprimé (et les nom de mes méthodes sont TRÈS MAL choisis). La méthode "moveOnMap" est une méthode permettant uniquement d'inscrire un Moveable dans la TileMap.
Non!!! En vertu de la loi de Déméter, ce qui apparitent à la TileMap doit rester à la TileMap : la classe Movable ne doit rien connaitre de ce qui permet en interne à la TileMap de travailler!

C'est d'autant plus vrai que TileMap est une donnée "externe" à la classe Movable, vu que tu la transmet comme paramètre à la fonction, et que l'instance de TileMap contient -- a priori -- l'instance de Movable que l'on veut déplacer.

Si le but est effectivement de faire déplacer l'élément Movable (qu'il s'agisse d'un Item ou d'un Character importe peu) dans ta TileMap, la manière de s'y prendre ressemble à:
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
void Movable::moveOnMap(TileMap & map){
    map.move(this, // parce qu'il faut bien indiquer quel élément doit être déplacé
             newPos); // parce qu'il faut indiquer où il veut aller dans la TileMap
}
Et les méthodes "addItem" et "addPlayer" prennent en argument un shared_ptr<Item> et shared_ptr<Character> respectivement (que je vais devoir changer du coup)
Non!!!

Encore une fois, la loi de Déméter est claire et sans appel : quand tu utilises (une instance de) la classe TileMap, ce qui est privé à la classe DOIT rester privé!

Autrement dit, en tant qu'utilisateur de TileMap, tu ne dois déjà pas savoir qu'elle manipule des pointeurs intelligents en interne. Et, par conséquent, aussi surprenant que cela puisse paraître, tu ne dois pas savoir qu'elle manipule des objets de type Item et des objets de type Character!

Tout ce que tu as le droit de savoir, ce sont les informations dont elles a besoin pour pouvoir créer elle-même les pointeurs intelligents qu'elle manipule; et par conséquent, pour pouvoir créer les objets qui dont la durée de vie sera gérée par ces pointeurs.

Je ne sais pas quels sont les paramètres nécessaires aux constructeurs de tes classe Item et Character, mais, en gros, les fonctions addItem et addCharacter de ta classe TileMap devraient ressembler à quelque chose comme
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
void TileMap::addItem(/* paramètres requis pour la création d'un Item */
                      /*, la position où le placer dans la tilemap*/){
    /* la création du pointeur intelligent et du Item  associé se fait ici 
     * et !!! NULLE PART AILLEURS !!!
     */
}
void TileMap::addCharacter(/* paramètres requis pour la création d'un Character */
                            /*, la position où le placer dans la tilemap*/){
    /* la création du pointeur intelligent et du Character associé se fait ici 
     * et !!! NULLE PART AILLEURS !!!
     */
}
C'est ensuite la méthode "move" redéfinie sur Item et Character qui va "gérer" les collisions.
Alors, dis moi : quelle est la différence entre
  1. la collision d'un Item avec un Item
  2. la collision d'un Item avec un Character
  3. la collision d'un Character avec un Item
  4. la collision d'un Character avec un Character

(concentre toi surtout sur les points (2) et (3) )

Je veux bien que les classes Item et Character redéfinissent la fonction, mais, pour cela, il faut forcément qu'il y ait une différence de comportement, autrement, la redéfinition n'a aucun sens

En outre, nous sommes de nouveau confrontés au SRP: D'après son nom, la fonction move a pour but de ... permettre de changer la position de l'élément Movable. A ce titre, elle ne peut donc pas prendre la responsabilité de vérifier si le déplacement occasionne une collision ou non.

Et, si elle ne s'occupe pas de tester si le déplacement occasionne une collision (parce que cette responsabilité aura été déléguée à "quelque chose" qui ne fait que cela), on en arrive à se poser une autre question: quelle est la différence de comportement entre le fait de déplacer un Item et celui de déplacer un Character

Or, a priori, il n'y aura aucune différence de comportement, car une forme proche de
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
void Movable :: move(){
    Position newPos;
    /* on calcule la "nouvelle position" ici... la logique est la même pour les deux */
    CollisionTester tester; //on délègue le test des collision à un objet qui ne s'occupe que de cela */
    if(! tester.collide(this, newPos)){   /* si l'objet courant n'entre pas en collision avec "autre chose
                                        * en allant vers sa nouvelle position
                                        */
       m_position = newPos; // alors, la nouvelle position devient la position de l'objet
    }
}
sera suffisamment "générique" que pour s'adapter à n'importe quel type d'élément Movable, qu'en penses tu
Je n'ai pas bien compris ce que tu as voulu dire. Qu'entends-tu par "Lorsqu'un objet se déplace, tu déplace le pointeur sous-jacent de unique ptr d'une case à l'autre" ?
Je présume que ton incompréhension vient du terme "pointeur sous-jacent"... Je vais donc commencer par expliquer ce point

Le "pointeur sous-jacent" correspond au pointeur pour lequel les classes unique_ptr et shared_ptr prennent la responsabilité de libérer les ressources lorsqu'il n'est plus utile.

Pour faire simple, cela correspond au pointeur "nu" que tu récupère lorsque tu fais appel aux fonctions membre get() et release() de ces classes.

Pour le reste, au cas où j'aurais mal compris ta question (mais, au vu du code que tu présentais pour moveOnMap, je ne crois pas que ce soit le cas), on va commencer par enfoncer une porte ouverte : la notion de déplacement implique forcément de quitter une position afin d'en rejoindre une autre.

Oui, je sais, cela fait drôle d'énoncer de la sorte quelque chose qui semble pourtant "tomber sous le sens", mais ca reste une bonne habitude de conception à prendre, surtout pour les choses qui semblent "tellement logiques" qu'elle "vont forcément de soi", pour être sur que ces chose "si logiques" soient exprimées (dans l'analyse des besoins, et, plus tard, dans le code ).

Enfin, bref, la TileMap va représenter ... ton plateau de jeu. Il ne faudra pas s'étonner si on trouve en interne un code du genre de
Code : Sélectionner tout - Visualiser dans une fenêtre à part
std::vector<std::unique_ptr<Movable>> m_cases; // toutes les cases du plateau
dans la partie privée de la classe

A priori, une case est "vide" lorsque le "pointeur sous-jacent" est égal à nullptr, et occupée lorsque ... ce n'est justement pas le cas

La fonction move de la classe TileMap pourrait donc prendre une forme proche de
Code : Sélectionner tout - Visualiser dans une fenêtre à part
1
2
3
4
5
6
7
8
9
10
11
12
13
void TileMap::move(Position const & start, Position const & arrival){
    /* on commence par transformer les deux positions en indices du tableau */
    size_t startIndex = toIndex(start);
    size_t arrivalIndex = toIndex(arrival);
    /* on s'assure que la position d'arrivée est vide (c'est un bon moyen de tester les collisions, tiens ... ) */
    if(m_cases[arrivalIndex]==nullptr){
        /* Chouette, l'objet (quel qu'il soit) qui se trouve à la position de départ
         * peut atteindre sa position d'arrivée 
         */
       auto * mover = m_cases[startIndex].release(); // il quitte sa position de départ
       m_cases[arrivalIndex].reset(mover); // pour rejoindre sa position d'arrivée
    }
}
Voilà, à peut de chose près ce que je voulais faire passer comme idée

Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Viadeo Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Twitter Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Google Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Facebook Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Digg Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Delicious Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog MySpace Envoyer le billet « Jeu de plateforme POO : Problème de design » dans le blog Yahoo

Commentaires