Licence CC BY-NC-SA

TP : un labyrinthe

Ce contenu est obsolète. Il peut contenir des informations intéressantes mais soyez prudent avec celles-ci.

Nous voici arrivés au dernier TP de ce cours ! Et comme beaucoup de personnes m'ont demandé comment faire un jeu, je vais vous indiquer ici quelques pistes de réflexion en créant un jeu relativement simple : un labyrinthe. Et en dépit de l'apparente simplicité de ce jeu, vous verrez qu'il faut penser à beaucoup de choses pour que le jeu reste amusant et cohérent.

Nous nous baserons ici uniquement sur les API que nous connaissons déjà. Ainsi, ce TP n'aborde pas Open GL par exemple, dont la maîtrise va bien au-delà de l'objectif de ce cours ! Mais vous verrez qu'avec un brin d'astuce il est déjà possible de faire beaucoup avec ce que nous avons à portée de main.

Objectifs

Vous l'aurez compris, nous allons faire un labyrinthe. Le principe du jeu est très simple : le joueur utilise l'accéléromètre de son téléphone pour diriger une boule. Ainsi, quand il penche l'appareil vers le bas, la boule se déplace vers le bas. Quand il penche l'appareil vers le haut, la boule se dirige vers le haut, de même pour la gauche et la droite. L'objectif est de pouvoir placer la boule à un emplacement particulier qui symbolisera la sortie. Cependant, le parcours sera semé d'embûches ! Il faudra en effet faire en sorte de zigzaguer entre des trous situés dans le sol, placés par les immondes Zörglubienotchs qui n'ont qu'un seul objectif : détruire le monde (Ha ! Ha ! Ha ! Ha !).

Le scénario est optionnel. :p

La figure suivante est un aperçu du résultat final que j'obtiens.

Le labyrinthe

On peut y voir les différents éléments qui composent le jeu :

  • La boule verte, le seul élément qui bouge quand vous bougez votre téléphone.
  • Une case blanche, qui indique le départ du labyrinthe.
  • Une case rouge, qui indique l'objectif à atteindre pour détruire le roi des Zörglubienotchs.
  • Plein de cases noires : ce sont les pièges posés par les Zörglubienotchs et qui détruisent votre boule.

Quand l'utilisateur perd, une boîte de dialogue le signale et le jeu se met en pause. Quand l'utilisateur gagne, une autre boîte de dialogue le signale et le jeu se met en pause, c'est aussi simple que cela !

Avant de vous laisser vous aventurer seuls, laissez-moi vous donner quelques indications qui pourraient vous être précieuses.

Spécifications techniques

Organisation du code

De manière générale, quand on développe un jeu, on doit penser à trois moteurs qui permettront de gérer les différentes composantes qui constituent le jeu :

  • Le moteur graphique qui s'occupera de dessiner.
  • Le moteur physique qui s'occupera de gérer les positions, déplacements et interactions entre les éléments.
  • Le moteur multimédia qui joue les animations et les sons au bon moment.

Nous n'utiliserons que deux de ces moteurs : le moteur graphique et le moteur physique. Cette organisation implique une chose : il y aura deux représentations pour chaque élément. Par exemple, une représentation graphique de la boule — celle que connaîtra le moteur graphique — et une représentation physique — celle que connaîtra le moteur physique. On peut ainsi dire que la boule sera divisée en deux parties distinctes, qu'il faudra lier pour avoir un ensemble cohérent.

La toute première chose à laquelle il faut penser, c'est qu'on va donner du matériel à ces moteurs. Le moteur graphique ne peut dessiner s'il n'a rien à dessiner, le moteur physique ne peut calculer de déplacements s'il n'y a pas quelque chose qui bouge ! On va ainsi définir des modèles qui vont contenir les différentes informations sur les constituants.

Les modèles

Comme je viens de le dire, un modèle sera une classe Java qui contiendra des informations sur les constituants du jeu. Ces informations dépendront bien entendu de l'objet représenté. Réfléchissons maintenant à ce qui constitue notre jeu. Nous avons déjà une boule. Ensuite, nous avons des trous dans lesquels peut tomber la boule, une case de départ et une case d'arrivée. Ces trois types d'objets ne bougent pas, et se dessinent toujours un peu de la même manière ! On peut alors décréter qu'ils sont assez similaires quand même. Voyons maintenant ce que doivent contenir les modèles.

La boule

Il s'agit du cœur du jeu, de l'élément le plus compliqué à gérer. Tout d'abord, il va se déplacer, il nous faut donc connaître sa position. Le Canvas du SurfaceView se comporte comme n'importe quel autre Canvas que nous avons vu, c'est-à-dire qu'il possède un axe x qui va de gauche à droite (le rebord gauche vaut 0 et le rebord droit vaut la taille de l'écran en largeur). Il possède aussi un axe y qui va de haut en bas (le plafond du téléphone vaut 0 et le plancher vaut la taille de l'écran en hauteur). Vous aurez donc besoin de deux attributs pour situer votre boule sur le Canvas : un pour l'axe x, un pour l'axe y.

En plus de la position, il faut penser à la vitesse. Eh oui, plus la boule roule, plus elle accélère ! Comme notre boule se déplace sur deux axes (x et y), on aura besoin de deux indicateurs de vitesse : un pour l'axe x, et un pour l'axe y. Alors, accélérer, c'est bien, mais si notre boule dépasse la vitesse du son, c'est moins pratique pour jouer quand même. Il nous faudra alors aussi un attribut qui indiquera la vitesse à ne pas dépasser.

Pour le dessin, nous aurons aussi besoin d'indiquer la taille de la boule ainsi que sa couleur. De cette manière, on a pensé à tout, on obtient alors cette classe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Boule {
  // Je garde le rayon dans une constante au cas où j'aurais besoin d'y accéder depuis une autre classe
  public static final int RAYON = 10;

  // Ma boule sera verte
  private int mCouleur = Color.GREEN;

  // Je n'initialise pas ma position puisque je l'ignore au démarrage
  private float mX;
  private float mY;

  // La vitesse est nulle au début du jeu
  private float mSpeedX = 0;
  private float mSpeedY = 0;

  // Après quelques tests, pour moi, la vitesse maximale optimale est 20
  private static final float MAX_SPEED = 20.0f;

Les blocs

Même s'ils ont un comportement physique similaire, les blocs ont tous un dessin et un objectif différent. Il nous faut ainsi un moyen de les différencier, en dépit du fait qu'ils soient tous des objets de la classe Bloc. Alors comment faire ? Il existe deux solutions :

  • Soit on crée des classes qui dérivent de Bloc pour chaque type de bloc, auquel cas on pourra tester si un objet appartient à une classe particulière avec l'instruction instanceof. Par exemple, bloc instanceof sdz.chapitreCinq.labyrinthe.Trou.
  • Ou alors on ajoute un attribut type à la classe Bloc, qui contiendra le type de notre bloc. Tous les types possibles seront alors décrits dans une énumération.

