1. Introduction

Dans cette cinquième partie de ce tutoriel, nous marquons le début de notre éloignement du jeu du morpion. En effet, les concepts présentés n'ont plus vraiment un rapport avec le jeu du morpion. Bien sûr, nous continuons de nous intéresser au cas du morpion, mais les concepts s'appliquent à n'importe quel jeu.

Nous allons, dans cette partie, mettre en place des fichiers de configuration et mettre plus en valeur la classe Moteur. A la fin de cette partie, nous aurons donc un morpion avec un menu et le tout sera paramétré avec des fichiers textes. Ceci permettra de modifier certains aspects du jeu sans devoir tout recompiler.

2. La classe Jeu

Il y a une seule modification dans la classe Jeu. On va utiliser un fichier pour paramétrer le plateau de jeu. L'utilisation de ce fichier va donc changer la fonction init. Ce sera le seul changement de cette classe. Enumérons les choses qui sont paramétrables :

  1. Nom des fichiers images qui seront à charger.
  2. La taille d'une case.
  3. La position des 9 cases du jeu.

Donc le fichier commence par les noms des différents fichiers images nécessaires pour l'affichage. Ensuite, vu que chaque case aura les mêmes dimensions, on définit la taille d'une case. Enfin, les neuf prochaines lignes contiennent les positions des neuf cases du morpion.

Fichier d'entrée pour le plateau
Sélectionnez

data/bg.bmp
data/vide.bmp
data/o.bmp
data/x.bmp
data/gagneo.bmp
data/gagnex.bmp
150 150
93 25
368 35
628 42
53 225
328 235
588 242
93 425
368 435
628 442

Forcément, ce fichier transforme une partie de la fonction init. Cette section présente donc les changements de cette fonction.

2.1. Le fichier Jeu.h

Si on regarde la version précédente de l'implémentation de la fonction init, les noms des fichiers images sont codés en dur, donc on pourrait aussi coder en dur le nom du fichier d'entrée. Mais, pour faire encore mieux, on va passer le nom du fichier en paramètre.

Changement du prototype de la fonction init
Sélectionnez

    //Fonction d'initialisation (chargement des surfaces)
    bool init(const std::string &file);

2.2. Le fichier Jeu.cpp

La seule chose qui change dans le fichier Jeu.cpp est donc la fonction init. Mais, si nous regardons bien, on passe simplement d'une version codée en dur à une version avec fichier de configuration.

La fonction commence donc par l'ouverture du fichier et la vérification que l'ouverture s'est bien passée.

Ouverture du fichier de configuration
Sélectionnez

    ifstream inputfile(file.c_str());
 
    //Test sur l'ouverture de la fonction
    if(inputfile==NULL)
        {
        std::cerr << "Erreur dans l'ouverture de " << file << std::endl;
        return false;
        }

Une fois que nous avons ouvert le fichier, nous allons pouvoir passer à la phase de chargement des surfaces SDL. Un problème potentiel est le passage des string au type char * qui est nécessaire pour utiliser la fonction SDL_LoadBMP. On utilisera donc la fonction c_str de la classe string pour pouvoir utiliser la fonction SDL.

Alors, vous me demanderez pourquoi ne pas passer directement par un char*?

Puisque ce tutoriel se fait en C++/SDL, je tente de garder le code au maximum dans le C++. L'utilisation du type char* est presque considérée comme une hérésie en C++, c'est pour cela que je préfère passer par la classe string. Hérésie parce qu'en C++, la STL fournit une classe permettant de faire beaucoup plus de choses en transparence que ce qu'on peut faire en C. Sans rentrer dans le débat "Qu'est-ce qui est mieux: les strings ou les char*?", je dirais qu'une bonne habitude est d'utiliser les char* en C et les string en C++. Dans de rares circonstances, il sera probablement préférable d'utiliser des char* mais, pour la plupart des programmeurs (moi y compris!), ces occasions ne se présenteront jamais.

Je prendrai un paragraphe de plus pour bien expliquer qu'il y a beaucoup d'avantages à l'utilisation de la classe string. Premièrement, toutes les fonctions C++ de la STL vont utiliser la classe string à la place du type char*. Ensuite, beaucoup de vérifications sont faites sans que le programmeur soit obligé de s'y intéresser. Finalement, bien que le type char* existe en C++, je pense (et je suis à la base un programmeur C, alors si je le dis, c'est que j'en suis convaincu...) que la classe string servira plus au programmeur dans 99% des cas. Oubliez donc l'utilisation du char* lorsque vous programmez en C++.

