• 30 hours
  • Medium

Free online content available in this course.

course.header.alt.is_video

course.header.alt.is_certifying

Got it!

Last updated on 1/13/20

TP de mise en pratique : développez un jeu de Morpion

Log in or subscribe for free to enjoy all this course has to offer!

Le jeu de morpion, souvent confondu avec le Tic-Tac-Toe, est un jeu se jouant sur une grille de taille arbitrairement grande, et ou 2 joueurs s'affrontent. L'un joue en plaçant des croix, tandis que l'autre a les ronds. Le gagnant est le premier qui aligne 5 symboles identiques en ligne (horizontale ou verticale) ou en diagonale.

Nous vous proposons de réaliser un jeu de morpion en 2 activités. La première permettra de choisir une dimension pour la surface de jeu et la seconde serait l'activité de jeu à proprement parler. Voici un exemple de visuels qui pourraient constituer ce jeu :

Visuels proposé pour le morpion
Visuels proposés pour le morpion

Idéalement, le fait de bouger le curseur dans l'activité d'accueil se traduira par la mise à jour de l'affichage textuel de la taille au dessus ("20x20" dans l'exemple ici).

Les objectifs visés ici sont :

  • créer une application contenant plusieurs activités et plusieurs classes ;

  • utiliser des widgets autres que le bouton ou le TextView (avec des écouteurs) ;

  • faire une interface graphique personnalisée.

L'aspect algorithmique de détection de fin et d'identification du vainqueur est intéressant mais n'entre pas dans les  objectifs de ce cours. Ainsi, n'hésitez pas à vous aider de la proposition de correction pour avancer sur ce point si c'est le seul bloquant. Par exemple, le premier élément de correction est une proposition de diagramme de classe. Si vous avez du mal à prendre du recul sur les différentes parties, cela peut vous aider.

Bon développement !

Éléments de correction

Les points importants qui seront discutés dans ces éléments de correction sont :

  • Quelles classes créer et quels choix structurels sont importants ?

  • Comment gérer la mise à jour de l'affichage de la taille de la grille quand le curseur bouge ?

  • Comment appeler l'activité de Jeu avec la taille en paramètre ?

  • Quelle structure de donnée pour les coup joués ? (et les afficher)

  • Comment saisir un nouveau coup ?

  • Comment détecter la fin du jeu ?

En fin de ce chapitre, vous trouverez un lien Github permettant de récupérer un exemple de corrigé complet, duquel ont été extraits les éléments de correction présentés.

Structure proposée

Pour répondre au problème tel que formulé, il est important d'avoir 2 activités : l'une étant l'écran d'accueil (MainActivity), tandis que l'autre est l'écran de jeu (Morpion).

Sur l'écran d'accueil, l'objectif est de sélectionner une taille. Il faut donc des Widgets permettant de le faire (ici, un SeekBar pour sélectionner et un TextView  pour faire un retour visuel numérique) ainsi qu'un bouton permettant de valider le choix et de lancer le jeu.

Dans l'écran de jeu, il faut savoir afficher un plateau de jeu, ajouter un coup et arrêter le jeu. Ces deux derniers éléments sont les méthodes proposées dans la classe Morpion. Pour l'affichage, nous avons vu qu'il fallait créer une classe à part entière (Plateau), qui hérite de View  et qui redéfinisse la méthode onDraw.

Dans cette implémentation, j'ai fait un choix désastreux en terme de coût d'exécution (qui reste complètement acceptable compte tenu de la taille des données que l'on manipule), mais qui simplifie grandement la lisibilité du code : plutôt que de stocker les coups joués dans un tableau à 2 dimensions, je propose de les stocker dans une liste. Ainsi, il est nécessaire d'avoir une classe Pion, qui matérialise un coup joué.

Proposition de structure générale pour le programme de Morpion
Proposition de structure générale pour le programme de Morpion

Affichage numérique de la taille choisie

Si vous avez utilisé un SeekBar, alors le type à respecter pour récupérer en live son activité estOnSeekBarChangeListener. Il nécessite de fournir 3 méthodes :

public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
//...
}
public void onStartTrackingTouch(SeekBar seekBar) {
//...
}
public void onStopTrackingTouch(SeekBar seekBar) {
//...
}

Les noms sont assez explicites et on notera que seul onProgressChanged  sera utile ici. Les deux autres méthodes pourront être laissées vides. Lorsque onProgressChanged  est appelée par le système elle récupère notamment en paramètre la nouvelle valeur de la ProgressBar. Donc il n'est même pas nécessaire de faire un appel à getProgress()  :soleil:.

public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
tvTaille.setText((MIN_TAILLE +i)+"x"+(MIN_TAILLE +i));
}
public void onStartTrackingTouch(SeekBar seekBar) {}
public void onStopTrackingTouch(SeekBar seekBar) {}

Créer une application composée de 2 activités