J'ai privilégié la seconde méthode, tout simplement parce qu'elle impliquait d'utiliser les énumérations, ce qui en fait un exemple pédagogiquement plus intéressant.

C'est quoi une énumération ?

Avec la programmation orientée objet, on utilise plus rarement les énumérations, et pourtant elles sont pratiques ! Une énumération, c'est une façon de décrire une liste de constantes. Il existe trois types de blocs (trou, départ, arrivée), on aura donc trois types de constantes dans notre énumération :

1
enum  Type { TROU, DEPART, ARRIVEE };

Comme vous pouvez le voir, on n'a pas besoin d'ajouter une valeur à nos constantes ; en effet, leur nom fera office de valeur.

Autre chose : comme il faut placer les blocs, nous avons encore une fois besoin des coordonnées du bloc. De plus, il est nécessaire de définir la taille d'un bloc. De ce fait, on obtient :

1
2
3
4
5
6
7
8
9
public class Bloc {
  enum  Type { TROU, DEBUT, FIN };

  private float SIZE = Boule.RAYON * 2;

  private float mX;
  private float mY;

  private Type mType = null;

Comme vous pouvez le voir, j'ai fait en sorte qu'un bloc ait deux fois la taille de la boule.

Le moteur graphique

Très simple à comprendre, il sera en charge de dessiner les composants de notre scène de jeu. Ce à quoi il faut faire attention ici, c'est que certains éléments se déplacent (je pense en particulier à la boule). Il faut ainsi faire en sorte que le dessin corresponde toujours à la position exacte de l'élément : il ne faut pas que la boule se trouve à un emplacement et que le dessin affiche toujours son ancien emplacement. Regardez la figure suivante.

À gauche le dessin de la boule, à droite sa représentation physique

Maintenant, regardez la figure suivante.

Représentation des deux moteurs au temps T à gauche, T+1 à droite

À gauche, les deux représentations se superposent : la boule ne bouge pas, alors, au moment de dessiner la boule, il suffit de la dessiner au même endroit que précédemment. Cependant, à l'instant suivant (à droite), le joueur penche l'appareil, et la boule se met à se déplacer. On peut voir que la représentation graphique est restée au même endroit alors que la représentation physique a bougé, et donc ce que le joueur voit n'est pas ce que le jeu sait de l'emplacement de la boule. C'est ce que je veux dire par « il faut faire en sorte que le dessin corresponde toujours à la position exacte de l'élément ». Ainsi, à chaque fois que vous voulez dessiner la boule, il faudra le faire avec sa position exacte.

Pour effectuer les dessins, on va utiliser un SurfaceView, puisqu'il s'agit de la manière la plus facile de dessiner avec de bonnes performances. Ensuite, chaque élément devra être dessiné sur le Canvas du SurfaceView. Par exemple, chez moi, la boule est un disque de rayon 10 et de couleur verte.

Pour vous faciliter la vie, je vous propose de récupérer tout simplement le framework que nous avons écrit dans le chapitre sur le dessin, puisqu'il convient parfaitement à ce projet. Il ne vous reste plus ensuite qu'à dessiner dans la méthode de callback void onDraw(Canvas canvas).

Pour adapter le dessin à tous les périphériques, vos éléments doivent être proportionnels à la taille de l'écran. Je pense au moins aux différents blocs qui doivent rentrer dans tous les écrans, même les plus petits.

Le moteur physique

Plus délicat à gérer que le moteur graphique, le moteur physique gère la position, les déplacements et l'interaction entre les différents éléments de votre jeu. De plus, dans notre cas particulier, il faudra aussi manipuler l'accéléromètre ! Vous savez déjà le faire normalement, alors pas de soucis ! Cependant, qu'allons-nous faire des données fournies par le capteur ? Eh bien, nous n'avons besoin que de deux données : les deux axes. J'ai choisi de faire en sorte que la position de base soit le téléphone posé à plat sur une table. Quand l'utilisateur penche le téléphone vers lui, la boule « tombe », comme si elle était attirée par la gravité. Si l'utilisateur penche l'appareil dans l'autre sens quand la boule « tombe », alors elle remonte une pente, elle a du mal à « monter » et elle se met à rouler dans le sens de la pente, comme le ferait une vraie boule. De ce fait, j'ai conservé les données sur deux axes seulement : x et y.

Ces données servent à modifier la vitesse de la boule. Si la boule roule dans le sens de la pente, elle prend de la vitesse et donc sa vitesse augmente avec la valeur du capteur. Si la vitesse dépasse la vitesse maximale, alors on impose la vitesse maximale comme vitesse de la boule. Enfin, si la vitesse est négative… cela veut tout simplement dire que la boule se dirige vers la gauche ou le haut, c'est normal !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
SensorEventListener mSensorEventListener = new SensorEventListener() {
  @Override
  public void onSensorChanged(SensorEvent event) {
    // La valeur sur l'axe x
    float x = event.values[0];
    // La valeur sur l'axe y
    float y = event.values[1];

    // On accélère ou décélère en fonction des valeurs données
    boule.xSpeed = boule.xSpeed + x;
    // On vérifie qu'on ne dépasse pas la vitesse maximale
    if(boule.xSpeed > Boule.MAX_SPEED)
      boule.xSpeed = Boule.MAX_SPEED;
    if(boule.xSpeed < Boule.MAX_SPEED)
      boule.xSpeed = -Boule.MAX_SPEED;

    boule.ySpeed = boule.ySpeed + y;
    if(boule.ySpeed > Boule.MAX_SPEED)
      boule.ySpeed = Boule.MAX_SPEED;
    if(boule.ySpeed < Boule.MAX_SPEED)
      boule.ySpeed = -Boule.MAX_SPEED;

    // Puis on modifie les coordonnées en fonction de la vitesse
    boule.x += xSpeed;
    boule.y += ySpeed;
  }

  @Override
  public void onAccuracyChanged(Sensor sensor, int accuracy) {

  }
}

Maintenant que notre boule bouge, que faire quand elle rencontre un bloc ? Comment détecter cette rencontre ? Le plus simple est encore d'utiliser des objets de type RectF, tout simplement parce qu'ils possèdent une méthode qui permet de détecter si deux RectF entrent en collision. Cette méthode est boolean intersect(RectF r) : le boolean retourné vaudra true si les deux rectangles entrent bien en collision et r sera remplacé par le rectangle formé par la collision.

Je le répète, le rectangle passé en attribut sera modifié par cette méthode, il vous faut donc faire une copie du rectangle dont vous souhaitez vérifier la collision, sinon il sera modifié. Pour copier un RectF, utilisez le constructeur public RectF(RectF r).

Ainsi, on va rajouter un rectangle à nos blocs et à notre boule. C'est très simple, il vous suffit de deux données : les coordonnées du point en haut à gauche (sur l'axe x et l'axe y), puis la taille du rectangle. Avec ces données, on peut très bien construire un rectangle, voyez vous-mêmes :

1
public RectF (float left, float top, float right, float bottom)

En fait, l'attribut left correspond à la coordonnée sur l'axe x du côté gauche du rectangle, top à la coordonnée sur l'axe y du plafond, right à la coordonnée sur l'axe y du côté droit et bottom à la coordonnée sur l'axe y du plancher. De ce fait, avec les données que je vous ai demandées, il suffit de faire :

1
public RectF (float coordonnee_x, float coordonnee_y, float coordonnee_x + taille_du_rectangle, float coordonnee_y + taille_du_rectangle)

Mais comment faire pour la boule ? C'est un disque, pas un rectangle !

Cela peut sembler bizarre, mais on n'a nullement besoin d'une représentation exacte de la boule, on peut accompagner sa représentation d'un rectangle, tout simplement parce que la majorité des collisions ne peuvent pas se faire en diagonale, uniquement sur les rebords extrêmes de la boule, comme schématisé à la figure suivante.

Emplacement des collisions

Bien sûr, les collisions qui se feront sur les diagonales ne seront pas précises, mais franchement elles sont tellement rares et ce serait tellement complexe de les gérer qu'on va simplement les laisser tomber. De ce fait, il faut ajouter un RectF dans les attributs de la boule et, à chaque fois qu'elle bouge, il faut mettre à jour les coordonnées du rectangle pour qu'il englobe bien la boule et puisse ainsi détecter les collisions.

Le labyrinthe

C'est très simple, pour cette version simplifiée, le labyrinthe sera tout simplement une liste de blocs qui est générée au lancement de l'application. Chez moi, j'ai utilisé le labyrinthe suivant :

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
List<Bloc> Blocs = new ArrayList<Bloc>();
Blocs.add(new Bloc(Type.TROU, 0, 0));
Blocs.add(new Bloc(Type.TROU, 0, 1));
Blocs.add(new Bloc(Type.TROU, 0, 2));
Blocs.add(new Bloc(Type.TROU, 0, 3));
Blocs.add(new Bloc(Type.TROU, 0, 4));
Blocs.add(new Bloc(Type.TROU, 0, 5));
Blocs.add(new Bloc(Type.TROU, 0, 6));
Blocs.add(new Bloc(Type.TROU, 0, 7));
Blocs.add(new Bloc(Type.TROU, 0, 8));
Blocs.add(new Bloc(Type.TROU, 0, 9));
Blocs.add(new Bloc(Type.TROU, 0, 10));
Blocs.add(new Bloc(Type.TROU, 0, 11));
Blocs.add(new Bloc(Type.TROU, 0, 12));
Blocs.add(new Bloc(Type.TROU, 0, 13));

Blocs.add(new Bloc(Type.TROU, 1, 0));
Blocs.add(new Bloc(Type.TROU, 1, 13));

Blocs.add(new Bloc(Type.TROU, 2, 0));
Blocs.add(new Bloc(Type.TROU, 2, 13));

Blocs.add(new Bloc(Type.TROU, 3, 0));
Blocs.add(new Bloc(Type.TROU, 3, 13));

Blocs.add(new Bloc(Type.TROU, 4, 0));
Blocs.add(new Bloc(Type.TROU, 4, 1));
Blocs.add(new Bloc(Type.TROU, 4, 2));
Blocs.add(new Bloc(Type.TROU, 4, 3));
Blocs.add(new Bloc(Type.TROU, 4, 4));
Blocs.add(new Bloc(Type.TROU, 4, 5));
Blocs.add(new Bloc(Type.TROU, 4, 6));
Blocs.add(new Bloc(Type.TROU, 4, 7));
Blocs.add(new Bloc(Type.TROU, 4, 8));
Blocs.add(new Bloc(Type.TROU, 4, 9));
Blocs.add(new Bloc(Type.TROU, 4, 10));
Blocs.add(new Bloc(Type.TROU, 4, 13));

Blocs.add(new Bloc(Type.TROU, 5, 0));
Blocs.add(new Bloc(Type.TROU, 5, 13));

Blocs.add(new Bloc(Type.TROU, 6, 0));
Blocs.add(new Bloc(Type.TROU, 6, 13));

Blocs.add(new Bloc(Type.TROU, 7, 0));
Blocs.add(new Bloc(Type.TROU, 7, 1));
Blocs.add(new Bloc(Type.TROU, 7, 2));
Blocs.add(new Bloc(Type.TROU, 7, 5));
Blocs.add(new Bloc(Type.TROU, 7, 6));
Blocs.add(new Bloc(Type.TROU, 7, 9));
Blocs.add(new Bloc(Type.TROU, 7, 10));
Blocs.add(new Bloc(Type.TROU, 7, 11));
Blocs.add(new Bloc(Type.TROU, 7, 12));
Blocs.add(new Bloc(Type.TROU, 7, 13));

Blocs.add(new Bloc(Type.TROU, 8, 0));
Blocs.add(new Bloc(Type.TROU, 8, 5));
Blocs.add(new Bloc(Type.TROU, 8, 9));
Blocs.add(new Bloc(Type.TROU, 8, 13));

Blocs.add(new Bloc(Type.TROU, 9, 0));
Blocs.add(new Bloc(Type.TROU, 9, 5));
Blocs.add(new Bloc(Type.TROU, 9, 9));
Blocs.add(new Bloc(Type.TROU, 9, 13));

Blocs.add(new Bloc(Type.TROU, 10, 0));
Blocs.add(new Bloc(Type.TROU, 10, 5));
Blocs.add(new Bloc(Type.TROU, 10, 9));
Blocs.add(new Bloc(Type.TROU, 10, 13));

Blocs.add(new Bloc(Type.TROU, 11, 0));
Blocs.add(new Bloc(Type.TROU, 11, 5));
Blocs.add(new Bloc(Type.TROU, 11, 9));
Blocs.add(new Bloc(Type.TROU, 11, 13));

Blocs.add(new Bloc(Type.TROU, 12, 0));
Blocs.add(new Bloc(Type.TROU, 12, 1));
Blocs.add(new Bloc(Type.TROU, 12, 2));
Blocs.add(new Bloc(Type.TROU, 12, 3));
Blocs.add(new Bloc(Type.TROU, 12, 4));
Blocs.add(new Bloc(Type.TROU, 12, 5));
Blocs.add(new Bloc(Type.TROU, 12, 8));
Blocs.add(new Bloc(Type.TROU, 12, 9));
Blocs.add(new Bloc(Type.TROU, 12, 13));

Blocs.add(new Bloc(Type.TROU, 13, 0));
Blocs.add(new Bloc(Type.TROU, 13, 8));
Blocs.add(new Bloc(Type.TROU, 13, 13));

Blocs.add(new Bloc(Type.TROU, 14, 0));
Blocs.add(new Bloc(Type.TROU, 14, 8));
Blocs.add(new Bloc(Type.TROU, 14, 13));

Blocs.add(new Bloc(Type.TROU, 15, 0));
Blocs.add(new Bloc(Type.TROU, 15, 8));
Blocs.add(new Bloc(Type.TROU, 15, 13));

Blocs.add(new Bloc(Type.TROU, 16, 0));
Blocs.add(new Bloc(Type.TROU, 16, 4));
Blocs.add(new Bloc(Type.TROU, 16, 5));
Blocs.add(new Bloc(Type.TROU, 16, 6));
Blocs.add(new Bloc(Type.TROU, 16, 7));
Blocs.add(new Bloc(Type.TROU, 16, 8));
Blocs.add(new Bloc(Type.TROU, 16, 9));
Blocs.add(new Bloc(Type.TROU, 16, 13));

Blocs.add(new Bloc(Type.TROU, 17, 0));
Blocs.add(new Bloc(Type.TROU, 17, 13));

Blocs.add(new Bloc(Type.TROU, 18, 0));
Blocs.add(new Bloc(Type.TROU, 18, 13));

Blocs.add(new Bloc(Type.TROU, 19, 0));
Blocs.add(new Bloc(Type.TROU, 19, 1));
Blocs.add(new Bloc(Type.TROU, 19, 2));
Blocs.add(new Bloc(Type.TROU, 19, 3));
Blocs.add(new Bloc(Type.TROU, 19, 4));
Blocs.add(new Bloc(Type.TROU, 19, 5));
Blocs.add(new Bloc(Type.TROU, 19, 6));
Blocs.add(new Bloc(Type.TROU, 19, 7));
Blocs.add(new Bloc(Type.TROU, 19, 8));
Blocs.add(new Bloc(Type.TROU, 19, 9));
Blocs.add(new Bloc(Type.TROU, 19, 10));
Blocs.add(new Bloc(Type.TROU, 19, 11));
Blocs.add(new Bloc(Type.TROU, 19, 12));
Blocs.add(new Bloc(Type.TROU, 19, 13));

Blocs.add(new Bloc(Type.DEPART, 2, 2));

Blocs.add(new Bloc(Type.ARRIVEE, 8, 11));

Comme vous pouvez le voir, ma méthode pour construire un bloc est simple, j'ai besoin de :

