Moving Ball > Problème de test de sortie d'écran non effectué

Ok : ajoutons un 3e niveau de difficulté :

enum class Difficulty : uint8_t {

  easy,
  medium,
  hard

};

Difficulty est une énumération, qui définit un nouveau type, et qui est composée d’un ensemble de valeurs constantes nommées, qu’on appelle des énumérateurs, et dont le type sous-jacent est uint8_t.

À chaque nom d’une énumération est assignée une valeur entière qui correspond à sa place dans l’ordre des valeurs de l’énumération. Par défaut, la première valeur est 0, la suivante 1, et ainsi de suite.

Mais on peut tout à fait définir explicitement la valeur d’un énumérateur.
Autrement dit, on pourrait aussi écrire les choses ainsi :

enum class Difficulty : uint8_t {

  easy   = 0,
  medium = 1,
  hard   = 2

};

On peut également choisir de leur affecter des valeurs arbitraires :

enum class Difficulty : uint8_t {

  easy   = 2,
  medium = 4,
  hard   = 8

};

Dans notre cas, il est plus intéressant de conserver les valeurs entières contigües affectées par défaut :

enum class Difficulty : uint8_t {

  easy,
  medium,
  hard

};

Donc :

  • Difficulty::easy = 0
  • Difficulty::medium = 1
  • Difficulty::hard = 2

Pourquoi ? Parce-ce qu’on va justement se servir de ces valeurs entières pour positionner le curseur de sélection du niveau de difficulté :

gb.display.fillRect(20, 31 + 10*(uint8_t)game.difficulty, 4, 4);

L’expression (uint8_t)game.difficulty permet justement d’obtenir la valeur entière associée à l’énumérateur stocké dans la variable game.difficulty.

Ceci permet donc d’obtenir l’une des valeurs 0, 1 ou 2. Par conséquent, selon la valeur entière sous-jacente de game.difficulty on obtient l’un des 3 positionnements suivants. :

gb.display.fillRect(20, 31, 4, 4); // pour Difficulty::easy

gb.display.fillRect(20, 41, 4, 4); // pour Difficulty::medium

gb.display.fillRect(20, 51, 4, 4); // pour Difficulty::hard

Pratique, nan ?

Maintenant, pour implémenter la gestion du curseur de sélection, il existe plusieurs façons de le faire, en jouant sur les énumérateurs. Mais je préfère te donner la plus simple d’entre elles, puisque tu débutes ton apprentisssage du C++. Les autres font intervenir des notions plus avancées qui ne sont pas triviales :wink:


void menu() {

  if (gb.buttons.pressed(BUTTON_UP)) {
    switch (game.difficulty) {
      case Difficulty::medium: game.difficulty = Difficulty::easy; break;
      case Difficulty::hard:   game.difficulty = Difficulty::medium;
    }
  } else if (gb.buttons.pressed(BUTTON_DOWN)) {
    switch (game.difficulty) {
      case Difficulty::easy:   game.difficulty = Difficulty::medium; break;
      case Difficulty::medium: game.difficulty = Difficulty::hard;
    }
  } else if (gb.buttons.pressed(BUTTON_A))
    game.state = State::play;

  gb.display.print(6,  10, "SELECT DIFFICULTY");
  gb.display.print(32, 30, "EASY");
  gb.display.print(32, 40, "MEDIUM");
  gb.display.print(32, 50, "HARD");

  gb.display.fillRect(20, 31 + 10*(uint8_t)game.difficulty, 4, 4);

}

L’inconvénient ici réside dans le fait d’avoir à modifier les règles de changement de valeur de game.difficulty dès lors que tu ajoutes un nouvel énumérateur (donc un nouveau niveau de difficulté), ou bien que tu en retires un. Mais pour un petit ensemble d’énumérateurs, ça reste assez facile à gérer.

Enfin, pour répondre à ta dernière question :

Note que gb.frameCount n’est pas une fonction, mais un attribut (une propriété) de l’objet gb.

Par exemple, si on souhaite simplement modifier la couleur du curseur de sélection tous les 8 frames, on peut implémenter ça ainsi :

uint8_t phase = gb.frameCount % 24;

       if (phase < 8)  gb.display.setColor(BLUE);
  else if (phase < 16) gb.display.setColor(WHITE);
  else                 gb.display.setColor(RED);

  gb.display.fillRect(20, 31 + 10*(uint8_t)game.difficulty, 4, 4);
  • quand phase est compris entre 0 et 7, le curseur passe au bleu,
  • quand phase est compris entre 8 et 15, le curseur passe au blanc,
  • quand phase est compris entre 16 et 23, le curseur passe au rouge.

Est-ce que ça répond à ta question ? Ne sachant pas ce que tu entends par “modifier un élément graphique”, j’ai pris cet exemple au hasard, mais tu pensais peut-être à autre chose ?

Merci @Steph pour ton explication, toujours aussi claire !

Yep !

En fait, dans le cas du choix de difficulté HARD, j’aimerais que la raquette du joueur se contracte et s’étende de manière fluide.
Je comptais pour ce faire utiliser l’attribut framecount() pour que, par exemple, toutes les 4 ou 6 images, la raquette perde un pixel en taille (jusqu’à ce qu’elle se réduise de moitié) puis, que la raquette regagne quelques pixel (12 max) et ainsi de suite.

En utilisant le modulo, l’effet est saccadé, car la réduction / augmentation se fait trop rapidement sur quelques frames, puis rien ne se passe sur les frames suivantes.
Du coup, j’avais imaginé que le code suivant (à revoir) ne s’applique par exemple que tous les 4 ou 6 frames.