Voir cette question de la FAQ C++ pour plus d'informations.

Chargements des surfaces SDL
Sélectionnez

    string s;

    //On charge toutes les images dans les surfaces associées
    inputfile >> s;
    bg = SDL_LoadBMP(s.c_str());
    inputfile >> s;
    vide = SDL_LoadBMP(s.c_str());
    inputfile >> s;
    o = SDL_LoadBMP(s.c_str());
    inputfile >> s;
    x = SDL_LoadBMP(s.c_str());
    inputfile >> s;
    gagneo = SDL_LoadBMP(s.c_str());
    inputfile >> s;
    gagnex = SDL_LoadBMP(s.c_str());

Une fois que les images sont correctement chargées (je ne remettrais pas la vérification du chargement mais, ne vous inquiétez pas, elle se trouve encore après le chargement), on va initialiser les positions et les surfaces des cases.

Pour le faire, on utilise les fonctions setPos et setImage présentées dans les parties précédentes. La seule différence est l'obtention des positions par le fichier à la place d'avoir les positions codées en dur.

Chargement des positions et des surfaces
Sélectionnez

    //Initialisation de chaque case
    for(i=0;i<3;i++)
        for(j=0;j<3;j++)
        {
        inputfile >> r.x >> r.y;

        //On initialise les positions de chaque case
        plateau[i][j].setPos(&r);
        //On initialise les images de chaque case
        plateau[i][j].setImage(vide,o,x,gagneo,gagnex);
        }

En conclusion pour les changements de la classe Jeu, on voit qu'on a simplement changé la mise en place des paramètres codés en dur par le passage par un fichier de configuration. Rien de vraiment spécial mais je pense que c'est important de voir cela une fois en détail.

3. Le menu

Nous allons continuer cette partie par la présentation de la classe menu. Nous proposons ici une solution parmi tant d'autres. Celle-ci a l'avantage d'être facile à mettre en place et à gérer.

Comme toujours, cette facilité vient avec un inconvénient : le menu sera assez limité et simpliste. Voici une image du menu :

Image non disponible
Le menu

Une classe Menu n'est pas toujours une chose évidente à mettre en place. N'oublions pas comment sont séparées les données du jeu. En effet, nous avons la classe Jeu et la classe Moteur. Il existe plusieurs possibilités dans le positionnement de cette nouvelle classe par rapport aux autres.

L'image suivante montre les trois techniques d'intégration de cette nouvelle classe Menu. Les noeuds représentent les classes, les flèches représentent le fait que la classe peut appeler directement des fonctions de l'autre classe. Je fais exprès de faire l'amalgame entre classe et instance dans la dernière phrase puisque dans l'implémentation de ce morpion, on remarque que le programme est tellement simpliste que toutes les classes sont finalement ce qu'on appelle des singletons.

Je vais donc prendre ce paragraphe pour présenter les singletons de façon théorique. Un singleton est une technique qui permet de restreindre le nombre d'instances de la classe dans le programme à un. Bien que je comprends parfaitement les raisons de cette technique, je ne la mets que très rarement en place. La plus grande raison : je veux garder mon code simple, lisible et je ne veux pas perdre trop de temps sur des détails d'implémentation, j'aime voir le programme fonctionner et, seulement lorsqu'il commence à tourner, je prends le temps d'optimiser le code. Je vous conseille de connaître l'existence de cette technique singleton, de savoir quand il est judicieux de s'en servir.

Sans aller jusqu'à faire un singleton, on peut réduire le risque d'avoir des copies d'une instance en surchangeant le constructeur par copie et l'opérateur d'affectation. En effet, en les déclarant comme des fonctions privées (et ne les implémentant pas), on enlève l'occasion d'utiliser ce constructeur et de faire une copie du pointeur.

 
Sélectionnez

//Fonctions privées:
Menu(const Menu&);            //Constructeur par copie
Menu& operator=(const Menu&);

Revenons donc à notre intégration de la classe Menu, voici une image qui résume les trois grandes possibilités :