  • Son type (TROU, DEPART ou ARRIVEE).
  • Sa position sur l'axe x (attention, sa position en blocs et pas en pixels. Par exemple, si je mets 5, je parle du cinquième bloc, pas du cinquième pixel).
  • Sa position sur l'axe y (en blocs aussi).

Ma solution

Le Manifest

La première chose à faire est de modifier le Manifest. Vous verrez deux choses particulières :

  • L'appareil est bloqué en mode paysage (<activity android:configChanges="orientation" android:screenOrientation="landscape" >).
  • L'application n'est pas montrée aux utilisateurs qui n'ont pas d'accéléromètre (<uses-feature android:name="android.hardware.sensor.accelerometer" android:required="true" />).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  package="sdz.chapitreCinq"
  android:versionCode="1"
  android:versionName="1.0" >

  <uses-sdk
    android:minSdkVersion="7"
    android:targetSdkVersion="7" />

  <uses-feature
    android:name="android.hardware.sensor.accelerometer"
    android:required="true" />

  <application
    android:icon="@drawable/ic_launcher"
    android:label="@string/app_name" >
    <activity
      android:name="sdz.chapitreCinq.LabyrintheActivity"
      android:configChanges="orientation"
      android:label="@string/app_name"
      android:screenOrientation="landscape" >
      <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
      </intent-filter>
    </activity>
  </application>

</manifest>

Les modèles

Nous allons tout d'abord voir les différents modèles qui permettent de décrire les composants de notre jeu.

Les blocs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import android.graphics.RectF;

public class Bloc {
    enum  Type { TROU, DEPART, ARRIVEE };

