I. Introduction

Bienvenue à la quatrième partie du tutoriel sur le jeu du morpion. Pour le moment, notre code fonctionne et il fait ce que nous voulons. Mais, bien sûr, il ne fait pas exactement ce que nous voulons puisque nous avons une nouvelle partie (et encore d'autres sont prévues!).

Dans cette partie, nous allons enfin créer une classe Moteur et une classe Objet. La classe Moteur sera le lien entre la fonction main et la classe Jeu. La classe Objet sera là pour internaliser une bonne partie du code qui se trouve en ce moment dans la classe Jeu.

Nous allons ajouter une fonctionnalité au morpion : il ne sera plus nécessaire d'avoir le plateau du morpion sur toute la fenêtre! Nous allons pouvoir séparer les cases du morpion et gérer de façon intelligente le clic de la souris.

II. Le programme

Nous allons d'abord présenter les modifications du code et, seulement ensuite, nous présenterons les nouvelles classes. Cela peut sembler étonnant, mais je pense qu'il est plus facile de comprendre le fonctionnement d'une classe en regardant comment elle est utilisée et non comment elle est définie.

II-A. Modification de la classe Jeu

Nous allons commencer par montrer les différences dans la classe Jeu. Finalement, si nous regardons les membres et les fonctions membres, il n'y a pas beaucoup de changements. La seule modification est le passage de :

Ancienne déclaration du plateau
Sélectionnez
  
//Le plateau jeu
Case plateau[3][3];
  

à

Nouvelle déclaration du plateau
Sélectionnez
 
//Le plateau jeu
Objet plateau[3][3];
  

Mais, la majorité du code de cette classe a changé. Lorsque nous allons étudier de plus près les changements, nous verrons qu'en fait, à la place d'accéder directement au type d'une case, on utilise les fonctions de la classe Jeu. Maintenant, étudions les fonctions modifiées une à une.

II-A-1. Fonction Init

Dans la fonction init, il y aura seulement un ajout à la fin de la fonction : la mise en place des positions des cases du plateau du morpion et, ensuite, la classe Jeu passera les surfaces qu'utiliseront les instances de la classe Objet. En fait, ceci est une nouveauté dans le code.

A la place d'avoir l'écran divise en 9 cases, on va avoir les cases parsemées dans la fenêtre. Voici une image du jeu de la version 4 (histoire de vous donner envie!) :

Image non disponible
Le nouveau morpion

Sans montrer tout le code de la fonction init (il serait un peu trop long), nous montrerons qu'une petite partie.

Initialisation des cases du plateau
Sélectionnez
 
    SDL_Rect r;
 
    /* Début du code d'initialisation */
    /* Mettre en place la largeur et l'hauteur d'une case */    
    r.w = 150;
    r.h = 150;
 
    /* Mise en place de la position de la première case */
    r.x = 74;
    r.y = 51;
    plateau[0][0].setPos(&r);
 
    /*
    Positionnement des 8 autres cases
    */
 
    //Mise en place des surfaces des cases
    for(i=0;i<3;i++)
        {
        for(j=0;j<3;j++)
            {
            plateau[i][j].setImage(vide,o,x,gagneo,gagnex);
            }
        }
  

Dans cette partie, nous utilisons une structure SDL_Rect pour initialiser la position des cases. Une fois que chacune des cases est initialisée, on parcourt les cases et on initialise les pointeurs des surfaces des cases. Vous remarquerez que j'ai ajouté une surface nommée vide. C'est le carré semi transparent que vous avez vu dans l'image.

La classe Jeu possède et gère les surfaces du programme. Lorsque je programme un jeu, une classe va gérer les surfaces et les autres possèdent seulement des pointeurs. Cela permet de centraliser l'information et limiter les redondances. Pour le moment, c'est la classe Jeu qui contiendra les surfaces du jeu.

Vous remarquerez aussi que l'affichage de la case vide est transparent. Ceci est fait grâce au canal alpha. La valeur du canal alpha donne le pourcentage de transparence des pixels. Pour définir la valeur de ce canal, on utilise la fonction SDL_SetAlpha. D'un point de vue graphique, lorsqu'on dessine un pixel à l'écran, on veut calculer la couleur de ce pixel. Pour cela, on utilise la couleur de l'ancien pixel (celle qui se trouve déjà à l'écran), la couleur du nouveau pixel (celle de la surface qu'on va copier à l'écran) et la valeur du canal alpha pour calculer la nouvelle couleur du pixel.