// Contrôles de la raquette du joueur de gauche (1)
  if (gb.buttons.repeat(BUTTON_UP, 0) && (raquette1.posY > 0)) {
    raquette1.posY -= 2;
  }
  if (gb.buttons.repeat(BUTTON_DOWN, 0) && (raquette1.posY+raquette1.hauteur < gb.display.height())) {
    raquette1.posY += 2;
  }

  if ((game.difficulty == Difficulty::hard) && ((gb.frameCount % 25) % 4 < 2)) {
    if (raquette1.hauteur >= 8) {
      raquette1.hauteur -= 1;
      raquette1.posY -= 1;
    }
    else if (raquette1.hauteur <= 12) {
      raquette1.hauteur += 1;
      raquette1.posY += 1;
    }

  }

Dis autrement, sur les frames 0 à 5, pas d’action, frame 6, on décrémente ou augmente la taille de la raquette selon la règle édictée ci dessus, frame 6 à 11, rien ne se passe, frame 12, on applique la décrémentation ou augmentation et ainsi de suite, en espérant qu’à l’arrivée, l’effet soit fluide pour l’utilisateur.

Bref, ajouter du piment dans le jeux :smiley:

Salut @Cric,

Pour déclencher une action à intervalles réguliers, par exemple toutes les 4 frames, tu peux utiliser gb.frameCount de la manière suivante :

if (gb.frameCount % 4 == 0) {

  // et là tu fais ce que t'as à faire...

}

Chaque appel à la fonction loop() implique une incrémentation d’une unité sur la propriété gb.frameCount. Par conséquent, l’expression :

gb.frameCount % 4

génèrera indéfiniment, la succession des valeurs suivantes :

0, 1, 2, 3  // puis ça recommence à 0, etc.

Du coup, il suffit d’intercepter l’une de ces valeurs pour déclencher une action à intervalles réguliers. Par exemple, lorsqu’on atteint la valeur 0 :

gb.frameCount % 4 == 0

(l’opérateur % est prioritaire sur ==)

Maintenant, pour en revenir à ce que tu souhaites faire, c’est-à-dire la possibilité de rétrécir ou élargir la raquette, j’t’ai pondu un bout de code qui fait le boulot. Teste-le sur ta Meta :

  • BUTTON_UP pour déplacer la raquette vers le haut
  • BUTTON_DOWN pour déplacer la raquette vers le bas
  • BUTTON_A pour rétrécir la raquette
  • BUTTON_B pour élargir la raquette

J’en ai profité pour t’orienter davantage sur la programmation orientée objet et te faire découvrir une autre façon de programmer, un peu plus structurée que du code monolithique pondu au kilomètre… :joy:

On définit un modèle nommé Paddle qui décrit :

  • sa structure (de quoi il est fait) => ce sont ses attributs (qui sont des variables internes),
  • son comportement (de quelle manière il peut agir sur lui même ou sur le reste du “monde”) => ce sont ses méthodes (qui sont des fonctions)

Le modèle peut être vu comme un plan de construction, à partir duquel on va pourvoir construire des objets, qui respectent tous ce plan de construction. Mais chaque propriété pourra s’exprimer différemment chez un objet ou un autre. Par exemple, le modèle Paddle précise que chaque instance dispose des attributs x et y :

struct Paddle {

  uint8_t x;
  uint8_t y;

};

Mais si on construit deux instances de ce modèle :

Paddle player_1;
Paddle player_2;

player_1.x = 4;
player_1.y = 32;

player_2.x = 73;
player_2.y = 32;

Tu vois qu’on peut attribuer des valeurs différentes aux propriétés x et y de chaque instance.

On peut d’ailleurs faire en sorte de paramétrer la construction d’une instance en définissant un constructeur qui accepte des arguments. Un constructeur est une méthode un peu particulière qui porte nécessairement le nom du modèle auquel il est rattaché. Par exemple, on pourrait définir le constructeur suivant :

struct Paddle {

  uint8_t x;
  uint8_t y;

  Paddle(uint8_t a, uint8_t b) {

    x = a;
    y = b;

  }

};

Et construire deux instances ainsi :

Paddle player_1(4, 32);
Paddle player_2(73, 32);

Ceci permet d’affecter des valeurs initiales aux propriétés x et y de chaque instance du modèle Paddle, au moment de leur création.

On pourrait également écrire le constructeur de cette manière :

struct Paddle {

  uint8_t x;
  uint8_t y;

  Paddle(uint8_t x, uint8_t y) {

    this->x = x;
    this->y = y;

  }

};

Dans ce cas, les arguments portent les mêmes noms que les attributs. Pour pouvoir les différencier dans le corps du constructeur, C++ fournit un opérateur d’autoréférence nommé this qui est un pointeur sur l’instance elle-même. Par conséquent quand on écrit this->x on fait référence à l’attribut x de l’objet.

Lorsque le constructeur accepte des arguments nommés x et y, ces arguments sont déclarés comme des variables locales au constructeur, mais viennent masquer les attributs x et y définis au niveau du modèle… Ça pose problème, car si on écrivait :

struct Paddle {

  uint8_t x;
  uint8_t y;

  Paddle(uint8_t x, uint8_t y) {

    x = x;
    y = y;

  }

};

Il y aurait une indétermination lié au conflit de nommage… de quel x on parle (à gauche comme à droite) quand on écrit :

x = x;  // ???...

En réalité, ici, on ne ferait que réaffecter à l’argument x sa propre valeur… ce qui ne servirait évidemment à rien…

L’opérateur this permet de lever l’ambigüité :

this->x = x;
  • à gauche on fait référence à l’attribut de l’objet,
  • à droite on fait référence à l’argument du constructeur.

