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.
La figure suivante est un aperçu du résultat final que j'obtiens.
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'instructioninstanceof
. Par exemple,bloc instanceof sdz.chapitreCinq.labyrinthe.Trou
. - Ou alors on ajoute un attribut
type
à la classeBloc
, 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.
Maintenant, regardez la figure suivante.
À 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.
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
ouARRIVEE
). - 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(); } } |
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.