Prototype de la fonction SDL_SetAlpha
Sélectionnez
 
int SDL_SetAlpha(SDL_Surface *surface, Uint32 flag, Uint8 alpha);
  

Cette fonction prend une surface en paramètre. Le deuxième paramètre de cette fonction ressemble beaucoup au paramètre similaire passé à la fonction SDL_SetColorKey. Voici les valeurs possibles :

  1. Si flag vaut 0, alors l'alpha-blending sera désactivé;
  2. Si flag vaut SDL_SRCALPHA, alors l'alpha-blending sera activé;
  3. Si on ajoute l'option SDL_RLEACCEL, l'accélération RLE sera activé.

Voici, comment on l'appelle :

Utilisation de SDL_SetAlpha
Sélectionnez
 
//Mise en place de la transparence
if(SDL_SetAlpha(vide,SDL_SRCALPHA,180)==-1)
    cout << "Erreur avec la transparence de la case vide" << endl;
  

Et voici comment on ajoute l'accélération SDL_RLEACCEL :

Utilisation de SDL_SetAlpha
Sélectionnez
 
//Mise en place de la transparence
if(SDL_SetAlpha(vide,SDL_SRCALPHA|SDL_RLEACCEL,180)==-1)
    cout << "Erreur avec la transparence de la case vide" << endl;
  

Et le dernier paramètre est le pourcentage de transparence. Si on met 0, alors l'image sera entièrement transparente. Si on met 255, l'image sera totalement opaque. Pour revenir à des choses que vous connaissez, une implémentation possible pour la fonction SDL_SetColorKey serait de mettre à zéro le canal alpha de tous les pixels de la couleur que vous passez en paramètre.

Le calcul de la couleur du pixel se fait à partir de l'ancienne couleur, la nouvelle couleur et la valeur du canal alpha. Voici le calcul de la nouvelle couleur :

Calcul de la couleur du nouveau pixel
Sélectionnez
 
On veut mettre le pixel de l'image A sur le pixel de l'image B.
Soit dest la couleur du pixel de l'image B.
Soit orig la couleur du pixel de l'image A.
Soit alpha le canal alpha du pixel de l'image A.
Soit res la couleur du pixel résultat.
 
res = (dest * (255-alpha)/255)) + ((orig * alpha)/255)
  

II-A-2. La fonction videJeu

Le code de la fonction videJeu est presque le même que dans la partie 3. Le seul changement est l'appel à la fonction init pour chaque case. En effet, ce sera maintenant à chaque case de se remettre à zéro.

Nouvelle version de videJeu
Sélectionnez
 
void Jeu::videJeu()
{
    int i,j;
 
    //On met toutes les cases à Vide
    for(i=0;i<3;i++)
        for(j=0;j<3;j++)
            plateau[i][j].init();
 
    //On commence par Rond
    tour = Rond;
 
    //On met fini à false
    fini = false;
}
  

II-A-3. La fonction verifFini

Comme pour la fonction videJeu, la fonction verifFini ressemble énormément à la version de la troisième partie. La seule différence est l'utilisation des fonctions pour les comparaisons et pour les mises à jour. Voici le code qui vérifie l'alignement par ligne :

Nouvelle version de verifFini
Sélectionnez
 
for(i=0;i<3;i++)
    if(!plateau[i][0].estVide())
        {
        if((plateau[i][0].memeType(plateau[i][1]))&&(plateau[i][0].memeType(plateau[i][2])))
            {
            fini = true;
            typegagne = plateau[i][0].getType();
            plateau[i][0].setType(Gagne);
            plateau[i][1].setType(Gagne);
            plateau[i][2].setType(Gagne);
            }
        }
    else
        casevide = true;
  

Et la dernière partie de la fonction devient donc :

Nouvelle version de la fin de verifFini
Sélectionnez
 
//Dernieres cases à vérifier pour le booléen casevide
if( (plateau[1][1].estVide())||(plateau[1][2].estVide())||
    (plateau[2][1].estVide())||(plateau[2][2].estVide()))
        casevide = true;
  