C++ nous permet également de définir un constructeur avec une liste d’initialisation des attributs avant que le corps du constructeur soit exécuté. Par exemple, on pourrait réécrire notre constructeur de la façon suivante :

struct Paddle {

  uint8_t x;
  uint8_t y;

  Paddle(uint8_t x, uint8_t y) : x(x), y(y) {

    // et on n'a rien à faire de plus ici...

  }

};

C’est une manière plus concise d’écrire exactement la même chose que précédemment. Ici il n’y pas d’ambigüité, ce qui se trouve entre parenthèses fait référence aux arguments du constructeur. Alors que ce qui précède la parenthèse ouvrante fait référence à l’attribut de l’objet.

Voilà, ceci devrait te permettre de mieux comprendre l’écriture du constructeur que j’ai défini dans mon code :

Paddle(uint8_t x, uint8_t y) : x(x), y(y), h(H), th(h), sizing(false) {}

Tu noteras également que j’ai défini quelques constantes à l’intérieur du modèle Paddle :

struct Paddle {

  static const uint8_t W        = 3;
  static const uint8_t H        = 16;
  static const uint8_t H_SHRUNK = 8;
  static const uint8_t VY       = 2;

};

Ces constantes sont définies dans l’espace de nommage du modèle Paddle et sont accessibles par toutes les instances. Mais aucune copie de ces constantes ne sont reportées au sein de chaque instance. Elles sont définies de manière unique et sont partagées par toutes les instances. C’est ce que précise le mot-clef static.

Bon, je ne vais pas te pondre ici un cours complet sur la programmation orientée objet. Il y aurait beaucoup trop à dire, surtout avec les subtilités du C++, et tu serais très vite rassasié… Mais je trouvais intéressant de t’amener à t’y intéresser :wink:

Si c’est imbitable pour toi, n’hésite pas à me le dire, et je reviendrai à un style de programmation plus procédural.

Concernant le rétrécissement ou l’élargissement de la raquette, c’est dans la méthode update() du modèle Paddle que ça se passe. J’ai implémenté deux approches pour donner deux effets visuels différents :

  • une première approche où la variation de la largeur de la raquette suit une progression arithmétique (donc linéaire),
  • et une seconde approche où la variation de la largeur de la raquette suit une progression géométrique (donc exponentielle).

Une macro en début de code permet de déterminer quelle progression tu veux appliquer :

/**
 * 0 for an arithmetical progression
 * 1 for a geometrical progression
 */
#define GEOMETRIC 0

Je te laisse tester les 2, tu appliqueras celle que tu préfères…

Progression arithmétique de raison 2 :

demo-arithmetical

Progression géométrique de raison 0.5 :

demo-geometrical

On pourrait utiliser gb.frameCount pour espacer les variations par un nombre constant de frames (comme expliqué au début de ce post), mais l’effet obtenu est saccadé… même avec gb.frameCount % 2 == 0 qui n’opère qu’une frame sur deux. Donc je n’ai pas utilisé cette possibilité qui me semble insatisfaisante.

Je suppose que tu n’auras pas trop de mal à comprendre comment tout s’articule. Mais si une zone d’ombre subsiste, n’hésite pas à revenir poser de nouvelles questions :wink:

1 Like

Salut @Cric,

Plus de nouvelles, bonne nouvelles ?..

Hello @Steph,

J’ai étudié ton code avec attention :

  • J’ai beaucoup de mal à me mettre au CPP (plus particulièrement le Struct Paddle) => je vais à nouveau regarder ce WE ;
  • il y a des bouts de code que je ne comprends pas (exemple ci après)
        #if GEOMETRIC
            fh = h;
        #endif
  • j’ai beaucoup de mal à comprendre l’algo suivant :
        if (sizing) {

            #if GEOMETRIC

                float_t dh = th - fh;

                if (abs(dh) < 1)  {

                    h      = th;
                    sizing = false;

                } else {
                    
                    fh += .5f * dh;

                    if (abs(fh - h) > 1.5f) h += fh < h ? -2 : 2;

                }

            #else

                if (h == th) sizing = false;
                else         h += th - h < 0 ? -2 : 2;
            
            #endif
            
        }

Bref, c’est pas encore gagné :thinking:

En C++, les struct permettent d’agréger des variables (donc des données) avec des services dédiés à la manipulation de ces données (qu’on appelle des méthodes, mais qui sont de simples fonctions). Cette implémentation est la mise en oeuvre du paradigme de la programmation orientée objet. Le C++ introduit également le mot-clef class, qui se distingue du mot-clef struct, pour désigner des objets qui sont sémantiquement identiques, mais avec des règles d’encapsulation différentes… Je ne vais pas rentrer dans ces détails ici, pour ne pas compliquer les choses, et considérer que, pour le moment, tu peux t’en tenir aux struct. Mais, oui, tu as tout intérêt à creuser ces notions fondamentales de ton côté !

Maintenant pour en revenir aux “bouts de code que tu ne comprends pas”, c’est pas compliqué. C++ te permet de définir ce que l’on appelle des macros qui seront traitées par le préprocesseur, qui va modifier le code source avant la compilation. Par exemple, dans mon code, j’ai défini la macro GEOMETRIC ainsi :

/**
 * 0 for an arithmetical progression
 * 1 for a geometrical progression
 */
#define GEOMETRIC 0

Autrement dit, si je veux que le changement de taille de la raquette suive une progression arithmétique, je dois écrire ceci :

#define GEOMETRIC 0

Mais si je veux qu’il suive une progression géométrique, je dois écrire cela :