    private float SIZE = Boule.RAYON * 2;

    private Type mType = null;
    private RectF mRectangle = null;

    public Type getType() {
        return mType;
    }

    public RectF getRectangle() {
        return mRectangle;
    }

    public Bloc(Type pType, int pX, int pY) {
        this.mType = pType;
        this.mRectangle = new RectF(pX * SIZE, pY * SIZE, (pX + 1) * SIZE, (pY + 1) * SIZE);
    }
}

Rien de spécial ici, je vous ai déjà parlé de tout auparavant. Remarquez le calcul qui permet de placer un bloc en fonction de sa position en tant que bloc et non en pixels.

La boule

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import android.graphics.Color;
import android.graphics.RectF;

public class Boule {
    // Rayon de la boule
    public static final int RAYON = 10;

    // Couleur de la boule
    private int mCouleur = Color.GREEN;
    public int getCouleur() {
        return mCouleur;
    }

    // Vitesse maximale autorisée pour la boule
    private static final float MAX_SPEED = 20.0f;

    // Permet à la boule d'accélérer moins vite
    private static final float COMPENSATEUR = 8.0f;

    // Utilisé pour compenser les rebonds
    private static final float REBOND = 1.75f;

    // Le rectangle qui correspond à la position de départ de la boule
    private RectF mInitialRectangle = null;

    // A partir du rectangle initial on détermine la position de la boule
    public void setInitialRectangle(RectF pInitialRectangle) {
        this.mInitialRectangle = pInitialRectangle;
        this.mX = pInitialRectangle.left + RAYON;
        this.mY = pInitialRectangle.top + RAYON;
    }

    // Le rectangle de collision
    private RectF mRectangle = null;

    // Coordonnées en x
    private float mX;
    public float getX() {
        return mX;
    }
    public void setPosX(float pPosX) {
        mX = pPosX;

        // Si la boule sort du cadre, on rebondit
        if(mX < RAYON) {
            mX = RAYON;
            // Rebondir, c'est changer la direction de la balle
            mSpeedY = -mSpeedY / REBOND;
        } else if(mX > mWidth - RAYON) {
            mX = mWidth - RAYON;
            mSpeedY = -mSpeedY / REBOND;
        }
    }

    // Coordonnées en y
    private float mY;
    public float getY() {
        return mY;
    }

    public void setPosY(float pPosY) {
        mY = pPosY;
        if(mY < RAYON) {
            mY = RAYON;
            mSpeedX = -mSpeedX / REBOND;
        } else if(mY > mHeight - RAYON) {
            mY = mHeight - RAYON;
            mSpeedX = -mSpeedX / REBOND;
        }
    }