Finalement, cette fonction montre bien le changement entre une version avec énumération et une version objet. Toutes les comparaisons et toutes les mises à jour passent par des fonctions, mais le fond est le même.

II-A-4. La fonction clic

La fonction clic est devenue nettement plus compliquée. En effet, nous n'avons plus la facilité de pouvoir calculer la case par rapport au couple (x,y). En fait, il va falloir parcourir chaque case du tableau plateau.

La fonction clic
Sélectionnez
 
void Jeu::clic(int x, int y)
{
    int i,j;
    bool trouve=false;
 
    //Si le jeu n'est pas fini
    if(!fini)
        {
 
        //On parcourt tout le plateau pour trouver la case associée
        for(i=0;(i<3)&&(!trouve);i++)
            for(j=0;(j<3)&&(!trouve);j++)
                {
 
                //Si le clic a touché cette case
                if(plateau[i][j].estDedans(x,y))
                    {
 
                    //Si la case est vide
                    if((plateau[i][j].estVide()))
                        {
                        //On met le type à jour
                        plateau[i][j].setType(tour);
 
                        //On met à jour le tour
                        tour = (tour==Rond)?Croix:Rond;
                        }
 
                    //On dit qu'on a trouvé la case associé
                    trouve = true;
                    }
                }
        }
 
    //Si le clic a touché une case, il faudra vérifier si la partie est terminée
    if(trouve)
        verifFini();
}
  

On ajoute un test au début du parcours pour vérifier que la partie n'est pas finie. Si ce n'est pas le cas, on va tester chaque case. Si le clic correspond à la case, alors on regarde si la case est vide. Enfin, on met à jour le type de la case si toutes ces conditions sont remplies.

Donc pour savoir si nous sommes dans la case, la classe Objet possède une méthode qui rendra un booléen pour savoir si la position (x,y) est dans la zone de la case. Finalement, on a l'impression que le code est plus compliqué, mais il est plus lisible. En utilisant cette nouvelle classe Objet, je trouve personnellement que la fonction clic est plus facile à comprendre.

En effet, ce code dit bien : "Pour chaque case, on vérifie si le clic est dans la case courante. Si c'est le cas, on teste si la case est vide et enfin on met à jour le type de la case si nécessaire".

Vous vous demandez sûrement l'avantage d'une telle séparation du code et d'une complication de la fonction clic. Premièrement, je rappellerais l'image que j'ai donné au début du tutoriel. Est-ce que vous voudriez faire cela à la main ? Il faudrait vérifier chaque case séparément, le code serait long et difficile à modifier. En plus, qui nous oblige d'avoir des rectangles comme forme d'une case ? Il suffit de définir un objet qui permet d'avoir une case en forme d'ellipse/de triangle/etc. Le calcul étant internalisé, la classe Jeu n'a pas besoin de s'en soucier. Tout ce qu'elle sait, c'est que chaque objet est capable de dire si (x,y) est incluse dedans. C'est là, tout l'intérêt de la classe Objet.

Mais nous pourrions aller encore plus loin : qu'est-ce qui oblige les cases d'être à un endroit fixe ? Nous pourrions mettre en place une version qui fait bouger les cases. Le code de la classe Objet serait à compléter mais, de nouveau, la classe Jeu ne changerait pas.

Enfin, remarquez l'utilisation du booléen trouve pour ne pas rester inutilement dans la boucle.

Finalement, la fonction se termine avec un test qui nous dit si l'état du jeu change. Si c'est le cas, on vérifie si la partie est terminée (mettant donc le booléen fini à jour).

II-A-5. La fonction aff

La fonction aff est devenue plus simple puisque nous avons migré le code d'affichage à l'objet sous-jacent. En effet, nous supposons maintenant qu'un objet correctement initialisé saura s'occuper de l'affichage correctement.

La fonction d'affichage
Sélectionnez
 
void Jeu::aff(SDL_Surface *screen)
{
    int k,l;
 
    //Dessiner le fond d'ecran
    SDL_BlitSurface(bg,NULL,screen,NULL);
 
    //Dessiner chaque case
    for(k=0;k<3;k++)
        for(l=0;l<3;l++)
            {
            plateau[k][l].affiche(screen);
            }
}
  