#define GEOMETRIC 1

Le préprocesseur va analyser le code source et y apporter des modifications en fonction des directives de précompilation qu’il va y trouver. Par exemple, partout où il trouvera la chaîne GEOMETRIC, il la substituera avec la valeur qu’elle représente (donc 0 ou 1, en fonction de ce que tu auras défini dans la macro).

Mais il remplacera aussi ce bout de code :

#if GEOMETRIC
    float_t fh;
#endif

en fonction de la valeur représentée par GEOMETRIC, donc :

  • si GEOMETRIC vaut 1, il substituera le code ci-dessus par un simple :

    float_t th;
    
  • et si GEOMETRIC vaut 0, alors il le remplacera par… rien du tout ! Autrement dit, le bout de code compris entre #if et #endif sera tout simplement supprimé.

Même chose pour ce qui se passe dans la méthode update(). Autrement dit :

  • si GEOMETRIC vaut 0, la fonction update() se réduit à :

    void update() {
    
        if (sizing) {
            if (h == th) sizing = false;
            else         h += th - h < 0 ? -2 : 2;
        }
    
        uint8_t h2 = .5f * h; 
    
                if (y < h2)            y = h2;
        else if (y + h2 > SCREEN_H) y = SCREEN_H - h2;
    
    }
    
  • par contre, si GEOMETRIC vaut 1, alors la fonction update() devient :

    void update() {
    
        if (sizing) {
            float_t dh = th - fh;
            if (abs(dh) < 1)  {
                h      = th;
                sizing = false;
            } else {
                fh += .5f * dh;
                if (abs(fh - h) > 1.5f) h += fh < h ? -2 : 2;
            }
        }
    
        uint8_t h2 = .5f * h; 
    
                if (y < h2)            y = h2;
        else if (y + h2 > SCREEN_H) y = SCREEN_H - h2;
    
    }
    

Pratique, nan ?

Merci @Steph, c’est très clair.

Comme je trouve l’approche géométrique plus spectaculaire au niveau du rendu, j’ai simplifié ton code pour en faciliter la lecture en conservant les parties pour GEOMETRIC équivalant 1.

Par contre, je ne comprends pas cette formule (notamment .5f et < h ? -2 : 2)

        else {
            fh += .5f * dh;
            if (abs(fh - h) > 1.5f) h += fh < h ? -2 : 2;
        }

Je continue mes investigations !

Ha… personnellement, je trouvais l’effet obtenu plus sympa avec une progression arithmétique. Du coup j’ai bien fait de te proposer une autre solution. :slightly_smiling_face:

Bref, donc, oui, tu peux simplifier le code en enlevant toutes les structures conditionnelles qui prennent en compte la valeur de la macro GEOMETRIC en ne retenant que les portions de codes où on considère qu’elle vaut 1.

Et l’instruction suivante :

if (abs(fh - h) > 1.5f) h += fh < h ? -2 : 2;

est équivalente à :

if (abs(fh - h) > 1.5f) {
    if (fh < h) {
        h -= 2;
    } else {
        h += 2;
    }
}

Est-ce que c’est plus clair pour toi ?

1 Like