Image non disponible
Intégration de la classe Menu
  1. La classe Menu se place entre le moteur et le jeu. Cette solution permet de rendre le menu plus dépendant du jeu et le moteur peut rester indépendant de toutes les interactions possibles entre les classes Menu et Jeu. Cette solution sera rapidement écartée puisque le moteur ne possède plus un lien direct avec le jeu, cela rendra la gestion du jeu beaucoup moins habile.
  2. La classe Menu se place au même niveau que la classe Jeu sans avoir un lien direct vers le jeu. Donc toutes les interactions entre les deux se feront à travers la classe Moteur. Cette solution permet de garder les 3 classes relativement indépendantes. Par contre, l'inconvénient est le nombre de fonctions de gestion que le moteur va devoir intégrer.
  3. La classe Menu se place au même niveau que la classe Jeu mais aura un lien direct vers le jeu. Cette dernière solution possède l'avantage que le moteur peut rester relativement simple et le menu peut gérer entièrement le jeu sans devoir passer par le moteur. L'inconvénient est le nombre d'interaction entre les classes. En effet, par rapport à la deuxième solution, on doit gérer parfaitement bien toutes les interactions entre les 3 classes, ce qui n'est pas forcément évident.

Puisque nous voulons garder le programme assez simple et le plus réutilisable possible, la deuxième solution semble la plus appropriée. Le menu passera donc par le moteur pour interagir avec le jeu.

Ce menu va donc être la première chose que le joueur va voir. Voici ce que nous voulons que le jeu puisse faire avec le menu :

  • Quitter le jeu à partir du menu mais aussi lancer le jeu.
  • Retourner dans le menu à partir du jeu. Lorsqu'on y revient, il faut pouvoir retrouver le jeu dans l'état où on l'a quitté mais aussi relancer une nouvelle partie. Nous utiliserons la touche Echap pour passer du jeu au menu et vice-versa.

3.1. Présentation du fichier d'entrée

Notre menu sera défini par une image de fond, une image pour le titre, une pour le bouton Nouveau et une pour le bouton Quitter. Voici le fichier de données :

Fichier de données du menu
Sélectionnez
data/menu.bmp
data/titre.bmp
20 30
data/nouveau.bmp
52 277
data/quitter.bmp
372 490

Ce fichier contient donc le nom de l'image de fond. Ensuite, nous avons, successivement, le nom et la position des fichiers contenant le titre, le bouton nouveau et le bouton quitter. Regardons comment est implémentée la gestion du menu.

3.2. Le fichier Menu.h

La classe Menu ne possède pas beaucoup de membres et pas beaucoup de fonctions. Il y a un tableau de SDL_Surface* pour les différentes images et des variables pour contenir les positions du titre et des boutons du menu. Voici leur déclaration :

Membres de la classe
Sélectionnez

//Surfaces pour le menu
SDL_Surface *images[4];
		
//Position et taille des boutons nouveau et quitter et du titre
SDL_Rect nouveau,
    quitter,
    titre;

Dans les fonctions membres de la classe menu, il y a le constructeur, le destructeur, la fonction d'initialisation, la fonction qui gère le clic de la souris et l'affichage. Voici leur déclaration :

Fonctions membres de la classe
Sélectionnez

//Constructeur
Menu();
//Destructeur
~Menu();

//Fonction d'initialisation
bool init(const std::string &file);
		
//Gestion du clic
void clic(int x, int y);

//Fonction d'affichage
void aff(SDL_Surface *screen);

3.3. Le fichier Menu.cpp

L'implémentation de la classe Menu est relativement simple. Nous allons, à présent, montrer les fonctions une à une.

3.3.1. Constructeur et Destructeur

Ces deux fonctions gèrent le tableau des surfaces. Le constructeur met le tableau à NULL :

Constructeur
Sélectionnez

//Constructeur
Menu::Menu()
{
    int i;
    for(i=0;i<4;i++)
        images[i] = NULL;
}

Le destructeur libère les surfaces :

Destructeur
Sélectionnez

Menu::~Menu()
{
    int i;
    for(i=0;i<4;i++)
	   {
	   SDL_FreeSurface(images[i]) , images[i] = NULL;
	   }
}

3.3.2. La fonction init

La fonction init de la classe Menu ressemble à la fonction du même nom de la classe Jeu. On utilisera ce fichier pour initialiser le menu, voici son contenu :