    // Vitesse sur l'axe x
    private float mSpeedX = 0;
    // Utilisé quand on rebondit sur les murs horizontaux
    public void changeXSpeed() {
        mSpeedX = -mSpeedX;
    }

    // Vitesse sur l'axe y
    private float mSpeedY = 0;
    // Utilisé quand on rebondit sur les murs verticaux
    public void changeYSpeed() {
        mSpeedY = -mSpeedY;
    }

    // Taille de l'écran en hauteur
    private int mHeight = -1;
    public void setHeight(int pHeight) {
        this.mHeight = pHeight;
    }

    // Taille de l'écran en largeur
    private int mWidth = -1;
    public void setWidth(int pWidth) {
        this.mWidth = pWidth;
    }

    public Boule() {
        mRectangle = new RectF();
    }

    // Mettre à jour les coordonnées de la boule
    public RectF putXAndY(float pX, float pY) {
        mSpeedX += pX / COMPENSATEUR;
        if(mSpeedX > MAX_SPEED)
            mSpeedX = MAX_SPEED;
        if(mSpeedX < -MAX_SPEED)
            mSpeedX = -MAX_SPEED;

        mSpeedY += pY / COMPENSATEUR;
        if(mSpeedY > MAX_SPEED)
            mSpeedY = MAX_SPEED;
        if(mSpeedY < -MAX_SPEED)
            mSpeedY = -MAX_SPEED;

        setPosX(mX + mSpeedY);
        setPosY(mY + mSpeedX);

        // Met à jour les coordonnées du rectangle de collision
        mRectangle.set(mX - RAYON, mY - RAYON, mX + RAYON, mY + RAYON);

        return mRectangle;
    }

    // Remet la boule à sa position de départ
    public void reset() {
        mSpeedX = 0;
        mSpeedY = 0;
        this.mX = mInitialRectangle.left + RAYON;
        this.mY = mInitialRectangle.top + RAYON;
    }
}

Le moteur graphique

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import java.util.List;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.view.SurfaceHolder;
import android.view.SurfaceView;

public class LabyrintheView extends SurfaceView implements SurfaceHolder.Callback {
    Boule mBoule;
    public Boule getBoule() {
        return mBoule;
    }

    public void setBoule(Boule pBoule) {
        this.mBoule = pBoule;
    }

    SurfaceHolder mSurfaceHolder;
    DrawingThread mThread;

    private List<Bloc> mBlocks = null;
    public List<Bloc> getBlocks() {
        return mBlocks;
    }

    public void setBlocks(List<Bloc> pBlocks) {
        this.mBlocks = pBlocks;
    }

    Paint mPaint; 

    public LabyrintheView(Context pContext) {
        super(pContext);
        mSurfaceHolder = getHolder();
        mSurfaceHolder.addCallback(this);
        mThread = new DrawingThread();

        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL);

        mBoule = new Boule();
    }

    @Override
    protected void onDraw(Canvas pCanvas) {
        // Dessiner le fond de l'écran en premier
        pCanvas.drawColor(Color.CYAN);
        if(mBlocks != null) {
            // Dessiner tous les blocs du labyrinthe
            for(Bloc b : mBlocks) {
                switch(b.getType()) {
                case DEPART:
                    mPaint.setColor(Color.WHITE);
                    break;
                case ARRIVEE:
                    mPaint.setColor(Color.RED);
                    break;
                case TROU:
                    mPaint.setColor(Color.BLACK);
                    break;
                }
                pCanvas.drawRect(b.getRectangle(), mPaint);
            }
        }

        // Dessiner la boule
        if(mBoule != null) {
            mPaint.setColor(mBoule.getCouleur());
            pCanvas.drawCircle(mBoule.getX(), mBoule.getY(), Boule.RAYON, mPaint);
        }
    }

    @Override
    public void surfaceChanged(SurfaceHolder pHolder, int pFormat, int pWidth, int pHeight) {
        //
    }

    @Override
    public void surfaceCreated(SurfaceHolder pHolder) {
        mThread.keepDrawing = true;
        mThread.start();
        // Quand on crée la boule, on lui indique les coordonnées de l'écran
        if(mBoule != null ) {
            this.mBoule.setHeight(getHeight());
            this.mBoule.setWidth(getWidth());
        }
    }

    @Override
    public void surfaceDestroyed(SurfaceHolder pHolder) {
        mThread.keepDrawing = false;
        boolean retry = true;
        while (retry) {
            try {
                mThread.join();
                retry = false;
            } catch (InterruptedException e) {}
        }

    }

    private class DrawingThread extends Thread {
        boolean keepDrawing = true;

        @Override
        public void run() {
            Canvas canvas;
            while (keepDrawing) {
                canvas = null;

                try {
                    canvas = mSurfaceHolder.lockCanvas();
                    synchronized (mSurfaceHolder) {
                        onDraw(canvas);
                    }
                } finally {
                    if (canvas != null)
                        mSurfaceHolder.unlockCanvasAndPost(canvas);
                }

                // Pour dessiner à 50 fps
                try {
                    Thread.sleep(20);
                } catch (InterruptedException e) {}
            }
        }
    }
}

Rien de formidable ici non plus, on se contente de reprendre le framework et d'ajouter les dessins dedans.

Le moteur physique

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import java.util.ArrayList;
import java.util.List;

import sdz.chapitreCinq.Bloc.Type;
import android.app.Service;
import android.graphics.RectF;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;

public class LabyrintheEngine {
    private Boule mBoule = null;
    public Boule getBoule() {
        return mBoule;
    }

    public void setBoule(Boule pBoule) {
        this.mBoule = pBoule;
    }

    // Le labyrinthe
    private List<Bloc> mBlocks = null;

    private LabyrintheActivity mActivity = null;

    private SensorManager mManager = null;
    private Sensor mAccelerometre = null;

    SensorEventListener mSensorEventListener = new SensorEventListener() {

        @Override
        public void onSensorChanged(SensorEvent pEvent) {
            float x = pEvent.values[0];
            float y = pEvent.values[1];

            if(mBoule != null) {
                // On met à jour les coordonnées de la boule
                RectF hitBox = mBoule.putXAndY(x, y);

                // Pour tous les blocs du labyrinthe
                for(Bloc block : mBlocks) {
                    // On crée un nouveau rectangle pour ne pas modifier celui du bloc
                    RectF inter = new RectF(block.getRectangle());
                    if(inter.intersect(hitBox)) {
                        // On agit différement en fonction du type de bloc
                        switch(block.getType()) {
                        case TROU:
                            mActivity.showDialog(LabyrintheActivity.DEFEAT_DIALOG);
                            break;

                        case DEPART:
                            break;

                        case ARRIVEE:
                            mActivity.showDialog(LabyrintheActivity.VICTORY_DIALOG);
                            break;
                        }
                        break;
                    }
                }
            }
        }

        @Override
        public void onAccuracyChanged(Sensor pSensor, int pAccuracy) {

        }
    };