Une fois la première activité créée, il est nécessaire d'en créer une seconde (l'activité de jeu) et de l'appeler en lui transmettant en paramètre la taille du terrain de jeu à générer. 

Pour créer une activité, c'est "facile" : New -> Activity -> Empty activity. Cette action ajoutera automatiquement l'activité au Manifest du projet.

Une fois l'Activité créée et déclarée, il faut la lancer et lui transmettre la taille en paramètre.

Pour lancer l'activité de manière explicite, nous avons vu qu'il fallait créer un Intent  et lui associer le nom de l'activité à lancer, puis faire un appel à startActivity. Si l'on veut en plus transmettre un paramètre entier, alors on pourra utiliser la méthode putExtra  qui permet d'ajouter un couple clef-valeur en paramètre. Cette méthode étant surchargée, elle préservera le type du paramètre (ici, un int).

Intent caller = new Intent(this, oc.demos.morpion.Morpion.class);
caller.putExtra(Morpion.EXTRA_SIZE, taille);
startActivity(caller);

Notez que dans cet exemple, pour ne pas nous tromper sur le nom du paramètre, nous avons respecté les conventions Android : le nom de la clef est une constante publique récupérée dans la classe qui attend ce paramètre en entrée.

Ainsi, du côté de l'activité Morpion, il est nécessaire de définir cet attribut et de récupérer le paramètre reçu :

public static final String EXTRA_SIZE = "oc.demos.morpion.taille";
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Récupération du paramètre d'appel.
// Valeur 20 par défaut si paramètre non reçu.
Intent intent = getIntent();
taille = intent.getIntExtra(EXTRA_SIZE, 20);
// ...
}

Dessiner la grille de jeu

Nous avons vu que pour  "dessiner", il est nécessaire de créer un classe héritant de View et d'y redéfinir onDraw. Ceci étant fait se pose ensuite la question de l'intégration à l'interface graphique, puis celle du dessin à proprement parler.

Comme l'affichage proposé de l'activité Morpion se limite au plateau de jeu uniquement, alors pas besoin de faire un layout. Il suffit d'instancier un Plateau  et de dire à l'activité de s'habiller avec cette vue. Dans l'exemple ci-dessous, on en profite pour lui transmettre la taille  désirée du plateau de jeu, ainsi qu'une référence vers l'historique  des coups joués. Avoir cette connaissance partagée entre les deux instances allégera les commandes futures de rafraichissement de l'affichage.

protected void onCreate(Bundle savedInstanceState) {
// ...
plateau = new Plateau(this, taille, historique);
setContentView(plateau);
// ...
}

Une fois l'intégration faite, il est nécessaire dans le Plateau  de préparer tous les crayons qui seront nécessaires pour construire le visuel, puis tracer n-1 lignes horizontales et autant de verticales. Enfin, pour finir, pour chaque coup présent dans l'historique, il faut placer la croix ou le rond à l'emplacement adéquate.

private int taille;
private Paint pLignes;
private List<Pion> historique;
public Plateau(Context context, int taille, List<Pion> historique) {
super(context);
this.taille= taille;
this.historique = historique;
pLignes = new Paint();
pLignes.setColor(Color.BLACK);
// ...
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = getWidth();
int h = getHeight();
int rayon = Math.min(w,h)/taille/2;
// Quadrillage
for(int i=1 ; i< taille ; i++) {
canvas.drawLine(i*w/taille, 0, i*w/taille, h, pLignes);
}
for(int j=1 ; j<= taille ; j++) {
canvas.drawLine(0,j*h/taille, w, j*h/taille, pLignes);
}
}

Coups joués : structure de données et affichage

Pour mémoriser l'ensemble des coups déjà joués, un tableau à 2 dimensions de coups serait intéressant. Il permettrait de faciliter la recherche d'alignements de manière locale, autour du dernier pion joué. Cependant, il complexifie un peu les écritures, puisqu'il devient nécessaire de gérer les bornes du tableau et d'avoir des doubles boucles imbriquées. J'ai donc fait ici un choix qui n'est pas efficace, mais dont la vitesse mesurée d'exécution est toujours restée sous la milliseconde : une liste chainée de coups.

Dans ce choix, il faut d'abord avoir la notion de coup ou de pion :

public class Pion {
public int x;
public int y;
public boolean estCroix;
public Pion(int x, int y, boolean estCroix) {
this.x=x;
this.y=y;
this.estCroix=estCroix;
}
}

Puis l'historique n'est qu'une liste de pions :

private List<Pion> historique = new LinkedList<>();

L'affichage consiste ensuite uniquement à parcourir la liste et à afficher chacun des symboles.

protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = getWidth();
int h = getHeight();
int rayon = Math.min(w,h)/taille/2;
// Quadrillage
// ...
// Placement de l'historique de jeu
for(Pion c:historique) {
if(c.estCroix) {
canvas.drawLine(
c.x*w/taille , c.y*h/taille ,
(c.x+1)*w/taille, (c.y+1)*h/taille,
pCroix);
canvas.drawLine(
(c.x+1)*w/taille, c.y*h/taille ,
c.x*w/taille , (c.y+1)*h/taille,
pCroix);
} else {
canvas.drawCircle(
(int)((c.x+0.5)*w/taille),
(int)((c.y+0.5)*h/taille),
rayon,
pRond);
}
}
}

Saisie des coups

Pour saisir un nouveau coup, il suffit d'associer un écouteur de touche ou de clic à la surface affichée et à récupérer l'emplacement de l'événement. Par un produit en croix, on ramène ces coordonnées écran à des coordonnées virtuelles dans la grille, puis on ajoute le pion à l'historique. Il suffit enfin de tester si le jeu est fini et de forcer la mise à jour de l'affichage pour que le TP soit fini.

public boolean onTouch(View view, MotionEvent motionEvent) {
if(motionEvent.getAction()==MotionEvent.ACTION_UP) {
int x = (int)(motionEvent.getX()*taille/plateau.getWidth());
int y = (int)(motionEvent.getY()*taille/plateau.getHeight());
if(addPion(x, y, joueurCourant)) {
if (!estFini(x, y)) {
joueurCourant = !joueurCourant;
}
}
}
plateau.postInvalidate();
return true;
}

L'ajout est trivial, puisque ce n'est que l'ajout d'un élément dans une liste si la case est libre :

public boolean addPion(int x, int y, boolean j) {
// On ne peut pas jouer dans une place occupée
for(Pion c : historique){
if(c.x==x && c.y==y) return false;
}
// On mémorise le coup
historique.add(new Pion(x, y, j));
return true;
}

La détection de la fin du jeu, en revanche, est un problème suffisamment compliqué pour être traité séparément.

Fin de jeu

On cherche à savoir si le jeu vient de se finir ou pas. On connait donc l'emplacement du dernier pion joué et on voudrait vérifier s'il fait partie d'un alignement de longueur 5 ou plus.

Je vous propose donc la décomposition suivante en trois méthodes :

private boolean estFini(int x, int y) {
// ...
}
private int compteAlignementDeCourants(int x, int y, int dx, int dy) {
// ...
}
private boolean estMemeQueCourant(int x, int y) {
// ...
}

La méthode estFini  correspond au service que l'on veut fournir : à partir de la position (x,y) du pion posé, répondre si oui ou non il y a un alignement suffisant.

Pour simplifier son code, on pourra écrire une méthode compteAlignementDeCourants  qui, pour une position donnée (x,y) et une direction donnée (dx, dy), renvoie le nombre de pions successifs de la forme du joueur courant. Enfin, comme on n'utilise pas un tableau, mais une liste, la dernière méthode permet de simplifier les écritures.

De manière plus complète, voici comment on pourrait écrire la détection de fin de jeu :

/** retourne vrai ssi il existe un pion en (x,y) et qu'il est
* du même symbole que celui du joueur courant. */
private boolean estMemeQueCourant(int x, int y) {
for(Pion c:historique) {
if (c.x==x && c.y==y & c.estCroix==joueurCourant) return true;
}
return false;
}
/** Retourne le nombre de pions du joueur courant qui sont alignés
* par rapport à une direction donnée (dx, dy).
* La valeur renvoyée considère les deux sens d'une même direction.
*/
private int compteAlignementDeCourants(int x, int y, int dx, int dy) {
int c = 0;
x+=dx;
y+=dy;
while(estMemeQueCourant(x,y)){
x+=dx;
y+=dy;
c++;
}
return c;
}
/** Retourne vrai ssi un alignement de 5 existe pour le joueur courant.
Si le jeu est fini, cette méthode désactive l'écouteur de touche (bloque le jeu)
et transmet la solution trouvée au plateau.
*/
private boolean estFini(int x, int y) {
int[][] directions = {
{1,0}, // Horizontal
{0,1}, // Vertical
{1,1}, // Diagonale \
{-1,1} // Diagonale /
};
for(int d = 0;d<directions.length; d++) {
int dx=directions[d][0];
int dy=directions[d][1];
int c1 = compteAlignementDeCourants(x,y,dx,dy);
int c2 = compteAlignementDeCourants(x,y,-dx,-dy);
int c=1+c1+c2;
Log.d("estFini","Direction "+d+" : "+c);
if(c>=5) {
int[] solution = {x+dx*c1, y+dy*c1,
x-dx*c2, y-dy*c2};
plateau.setVainqueur(joueurCourant, solution);
plateau.setOnTouchListener(null);
return true;
}
}
return false;
}

Exemple de corrigé complet

Une proposition de corrigé complet de ce TP est présente sur Github :

https://github.com/nstouls/demos_android/tree/master/Morpion

Example of certificate of achievement
Example of certificate of achievement