Fichier de données pour le menu
Sélectionnez

data/menu.bmp
data/titre.bmp
20 30
data/nouveau.bmp
52 277
data/quitter.bmp
372 490

On commence par donner l'image de fond et ensuite les fichiers contenant les images du titre, du bouton nouveau et du bouton quitter. Voici les images séparées :

Image non disponible
Image de fond du menu
Image non disponible
Image du titre
Image non disponible
Image du bouton nouveau
Image non disponible
Image du bouton quitter

J'ai déjà montré le résultat au début du tutoriel, je mettrais juste le lien

Le code de la fonction init est assez simple. On ouvre le fichier, on charge les images et les positions du titre et des boutons. Je pense que les commentaires sont assez explicites et, avec les explications de la même fonction dans la classe Jeu, cette fonction est facile à comprendre. Je vais simplement montrer le code, les commentaires devraient être suffisants pour sa compréhension.

Fonction init
Sélectionnez

//Fonction d'initialisation
bool Menu::init(const string &file)
{
    //Ouverture du fichier de paramétrage
    ifstream input(file.c_str());

    //Test si le fichier s'est bien ouvert
    if(input==NULL)
        {
        std::cerr << "Erreur dans l'ouverture du fichier " << file << std::endl;
        return false;
        }

    //Récupération du nom de l'image pour le menu
    string nom;
    input >> nom;

    //On charge l'image de fond
    images[0] = SDL_LoadBMP(nom.c_str());
	
    //On récupére la surface de l'image "Titre"
    input >> nom;
    images[1] = SDL_LoadBMP(nom.c_str());
	
    //Initialisation des positions du menu
    input >> titre.x >> titre.y;
	
    //On récupére la surface de l'image "Nouveau"
    input >> nom;
    images[2] = SDL_LoadBMP(nom.c_str());
	
    //Initialisation de la position du bouton nouveau
    input >> nouveau.x >> nouveau.y;
	
    //On récupére la surface de l'image "Quitter"
    input >> nom;
    images[3] = SDL_LoadBMP(nom.c_str());

    //Mise en place de la transparence	
    for(int i=1;i<4;i++)
        SDL_SetColorKey(images[i],SDL_SRCCOLORKEY,0);

    //On récupére la position du bouton quitter
    input >> quitter.x >> quitter.y;


    //On récupére la taille des images	
    titre.w = images[1]->w;
    titre.h = images[1]->h;

    nouveau.w = images[2]->w;
    nouveau.h = images[2]->h;
	
    quitter.w = images[3]->w;
    quitter.h = images[3]->h;
	
    //On ferme le fichier
    input.close();

    //On retourne vrai
    return true;
}

3.3.3. La fonction clic

La fonction clic vérifie si la position de la souris se trouve dans le bouton nouveau ou le bouton quitter. Si c'est le bouton nouveau, alors on remet le jeu à zéro et on demande au moteur d'afficher le jeu. Si c'est le bouton quitter, on demande au moteur de terminer le programme.

La fonction clic
Sélectionnez

//Gestion du clic
void Menu::clic(int x, int y)
{
//Est-ce qu'on est dans le bouton nouveau?
if((nouveau.x<x)&&(nouveau.x+nouveau.w>x)&&(nouveau.y<y)&&(nouveau.y+nouveau.h>y))
    {
    moteur.initJeu();
    moteur.setFonctionsJeu();
    }
//Est-ce qu'on est dans le bouton quitter?
else if((quitter.x<x)&&(quitter.x+quitter.w>x)&&(quitter.y<y)&&(quitter.y+quitter.h>y))
    moteur.fin();
}

3.3.4. La fonction aff

La fonction d'affichage vérifie si nous avons les 4 surfaces pour faire le menu. Si c'est bien le cas, alors on les affiche.

L'ordre d'affichage est important. D'abord, on affiche l'image de fond du menu. Ensuite, grâce à l'utilisation de la transparence, on affiche l'image du titre, l'image du bouton Nouveau et du bouton Quitter.

La fonction aff
Sélectionnez