    public LabyrintheEngine(LabyrintheActivity pView) {
        mActivity = pView;
        mManager = (SensorManager) mActivity.getBaseContext().getSystemService(Service.SENSOR_SERVICE);
        mAccelerometre = mManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
    }

    // Remet à zéro l'emplacement de la boule
    public void reset() {
        mBoule.reset();
    }

    // Arrête le capteur
    public void stop() {
        mManager.unregisterListener(mSensorEventListener, mAccelerometre);
    }

    // Redémarre le capteur
    public void resume() {
        mManager.registerListener(mSensorEventListener, mAccelerometre, SensorManager.SENSOR_DELAY_GAME);
    }

    // Construit le labyrinthe
    public List<Bloc> buildLabyrinthe() {
        mBlocks = new ArrayList<Bloc>();
        mBlocks.add(new Bloc(Type.TROU, 0, 0));
        mBlocks.add(new Bloc(Type.TROU, 0, 1));
        mBlocks.add(new Bloc(Type.TROU, 0, 2));
        mBlocks.add(new Bloc(Type.TROU, 0, 3));
        mBlocks.add(new Bloc(Type.TROU, 0, 4));
        mBlocks.add(new Bloc(Type.TROU, 0, 5));
        mBlocks.add(new Bloc(Type.TROU, 0, 6));
        mBlocks.add(new Bloc(Type.TROU, 0, 7));
        mBlocks.add(new Bloc(Type.TROU, 0, 8));
        mBlocks.add(new Bloc(Type.TROU, 0, 9));
        mBlocks.add(new Bloc(Type.TROU, 0, 10));
        mBlocks.add(new Bloc(Type.TROU, 0, 11));
        mBlocks.add(new Bloc(Type.TROU, 0, 12));
        mBlocks.add(new Bloc(Type.TROU, 0, 13));

        mBlocks.add(new Bloc(Type.TROU, 1, 0));
        mBlocks.add(new Bloc(Type.TROU, 1, 13));

        mBlocks.add(new Bloc(Type.TROU, 2, 0));
        mBlocks.add(new Bloc(Type.TROU, 2, 13));

        mBlocks.add(new Bloc(Type.TROU, 3, 0));
        mBlocks.add(new Bloc(Type.TROU, 3, 13));

        mBlocks.add(new Bloc(Type.TROU, 4, 0));
        mBlocks.add(new Bloc(Type.TROU, 4, 1));
        mBlocks.add(new Bloc(Type.TROU, 4, 2));
        mBlocks.add(new Bloc(Type.TROU, 4, 3));
        mBlocks.add(new Bloc(Type.TROU, 4, 4));
        mBlocks.add(new Bloc(Type.TROU, 4, 5));
        mBlocks.add(new Bloc(Type.TROU, 4, 6));
        mBlocks.add(new Bloc(Type.TROU, 4, 7));
        mBlocks.add(new Bloc(Type.TROU, 4, 8));
        mBlocks.add(new Bloc(Type.TROU, 4, 9));
        mBlocks.add(new Bloc(Type.TROU, 4, 10));
        mBlocks.add(new Bloc(Type.TROU, 4, 13));

        mBlocks.add(new Bloc(Type.TROU, 5, 0));
        mBlocks.add(new Bloc(Type.TROU, 5, 13));

        mBlocks.add(new Bloc(Type.TROU, 6, 0));
        mBlocks.add(new Bloc(Type.TROU, 6, 13));

        mBlocks.add(new Bloc(Type.TROU, 7, 0));
        mBlocks.add(new Bloc(Type.TROU, 7, 1));
        mBlocks.add(new Bloc(Type.TROU, 7, 2));
        mBlocks.add(new Bloc(Type.TROU, 7, 5));
        mBlocks.add(new Bloc(Type.TROU, 7, 6));
        mBlocks.add(new Bloc(Type.TROU, 7, 9));
        mBlocks.add(new Bloc(Type.TROU, 7, 10));
        mBlocks.add(new Bloc(Type.TROU, 7, 11));
        mBlocks.add(new Bloc(Type.TROU, 7, 12));
        mBlocks.add(new Bloc(Type.TROU, 7, 13));

        mBlocks.add(new Bloc(Type.TROU, 8, 0));
        mBlocks.add(new Bloc(Type.TROU, 8, 5));
        mBlocks.add(new Bloc(Type.TROU, 8, 9));
        mBlocks.add(new Bloc(Type.TROU, 8, 13));

        mBlocks.add(new Bloc(Type.TROU, 9, 0));
        mBlocks.add(new Bloc(Type.TROU, 9, 5));
        mBlocks.add(new Bloc(Type.TROU, 9, 9));
        mBlocks.add(new Bloc(Type.TROU, 9, 13));

        mBlocks.add(new Bloc(Type.TROU, 10, 0));
        mBlocks.add(new Bloc(Type.TROU, 10, 5));
        mBlocks.add(new Bloc(Type.TROU, 10, 9));
        mBlocks.add(new Bloc(Type.TROU, 10, 13));

        mBlocks.add(new Bloc(Type.TROU, 11, 0));
        mBlocks.add(new Bloc(Type.TROU, 11, 5));
        mBlocks.add(new Bloc(Type.TROU, 11, 9));
        mBlocks.add(new Bloc(Type.TROU, 11, 13));

        mBlocks.add(new Bloc(Type.TROU, 12, 0));
        mBlocks.add(new Bloc(Type.TROU, 12, 1));
        mBlocks.add(new Bloc(Type.TROU, 12, 2));
        mBlocks.add(new Bloc(Type.TROU, 12, 3));
        mBlocks.add(new Bloc(Type.TROU, 12, 4));
        mBlocks.add(new Bloc(Type.TROU, 12, 5));
        mBlocks.add(new Bloc(Type.TROU, 12, 9));
        mBlocks.add(new Bloc(Type.TROU, 12, 8));
        mBlocks.add(new Bloc(Type.TROU, 12, 13));

        mBlocks.add(new Bloc(Type.TROU, 13, 0));
        mBlocks.add(new Bloc(Type.TROU, 13, 8));
        mBlocks.add(new Bloc(Type.TROU, 13, 13));

        mBlocks.add(new Bloc(Type.TROU, 14, 0));
        mBlocks.add(new Bloc(Type.TROU, 14, 8));
        mBlocks.add(new Bloc(Type.TROU, 14, 13));

        mBlocks.add(new Bloc(Type.TROU, 15, 0));
        mBlocks.add(new Bloc(Type.TROU, 15, 8));
        mBlocks.add(new Bloc(Type.TROU, 15, 13));

        mBlocks.add(new Bloc(Type.TROU, 16, 0));
        mBlocks.add(new Bloc(Type.TROU, 16, 4));
        mBlocks.add(new Bloc(Type.TROU, 16, 5));
        mBlocks.add(new Bloc(Type.TROU, 16, 6));
        mBlocks.add(new Bloc(Type.TROU, 16, 7));
        mBlocks.add(new Bloc(Type.TROU, 16, 8));
        mBlocks.add(new Bloc(Type.TROU, 16, 9));
        mBlocks.add(new Bloc(Type.TROU, 16, 13));

        mBlocks.add(new Bloc(Type.TROU, 17, 0));
        mBlocks.add(new Bloc(Type.TROU, 17, 13));

        mBlocks.add(new Bloc(Type.TROU, 18, 0));
        mBlocks.add(new Bloc(Type.TROU, 18, 13));

        mBlocks.add(new Bloc(Type.TROU, 19, 0));
        mBlocks.add(new Bloc(Type.TROU, 19, 1));
        mBlocks.add(new Bloc(Type.TROU, 19, 2));
        mBlocks.add(new Bloc(Type.TROU, 19, 3));
        mBlocks.add(new Bloc(Type.TROU, 19, 4));
        mBlocks.add(new Bloc(Type.TROU, 19, 5));
        mBlocks.add(new Bloc(Type.TROU, 19, 6));
        mBlocks.add(new Bloc(Type.TROU, 19, 7));
        mBlocks.add(new Bloc(Type.TROU, 19, 8));
        mBlocks.add(new Bloc(Type.TROU, 19, 9));
        mBlocks.add(new Bloc(Type.TROU, 19, 10));
        mBlocks.add(new Bloc(Type.TROU, 19, 11));
        mBlocks.add(new Bloc(Type.TROU, 19, 12));
        mBlocks.add(new Bloc(Type.TROU, 19, 13));

        Bloc b = new Bloc(Type.DEPART, 2, 2);
        mBoule.setInitialRectangle(new RectF(b.getRectangle()));
        mBlocks.add(b);

        mBlocks.add(new Bloc(Type.ARRIVEE, 8, 11));

        return mBlocks;
    }

}