II-A-6. La fonction getFini

Enfin, la dernière fonction qu'on présentera de la classe Jeu est la fonction getFini. Cette fonction permettra au moteur de savoir si le jeu est fini. Voici le code étonnant qui fait ceci :

La fonction getFini
Sélectionnez
 
bool Jeu::getFini()
{return fini;}
  

On peut considérer, généralement, deux solutions pour l'implémentation de cette fonction :

  1. La première solution est celle que nous avons proposée. Nous avons gardé le booléen fini de la partie 3 et l'avons migré dans la classe Jeu. Mais ce sera à la classe Jeu de mettre à jour ce booléen lorsqu'il le faut. Dans le cas du morpion, cela est fait à chaque clic qui provoque un changement de l'état du jeu. C'est ce qu'on voit dans la fonction clic;
  2. La deuxième solution est de ne jamais calculer si le jeu est fini sauf si le moteur le demande. Ceci veut donc dire que la fonction getFini appellera verifFini. Cette deuxième solution est pratique lorsque le moteur n'a pas besoin de savoir tout de suite (ou tout le temps) après un changement d'état du jeu si la partie est finie. Il n'est donc pas nécessaire de faire exécuter le code de verifFini à chaque clic qui provoque un changement.

II-B. Modification du fichier Main.cpp

Les seules modifications qui sont faites dans ce fichier sont le passage d'une instance jeu à une instance moteur. Sinon, toutes les fonctions sont les mêmes.

II-C. Ajout de la classe Objet

Nous avons ajouté une classe Objet à ce projet puisque cela permet de gérer de façon plus complète les clics de souris et plus transparente l'affichage. Voyons comment nous avons implémente cette classe.

II-C-1. Le fichier Objet.h

Nous allons présenter dans cette section les membres de cet objet.

Les membres de la classe Objet
Sélectionnez
 
                //Les surfaces des cases
                std::vector<SDL_Surface *> images;
 
                //L'image à afficher
                int curimage;
 
                //Type de la case
                Case type;
 
                //Position de la case
                SDL_Rect pos;
  

Chaque objet possédera un vecteur d'images. La classe Jeu initialisera les surfaces de chaque objet.

Ensuite, nous avons un indice pour l'image qui sera affichée par l'objet et on aura une autre variable pour le type de la case. De nouveau, cette séparation des données est faite pour ne pas mélanger le type de l'image et l'image qu'on utilise pour faire le dessin.

Enfin, la variable pos donne la position et taille de chaque case.

II-C-2. Le ficher Objet.cpp

Dans cette section, nous présenterons les fonctions de la classe Objet.

II-C-2-a. Constructeur

Le constructeur initialise simplement les membres de la classe. On initialise la taille du vecteur images et on met tous les autres membres à zéro.

Le constructeur
Sélectionnez
 
Objet::Objet()
{
    int i;
 
    images.resize(5);
    for(i=0;i<5;i++)
        images[i] = NULL;
 
    curimage = 0;
    pos.x = 0;
    pos.y = 0;
    pos.w = 0;
    pos.h = 0;
}
  

Le destructeur étant vide, nous ne montrerons pas le code ici.

II-C-2-b. La fonction init

La fonction init est très facile à comprendre. Elle met curimage à zéro et le type de la case est Vide.

La fonction init
Sélectionnez
 
//Fonction d'initialisation
void Objet::init()
{
    //On met l'image à zéro et le type est Vide
    curimage = 0;
    type = Vide;
}
  

Finalement, cette fonction permettra de mettre la case à vide mais nous ajoutons la remise à zéro de la variable curimage.

Je vais maintenant bien insister sur la raison derrière l'existence des variables curimage et type. En effet, on aurait pu garder la même variable et l'utiliser à la fois pour le type de la case et s'en servir pour l'affichage. Mais, encore une fois, la séparation est faite pour bien dissocier les choses. En effet, le type de la case et ce qu'on affiche ne seront pas toujours associés, c'est pour cela que je dissocie tout de suite ces deux caractéristiques des cases.

II-C-2-c. Les fonctions setImage et setPos

La fonction setPos est très facile à comprendre. Avec l'opérateur d'affectation, on met les valeurs de la structure pos à jour.