//Fonction d'affichage
void Menu::aff(SDL_Surface *screen)
{
    //Si on a une image, on l'affiche
    if(images[0] && images[1] && images[2] && images[3])
    {
        SDL_BlitSurface(images[0],NULL,screen,NULL);
        SDL_BlitSurface(images[1],NULL,screen,&titre);
        SDL_BlitSurface(images[2],NULL,screen,&nouveau);
        SDL_BlitSurface(images[3],NULL,screen,&quitter);
    }
}

4. La classe Moteur

Depuis la création et l'intégration de la classe Menu, on a dû compléter la classe Moteur pour qu'elle prenne en compte l'interaction entre l'ancienne classe Jeu et la nouvelle classe.

4.1. Le fichier Moteur.h

Nous remarquerons que nous avons deux nouveaux membres dans cette classe. Tout d'abord, un pointeur vers le menu et un booléen qui nous servira pour savoir si on est dans le menu ou non. Nous avons aussi mis le menu et le jeu en pointeur et nous utiliserons les opérateurs new/delete pour la gestion dynamique de la mémoire.

Membres de la fonction moteur
Sélectionnez

    //Pointeur de jeu
    Jeu *jeu;
    //Pointeur de menu
    Menu *menu;
		
    //booléen qui sert à savoir dans le menu
    bool dansMenu;

Dans la partie déclarant les fonctions membres, nous avons ajouté:

Les fonctions de la classe Moteur
Sélectionnez

    //Fonction qui initialise le jeu (et le remet à zéro)
    void initJeu();
    //Fonction qui demande de passer en mode Jeu
    void setFonctionsJeu();
    //Fonction qui demande de passer en mode Menu 
    void setFonctionsMenu();
    //Fonction qui change d'état de jeu
    void echangeFonctions();
	
    //Fonction de fin (servira pour les sauvegardes)
    void fin();

Nous avons donc une fonction qui remet le jeu à zéro (donc qui vide les cases du morpion). Une qui demande d'afficher le jeu, une qui demande d'afficher le menu, une qui demande de changer entre le menu et le jeu et enfin une qui dit d'arrêter le moteur et donc le programme. Nous allons, à présent, voir l'implémentation de ces fonctions.

4.2. Le fichier Moteur.cpp

4.2.1. Le constructeur et destructeur

Puisque nous sommes maintenant en train d'utiliser de l'allocation dynamique pour le plateau de jeu et le menu, il est logique que dans le constructeur et le destructeur, nous nous occupons de la gestion mémoire.

Constructeur et Destructeur
Sélectionnez

Moteur::Moteur()
{
    //Création du jeu et du menu
    jeu = new Jeu();
    menu = new Menu();
	
    //Initialisation du booléen dansMenu
    dansMenu = true;
}

Moteur::~Moteur()
{
    delete jeu;
    delete menu;
}

4.2.2. La fonction init

La fonction init de la classe Moteur ouvre un troisième fichier de données. Dans ce fichier, on a mis les noms des fichiers pour paramétrer le jeu et le menu. C'est le dernier fichier qui permettra de paramétrer le programme. Voici son contenu :

Fichier de données pour le moteur
Sélectionnez

data/plateau.txt
data/menu.txt

Pour la troisième fois, on va voir comment ouvrir et lire un fichier. On l'ouvre, on teste s'il y a une erreur et passe les noms des fichiers au plateau de jeu et du menu.

 
Sélectionnez
 
bool Moteur::init()
{
    //Ouverture du fichier de paramétrage
    ifstream input("data/input.txt");
 
    if(input)
        {
        string s1,s2;	
        //On récupére les noms des fichiers pour paramétrer le jeu et le menu
        input >> s1 >> s2;
        return (jeu->init(s1) && (menu->init(s2)));
        }
    return false;
}

Cette fonction, retourne vrai si l'initialisation du jeu et l'initialisation du menu retournent vraies.

4.2.3. La fonction clic

La fonction clic du moteur est légérement plus compliqué. On utilise la valeur du booléen dansMenu pour décider si c'est vers le menu ou si c'est vers le jeu qu'il faut envoyer l'information.

Fonction de clic
Sélectionnez
 
void Moteur::clic(int x, int y)
{
    //Si on est dans le menu
    if(dansMenu)
        {
        menu->clic(x,y);
        }
    else
        {	
        if(jeu->getFini())
            {
            jeu->videJeu();
            }
        else
            jeu->clic(x,y);
        }
}