L'activité

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import java.util.List;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Dialog;
import android.content.DialogInterface;
import android.os.Bundle;

public class LabyrintheActivity extends Activity {
    // Identifiant de la boîte de dialogue de victoire
    public static final int VICTORY_DIALOG = 0;
    // Identifiant de la boîte de dialogue de défaite
    public static final int DEFEAT_DIALOG = 1;

    // Le moteur graphique du jeu
    private LabyrintheView mView = null;
    // Le moteur physique du jeu
    private LabyrintheEngine mEngine = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        mView = new LabyrintheView(this);
        setContentView(mView);

        mEngine = new LabyrintheEngine(this);

        Boule b = new Boule();
        mView.setBoule(b);
        mEngine.setBoule(b);

        List<Bloc> mList = mEngine.buildLabyrinthe();
        mView.setBlocks(mList);
    }

    @Override
    protected void onResume() {
        super.onResume();
        mEngine.resume();
    } 

    @Override
    protected void onPause() {
        super.onStop();
        mEngine.stop();
    }

    @Override
    public Dialog onCreateDialog (int id) {
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        switch(id) {
        case VICTORY_DIALOG:
            builder.setCancelable(false)
            .setMessage("Bravo, vous avez gagné !")
            .setTitle("Champion ! Le roi des Zörglubienotchs est mort grâce à vous !")
            .setNeutralButton("Recommencer", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    // L'utilisateur peut recommencer s'il le veut
                    mEngine.reset();
                    mEngine.resume();
                }
            });
            break;

        case DEFEAT_DIALOG:
            builder.setCancelable(false)
            .setMessage("La Terre a été détruite à cause de vos erreurs.")
            .setTitle("Bah bravo !")
            .setNeutralButton("Recommencer", new DialogInterface.OnClickListener() {
                @Override
                public void onClick(DialogInterface dialog, int which) {
                    mEngine.reset();
                    mEngine.resume();
                }
            });
        }
        return builder.create();
    }

    @Override
    public void onPrepareDialog (int id, Dialog box) {
        // A chaque fois qu'une boîte de dialogue est lancée, on arrête le moteur physique
        mEngine.stop();
    }
}

Télécharger le projet

Améliorations envisageables

Proposer plusieurs labyrinthes

Ce projet est quand même très limité, il ne propose qu'un labyrinthe. Avouons que jouer au même labyrinthe ad vitam aeternam est assez ennuyeux. On va alors envisager un système pour charger plusieurs labyrinthes. La première chose à faire, c'est de rajouter un modèle pour les labyrinthes. Il contiendra au moins une liste de blocs, comme précédemment :

1
2
3
public class Labyrinthe {
  List<Bloc> mBlocs = null;
}

Il suffira ensuite de passer le labyrinthe aux moteurs et de tout réinitialiser. Ainsi, on redessinera le labyrinthe, on cherchera le nouveau départ et on y placera la boule.

Enfin, si on fait cela, notre problème n'est pas vraiment résolu. C'est vrai qu'on pourra avoir plusieurs labyrinthes et qu'on pourra alterner entre eux, mais si on doit créer chaque fois un labyrinthe bloc par bloc, cela risque d'être quand même assez laborieux. Alors, comment créer un labyrinthe autrement ?

Une solution élégante serait d'avoir les labyrinthes enregistrés sur un fichier de façon à n'avoir qu'à le lire pour récupérer un labyrinthe et le partager avec le monde. Imaginons un peu comment fonctionnerait ce système. On pourrait avoir un fichier texte et chaque caractère correspondrait à un type de bloc. Par exemple :

  • o serait un trou ;
  • d, le départ ;
  • a, l'arrivée.

Si on envisage ce système, le labyrinthe précédent donnerait ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
oooooooooooooooooooo
o   o  o    o      o
o d o  o    o      o
o   o       o      o
o   o       o   o  o
o   o  oooooo   o  o
o   o  o        o  o
o   o           o  o
o   o  o    ooooo  o
o   o  oooooo      o
o   o  o           o
o      oa          o
o      o           o
oooooooooooooooooooo