Oui, c’est plus clair comme cela (même si je ne sais pas ce qu’est 1.5f ? f veut dire quoi ?

J’ai avancé cet après-midi sur mon code. Du coup, je m’y prends en 3 étapes :

  1. Simplification de ton code => fait (je pourrai y revenir si je change d’avis entre arithmétique et géométrique :wink:)
  2. Restructuration du code de mon Pong en m’inspirant du tient (i.e. Struct, Constructeur, 1 instance par raquette (joueur / Gamebuino)…) => en cours
  3. Intégration de ton code dans ma version de Pong restructurée => à faire

L’intérêt de reprendre mon code et de l’adapter est que ça me force à comprendre et à m’interroger.
Je ne te cacherai pas que j’en ai bien ch*é à décortiquer ton code, mais j’avoue qu’après y avoir passé du temps, il est bien plus lisible que le miens, et que cela me pousse à poursuivre en C++.

Cela m’amène une première question : dans le code suivant, tu appelles la méthode update() de l’instance player qui contient le corps du jeu.

void update() {

    player.update();
    
}

Dans mon cas, j’ai 2 instances de Paddle : player (raquette de gauche) et computer (raquette de droite). Du coup, est-ce qu’il faut je fasse pareil avec computer.update() ? Car ça n’a pas de sens d’exécuter 2 fois le corps du jeu ? Ou le corps du jeu doit être mis ailleurs que dans Paddle ?

Par contre, j’aurai bien ce code à exécuter pour afficher chacune des requêtes ?

void draw() {

    player.draw();
    computer.draw();
    
}

Je galère encore pour restructurer les “computer.posY” par exemple en C++, mais il faut que je prenne le temps de le faire.

Attention… le “corps du jeu” est contenu dans la fonction loop() et s’articule autour de la séquence classique :

readButtons();
update();
draw();

Et c’est dans la fonction globale update() qu’on est censés mettre à jour les variables du jeu :

void update() {

    player.update();

}

Dans mon petit exemple, je n’avais que les variables du joueur à mettre à jour, mais dans ton cas, il faudra également mettre à jour les variables de l’adversaire :

void update() {

    player.update();
    computer.update();

}

Même chose pour la fonction draw(), comme tu l’as justement suggéré. :slightly_smiling_face:

Bon courage !
Toutes les notions abordées dans ce thread te seront très utiles pour tes projets, et une fois le cap franchi, tu pourras te pencher sur des notions plus avancées (et découvrir les class par exemple). Mais chaque chose en son temps. J’ai moi-même encore une très longue route devant moi concernant le C++ (que j’ai découvert avec la Gamebuino) !

Bon, j’ai simplifié mon code pour commencer par la gestion de la raquette du joueur.

Première difficulté : la raquette sort de l’écran (et disparait) alors que normalement, je l’ai bloquée en haut et en bas avec le code suivant

    void update() {
           if (posY < 0)                           posY = 0;
      else if (posY+hauteur > gb.display.height()) posY = gb.display.height();
      }

Deuxième difficulté : comment faire pour passer la variable “hauteur” définie dans la fonction menu() (dont la taille dépend de la difficulté choisie) dans le modèle Paddle ?

  if (game.difficulty == Difficulty::hard) {
      hauteur = 8;
    }
  else {
      hauteur = 12;
    }

Dernière question par anticipation : lorsque je vais afficher la raquette gérée par l’ordinateur avec une couleur différente (par exemple rouge) de celle du joueur (bleu), comment faire ?
Car il n’y a qu’une seule méthode draw() dans Paddle…

Idem pour le calcul du positionnement de la raquette de l’ordinateur (que je vais mettre dans la méthode update()) : comment sera t-elle différenciée entre player.update() (où je mets le code pour ne pas sortir de l’écran) et computer.update() (qui contiendra le code de calcul de la position par rapport à la balle en mouvement) ?
Désolé pour les questions de néophyte…

Rapidement, parce-que je file au TAF là…

1. La raquette sort de l’écran…

Normal, tu ne la replaces pas correctement… Ce n’est pas :

posY = gb.display.height();

Mais :

posY = gb.display.height() - hauteur;

2. Passer la variable hauteur au modèle Paddle

Il suffit d’ajouter un argument h au constructeur :

Paddle(uint8_t posX, uint8_t posY, uint8_t h)
: posX(posX)
, posY(posY)
, vitesse(V)
, hauteur(h) // <-- la hauteur de la raquette est donc paramétrable
, largeur(L)
{}

Ici tu ne peux utiliser la constante H (hauteur fixée par défaut) définie dans Paddle, puisque ton menu induit une hauteur variable…

3. Des couleurs différentes pour les raquettes

Même chose, tu peux ajouter un argument color au constructeur, que tu dois stocker dans ton modèle pour l’utiliser au moment de dessiner la raquette :

struct Paddle {

    static const uint8_t V = 2;
    static const uint8_t L = 3;

    uint8_t  posX;
    uint8_t  posY;
    uint8_t  vitesse;
    uint8_t  hauteur;
    uint8_t  largeur;
    Color    color; // <-- chaque raquette a sa propre couleur
    
    Paddle(uint8_t posX, uint8_t posY, uint8_t h, Color color) // <-- paramétrée ici
    : posX(posX)
    , posY(posY)
    , vitesse(V)
    , hauteur(h)
    , largeur(L)
    , color(color) // <-- on l'affecte au modèle ici
    {}
    
    void up()   { posY -= V; }
    void down() { posY += V; }

    void update() {
        uint8_t sh = gb.display.height();
             if (posY < 0)            posY = 0;
        else if (posY + hauteur > sh) posY = sh - hauteur;
    }

    void draw() {

        gb.display.setColor(color); // <-- et on fixe la bonne couleur à l'affichage
        gb.display.fillRect(posX, posY, largeur, hauteur);

    }

};

Il faut que tu poses un peu et que tu reprennes un à un les concepts que je t’ai présentés… Tu disposes maintenant de toute la logique pour parvenir à trouver les réponses à tes questions tout seul il me semble. Tu ne crois pas ? Tu as toutes les briques, il ne reste plus qu’à les assembler correctement :wink:

Tu peux aussi t’inspirer des exemples traités dans mon tuto Handling images on the Gamebuino Meta qui utilisent les mêmes concepts… Notamment ceux des chapitres :

  • How to display your images
  • How to move your sprite

Je rebondis juste sur la fin de la réponse de Steph pour appuyer sur ce qu’il a dit: n’hésitez pas à regarder l’académie et à analyser un peu les codes. Rechercher fait certe perdre un peu de temps mais ça fait aussi progresser et ça évite de poser toujours les même questions sur le forum. En diverssifiant les questions, on rend sa lecture plus intéressante et en lisant un peu les codes, les tutos de l’académie et les références de base, ca rend le temps passé par ceux qui les ont écrit plus profitable.

@Steph, Après une pause de quelques semaines, je me lance dans le déboggage de mon jeu.

Il y a un truc que je n’arrive pas à faire : j’ai deux struct consécutives (Paddle et Ball) qui comportent des méthodes, et deux instances (paddle et ball) déclarées après chaque struct.
Ce qui donne un truc comme cela :

struct Paddle {
        ...code...
};

Paddle player(10, 30, LIGHTBLUE);
Paddle computer(gb.display.width()-12, 30, PINK);

struct Ball {
    ...code...
};

Ball ball(20, 20, bSpeed, -bSpeed, 4, 0);

Sauf que pour tester une collision entre la raquette et la balle, j’ai créé une méthode dans Paddle qui compare la position de la raquette (posY) avec celle de la balle (ball.posY) :

if (ball.posY > (posY+hauteur/2) && posY + hauteur < gbheight) {
    posY += 2;
}

Mais j’ai une erreur (error: ‘ball’ was not declared in this scope) car le compilateur ne reconnait pas ball.posY qui n’est déclaré qu’après l’instance paddle dans l’exemple de mon code, comme si il exécutait le code séquenciellement.

Du coup, comment faire car si j’inverse l’ordre des instances paddle et ball, ça me créé des erreurs dans l’autre sens (le compilateur ne reconnait pas les méthodes de paddle appelées dans ball) ? Y a t’il moyen “d’externaliser” l’instance ball pour qu’elle soit reconnue dans l’instance paddle ?

Bref, je ne sais pas si je suis clair :upside_down_face:
J’ajoute le lien vers le code si besoin.

P.S. Cette pause m’a fait du bien, il y a pas mal de concepts que j’ai compris et des trucs que j’ai améliorés par rapport à ma dernière version du code, et ce n’est pas fini !

Peut-être plus simplement : ball.posX est une variable locale utilisée par une instance dans une structure que j’essaie d’appeler depuis une instance d’une autre structure !
Ma question étant : comment rendre cette variable “globale” afin qu’elle soit utilisée par une instance qui n’est pas dans la même structure.

Désolé @Jicehel mais là tu te trompes… et tu risques d’embrouiller notre ami @Cric
Relis plus attentivement les messages suivants :

[ Arghhhh… @jichehel a supprimé ses messages… dommage, on aurait pu en discuter… ]

@Cric je te réponds dès que je trouve un moment…

Bonsoir @Cric,

En effet, le code est lu, compilé et executé de manière séquentielle. Avant d’utiliser une variable, un type, une structure de données, une classe, un objet, etc. ou d’invoquer une fonction ou une méthode, tu dois nécessairement la (ou le) déclarer au préalable.

Par exemple, le code suivant :

#include <Gamebuino-Meta.h>

struct Ball {
    uint8_t x, y;
};

struct Paddle {
    uint8_t x, y;
    void handleCollision() {
        if (ball.x < x) { }
    }
};

Ball   ball;
Paddle player, computer;

void setup() { gb.begin(); }

void loop() {
    gb.waitForUpdate();
    player.handleCollision();
}

… provoquera une erreur à la compilation :

/Users/steph/.../pong/pong.ino: In member function 'void Paddle::handleCollision()':
pong:10:13: error: 'ball' was not declared in this scope
         if (ball.x < x) { }
             ^~~~

… puisque la variable globale ball n’est déclarée qu’après l’instruction qui en fait usage.

Pour résoudre ceci, tu peux donc déclarer l’existence de la variable ball avant de l’utiliser :

#include <Gamebuino-Meta.h>

struct Ball {
    uint8_t x, y;
};

Ball ball;

struct Paddle {
    uint8_t x, y;
    void handleCollision() {
        if (ball.x < x) { }
    }
};

Paddle player, computer;

void setup() { gb.begin(); }

void loop() {
    gb.waitForUpdate();
    player.handleCollision();
}

Et là ça marche beaucoup mieux…

Mais plutôt que d’utiliser directement la variable globale ball dans la méthode handleCollision(), tu peux la passer comme argument à la méthode :

#include <Gamebuino-Meta.h>

struct Ball {
    uint8_t x, y;
};

struct Paddle {
    uint8_t x, y;
    void handleCollision(Ball b) {
        if (b.x < x) { }
    }
};

Ball   ball;
Paddle player, computer;

void setup() { gb.begin(); }

void loop() {
    gb.waitForUpdate();
    player.handleCollision(ball);
}

Ici, l’instruction :

player.handleCollision(ball);

… passe la variable ball par valeur à la méthode handleCollision() :

void handleCollision(Ball b) { ... }

Autrement dit, la variable b qui désigne cette valeur reçoit une copie de l’objet ball, et non l’objet ball en tant que tel. Par conséquent, si la méthode handleCollision() modifie la valeur d’un attribut de la variable b, par exemple en écrivant :

void handleCollision(Ball b) {
    b.x = 0;
}

C’est la valeur de l’attribut x de la copie qui sera modifiée !.. et ça n’aura aucun effet sur la valeur de l’attribut x de l’objet ball original.

Pour résoudre cette problématique, C++ te permet d’invoquer une fonction ou une méthode en lui passant comme argument, non pas la copie de l’objet, mais une référence à l’objet lui-même, c’est à dire une variable qui désigne son adresse mémoire. De cette manière, la fonction ou la méthode peut avoir directement accès à l’objet original (et modifier éventuellement la valeur de ses attributs).

Dans ce cas, on écrira les choses ainsi :

#include <Gamebuino-Meta.h>

struct Ball {
    uint8_t x, y;
};

struct Paddle {
    uint8_t x, y;
    void handleCollision(Ball &b) {
        if (b.x < x) { }
    }
};

Ball   ball;
Paddle player, computer;

void setup() { gb.begin(); }

void loop() {
    gb.waitForUpdate();
    player.handleCollision(ball);
}

La fonction déclare accepter une référence à un objet de type Ball (remarque bien l’usage de l’opérateur & qui préfixe le nom de la variable b) :

void handleCollision(Ball &b) { ... }

Et peut alors modifier directement l’objet original par le biais de sa référence, puisqu’il s’agit en réalité de son adresse mémoire. Par exemple, si on écrit :

void handleCollision(Ball &b) {
    b.x = 0;
}

Et qu’on invoque la méthode ainsi :

player.handleCollision(ball);

… alors la valeur de l’attribut x de la variable globale ball sera modifiée et vaudra 0 après l’invocation de la méthode handleCollision().

Néanmoins, la possibilité qu’une méthode puisse modifier la valeur d’une variable qui lui est passée par référence peut avoir des effets de bord indésirables. On peut donc empêcher cela en déclarant que l’objet passé par référence doit rester constant (ne doit pas être modifié) :

void handleCollision(const Ball &b) { ... }

Par conséquent, si tu écris :

void handleCollision(const Ball &b) {
    b.x = 0;
}

Tu obtiendras l’erreur de compilation suivante :

/Users/steph/.../pong/pong.ino: In member function 'void Paddle::handleCollision(const Ball&)':
pong:10:18: error: assignment of member 'Ball::x' in read-only object
         b.x = 0;
               ^

… te signifiant que tu ne peux pas affecter une valeur à l’attribut d’un objet en lecture seule (imposée par le mot-clé const).

Mais alors, tu vas me dire : “Ben il suffit de lui passer la copie et le tour est joué !”

Pas tout à fait => une copie implique de réserver un espace mémoire supplémentaire pour recevoir les données de la copie justement ! Alors qu’en passant l’objet par référence, tu n’effectues pas de copie de l’objet original en mémoire. Et c’est une énorme différence, surtout si ton objet comporte de nombreux attributs, ou des attributs qui pèsent lourd ! Ça te permet donc d’économiser de l’espace mémoire, dans tous les cas, que tu modifies ou non la valeur des attributs de l’objet passé par référence.

Il existe des notions très voisines à tout ce que je viens de t’expliquer, je veux parler des pointeurs. Mais je préfère ne pas les aborder tout de suite pour ne pas t’embrouiller la tête et te laisser le temps de digérer ma réponse…

Est-ce que j’ai été suffisamment clair ?

Pour résumer, voilà le code qui me paraît le plus approprié par-rapport à ton problème initial :

#include <Gamebuino-Meta.h>

struct Ball {
    uint8_t x, y;
};

struct Paddle {
    uint8_t x, y;
    void handleCollision(Ball &b) {
        if (b.x < x) { }
    }
};

Ball   ball;
Paddle player, computer;

void setup() { gb.begin(); }

void loop() {
    gb.waitForUpdate();
    player.handleCollision(ball);
}

Je n’ai pas déclaré void handleCollision(const Ball &b) parce-que j’imagine que ta gestion des collisions est susceptible de modifier les attributs de la balle. Néanmoins, de manière générale, pose-toi toujours la question de savoir de quel objet relève la responsabilité d’exécuter telle ou telle action, surtout quand il perturbe son entourage…

Pour finir, @Jicehel t’avait répondu avec 3 messages soulevant, par erreur, des points essentiels quant aux notions qui caractérisent les objets en C++. Mais il les a malheureusement retirés, et c’est bien dommage… Je n’aborderai donc pas ces points ici pour ne pas faire dériver la discussion en-dehors de ton propre questionnement. Je trouverai bien l’occasion d’y revenir à un moment clef :wink:

@Steph ,
Merci pour cette longue réponse parfaitement claire comme d’habitude.

Inverser l’ordre des 2 structures de données ne changerait rien à la problématique, puisque l’une et l’autre s’appellent mutuellement au travers de leurs méthodes…

Le passage d’argument dans la méthode (en copie ou en valeur) est effectivement ce que je ne savais pas faire. Comme je ne fais que lire la valeur de la position de la balle, une copie de la variable ball est suffisante pour ma comparaison.

Cependant, que j’utilise une copie ou un passage en valeur de la variable, j’ai toujours un message d’erreur lors de la compilation que je recopie ici :

In member function 'void Paddle::updateComputer(int&)':
Pong_1_Player_V3.2:81:21: error: request for member 'posY' in 'b', which is of non-class type 'int'
               if (b.posY > (posY+hauteur/2) && posY + hauteur < gbheight) {
                     ^~~~

Le code mis à jour avec ton code.

Hello @Cric

Pour commencer, je rebondis sur :

Le passage d’argument dans la méthode (en copie ou en valeur) est effectivement ce que je ne savais pas faire. Comme je ne fais que lire la valeur de la position de la balle, une copie de la variable ball est suffisante pour ma comparaison.

Manifestement, je n’ai pas été assez clair… :confused:

  1. Le passage par valeur implique une copie en mémoire, ça n’est donc pas l’idéal dans ton cas, puisque tu gaspilles de la mémoire.

  2. Il vaut mieux privilégier un passage par référence (pour économiser de la mémoire) et, elle doit être constante, puisque tu n’as pas besoin de modifier les valeurs des attributs de l’objet passé en argument.

Au passage, tu devrais également appliquer ce principe à la méthode update() de Ball :

struct Ball {
    
    void update(const Paddle &player, const Paddle &computer) { ... }
    
};

Par ailleurs, je vois que tu as modifié ton post en ajoutant un lien vers ton code et tu me précises également que les méthodes de Ball et Paddle s’appellent les unes les autres…

C’est un détail important qui implique de restructurer entièrement ton code pour séparer les déclarations de Ball et Paddle, de leurs définitions.

En effet, tu te trouves devant le dilemme suivant :

  1. D’une part, Paddle a besoin de connaître la définition de Ball ;

    struct Paddle {
    
        // ... code ...
    
        void Paddle::updateComputer(const Ball &b) {
            if (b.posY > (posY+hauteur/2) && posY + hauteur < gbheight) {
                posY += 2;
            } else if (b.posY < (posY + hauteur/2) && posY > 0) {
                posY -= 2;
            }
                if (posY < 1) posY = 2;
            else if (posY+hauteur > gbheight) posY = gbheight-hauteur;
        }
    
        // ... code ...
    
    };
    
  2. Et d’autre part, Ball a besoin de connnaître la définition de Paddle :

    struct Ball {
    
        // ... code ...
    
        void update(const Paddle &player, const Paddle &computer) {
            // ... code ...
            if ((posX == player.posX+player.largeur)
            && (posY+taille >= player.posY) 
            && (posY <= player.posY+player.hauteur)) {
                // ball.speedX = 1;
                speedX = 1;
            }
            // ... code ...
        }
    
        // ... code ...
    
    };
    

Ces dépendances croisées posent effectivement problème, et pour les résoudre, il faut :

  1. d’une part, séparer les déclarations et les définitions de chacun des deux modèles objet,
  2. et d’autre part, effectuer une déclaration anticipée (forward declaration) de l’un des deux modèles pour qu’il soit résolu dans l’autre (pour contourner le problème de l’oeuf et de la poule…).

Autrement dit, commençons par déclarer Paddle comme ceci:

struct Paddle {

    static const uint8_t V = 2; // Vitesse
    static const uint8_t L = 3; // Largeur
    static const uint8_t H = 12; // Hauteur

    uint8_t posX;
    uint8_t posY;
    uint8_t vitesse;
    uint8_t hauteur;
    uint8_t largeur;
    uint8_t gbheight = gb.display.height();
    Color   color;

    Paddle(uint8_t posX, uint8_t posY, Color color);

    void up();
    void down();
    void updatePlayer();
    void updateComputer(const Ball &b);
    void draw();
    void drawscore();

};

Tu vois que les méthodes sont déclarées, mais pas encore définies (le corps des méthodes n’est pas encore précisé). La définition viendra plus tard…

Petit problème tout de même :

void updateComputer(const Ball &b);

Alors que Ball n’a pas encore été déclarée…

Pour résoude cela, on peut effectuer une déclaration anticipée de Ball pour indiquer au compilateur que cette entité existe bien quelque-part dans le code et que sa définition suivra :

// Déclaration anticipée permettant de
// résoudre les dépendances croisées à venir
struct Ball;

struct Paddle {

    // ... code ...

    void updateComputer(const Ball &b);

    // ... code ...

};

On peut ensuite (après Paddle) procéder à la déclaration de Ball :

struct Ball {

    uint8_t posX;
    uint8_t posY;
    uint8_t speedX;
    uint8_t speedY;
    uint8_t taille;
    uint8_t modDiff; // Difficulté du jeu
    
    Ball(uint8_t posX, uint8_t posY, uint8_t speedX, uint8_t speedY, uint8_t taille, uint8_t modDiff);

    void update(const Paddle &player, const Paddle &computer);
    void start();
    void draw();

};

Ici :

void update(const Paddle &player, const Paddle &computer);

Tout se passe bien, puisque Paddle a déjà été déclaré.

Il ne reste plus qu’à fournir les définitions de Paddle et Ball.

Par exemple, le constructeur de Paddle est défini comme suit :

Paddle::Paddle(uint8_t posX, uint8_t posY, Color color)
: posX(posX)
, posY(posY)
, color(color)
, vitesse(V)
, hauteur(H)
, largeur(L)
{}

Puis viennent les définitions des méthodes up()et down() :

// Player Up (BUTTON_UP) or Down (BUTTON_DOWN)
void Paddle::up()   { posY -= V; }
void Paddle::down() { posY += V; }

… etc. je ne vais pas toutes les écrire ici…

Ensuite, il faut faire la même chose pour Ball.

Par exemple, le constructeur se définit ainsi :

Ball::Ball(uint8_t posX, uint8_t posY, uint8_t speedX, uint8_t speedY, uint8_t taille, uint8_t modDiff)
: posX(posX)
, posY(posY)
, speedX(speedX)
, speedY(speedY)
, taille(taille)
, modDiff(modDiff)
{}

La méthode draw() comme ça :

// Affichage de la balle en mouvement
void Ball::draw() {
    gb.display.setColor(YELLOW);
    gb.display.fillRect(posX, posY, taille, taille);       
}

… etc. faut faire pareil pour toutes les autres méthodes…

Note que, pour le cas de ton code, toutes les déclarations et définitions se trouvent dans le même fichier (le .ino), mais qu’il est beaucoup plus commode de scinder les choses dans différents fichiers.

C’est là qu’interviennent les notions de fichiers d’en-têtes (header), qui sont déclaratifs et portent l’extension .h, et de fichiers d’implémentation, qui portent l’extension .cpp.

Par exemple :

  • Paddle.h comporterait uniquement la déclaration de Paddle,
  • et Paddle.cpp ses définitions.

Mais ne compliquons pas les choses trop vite et admettons que tout se trouve dans ton .ino comme tu l’as fait jusque-là…

Tu trouveras une restructuration de ton code ici. Je n’ai pas analysé la pertinence de ton implémentation, j’ai juste réécris les choses pour que la compilation se passe bien…

Est-ce que tu y vois plus clair maintenant ?

1 Like

Hello @Steph,

  1. Concernant le passage par valeur, pour mon information, est-ce qu’à chaque fois qu’une copie est faite en mémoire, est-elle détruite au fur et à mesure de chaque rafraîchissement (i.e. il n’y a qu’une seule copie en mémoire à la fois) ? Où la mémoire est-elle dupliquée (saturée ?) à chacune des copies ?
    Dans le second cas, je comprends mieux l’intérêt du passage par référence…

  2. J’ai regardé ton code rapidement et cela va beaucoup m’aider, je t’en remercie. Il se fait tard, je n’aurai pas le temps de m’y mettre ce soir hélas :frowning:

  3. Je trouve mon code extrêmement bordélique et pas aisé à lire. Alors j’attends tes conseils avec impatience pour scinder mon code ! J’ai vu cette utilisation dans les exemples de code que j’ai étudiés, cela va beaucoup simplifier la compréhension du code. En même temps, c’est typiquement un truc que j’aurai pu trouver par moi même en cherchant un peu sur le net…