La fonction setPos
Sélectionnez
 
//Mise en place de la position de l'objet
void Objet::setPos(SDL_Rect *p)
{pos=*p;}
  

La fonction setImage initialise simplement le vecteur images. Voici son code :

La fonction setImage
Sélectionnez

 //Mise en place des surfaces
void Objet::setImage(SDL_Surface *vide,SDL_Surface *o, SDL_Surface* x, 
             SDL_Surface *gagneo, SDL_Surface *gagnex)
{
        images[0] = vide;
        images[1] = o;
        images[2] = x;
        images[3] = gagneo;
        images[4] = gagnex;
}
  

II-C-2-d. Les fonctions affiche et estDedans

La fonction affiche est devenue très simple. En effet, en utilisant l'indice curimage et le vecteur de surfaces images, c'est très facile de faire une copie de l'image sur la surface passée en argument.

La fonction affiche
Sélectionnez
 
//Fonction d'affichage
void Objet::affiche(SDL_Surface *dest)
{
    if(images[curimage])
        SDL_BlitSurface(images[curimage],NULL,dest,&pos);
}
  

La fonction estDedans est une simple fonction d'inclusion dans un point et un rectangle. Il suffit de voir si les abscisses et les ordonnées sont incluses dans les bornes associées à la case du jeu.

La fonction estDedans
Sélectionnez
 
//Est-ce que (x,y) est dans la case?
bool Objet::estDedans(int x, int y)
{
    return ( (x>pos.x)&&(x<pos.x+pos.w)&&(y>pos.y)&&(y<pos.y+pos.h));
}
  

II-C-2-e. La fonction estVide

Pour tester si une case est vide ou non, il suffit de tester la valeur de la variable type. Cela est montré dans la fonction suivante :

La fonction estVide
Sélectionnez
 
//Est-ce que la case est vide?
bool Objet::estVide()
{return type==Vide;}
  

II-C-2-f. Les fonctions getType, setType et memeType