C'est tout de suite plus graphique, plus facile à développer, à entretenir et à déboguer. Pour transformer ce fichier texte en labyrinthe, il suffit de créer une boucle qui lira le fichier caractère par caractère, puis qui créera un bloc en fonction de la présence ou non d'un caractère à l'emplacement lu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
InputStreamReader input = null;
BufferedReader reader = null;
Bloc bloc = null;
try {
  input = new InputStreamReader(new FileInputStream(fichier_du_labyrinthe), Charset.forName("UTF-8"));
  reader = new BufferedReader(input);

  // L'indice qui correspond aux colonnes dans le fichier
  int i = 0;
  // L'indice qui correspond aux lignes dans le fichier
  int j = 0;

  // La valeur récupérée par le flux
  int c;
  // Tant que la valeur n'est pas de -1, c'est qu'on lit un caractère du fichier
  while((c = reader.read()) != -1) {
    char character = (char) c;
    if(character == 'o')
      bloc = new Bloc(Type.TROU, i, j);
    else if(character == 'd')
      bloc = new Bloc(Type.DEPART, i, j);
    else if(character == 'a')
      bloc = new Bloc(Type.ARRIVEE, i, j);
    else if (character == '\n') {
      // Si le caractère est un retour à la ligne, on retourne avant la première colonne
      // Car on aura i++ juste après, ainsi i vaudra 0, la première colonne !
      i = -1;
      // Et on passe à la ligne suivante
      j++;
    }
    // Si le bloc n'est pas nul, alors le caractère n'était pas un retour à la ligne
    if(bloc != null)
      // On l'ajoute alors au labyrinthe
      labyrinthe.addBloc(bloc);
    // On passe à la colonne suivante
    i++;
    // On remet bloc à null, utile quand on a un retour à la ligne pour ne pas ajouter de bloc qui n'existe pas
    bloc = null;
  }
} catch (IllegalCharsetNameException e) {
  e.printStackTrace();
} catch (UnsupportedCharsetException e) {
  e.printStackTrace();
} catch (FileNotFoundException e) {
  e.printStackTrace();
} catch (IOException e) {
  e.printStackTrace();
} finally {
  if(input != null)
    try {
      input.close();
    } catch (IOException e1) {
      e1.printStackTrace();
    }
  if(reader != null)
    try {
      reader.close();
    } catch (IOException e) {
      e.printStackTrace();
    }
}

Pour les plus motivés d'entre vous, il est possible aussi de développer un éditeur de niveaux. Imaginez, vous possédez un menu qui permet de choisir le bloc à ajouter, puis il suffira à l'utilisateur de cliquer à l'endroit où il voudra que le bloc se place.

Vérifiez toujours qu'un labyrinthe a un départ et une arrivée, sinon l'utilisateur va tourner en rond pendant des heures ou n'aura même pas de boule !

Ajouter des sons

Parce qu'un peu de musique et des effets sonores permettent d'améliorer l'immersion. Enfin, si tant est qu'on puisse avoir de l'immersion dans ce genre de jeux avec de si jolis graphismes… Bref, il existe deux types de sons que devrait jouer notre jeu :

  • Une musique de fond ;
  • Des effets sonores. Par exemple, quand la boule de l'utilisateur tombe dans un trou, cela pourrait être amusant d'avoir le son d'une foule qui le hue.

Pour la musique, c'est simple, vous savez déjà le faire ! Utilisez un MediaPlayer pour jouer la musique en fond, ce n'est pas plus compliqué que cela. Si vous avez plusieurs musiques, vous pouvez aussi très bien créer une liste de lecture et passer d'une chanson à l'autre dès que la lecture d'une piste est terminée.

Pour les effets sonores, c'est beaucoup plus subtil. On va plutôt utiliser un SoundPool. En effet, il est possible qu'on ait à jouer plusieurs effets sonores en même temps, ce que MediaPlayer ne gère pas correctement ! De plus, MediaPlayer est lourd à utiliser, et on voudra qu'un effet sonore soit plutôt réactif. C'est pourquoi on va se pencher sur SoundPool.

Contrairement à MediaPlayer, SoundPool va devoir précharger les sons qu'il va jouer au lancement de l'application. Les sons vont être convertis en un format que supportera mieux Android afin de diminuer la latence de leur lecture. Pour les plus minutieux, vous pouvez même gérer le nombre de flux audio que vous voulez en même temps. Si vous demandez à SoundPool de jouer un morceau de plus que vous ne l'avez autorisé, il va automatiquement fermer un flux précédent, généralement le plus ancien. Enfin, vous pouvez aussi préciser une priorité manuellement pour gérer les flux que vous souhaitez garder. Par exemple, si vous jouez la musique dans un SoundPool, il faudrait pouvoir la garder quoi qu'il arrive, même si le nombre de flux autorisés est dépassé. Vous pouvez donc donner à la musique de fond une grosse priorité pour qu'elle ne soit pas fermée.

Ainsi, le plus gros défaut de cette méthode est qu'elle prend du temps au chargement. Vous devez insérer chaque son que vous allez utiliser avec la méthode int load(String path, int priority), path étant l'emplacement du son et priority la priorité que vous souhaitez lui donner (0 étant la valeur la plus basse possible). L'entier retourné sera l'identifiant de ce son, gardez donc cette valeur précieusement.

Si vous avez plusieurs niveaux, et que chaque niveau utilise un ensemble de sons différents, il est important que le chargement des sons se fasse en parallèle du chargement du niveau (dans un thread, donc) et surtout tout au début, pour que le chargement ne soit pas trop retardé par ce processus lent.

Une fois le niveau chargé, vous pouvez lancer la lecture d'un son avec la méthode int play(int soundID, float leftVolume, float rightVolume, int priority, int loop, float rate), les paramètres étant :

  • En tout premier l'identifiant du son, qui vous a été donné par la méthode load().
  • Le volume à gauche et le volume à droite, utile pour la lecture en stéréo. La valeur la plus basse est 0, la plus haute est 1.
  • La priorité de ce flux. 0 est le plus bas possible.
  • Le nombre de fois que le son doit être répété. On met 0 pour jamais, -1 pour toujours, toute autre valeur positive pour un nombre précis.
  • Et enfin la vitesse de lecture. 1.0 est la vitesse par défaut, 2.0 sera deux fois plus rapide et 0.5 deux fois plus lent.

La valeur retournée est l'identifiant du flux. C'est intéressant, car cela vous permet de manipuler votre flux. Par exemple, vous pouvez arrêter un flux avec void pause(int streamID) et le reprendre avec void resume(int streamID).

Enfin, une fois que vous avez fini un niveau, il vous faut appeler la méthode void release() pour libérer la mémoire, en particulier les sons retenus en mémoire. La référence au SoundPool vaudra null. Il vous faut donc créer un nouveau SoundPool par niveau, cela vous permet de libérer la mémoire entre chaque chargement.

Créer le moteur graphique et physique du jeu requiert beaucoup de temps et d'effort. C'est pourquoi il est souvent conseillé de faire appel à des moteurs préexistants comme AndEngine par exemple, qui est gratuit et open source. Son utilisation sort du cadre de ce cours ; cependant, si vous voulez faire un jeu, je vous conseille de vous y pencher sérieusement.