C++ APPRENDRE ET PROGRAMMER

Commentaires

Transcription

C++ APPRENDRE ET PROGRAMMER
"C++ APPRENDRE ET PROGRAMMER"
Sélection de Bonnes Feuilles -> extrait du Chap 5 :
"Manipulation d'une matrice et classes"
Août 2012
Mise en place du projet
139
C
e chapitre va être l’occasion de développer le jeu du labyrinthe, un programme
un petit peu plus évolué par rapport à ce que nous avons fait jusqu’ici. Nous
utiliserons en ce sens la bibliothèque graphique OpenGL.
Cette bibliothèque a été présentée au chapitre Graphique de données.
Le jeu du labyrinthe consiste à déplacer un bonhomme dans un niveau
"labyrinthique", un peu à la manière d’un Pacman, le "fossile" du jeu vidéo. Des
ennemis donneront du fil à retordre au joueur afin d’éviter que cela ne devienne un
parcours de santé. De plus, les déplacements de ces ennemis seront contrôlés par
une intelligence artificielle d’une complexité extrême, proche du hasard, ce qui
apportera un côté "imprévisible" au déroulement du jeu.
Le niveau du jeu sera vu "de dessus", en deux dimensions. Le joueur pourra faire
évoluer son personnage dans le niveau en le contrôlant à l’aide des flèches
directionnelles de son clavier. Partant d’un point A, il essaiera de rejoindre un
point B tout en évitant les ennemis sanguinaires rodant aux alentours.
Nous souhaitons que la création des niveaux de jeu soit facilement réalisable. Notre
objectif est d’offrir à quiconque la possibilité de s’en donner à cœur joie pour
"designer" ses propres niveaux et y placer stratégiquement des ennemis. De plus, la
taille des niveaux ne sera pas fixée, ce qui permettra de créer des plateaux de toutes
sortes : petits, grands, carrés, rectangulaires, etc.
5.1
Mise en place du projet
Configuration pour OpenGL
Créez un nouveau projet vide de type Application Console Win32. Nommez-le
Labyrinthe dans une nouvelle solution également appelée Labyrinthe. Créez-y un
nouveau fichier source vierge, main.cpp par exemple. Incluez les fichiers d’en-tête
habituels et créez une fonction main vide. En employant GLUT, configurez le projet
pour qu’il puisse accueillir cette bibliothèque.
Pour configurer le projet, reportez-vous au chapitre Graphique de données. Par
ailleurs, un récapitulatif complet, expliquant comment configurer un projet Visual
C++ pour les bibliothèques OpenGL et GLUT, est consultable dans l’annexe
Configurer un projet pour GLUT et OpenGL.
Vérifiez que le fichier d’en-tête glut.h est bien inclus depuis votre fichier source.
#include "GL/glut.h"
5
140
Chapitre 5 - Manipulation d’une matrice et classes
Mise en place des données principales
La première donnée que nous pouvons déclarer est la taille du niveau. Nous aurons
forcément besoin du nombre de colonnes et de lignes de la grille du niveau pour
pouvoir le gérer. Déclarez deux valeurs entières globales.
int NbColonnes, NbLignes; // Taille du niveau
Dans la fonction principale du programme, commençons par affecter des valeurs
aux trois variables globales pour ne pas les laisser non initialisées (les affectations
multiples du genre TailleX=TailleY=0 sont possibles). Créons la fenêtre OpenGL et
définissons la fonction LabyAffichage en tant que fonction d’affichage, et LabyRedim en
tant que fonction de redimensionnement.
Reportez-vous au chapitre Graphique de données pour savoir comment utiliser
GLUT et créer une fenêtre OpenGL.
Listing 5-1 : Fonction principale du programme
void main(void)
{
NbColonnes=NbLignes=0; // Initialisation de la taille
/* Gestion de graphique */
// Position de la fenêtre
glutInitWindowPosition(10, 10);
// Taille de la fenêtre
glutInitWindowSize(500, 500);
// Mode d'affichage
glutInitDisplayMode(GLUT_RGBA | GLUT_SINGLE);
// Création de la fenêtre
glutCreateWindow("Labyrinthe");
// Fonction d'affichage
glutDisplayFunc(LabyAffichage);
// Fonction de redimentionnement
glutReshapeFunc(LabyRedim);
glutMainLoop();
}
Bien entendu, nous devons définir ces deux fonctions d’événements dans notre
programme. La fonction d’affichage ne doit posséder aucun paramètre. C’est là que
toutes les instructions d’affichage seront appelées. Nous effacerons tout d’abord
l’écran de jeu afin de pouvoir commencer le dessin sur un support vierge, en
spécifiant la couleur de fond en blanc. L’instruction glMatrixMode (que nous ne
détaillerons pas dans ce chapitre) permet de spécifier la matrice active en vue de
Mise en place du projet
141
disposer les éléments sur la scène lors du dessin. Après les instructions d’affichage,
l’appel à glFlush permet de terminer le dessin de la scène.
Listing 5-2 : Fonction d’affichage
void LabyAffichage();
void LabyAffichage() {
// Définit la couleur de fond
glClearColor(1.0, 1.0, 1.0, 1.0);
// Efface l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Définit la matrice de modélisation active
glMatrixMode(GL_MODELVIEW);
/* Instructions d'affichage ici */
glFlush(); // Achève l'affichage
}
Les fonctionnalités plus avancées d’OpenGL, comme la manipulation des matrices
de modélisation, sont traitées au chapitre Initiation à la 3D temps réel : OpenGL.
La fonction de redimensionnement demande en paramètre deux valeurs entières
correspondant à la taille de la fenêtre (largeur/hauteur). L’instruction gluOrtho2D
permet de spécifier l’échelle du dessin. Le premier couple (0.0, NbColonnes) permet
de définir les coordonnées maximales horizontalement (gauche/droite) et (NbLignes,
0.0) les coordonnées extrêmes verticalement (bas/haut, et non haut/bas !). Ainsi
l’appel à gluOrtho2D(0.0, NbColonnes, NbLignes, 0.0) spécifie le pixel de coordonnées
(0, 0) comme étant le coin supérieur gauche de la fenêtre et le pixel (NbColonnes,
NbLignes) le coin inférieur droit.
Listing 5-3 : Fonction de redimensionnement
void LabyRedim(int x, int y);
void LabyRedim(int x, int y) {
glViewport(0, 0, x, y);
glMatrixMode(GL_PROJECTION);
glLoadIdentity();
gluOrtho2D(0.0, (double)NbColonnes,
(double)NbLignes, 0.0);
}
5
142
5.2
Chapitre 5 - Manipulation d’une matrice et classes
Gérer le niveau : les matrices
Les niveaux de jeu dans lesquels le joueur évoluera seront des labyrinthes, en deux
dimensions et "vus de dessus" comme dans Pacman ou Bomberman. Dans notre
programme, nous stockerons ces informations dans un tableau, non pas à une seule
dimension comme celui qui nous a servi à stocker N élèves (tableau "ligne"), mais à
deux dimensions (il contiendra donc des lignes et des colonnes).
Pour savoir comment stocker N élèves dans un tableau à une dimension, reportezvous au chapitre Interface de saisie.
Pour tracer les niveaux, nous pourrions définir les tableaux "directement dans le
code", c’est-à-dire "en dur" comme on le dit en jargon de programmation. L’ennui de
cette solution provient du manque inhérent d’évolutivité et de souplesse du
programme. Elle nous contraindrait à modifier le code du programme et donc à le
recompiler avec Visual, s’il devenait nécessaire de modifier la conception ou la taille
d’un niveau. Un joueur ne pourrait pas, dès lors, créer ses propres niveaux de jeu.
Une solution beaucoup plus pratique, et évolutive, est de stocker les données d’un
programme dans des fichiers externes. Dans n’importe quel jeu vidéo, les animations
des personnages ne sont pas conservées en dur dans le programme. Elles sont
stockées dans des fichiers externes spéciaux, édités à l’aide de logiciels d’animation
(3D Studio, Maya…). De même pour les textures (les images plaquées sur les
polygones d’une scène 3D) sont généralement stockées dans des fichiers image de
type .bmp, .jpg ou encore .tga.
Stocker les niveaux
Figure 5-1 :
Fichier niveau.txt
Pour notre part, nous allons stocker les niveaux de notre jeu dans des fichiers texte
facilement éditables à l’aide de n’importe quel éditeur de texte, tel le Bloc-notes.
Gérer le niveau : les matrices
143
Ouvrons donc le Bloc-notes de Windows et créons un nouveau fichier texte que
nous nommerons niveau.txt. Nous allons devoir à présent "dessiner" le niveau de jeu
sous forme d’une grille de caractères. Choisissons par exemple le caractère 0 pour
spécifier un mur, et le caractère 1 une allée (ou un couloir). Formons cette grille en
composant un assemblage astucieux de 0 et de 1. Voici un exemple de petit niveau
labyrinthique réalisable :
Sauvegardons ce fichier dans le dossier du projet Labyrinthe\Labyrinthe, à côté du
fichier source de ce dernier.
Figure 5-2 :
Enregistrement
du fichier niveau.
txt
5
Bien qu’étant stocké dans ce fichier texte, le contenu du niveau de jeu devra être
récupéré dans notre programme. Le type de données que nous allons utiliser pour
stocker une grille de ce format est un tableau à deux dimensions : une dimension
pour les colonnes, et une dimension pour les lignes. Mathématiquement, cela se
nomme une matrice. Nous allons voir comment allouer un tableau de ce type.
De façon statique, un tableau à deux dimensions de 4 × 5 contenant des booléens
s’alloue de la façon suivante :
bool tableau_statique[4][5];
Cependant, dans notre cas, ne connaissant pas la taille du niveau à l’avance, nous
devons passer par une allocation dynamique. Avant de pouvoir allouer cette
matrice, nous devons connaître ses dimensions. Il serait possible de les déterminer
en lisant une première fois le contenu du fichier, puis en le lisant une deuxième fois
pour remplir le tableau. Mais il est plus simple et plus rapide d’écrire la taille de la
matrice directement dans le fichier où est stocké le niveau. Spécifions tout d’abord
son nombre de colonnes, puis son nombre de lignes.
144
Chapitre 5 - Manipulation d’une matrice et classes
Figure 5-3 :
Ajout de la taille
de la grille dans le
fichier du niveau
Allouer un tableau à deux dimensions
Un tableau à une dimension est défini par un pointeur pointant sur la première
cellule. Allouer un tableau d’entiers à l’aide de l’instruction int* tab = new int[N]
aurait pour effet de créer N cellules pouvant accueillir chacune un entier.
Un tableau à deux dimensions est en réalité un tableau de tableaux, ce qui
correspond à un double pointeur. La marche à suivre pour allouer un tableau de
C × L entiers est donc d’allouer un tableau de C cellules. Chaque cellule peut
accueillir un pointeur (simple) sur un entier. Il faut ensuite allouer un tableau de
L entiers pour chacune de ces C cellules. Le schéma suivant représente une
allocation à deux dimensions :
Figure 5-4 :
Schématisation
d’une allocation
dynamique d’un
tableau à deux
dimensions
Double pointeur
Commençons par déclarer en global le double pointeur permettant d’identifier la
matrice du niveau. Un double pointeur se déclare à l’aide de deux étoiles. De la
même manière, un triple pointeur se déclare avec trois étoiles. Étant donné que
l’information contenue dans un fichier texte est du texte, choisissons une matrice de
caractères (type char) pour stocker les données du niveau. Cela simplifiera la lecture.
char** Matrice;
// Matrice contenant le niveau
Gérer le niveau : les matrices
145
Initialisons ce double pointeur à la valeur NULL (il ne pointe sur rien) au début de la
fonction main (vous pouvez tout à fait l’initialiser directement lors de sa déclaration).
Matrice = NULL;
// Initialisation du double pointeur
Créons une fonction OuvrirNiveau qui permettra de charger un niveau. Elle prendra
comme paramètre une chaîne de caractères correspondant au nom du fichier
contenant le niveau à charger. L’appel de cette fonction sera de la forme
OuvrirNiveau("niveau.txt"). Une chaîne de caractères étant un tableau, et un tableau
étant défini par un pointeur sur la première cellule, un paramètre chaîne de
caractères est du type char*.
void OuvrirNiveau(char* nom_fichier);
void OuvrirNiveau(char* nom_fichier) // Définition
{
// Instruction d'ouverture du niveau
}
Tableau en paramètre
Passer un tableau (par exemple d’entiers) en paramètre à une fonction se
réalise via la syntaxe int * nom. Il est également possible d’utiliser la syntaxe
int nom[]. Ainsi les deux déclarations suivantes sont équivalentes :
void fonction1(int* tableau);
void fonction2(int tableau[]);
// Ces deux déclarations
// sont identiques
À l’intérieur de notre fonction, nous allons allouer la matrice Matrice avant de lire le
fichier niveau.txt pour charger les informations contenues. Pour allouer cette
matrice, nous devons connaître sa taille NbColonnes × NbLignes. Dans un premier
temps, fixons ces valeurs.
NbColonnes = 10;
NbLignes = 8;
Allocation dynamique
Maintenant que la taille de la matrice est fixée, nous pouvons allouer cette dernière.
Comme nous l’avons mentionné précédemment, une allocation dynamique à deux
dimensions nécessite deux étapes :
j allouer un tableau de NbColonnes pointeurs sur char :
// Allocation du tableau du niveau
Matrice = new char*[NbColonnes];
5
146
Chapitre 5 - Manipulation d’une matrice et classes
j allouer un tableau de NbLignes cellules (de type char) par pointeur :
for(int i=0; i< NbColonnes; i++)
Matrice[i] = new char[NbLignes];
Il est préférable d’initialiser les valeurs contenues dans cette matrice à des valeurs
par défaut comme le caractère 0, caractérisant un mur. La syntaxe pour accéder aux
cellules d’un tableau à une dimension repose sur des crochets. Ici l’instruction
Matrice[3] donne accès au tableau caractérisant la quatrième ligne de la matrice. La
syntaxe pour accéder au caractère stocké à la quatrième ligne de la deuxième
colonne est Matrice[1][3]. De la même façon, accéder aux cellules d’un tableau à trois
dimensions, dans un jeu de Rubik’s Cube par exemple, se ferait ainsi : Rubiks[x][y][z].
Pour parcourir la matrice entièrement, c’est-à-dire pour initialiser chacune de ses
cellules, nous allons faire appel à une double boucle. Nous devons passer toutes les
colonnes en revue, et pour chacune de ces colonnes, parcourir les NbLignes lignes.
Notez qu’il est également possible de faire l’inverse (c’est-à-dire parcourir d’abord
les lignes puis les colonnes), cela ne change rien ici.
// Initialisation des valeurs du tableau
for(int i=0; i<NbColonnes; i++)
for(int j=0; j<NbLignes; j++)
Matrice[i][j] = '0';
Boucle en bloc
Il est possible de programmer cette double boucle en spécifiant les blocs de
cette façon :
for(int i=0; i<NbColonnes; i++)
{
for(int j=0; j<NbLignes; j++)
{
Matrice[i][j] = ’0’;
}
}
Mais cela n’est pas nécessaire. En effet, la boucle parcourant les lignes
compte comme une seule instruction aux yeux de la première boucle
traitant les colonnes.
Libérer la mémoire d’une matrice
Qui dit allocation dit désallocation ! Si nous ne respectons pas ce principe de
réservation et de libération de mémoire, nous nous exposons à une "fuite de
Gérer le niveau : les matrices
147
mémoire". Préparons une fonction LibererMemoire chargée de supprimer le tableau
Matrice.
Listing 5-4 : Déclaration et corps de la fonction de désallocation
void LibererMemoire();
/* … */
void LibererMemoire() // Définition
{
/* Instructions de libération de mémoire */
}
Une désallocation consiste en une allocation inverse. Une matrice est composée
d’une ligne de pointeurs, chacun d’entre eux étant alloué d’une colonne. Il faut donc
tout d’abord libérer la mémoire occupée par les NbColonnes colonnes à l’aide d’une
boucle. Dans un deuxième temps, la libération de la ligne de NbLignes pointeurs
permet de supprimer la totalité des données de la matrice.
Listing 5-5 : Libération de la mémoire allouée par une matrice
void LibererMemoire()
{
// Vérifie que Matrice a bien été allouée
if(Matrice != NULL)
{
for(int i=0; i<NbColonnes; i++)
// Libération des colonnes
delete [] Matrice[i];
// Libération de la ligne de pointeurs
delete [] Matrice;
}
}
Tester avant de désallouer
Avant de libérer de la mémoire allouée dynamiquement et accessible par un
pointeur, mieux vaut tester que ce pointeur pointe effectivement sur une
case mémoire existante. Car tenter de libérer de la mémoire n’existant pas
aura pour effet de faire planter le programme. C’est pourquoi avant
d’appeler les instructions delete, nous avons testé au préalable que la
matrice a bien été allouée en comparant le double pointeur Matrice avec la
valeur NULL.
Une bonne question à se poser à présent est : quand doit-on appeler cette fonction
de libération de mémoire ? La libération de données allouées dynamiquement doit
5
148
Chapitre 5 - Manipulation d’une matrice et classes
être effectuée lorsque ces données ne sont plus utiles au programme. Ici nous nous
servirons de la matrice du niveau pendant la totalité du jeu : la fonction
LibererMemoire est à appeler en fin de programme. Pour l’instant, nous ne savons pas
quand elle peut intervenir, car le programme va être monopolisé jusqu’à la fin par la
boucle de traitement de GLUT à partir de l’instruction glutMainLoop. Nous verrons
où appeler LiberationDonnees plus tard, quand nous connaîtrons la condition d’arrêt
du jeu.
Récupérer le contenu d’un fichier texte
Nous allons maintenant ouvrir le fichier où est stocké le niveau pour charger ses
données et remplir ainsi la matrice Matrice. Les fonctions utiles à la manipulation de
fichiers en C++ sont dans le fichier fstream, à inclure dans le projet.
#include <fstream>
Ouvrir le fichier
Au début de la fonction OuvrirNiveau, déclarons une variable de type ifstream ("input
file stream", "fichier en ouverture" en français). En réalité, ici nous déclarons, non pas
une variable, mais un "objet". Un objet est une variable dont le type est une classe
(nous traiterons les classes ultérieurement). Une classe est en quelque sorte une
"structure plus évoluée", car en plus de pouvoir posséder des variables membres, elle
peut contenir des fonctions membres. Par exemple, les objets de type ifstream
possèdent une fonction membre open qui permet d’ouvrir le fichier passé en
paramètre. L’objet fichier de type ifstream peut ouvrir le fichier toto.txt à l’aide de
l’instruction fichier.open("toto.txt"). Dans notre cas, c’est le paramètre passé à la
fonction OuvrirNiveau qui correspond au nom du fichier à ouvrir.
ifstream fichier;
fichier.open(nom_fichier);
// Objet de type ifstream
// Ouverture en lecture seule
Si le fichier à charger n’existe pas ou si son nom est erroné, le programme ne pourra
pas charger le niveau et donc fonctionner. Heureusement il est possible de tester si
l’ouverture (et même l’écriture) d’un fichier s’est déroulée sans incident. Il suffit
pour cela de vérifier la valeur de l’objet de type ifstream : si elle est fausse
(équivalente à l’état false ou à la valeur numérique 0), cela signifie que le fichier ne
s’est pas ouvert correctement ; dans le cas contraire (si la valeur de l’objet est true ou
égale à toutes valeurs numériques différentes de 0), le fichier est ouvert et prêt à
l’emploi.
if(!fichier) {
// Test de l'existence du fichier
cout<<"Erreur lors de l'ouverture du fichier !"<<endl;
system("pause");
exit(1);
}
Gérer le niveau : les matrices
149
Lire dans le fichier
Maintenant que le fichier est correctement ouvert en lecture, nous allons récupérer
son contenu. Lire une information contenue dans un fichier et la stocker dans une
variable se réalise de la même façon qu’une récupération de saisie au clavier cin. En
effet, la classe ifstream propose elle aussi l’utilisation de l’opérateur >> pour capter
un flux entrant. Récupérons tout d’abord les dimensions du niveau en remplaçant la
portion de code où nous spécifions temporairement ces valeurs.
// Lecture de la taille du niveau
fichier >> NbColonnes;
fichier >> NbLignes;
Une fois que la taille du niveau est connue, l’allocation dynamique et l’initialisation
de la matrice doivent être réalisées. Ensuite, nous pourrons lire les données
contenues dans le fichier. Récupérons caractère par caractère les cellules de la grille.
Mais attention : il est indispensable de parcourir tout d’abord les lignes, puis les
colonnes. En effet, le sens de lecture d’un fichier (qui est d’ailleurs le sens de lecture
que nous connaissons et utilisons tous) est de la gauche vers la droite et de haut en
bas.
A B C D
E F G H
I J K L
Imaginez que ce texte soit contenu dans un fichier. Lire ce fichier en parcourant les
colonnes avant les lignes reviendrait à capturer ceci :
A
B
C
D
E
F
G
H
I
J
K
L
Programmons la double boucle qui parcourt les lignes puis les colonnes en capturant
le caractère en cours dans la bonne cellule de la matrice.
// Lecture du tableau du niveau, caractère par caractère
for(int j=0; j<NbLignes; j++)
for(int i=0; i<NbColonnes; i++)
fichier >> Matrice[i][j];
Lorsqu’un fichier devient inutile, il est nécessaire de le fermer. Ce bon réflexe
contribue à un programme propre et performant. Les objets de type ifstream
possèdent une fonction membre close, qui permet de fermer le fichier ouvert après
utilisation. Appelons donc cette fonction.
fichier.close();
// Fermeture du fichier
5
150
Chapitre 5 - Manipulation d’une matrice et classes
Voici un récapitulatif complet de la fonction d’ouverture du niveau OuvrirNiveau :
Listing 5-6 : Fonction OuvrirNiveau complète
void OuvrirNiveau(char* nom_fichier)
{
ifstream fichier;
// Objet de type ifstream
fichier.open(nom_fichier); // Ouverture du fichier
if(fichier == 0) { // Test de l'existence du fichier
cout << "Erreur lors de l'ouverture du fichier !"
<< endl;
system("pause");
exit(1);
}
// Lecture de la taille du niveau
fichier >> NbColonnes;
fichier >> NbLignes;
// Allocation du tableau du niveau
Matrice = new char*[NbColonnes];
for(int i=0; i<NbColonnes; i++)
Matrice[i] = new char[NbLignes];
// Initialisation des valeurs du tableau
for(int i=0; i<NbColonnes; i++)
for(int j=0; j<NbLignes; j++)
Matrice[i][j] = '0';
// Lecture du tableau du niveau, caractère par caractère
for(int j=0; j<NbLignes; j++)
for(int i=0; i<NbColonnes; i++)
fichier >> Matrice[i][j];
fichier.close();
// Fermeture du fichier
}
Maintenant nous devons appeler la fonction OuvrirNiveau pour ouvrir le niveau. Cela
va se passer dans la fonction main. Il est impératif d’effectuer cet appel avant
l’instruction glutMainLoop, qui lance la boucle de traitement du jeu et n’en sort
jamais.
OuvrirNiveau("niveau.txt");
// Ouverture de "niveau.txt"
Gérer le niveau : les matrices
151
Afficher le niveau
À présent que la grille de jeu est correctement stockée en mémoire dans notre
tableau à deux dimensions Matrice, nous pouvons l’afficher. Il ne s’agit pas d’afficher
la matrice de caractères brute telle quelle à l’écran, mais plutôt d’utiliser les
informations contenues à l’intérieur pour dessiner un "joli décor" de jeu.
Actuellement, la fonction d’affichage ressemble à ceci :
Listing 5-7 : Fonction d’affichage OpenGL
void LabyAffichage()
{
// Définit la couleur de fond
glClearColor(1.0, 1.0, 1.0, 1.0);
// Efface l'écran
glClear(GL_COLOR_BUFFER_BIT);
// Définit la matrice de modélisation active
glMatrixMode(GL_MODELVIEW);
/* Instructions d'affichage ici */
glFlush();
// Achève l'affichage
}
Elle se contente d’effacer l’écran et de le remplir d’une couleur de fond blanche.
Principe d’affichage du décor
Pour tracer le niveau de jeu à partir de la matrice de caractères, plusieurs solutions
sont envisageables. Il serait possible de tracer des lignes entourant les "blocs de
murs", ou alors au contraire de tracer des lignes délimitant les allées. Mais la
solution la plus simple, qui est d’ailleurs celle que nous allons utiliser, consiste à
"remplir" les cellules occupées par un mur en dessinant un carré de couleur à leur
position. La couleur du fond est blanche ; c’est celle des allées. Pour la couleur des
murs, choisissons par exemple une couleur grise à l’aide de la fonction glColor3d :
(0, 0, 0) étant le noir et (1, 1, 1) étant du blanc, (0.5, 0.5, 0.5) fera l’affaire. Nous
allons ensuite annoncer un ordre d’affichage avec glBegin. Pour dessiner des carrés,
utilisons le paramètre GL_QUADS (pour "quadrilatère"). À partir de maintenant,
chaque "groupe" de quatre points (vertices) formera un quadrilatère. Parcourons
toutes les cellules de la matrice et traçons un quadrilatère gris si la cellule en cours
est un mur (donc si le caractère stocké est 0). De façon générale, les vertices à
définir (à l’aide de glVertex2d) pour tracer le carré correspondant à la cellule d’indice
[i][j] ont pour coordonnées (i, j) (i, j+1) (i+1, j+1) (i+1, j).
5
152
Chapitre 5 - Manipulation d’une matrice et classes
Figure 5-5 :
Correspondance
entre l’indice de
la cellule et la
position des
vertices à relier
Fonction d’affichage
Programmons le code nécessaire à l’affichage du niveau dans une nouvelle fonction
pour éviter que LabyAffichage ne soit trop longue.
Listing 5-8 : Fonction d’affichage du niveau
void DessinerNiveau();
// Déclaration en début de fichier
void DessinerNiveau()
// Définition
{
glColor3d(0.5, 0.5, 0.5); // Couleur grise
// Commence l'affichage de quadrilatères
glBegin(GL_QUADS);
// Parcourt toutes les cellules de la matrice
for(int i=0; i<NbColonnes; i++)
for(int j=0; j<NbLignes; j++)
// Si c'est un mur, on dessine un carré
if(Matrice[i][j] == '0')
{
// Place les points du carré
glVertex2d(i,
j);
glVertex2d(i,
j+1);
glVertex2d(i+1, j+1);
glVertex2d(i+1, j);
}
glEnd();
// Achève l'affichage
}
Appelons cette fonction depuis la "fonction d’affichage principale" LabyAffichage,
juste avant l’instruction glFlush.
void LabyAffichage()
{
/* … */
// Définit la matrice de modélisation active
glMatrixMode(GL_MODELVIEW);
DessinerNiveau(); // Affiche le niveau
glFlush();
// Achève l'affichage
}
Classe Joueur
153
Figure 5-6 :
Affichage du
niveau de jeu
5
Disproportion
L’affichage peut sembler disproportionné. Cela vient du fait que la grille du
niveau créée est rectangulaire, tandis que la fenêtre GLUT est carrée. Si la
déformation vous gêne, il suffit de redimensionner manuellement la fenêtre
afin d’obtenir un rapport de taille correct.
5.3
Classe Joueur
Le niveau est défini, chargé et tracé. Mais il est vide. L’intérêt est de pouvoir évoluer
dans ce niveau en y déplaçant un personnage. C’est ce que nous allons traiter dans
cette section.
Classes et autres concepts afférents
Concept de classe
Jusqu’à maintenant, tout ce que nous avons vu ne relève pas vraiment de la
"programmation orientée objet" (ou POO) du langage C++. La notion de classe que
nous allons présenter va nous permettre d’introduire cette nouvelle approche de la
programmation.
154
Chapitre 5 - Manipulation d’une matrice et classes
Une classe est un nouveau type de données qui contient des données et des
méthodes. Les méthodes permettent de travailler avec les données et de les
manipuler. En d’autres termes, les données d’une classe sont des variables (des
entiers, des flottants…) ou des objets (des objets d’autres classes). Les méthodes de
cette classe sont des fonctions utilisables.
Un objet est une instance d’une classe. Les termes "instance d’une classe C" et "objet
du type C" sont équivalents. Par exemple, dans une déclaration de variable int var;, int
(qui est un type) est comparable à une classe, et var (qui est une variable) à une
instance ou à un objet.
Cela rappelle les structures. En effet, les classes sont des structures plus évoluées.
Par convention, les structures sont utilisées pour stocker seulement des données. Les
classes ajoutent à cela la possibilité d’inclure des méthodes, de spécifier des
catégories de membres, et le concept d’héritage.
Nous reviendrons sur l’héritage au chapitre Hiérarchie de classes et listes chaînées.
La syntaxe de déclaration d’une classe est la suivante :
Listing 5-9 : Syntaxe de déclaration d’une classe
class nom_de_la_classe
{
/* Tout ce qui est déclaré ici fait partie de la classe */
}; // Ne pas oublier le point-virgule !
Visibilité des données
Toutes les données et méthodes déclarées dans le bloc de la classe sont membres de
cette classe. Il existe trois catégories de membres de classe :
j les membres publics (mot-clé public), accessibles à "tout le monde" ;
j les membres privés (mot-clé private), accessibles seulement aux membres et
amis de la classe concernée ;
j les membres protégés (mot-clé protected), sur lesquels nous reviendrons
lorsque nous expliquerons la notion d’héritage.
Pour spécifier une catégorie de données, il suffit de l’indiquer à l’aide de la syntaxe
du type catégorie:. Tous les membres, déclarés après cette spécification, font partie de
la catégorie indiquée. Par défaut, la catégorie est private.
Classe Joueur
155
Listing 5-10 : Catégories de données dans une classe
class nom_de_la_classe
{
/* Membres privés */
int variable_private;
void fonction_private(void);
public:
/* Membres publics */
float variable_public;
void fonction_public(void);
};
Tous les membres sont accessibles depuis les fonctions membres de la classe. Par
exemple, il est possible de modifier la valeur de variable_private depuis fonction_public
ou fonction_private. Par contre, seuls les membres publics sont accessibles à
l’extérieur, comme en témoigne le code suivant :
Listing 5-11 : Visibilité des membres d’une classe
void main()
{
// Déclaration d'une instance de la classe
nom_de_la_classe Objet;
Objet.variable_public = 10;
Objet.fonction_public();
Objet.variable_private = 10;
Objet.fonction_private();
//
//
//
//
Accès
Appel
Accès
Appel
autorisé
autorisé
interdit : erreur
interdit : erreur
}
Différence entre struct et class
En C, les classes n’existent pas et les structures ne peuvent posséder que
des données publiques (il n’y a pas de fonctions membres par exemple).
En C++, les structures et les classes sont équivalentes, à ceci près : la
catégorie des membres est par défaut public dans une structure (private pour
une classe). Mais une structure peut tout à fait posséder des fonctions
membres, ou hériter d’une structure mère (ou même d’une classe !).
Malgré ce lien de parenté entre structure et classe et les possibilités que cela
offre, nous utiliserons les structures seulement pour conditionner des
données publiques, sans fonction membre et trace d’héritage. Il est
préférable de garder les "structures style C" et de travailler avec des classes
pour le reste.
5
156
Chapitre 5 - Manipulation d’une matrice et classes
Encapsulation
Par tradition, il est préférable de déclarer les classes dans des fichiers d’en-tête
spécifiques, du même nom que la classe pour éviter toute ambiguïté. Les définitions
des classes sont alors à placer dans des fichiers sources .cpp portant le même nom.
Dans l’Explorateur de solutions du projet, de la même façon que nous avons ajouté
le fichier principal main.cpp, ajoutons un fichier Joueur.cpp dans le dossier Fichiers
sources, et un fichier Joueur.h dans le dossier Fichiers d’en-tête.
Figure 5-7 :
Explorateur de
solutions
Ouvrons le fichier d’en-tête Joueur.h. Incluons glut.h qui nous sera utile par la suite,
et saisissons la déclaration de la classe Joueur. Pour l’instant, un joueur "a besoin" de
sa position sur la grille de jeu. Plaçons ces données en tant que membres privés.
#include "GL/glut.h"
class Joueur
{
private:
// Position Colonne/Ligne sur la matrice
int PosC, PosL;
};
Vie publique, vie privée
Ici la spécification private ne sert à rien puisque la catégorie des membres est
par défaut private. Cela dit, nous préférons la spécifier malgré tout : plus le
code est explicite, moins il est source d’erreurs.
Classe Joueur
157
Bien que loin d’être complète, la classe Joueur est déjà utilisable. Essayons de créer
un objet de type Joueur. Dans le fichier principal main.cpp, déclarons en global un
objet de type Joueur ; il ne doit pas, bien entendu, porter le nom Joueur. Nommons-le
monJoueur.
Joueur monJoueur;
// Objet global de type Joueur
Théoriquement, les erreurs de compilation devraient être au rendez-vous, dont la
principale : "erreur de syntaxe : absence de ’;’ avant l’identificateur ’monJoueur’". En
effet, le fichier main.cpp ne connaît pas encore la classe Joueur, cette dernière étant
déclarée dans le fichier Joueur.h. Incluons cet en-tête (ici le fichier Joueur.h faisant
partie intégrante du projet, nous devons utiliser la syntaxe #include "fichier" et non
#include <fichier>).
#include "Joueur.h" // Inclusion du fichier d'en-tête Joueur
L’oubli étant corrigé, nous pouvons utiliser l’objet monJoueur. Par exemple, dans la
fonction main, saisissez monJoueur. (monJoueur suivi d’un point) pour accéder aux
membres de l’objet. Si Visual décide de le faire fonctionner, vous devriez voir
apparaître les membres PosC et PosL.
Figure 5-8 :
Liste des
membres des
instances de
Joueur
Les petits cadenas sur les icônes, symbolisant ces deux variables, rappelle que
leur catégorie est privée : il est impossible d’y accéder d’ici. Tenter de leur
affecter une valeur quelconque (par exemple avec l’instruction monJoueur.PosC = 2)
fait réagir le compilateur :
error C2248: 'Joueur::PosC' : impossible d'accéder à private
✂ membre déclaré(e) dans la classe 'Joueur'
Icônes existantes
Voici un tableau présentant les différents icônes que vous pourrez trouver
pour représenter les membres des classes :
5
158
Chapitre 5 - Manipulation d’une matrice et classes
Tableau 5-1 : Icônes représentatives des membres d’une instance
Icône
Type
Catégorie
Variable, objet, pointeur
public
Variable, objet, pointeur
private
Variable, objet, pointeur
protected
Fonction
public
Fonction
private
Fonction
protected
C’est bien là le but de la protection de ce type de membres. Il s’agit d’interdire leur
modification et leur accès manuel. Vous ne pourrez jamais par mégarde (vous ou
votre collègue programmeur travaillant sur le même projet) modifier les données de
votre classe, et mettre en péril son bon fonctionnement, et celui du programme.
D’un point de vue organisation, le principe de la POO "pure" est de définir toutes les
données en tant que membres privés. Elles doivent être manipulables via des
méthodes déclarées publiques, qui elles, sont accessibles à l’utilisateur. C’est le
principe de l’encapsulation.
Accesseurs
Fidèle au principe de l’encapsulation, nous avons déclaré les données PosC et PosL
en tant que membres privés. Dans notre programme, nous avons besoin de
connaître la position du joueur à différents endroits (par exemple, pour savoir s’il a
trouvé la sortie du niveau). Pour pouvoir accéder malgré tout aux valeurs de ces
membres privés, nous allons créer des accesseurs. Un accesseur est une fonction
publique qui retourne la valeur d’un membre, généralement de visibilité privée. Le
programmeur ne peut pas modifier la valeur du membre via cet accesseur. Il accède
en quelque sorte à ce membre en "lecture seule".
Noms des accesseurs
Habituellement, les fonctions d’accès aux valeurs des données d’une classe
sont nommées Get suivies du nom de la donnée. Par exemple, l’accesseur à
la variable Prix pourrait être appelé GetPrix.
Classe Joueur
159
Définissons les accesseurs aux membres PosC et PosL. Ces fonctions étant membres
de la classe Joueur, nous pouvons accéder à toutes les données (privées ou non)
contenues dans la classe Joueur depuis le corps de ces fonctions.
Listing 5-12 : Déclaration d’accesseurs
class Joueur
{
private:
// Position Colonne/Ligne sur la matrice
int PosC, PosL;
public:
// Accesseurs
int GetPosC()
{
return PosC;
}
int GetPosL()
{
return PosL;
}
};
Définitions à la volée
Il est possible de définir directement le code d’une fonction dans une classe.
C’est généralement ce qui est fait lorsque les fonctions comptent peu de
lignes de programmation. Il est en outre possible de déclarer la fonction
membre dans la déclaration de la classe et de placer sa définition à
l’extérieur, habituellement dans un fichier nom_de_la_classe.cpp. Nous
opterons pour cette solution pour les fonctions "conséquentes".
Nos deux accesseurs ne modifient pas les valeurs de nos membres. Ils se contentent
de renvoyer ces valeurs (ce qui est généralement le cas de tout accesseur). Il est
judicieux ici de les déclarer en tant que fonctions constantes à l’aide du mot-clé
const. Une fonction membre constante d’une classe ne peut pas modifier de
membres de sa classe.
Listing 5-13 : Accesseurs : fonctions constantes
class Joueur
{
private:
// Position Colonne/Ligne sur la matrice
int PosC, PosL;
5
160
Chapitre 5 - Manipulation d’une matrice et classes
public:
// Accesseurs
int GetPosC()
int GetPosL()
};
const {return PosC;}
const {return PosL;}
Maintenant que les accesseurs sont déclarés et définis, nous pouvons les utiliser.
Figure 5-9 :
Accès à la valeur
d’une variable via
son accesseur
Constructeurs
Les valeurs de PosC et PosL n’ont jamais été initialisées, ce qui pose problème si l’on
cherche à accéder à ces valeurs inexistantes.
Pour initialiser les membres privés d’une classe, il est possible de créer une fonction
publique qui s’en charge.
Listing 5-14 : Fonction publique d’initialisation
class Joueur
{
private:
// Position Colonne/Ligne sur la matrice
int PosC, PosL;
public:
// Accesseurs
int GetPosC()
int GetPosL()
const {return PosC;}
const {return PosL;}
// Initialisation
void Initialisation() {
PosC = PosL = 0;
}
};
Appeler la fonction Initialisation pour chaque instance de la classe Joueur permet
d’initialiser ses données. Mais il y a plus pratique : une "fonction" appelée
automatiquement à la construction de l’instance d’une classe. C’est le constructeur.
Le constructeur se déclare un peu de la même façon qu’une fonction membre, sauf
qu’il doit porter le même nom que la classe et ne posséder aucun type de retour, pas
même le type void (c’est d’ailleurs ce qui le différencie d’une fonction membre
classique). Il doit être déclaré public.
Classe Joueur
161
Listing 5-15 : Constructeur de la classe Joueur
class Joueur
{
private:
// Position Colonne/Ligne sur la matrice
int PosC, PosL;
public:
// Accesseurs
int GetPosC() const {return PosC;}
int GetPosL() const {return PosL;}
// Constructeur par défaut
Joueur() {
PosC = PosL = 0;
}
};
Nous pouvons utiliser les accesseurs créés pour consulter les valeurs de PosC et PosL
de l’instance monJoueur.
cout << "Coordonnees du joueur (" << monJoueur.GetPosC()
<< ", " << monJoueur.GetPosL() << ")" << endl;
Le constructeur de la classe Joueur est un constructeur par défaut, c’est-à-dire qui ne
prend pas de paramètre. En effet, il est possible de spécifier des paramètres à un
constructeur, et même de faire cohabiter constructeurs par défaut et paramétrés. Le
concept mis en avant ici est très important : il s’agit de la surcharge de fonction.
Surcharge de fonction et constructeurs paramétrés
Que ce soit parmi les membres d’une classe ou en global, le C++ offre la possibilité
de déclarer plusieurs fonctions portant le même nom, à condition que le type ou le
nombre de leurs paramètres diffèrent. On parle alors de surcharge de fonction.
Listing 5-16 : Exemples de surcharges de fonctions
// Déclaration de la fonction "de base"
void fonction(int a, int b);
// Surcharge correcte : le paramètre 2 n'a pas le même type
void fonction(int a, float b); // Surcharge
// Surcharge correcte : le nombre de paramètres est différent
void fonction(int a);
Lors de l’appel de l’une de ces surcharges, c’est uniquement le type des valeurs
passées en paramètre qui permet de déterminer le corps de fonction qui sera
exécuté.
5
162
Chapitre 5 - Manipulation d’une matrice et classes
Listing 5-17 : Appel de surcharge de fonction
fonction(10, 6);
// Appel de la première fonction
fonction(10, 4.0f); // Appel de la deuxième fonction
fonction(10);
// Appel de la troisième fonction
C’est le type des paramètres qui importe, et non leur nom. Par exemple, la surcharge
suivante est interdite :
void fonction(int c, int d);
Si elle était autorisée, comment déterminer si l’appel à l’instruction fonction(10, 6)
doit exécuter la première fonction ou la nouvelle surcharge ?
Ce concept de surcharge de fonction se retrouve chez les constructeurs de classe. En
plus du constructeur par défaut (sans paramètre) qu’il est conseillé de créer, il est
possible de lui définir autant de surcharges que désiré. Cela est particulièrement
utile pour définir directement la valeur de certains membres au moment d’instancier
un objet. Imaginez la classe Pixel suivante ; le constructeur paramétré va permettre
de définir directement la position du pixel à la construction.
Listing 5-18 : Surcharge de constructeur dans une classe
class Pixel {
int x, y; // Coordonnées
public:
// Constructeurs
Pixel()
{x=y=0;}
// par défaut
Pixel(int a, int b) {x=a; y=b;} // paramétré
};
Lors de l’instanciation (création d’instance) d’un objet de la classe Pixel, ne pas
spécifier de paramètre implique l’utilisation du constructeur par défaut. Pour utiliser
l’un des constructeurs paramétrés, il faut spécifier la valeur des paramètres après le
nom de l’instance déclarée.
Listing 5-19 : Utilisation de constructeurs différents lors de l’instanciation d’objets
Pixel pix1;
// Constructeur par défaut : pixel (0, 0)
Pixel pix2(4, 8); // Constructeur paramétré : pixel (4, 8)
Constructeur par défaut
Il est préférable de déclarer un constructeur par défaut pour chaque classe
créée, même si elle ne requiert par d’initialisation de variables ou d’objets.
Cela permet d’éviter des erreurs par la suite, lors de l’instanciation d’objets
de cette classe.
Classe Joueur
163
Destructeur
Alors qu’un constructeur est appelé à la création d’une instance d’une classe, un
destructeur est appelé à sa destruction. Il doit contenir toutes les instructions
consistant à libérer la mémoire occupée par les allocations dynamiques dans la
classe. Un destructeur se déclare de la même façon qu’un constructeur : il doit
porter le même nom que la classe, ne posséder aucun type de retour et doit être
déclaré public. La seule différence vient du caractère ~ (tilde) qui doit précéder son
nom.
class TheClasse
{
public:
TheClasse();
~TheClasse();
};
// Constructeur
// Destructeur
Dans notre cas, la classe Joueur ayant seulement deux données membres sous forme
de variables, l’utilisation d’un destructeur s’avère inutile. Son utilisation est par
contre indispensable lors d’allocation de mémoire comme pour des tableaux
dynamiques.
Surcharge de destructeur ?
Contrairement à un constructeur, le destructeur d’une classe ne peut être
surchargé. Il ne prend pas de paramètre.
Afficher le joueur
Notre joueur possède donc une position dans le niveau, que nous pouvons consulter
par le biais d’accesseurs. Cette position n’existant pour l’instant qu’à l’état
numérique, nous devons la matérialiser en affichant le joueur à l’écran : c’est ce que
l’on appelle un avatar. Déclarons une fonction publique chargée de l’afficher.
Listing 5-20 : Déclaration en tant que membre public de la classe Joueur
void Dessiner();
Définition de fonction d’une classe
Comme cette fonction sera assez lourde, nous n’allons pas la programmer "à la
volée" dans la déclaration de la classe. Puisqu’il existe un moyen de définir les
fonctions membres dans le fichier Joueur.cpp, profitons-en : ouvrons le fichier
5
164
Chapitre 5 - Manipulation d’une matrice et classes
Joueur.cpp et incluons Joueur.h pour que les définitions puissent "voir" leur
déclaration.
#include "Joueur.h"
La définition externe d’une fonction membre d’une classe s’écrit sous cette forme :
type_retour nom_classe::nom_fonction(paramètres)
{
/* Corps de la fonction */
}
La différence avec la définition d’une classe non membre vient de la forme
nom_classe:: précédant le nom de la fonction. Définissons la fonction Dessiner.
Listing 5-21 : Fonction d’affichage du joueur
void Joueur::Dessiner()
// Définition dans le fichier Joueur.cpp
{
glPushMatrix();
glTranslated(PosC+0.5, PosL+0.5, 0.0);
glColor3d(0.0, 0.0, 0.0);
// Couleur noire
glutSolidSphere(0.3, 12, 12);
// Sphère de la tête
glColor3d (1.0, 1.0, 0.0);
// Couleur jaune
glTranslated(0.1, -0.1, 0.0);
glutSolidSphere(0.05, 12, 12); // Premier œil
glTranslated(-0.2, 0.0, 0.0);
glutSolidSphere(0.05, 12, 12); // Deuxième œil
glPopMatrix();
}
Pour plus de précisons sur OpenGL, reportez-vous au chapitre Initiation à la 3D
temps réel : OpenGL.
Assurez-vous que le fichier d’en-tête glut.h est bien inclus depuis Joueur.h, sans quoi
les appels aux instructions OpenGL et GLUT réalisées dans le corps de la fonction
Dessiner seraient impossibles.
Appelons comme il se doit l’affichage du joueur dans la fonction d’affichage
générale LabyAffichage.
Listing 5-22 : Appel de la fonction Dessiner de monJoueur depuis LabyAffichage
void LabyAffichage()
{
// Définit la couleur de fond
glClearColor(1.0, 1.0, 1.0, 1.0);
// Efface l'écran
glClear(GL_COLOR_BUFFER_BIT);
Classe Joueur
165
// Définit la matrice de modélisation active
glMatrixMode(GL_MODELVIEW);
DessinerNiveau();
monJoueur.Dessiner();
glFlush();
// Affiche le niveau
// Affiche l'avatar du joueur
// Achève l'affichage
}
Le constructeur de la classe Joueur spécifie la position initiale du joueur. Pour le
faire apparaître en bas du mini-labyrinthe, nous pourrions spécifier les coordonnées
(colonne d’indice 5, ligne d’indice 7). Cependant, tous les labyrinthes n’ayant pas
forcément cette configuration, il est plus intéressant de définir la position de départ
du joueur lors de la création du niveau.
Définir la position de départ
Prise en compte d’un caractère-clé
Jusqu’à maintenant, le caractère 0 correspond à un mur, et le caractère 1 à une
allée. Une bonne solution est de définir un autre caractère-clé pour placer le point
de départ du joueur. Choisissons par exemple le caractère j (comme "joueur").
Figure 5-10 :
Ajout d’un j dans
la matrice du
niveau
Reprenons la fonction de chargement du niveau nommée OuvrirNiveau. Examinons la
double boucle de lecture de la matrice contenue dans le fichier.
// Lecture du tableau du niveau, caractère par caractère
for(int j=0; j<NbLignes; j++)
for(int i=0; i<NbColonnes; i++)
fichier >> Matrice[i][j];
C’est ici que nous allons tester si le caractère lu est un j et agir en conséquence. Juste
après avoir rempli la cellule Matrice[i][j], testons le caractère directement à l’aide d’un
5
166
Chapitre 5 - Manipulation d’une matrice et classes
commutateur switch (le j ne sera pas le seul caractère-clé à être testé). Un bon
réflexe est de tester non seulement la minuscule j, mais aussi la majuscule J, pour
pallier les étourderies. Pour imposer à un commutateur de réaliser une même action
dans deux cas différents, il suffit de positionner les deux case à la suite.
Listing 5-23 : Deux conditions pour une action
// Lecture du tableau du niveau, caractère par caractère
for(int j=0; j<NbLignes; j++)
for(int i=0; i<NbColonnes; i++)
{
fichier >> Matrice[i][j]; // Lecture du caractère
switch(Matrice[i][j])
// Test du caractère lu
{
// Position initiale de joueur
case 'j':
// Teste à la fois la minuscule
case 'J': { // et la majuscule
/* Définir ici la position de départ */
break;
}
}
}
Mutateurs
Définir la position de départ du joueur revient à initialiser les valeurs de PosC et PosL
de l’instance monJoueur. Mais ces deux membres sont privés. Selon le principe de la
POO, créons des fonctions dont le but est de modifier la valeur de ces deux
membres : ce sont des mutateurs (encore appelés "modificateurs").
Listing 5-24 : Mutateurs des données privées PosC et PosL
class Joueur
{
/* … */
// Mutateurs
void SetPosC(int C)
void SetPosL(int L)
/* … */
}
{PosC = C;}
{PosL = L;}
Nom des mutateurs
Il est courant de nommer les fonctions modificatrices par Set suivi du nom
de la donnée à modifier. Par exemple, le mutateur de la variable Prix pourrait
se nommer SetPrix.