La particularité de la fonction setType est la gestion interne de curimage. En effet, il y a deux intérêts de cette variable :

  1. Nous avons une seule énumération Gagne mais nous voulons pouvoir distinguer une case gagnée de rond et de croix.
  2. Dans la version 3, si jamais nous avions deux alignements en même temps (par exemple, si vous avez une ligne horizontale et une ligne verticale qui se complète en même temps) le programme en affiche un seul (une seule sera considérée comme la ligne gagnante). Dans cette nouvelle version, nous pouvons afficher les deux alignements (voir l'image ci-dessous).
Image non disponible
Rond a gagné avec un double alignement

Finalement, l'ordre du stockage des surfaces que nous utilisons nous permet d'écrire cette fonction. En effet, si la case est vide, est un rond ou est une croix alors nous pouvons juste utiliser la valeur de l'énumération pour avoir l'indice de l'image.

Mais si la case est de type Gagne alors il faut ruser un peu. Nous savons que les surfaces sont stockées dans l'ordre Vide, Rond, Croix, Rond gagne et Croix gagne. Donc si la case est de type Gagne, l'indice de la surface est égale au type de la case (Rond ou Croix) plus 2.

La fonction setType
Sélectionnez
 
//On met en place le type de la case
void Objet::setType(Case t)
{
    switch(t)
    {
        //Dans ces cas, on a directement le type et curimage
        case Vide:
        case Rond:
        case Croix:
            type=t;
            curimage = t;
            break;
    //Dans le cas Gagne, on met seulement à jour curimage 
    //curimage = type+2 pour avoir l'indice de la surface Gagne X ou Gagne O
        case Gagne:
            curimage = type+2;
            break;
    }
}
  

La dernière fonction que nous présenterons pour cette classe est la fonction memeType qui retourne vrai si les objets sont de même type. Ne confondez pas même objet avec même type.

Certaines personnes demanderaient pourquoi ne pas utiliser/redéfinir l'operateur de comparaison == ?

La réponse est assez simple : je trouve qu'il est mauvais usage de redéfinir cet opérateur. Surtout que nous pourrions avoir besoin de vérifier les valeurs des adresses des objets. Donc le code de la fonction memeType est :

La fonction memeType
Sélectionnez
 
//Est-ce que l'objet est du même type?
bool Objet::memeType(Objet &obj)
{
    return obj.type == type;
}
  

II-D. Ajout de la classe Moteur

Nous arrivons au dernier grand changement de cette partie. Nous allons ajouter une classe Moteur. Cette classe va encore être vide à la fin de ce tutoriel mais on va la remplir assez rapidement et elle deviendra vite le centre d'intérêt du projet.

II-D-1. Le ficher Moteur.h

Pour le moment, le seul membre de cette classe est le plateau de jeu. Les seules fonctions membres de cette classe seront les fonctions clic et aff qui feront le lien entre la fonction main et la classe Jeu. Voici sa déclaration :

La classe Moteur
Sélectionnez
 
class Moteur
{
        private:
                //Le plateau de jeu
                Jeu jeu;
 
        public:
                //Constructeur/Destructeur
                Moteur();
                ~Moteur();
 
                //La fonction clic
                void clic(int , int);
                //La fonction d'affichage
                void aff(SDL_Surface *screen);
 
                //La fonction d'initialisation
                bool init();
};
  

II-D-2. Le ficher Moteur.cpp

Présentons maintenant l'implémentation de la classe Moteur.

II-D-2-a. Le Constructeur et le Destructeur

Le constructeur et le destructeur de cette classe étant encore vides, on ne va pas les montrer ici.

II-D-2-b. La fonction clic et la fonction aff

Les deux fonctions qui sont intéressantes à voir sont la fonction clic et la fonction aff. Voici la fonction clic :

La fonction clic
Sélectionnez
 
void Moteur::clic(int x, int y)
{
    if(jeu.getFini())
        {
        jeu.videJeu();
        }
    else
        jeu.clic(x,y);
}
  

Cette fonction teste donc si le jeu est terminé. Si c'est le cas, on vide le jeu. Sinon, on passe les coordonnées à l'instance du jeu.

La deuxième fonction est donc la fonction d'affichage. Voici son code :

La fonction aff
Sélectionnez
 
void Moteur::aff(SDL_Surface *screen)
{
        jeu.aff(screen);
}
  

Comme vous le voyez, la fonction d'affichage du moteur est simplement un appel à la fonction d'affichage du membre jeu.

II-D-2-c. Remarques sur la classe Moteur

Vous devez maintenant vous demander pourquoi avoir créé cette classe qui est entièrement (enfin presque) vide! Quel intérêt de l'avoir et quel est le futur de cette classe ? Pour mieux le comprendre, je vais présenter ce que va faire cette classe à la fin de cette série de tutoriels.

A la fin de ces tutoriels, nous allons avoir un morpion qui possède un menu, peut être joué à un ou deux joueurs, ce qui implique une présence d'intelligence artificielle et, en plus, possède un module son. Ce sera au moteur du jeu d'initialiser tous ces modules, de les coordonner et de les faire fonctionner en même temps.

En conclusion, il est vrai que cette classe est un peu vide pour l'instant, mais elle va se remplir très rapidement et va pouvoir faire nettement plus de choses.

III. Conclusion

Ok, je l'avoue. Cette partie a été la plus longue depuis le début et je m'en excuse. Beaucoup de choses ont été faites pour mettre en place la classe Objet et la classe Moteur. Et je trouvais important de le faire dans le même tutoriel. Nous avons pris une version d'un morpion assez simpliste et nous l'avons complétée pour le rendre bien plus performant et bien plus intéressant.

La classe Objet a été mise en place pour permettre une gestion plus transparente des clics de la souris et de l'affichage des cases du morpion. Sans cette classe, l'état actuel du morpion aurait été difficile à mettre en place.

La classe Moteur a été mise en place pour devenir le lien entre le jeu et la fonction main. Pour l'instant, il n'y a pas grand chose dedans mais je pense avoir bien expliqué l'importance de cette classe. Nous verrons rapidement son intérêt et comment s'en servir.

Enfin, dans la prochaine partie, nous allons voir comment initialiser les positions des cases à partir d'un fichier. Mais nous verrons aussi comment mettre en place un menu de jeu et le séparer du jeu sous-jacent.

Jc

Liens

IV. Téléchargement

Voici le code source de ce tutoriel: zip (948 Ko)

Voici la version pdf: pdf (137 Ko)