Il existe plusieurs techniques pour implémenter le moteur de jeu le plus rapidement possible. Une alternative serait d'utiliser des pointeurs de fonctions pour éviter l'utilisation de if. Mais, puisque nous avons seulement deux possibilités (entre le menu et le jeu), cette solution est parfaitement acceptable.

4.2.4. La fonction aff

La fonction d'affichage est exactement comme la fonction clic. On utilisera la valeur du booléen dansMenu pour décider si on affiche le menu ou le jeu.

Fonction d'affichage
Sélectionnez

void Moteur::aff(SDL_Surface *screen)
{
    //Si on est dans le menu
    if(dansMenu)
        {
        menu->aff(screen);
        }
    else
        {
        jeu->aff(screen);
        }
}

4.2.5. Les fonctions pour la gestion des modules

Nous pouvons présenter maintenant les fonctions qui serviront à dire au moteur si le programme est dans le mode jeu ou le mode menu. Je pense qu'elles sont facilement compréhensibles :

Autres fonctions
Sélectionnez

void Moteur::initJeu()
{
    jeu->videJeu();
}

void Moteur::echangeFonctions()
{
    dansMenu = !dansMenu;
}

void Moteur::setFonctionsJeu()
{
    //On met dansMenu false
    dansMenu = false;
}

void Moteur::setFonctionsMenu()
{
    //On met dansMenu true
    dansMenu = true;
}

On me dira qu'on pouvait faire tout ceci avec une fonction setdansMenu qui mettra directement à jour la valeur du booléen dansMenu. Mais, en l'écrivant comme je viens de la présenter, on pourra facilement passer à une version de programme ayant, par exemple, trois différents modules : un menu, le jeu du morpion et un jeu de pong!

L'utilisation d'un booléen n'est donc plus suffisante pour différencier les trois modules. C'est dans cette optique que je présente les fonctions comme ceci.

4.2.6. La fonction fin

La dernière fonction de cette classe est la fonction fin. Cette fonction a été mise en place pour que n'importe quel module puisse demander de quitter le programme. Il est préférable de passer par cette fonction pour que le moteur puisse faire les sauvegardes du jeu, demander les fermetures des fichiers et la libération de la mémoire avant la fin du programme.

Bien que dans cette version du programme, nous n'allons rien sauvegarder, rien fermer et il n'y a rien à libérer. Sa mise en place est importante et j'espère que cela vous apprendra quelque chose.

La fonction fin
Sélectionnez

void Moteur::fin()
{
    SDL_Event ev;
    //Le jeu est fini, on va émettre un événement SDL_QUIT
    //On pourrait mettre une phase de sauvegarde ici.
    ev.type = SDL_QUIT;
    SDL_PushEvent(&ev);
}

5. La fonction Main

La seule chose qu'on change dans la fonction main est l'ajout de la gestion de la touche Echap. On utilise la fonction echangeFonctions pour passer du menu au jeu (et vice-versa).

Evénement clavier
Sélectionnez

case SDL_KEYUP:
    if(event.key.keysym.sym==SDLK_q)
        moteur.fin();
    else if(event.key.keysym.sym==SDLK_ESCAPE)
        moteur.echangeFonctions();
    break;

6. Conclusion

Encore une fois, nous avons ici une partie assez imposante. Mais, de nouveau, nous avons fait beaucoup de choses pour rendre le jeu plus présentable et plus paramétrable. Maintenant, en modifiant les fichiers textes (sans devoir recompiler) nous pouvons changer tous les aspects du morpion.

Le fait de pouvoir tout changer sans devoir recompiler a beaucoup d'avantage. Premièrement, cela laisse n'importe quel utilisateur modifier et configurer le jeu comme bon lui semble. Cela vous permet de modifier la taille des cases, leur position.

Bien sûr, leur format est très simpliste et il serait bon de pouvoir mettre des commentaires dans ces fichiers textes.

Enfin, cette partie est la première qui a un rapport sur la programmation du jeu et moins sur le morpion. Dans la prochaine partie, nous allons nous attaquer à l'intelligence artificielle pour pouvoir jouer contre un ordinateur.

Jc

Liens

7. Téléchargements

Voici le code source de ce tutoriel : zip (1.2 Mo)

Voici la version pdf : pdf (210 Ko)