I. Introduction▲
Le jeu vidéo Snake
Ce jeu vidéo consiste à diriger un serpent (un ensemble « d'anneaux » reliés les uns aux autres ayant l'apparence d'un serpent), contraint à toujours avancer régulièrement. Le joueur ne peut faire varier sa vitesse, mais peut le faire tourner à droite ou à gauche de 90 degrés. Si la tête du serpent heurte les bords de la surface de jeu ou un des anneaux de son corps, la partie est terminée. Le but est de faire en sorte que la trajectoire du serpent croise l'emplacement d'un élément (traditionnellement une grenouille). Le serpent « mange » alors la grenouille et son corps s'allonge d'un anneau. Une nouvelle grenouille apparaît alors sur la surface de jeu à un emplacement aléatoire. Et ainsi de suite jusqu'à la mort du serpent. Au plus le serpent « mange » de grenouilles, au plus son corps devient grand et encombrant, augmentant naturellement la difficulté. Cette dernière est encore régulièrement augmentée lorsqu'un certain nombre de grenouilles sont « mangées », car l'avancement du serpent est alors plus rapide.
La page Wikipédia sur ce jeu : http://fr.wikipedia.org/wiki/Snake_(jeu_vidéo).
Vous pouvez essayer plusieurs versions flashs de ce jeu sur http://www.snake-game.biz/.
Voici une version particulièrement soignée que vous trouverez un peu partout en cherchant sur un moteur de recherche avec les mots clefs snake et humain : snake humain.
Télécharger le jar du jeu téléchargement ftp http
Télécharger les sources du jeu téléchargement ftp http
II. La fenêtre d'affichage▲
Nous aurons besoin d'une fenêtre et d'un conteneur dans lequel afficher le jeu.
Pour cela, nous sous-classons une JFrame dans une classe JFenetre. Nous paramétrons divers aspects de cette fenêtre depuis le constructeur. Puis nous créons et ajoutons un JPanel, afin de (à terme) pouvoir dessiner dedans.
Nous créons aussi une méthode main afin de pouvoir lancer l'application.
import
java.awt.Dimension;
import
javax.swing.JFrame;
import
javax.swing.JPanel;
public
class
JFenetre extends
JFrame {
public
JFenetre
(
) {
// titre de la fenêtre
super
(
"Snake"
);
// fermeture de l'application lorsque la fenêtre est fermée
setDefaultCloseOperation
(
EXIT_ON_CLOSE);
// pas de redimensionnement possible de la fenêtre
setResizable
(
false
);
// créer un conteneur qui affichera le jeu
JPanel content =
new
JPanel
(
);
// dimension de ce conteneur
content.setPreferredSize
(
new
Dimension
(
300
, 300
));
// ajouter le conteneur à la fenêtre
setContentPane
(
content);
}
// Lancement du jeu
public
static
void
main
(
String[] args) {
// création de la fenêtre
JFenetre fenetre =
new
JFenetre
(
);
// dimensionnement de la fenêre "au plus juste" suivant
// la taille des composants qu'elle contient
fenetre.pack
(
);
// centrage sur l'écran
fenetre.setLocationRelativeTo
(
null
);
// affichage
fenetre.setVisible
(
true
);
}
}
La fenêtre du jeu
III. Le modèle du jeu▲
Un élément important de cet article est le fait de dissocier le calcul du jeu de son affichage. Ces deux comportements seront réunis au sein d'une classe spéciale, représentant ce que l'on nomme le modèle du jeu.
La classe ModeleDuJeu structurera ce modèle de jeu. Nous y trouverons donc deux méthodes, l'une se chargeant des calculs du jeu et la seconde de l'affichage des éléments du jeu :
import
java.awt.Graphics;
public
class
ModeleDuJeu {
// le calcul du jeu
public
void
calcul
(
) {
}
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
}
}
Nous entendons par calcul du jeu plusieurs choses. Tout d'abord, la méthode aura pour tâche d'identifier les actions demandées par le joueur. Dans notre jeu, il sera possible de changer la direction du serpent au clavier. Nous devrons donc « interroger » le clavier afin de connaître les intentions du joueur (la « gestion des entrées »). Ensuite nous devrons calculer les positions des différents éléments du jeu (les anneaux du serpent et la grenouille) en tenant compte des intentions du joueur. Le calcul de ces positions nous permet de savoir s'il y a collision entre la tête du serpent et la grenouille, ou bien encore si la tête du serpent entre en collision avec les limites du plateau de jeu ou avec un de ses anneaux ce qui entraîne une fin de partie. La méthode doit donc aussi gérer la fin de partie.
L'affichage du modèle du jeu consiste à dessiner à l'écran les éléments du jeu, c'est-à-dire les anneaux du serpent et la grenouille en utilisant les positions de ces éléments déterminés par la précédente méthode de calcul du modèle.
Le modèle du jeu est donc en fait le cœur du jeu, la partie centrale de notre application.
Pour l'instant, ce modèle ne fait rien, nous en avons seulement écrit la structure. Nous verrons au fur et à mesure comment implémenter ces deux méthodes.
IV. Les cycles du jeu▲
Le fait de gérer le jeu avec un modèle, avec d'une part les calculs et d'autre part l'affichage, nous amène à mettre en place un cycle perpétuel de calcul/affichage. Pour cela nous utiliserons un thread dédié à ce mécanisme.
C'est assez simple. Nous instancions un Thread (en classe anonyme, car cela nous simplifiera la tâche, mais ce n'est évidemment pas une obligation) dans le constructeur de notre fenêtre graphique (JFenetre). Le Thread exécutera une boucle infinie avec une instruction while.
public
JFenetre
(
) {
// titre de la fenêtre
super
(
"Snake"
);
// fermeture de l'application lorsque la fenêtre est fermée
setDefaultCloseOperation
(
EXIT_ON_CLOSE);
// pas de redimensionnement possible de la fenêtre
setResizable
(
false
);
// créer un conteneur qui affichera le jeu
JPanel content =
new
JPanel
(
);
// dimension de ce conteneur
content.setPreferredSize
(
new
Dimension
(
300
, 300
));
// ajouter le conteneur à la fenêtre
setContentPane
(
content);
// Créer un thread infini
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
}
}
}
);
// lancer le thread
thread.start
(
);
}
Nous pouvons maintenant créer une instance de notre modèle de jeu (même si ce modèle ne fait rien pour l'instant) et appeler les méthodes de calcul et d'affichage.
La méthode de calcul sera explicitement appelée par le Thread :
private
ModeleDuJeu modele;
public
JFenetre
(
) {
// titre de la fenêtre
super
(
"Snake"
);
// créer le modèle du jeu
this
.modele =
new
ModeleDuJeu
(
);
// fermeture de l'application lorsque la fenêtre est fermée
setDefaultCloseOperation
(
EXIT_ON_CLOSE);
// pas de redimensionnement possible de la fenêtre
setResizable
(
false
);
// créer un conteneur qui affichera le jeu
JPanel content =
new
JPanel
(
);
// dimension de ce conteneur
content.setPreferredSize
(
new
Dimension
(
300
, 300
));
// ajouter le conteneur à la fenêtre
setContentPane
(
content);
// Créer un thread infini
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
// à chaque fois que la boucle est exécutée, la
// méthode de calcul du jeu est appelée.
// Comme la boucle est infinie, la méthode de calcul
// sera appelée en cycle perpétuel.
JFenetre.this
.modele.calcul
(
);
}
}
}
);
// lancer le thread
thread.start
(
);
}
En revanche, nous ne pouvons pas appeler la méthode d'affichage du jeu explicitement. En effet l'affichage doit être effectué dans l'EDT* (qui est un thread). La bonne et unique façon de procéder est de redéfinir la méthode paintComponent(Graphics) du conteneur devant afficher notre jeu, depuis laquelle nous appellerons explicitement la méthode d'affichage du modèle. Le Thread sera chargé d'appeler la méthode repaint() de ce conteneur, ce qui aura pour effet de demander à l'EDT d'appeler la méthode paintComponent(Graphics) du conteneur et donc de dessiner à l'écran notre jeu pour un instant t.
* EDT : Event Dispatch Thread.
Notez au passage que la référence du conteneur devra être déclarée finale pour pouvoir en disposer depuis le Thread.
import
java.awt.Graphics;
public
JFenetre
(
) {
// titre de la fenêtre
super
(
"Snake"
);
// créer le modèle du jeu
this
.modele =
new
ModeleDuJeu
(
);
// fermeture de l'application lorsque la fenêtre est fermée
setDefaultCloseOperation
(
EXIT_ON_CLOSE);
// pas de redimensionnement possible de la fenêtre
setResizable
(
false
);
// créer un conteneur qui affichera le jeu
final
JPanel content =
new
JPanel
(
) {
@Override
protected
void
paintComponent
(
Graphics g) {
super
.paintComponent
(
g);
// affichage du modèle du jeu
JFenetre.this
.modele.affichage
(
g);
}
}
;
// dimension de ce conteneur
content.setPreferredSize
(
new
Dimension
(
300
, 300
));
// ajouter le conteneur à la fenêtre
setContentPane
(
content);
// Créer un thread infini
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
// à chaque fois que la boucle est exécutée, la
// méthode de calcul du jeu est appelée.
// Comme la boucle est infinie, la méthode de calcul
// sera appelée en cycle perpétuel.
JFenetre.this
.modele.calcul
(
);
// demander à l'EDT de redessiner le conteneur
content.repaint
(
);
}
}
}
);
// lancer le thread
thread.start
(
);
}
Nous devons maintenant temporiser la boucle infinie, en demandant au Thread d'attendre un peu entre chaque appel des méthodes de calcul/affichage. Sans quoi la vitesse du jeu sera bien trop grande pour être perçue agréablement par l'œil humain. Nous mettrons (pour l'instant) un temps d'attente de 500 millisecondes, ce qui correspond à 2 cycles par secondes (autrement dit la méthode de calcul puis l'affichage du modèle sera fait toutes les demi-secondes).
Cette temporisation est rapidement obtenue avec la méthode static sleep(int) de la classe Thread. L'argument entier représentant un temps en millisecondes.
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
// à chaque fois que la boucle est exécutée, la
// méthode de calcul du jeu est appelée.
// Comme la boucle est infinie, la méthode de calcul
// sera appelée en cycle perpétuel.
JFenetre.this
.modele.calcul
(
);
// demander à l'EDT de redessiner le conteneur,
// ce qui aura pour effet de dessiner notre jeu
content.repaint
(
);
// temporisation
try
{
Thread.sleep
(
500
);
}
catch
(
InterruptedException e) {
//
}
}
}
}
);
Pour l'instant notre travail est invisible. Mais il est facile de mettre en évidence le fait que notre modèle effectue les calculs et l'affichage par cycle de 500 millisecondes.
Il suffit de créer un attribut entier dans la classe du modèle. Nous incrémentons cet entier depuis la méthode de calcul et l'affichons dans la méthode d'affichage. En lançant le jeu, vous devriez voir un nombre incrémenté d'un toutes les demi-secondes se dessiner dans le coin supérieur gauche :
import
java.awt.Graphics;
public
class
ModeleDuJeu {
private
int
entier;
// le calcul du jeu
public
void
calcul
(
) {
this
.entier++
;
}
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
g.drawString
(
String.valueOf
(
this
.entier), 5
, 15
);
}
}
Le code ci-dessus est temporaire. Une fois que vous avez constaté le fonctionnement des cycles, il est inutile de le conserver.
Écran obtenu après 25 cycles
Bien que basiques, nous disposons maintenant de la structure type permettant l'exécution d'un jeu vidéo en Swing. Il nous reste maintenant à écrire le principal, c'est-à-dire la gestion du jeu proprement dit.
V. Les dimensions du jeu▲
Le serpent ne sera pas déplacé pixel par pixel, mais du nombre pixel correspondant à la taille d'un anneau. Nous pouvons donc « voir » le plateau de jeu comme un damier ou une grille. Chaque anneau du serpent occupera une case de la grille. Lors du déplacement, les anneaux seront déplacés sur la case suivante en fonction de la direction du serpent. Les images ci-dessous illustrent un déplacement d'une case vers la gauche :
Déplacement d'une case vers la gauche
Nous avons cependant besoin de connaître le nombre de pixels qu'occupera un anneau (la hauteur sera égale à la largeur), soit la taille d'une case en pixels. Nous avons aussi besoin de savoir combien d'anneaux pourront être dessinés en largeur et en hauteur sur le plateau de jeu. Cela revient à savoir combien de case possédera le plateau de jeu en hauteur et en largeur.
Afin de pouvoir modifier rapidement ces données si l'envie nous en prenait, nous allons les définir comme des constantes. Nous les définirons dans une interface particulière, qu'il suffira d'implémenter dans les classes utilisant ces données.
public
interface
Constantes {
/**
* nombre de colonnes de la surface de jeu
*/
public
final
static
int
NBRE_DE_COLONNES =
30
;
/**
* nombre de lignes de la surface de jeu
*/
public
final
static
int
NBRE_DE_LIGNES =
30
;
/**
* dimension d'une case en pixels
*/
public
final
static
int
CASE_EN_PIXELS =
15
;
}
Nous pouvons maintenant utiliser ces constantes afin de définir la taille appropriée du plateau de jeu. La largeur sera égale au nombre de colonnes multipliées par la taille en pixels des colonnes. La hauteur au nombre de lignes par la taille en pixels des lignes. Dans le code ci-dessous les trois petits points (...) symbolisent le code que nous n'avons pas réécrit afin de ne pas surcharger inutilement la page.
public
class
JFenetre extends
JFrame implements
Constantes {
...
public
JFenetre
(
) {
...
// dimension de ce conteneur
content.setPreferredSize
(
new
Dimension
(
NBRE_DE_COLONNES *
CASE_EN_PIXELS,
NBRE_DE_LIGNES *
CASE_EN_PIXELS));
...
}
...
}
VI. L'affichage du serpent▲
Les anneaux du serpent ainsi que la grenouille occuperont une case. Il serait pratique de disposer d'une classe permettant d'obtenir des objets identifiant une case précise. Pour obtenir la coordonnée en pixel d'une case, il suffira de multiplier l'indice des abscisses par la taille d'une case et l'indice des ordonnées par la taille d'une case. L'image ci-dessous illustre ce principe :
Coordonnée d'une case (taille de 15 pixels)
Nous allons créer cette classe très simple permettant de connaître les indices de la case ainsi que leurs coordonnées en pixels.
public
class
Case implements
Constantes {
private
int
xIndice;
private
int
yIndice;
public
Case
(
int
xIndice, int
yIndice) {
this
.xIndice =
xIndice;
this
.yIndice =
yIndice;
}
// indice horizontal
public
void
setIndiceX
(
int
x) {
this
.xIndice =
x;
}
// indice horizontal
public
int
getIndiceX
(
) {
return
this
.xIndice;
}
// indice vertical
public
void
setIndiceY
(
int
y) {
this
.yIndice =
y;
}
// indice vertical
public
int
getIndiceY
(
) {
return
this
.yIndice;
}
// coordonnée horizontale en pixels
public
int
getX
(
) {
return
this
.xIndice *
CASE_EN_PIXELS;
}
// coordonnée verticale en pixels
public
int
getY
(
) {
return
this
.yIndice *
CASE_EN_PIXELS;
}
public
int
getLargeur
(
) {
return
CASE_EN_PIXELS;
}
public
int
getHauteur
(
) {
return
CASE_EN_PIXELS;
}
}
Nous allons aussi créer une classe Serpent, qui nous permettra de gérer le serpent.
Notre serpent est constitué d'anneaux et donc de cases. Nous créerons une collection d'objets Case qui stockera les cases constituant le serpent. Une LinkedList sera appropriée. D'abord, parce que c'est une liste ordonnée, ce qui signifie que l'ordre des éléments contenus dans la liste ne varie pas. La tête du serpent sera toujours le premier élément, et la queue le dernier. Ensuite parce que cette collection possède une méthode pour insérer un élément en première position et une méthode pour supprimer le dernier élément de la liste, ce qui nous sera utile pour faire avancer le serpent comme nous le verrons par la suite.
Cette classe possédera aussi une méthode qui lui permettra de se dessiner, ainsi qu'une méthode de calcul qui nous permettra de modéliser le comportement du serpent. Évidemment, nous appellerons cette méthode de calcul du serpent depuis la méthode de calcul du modèle et la méthode d'affichage du serpent depuis la méthode d'affichage du modèle.
import
java.awt.Graphics;
import
java.util.LinkedList;
public
class
Serpent {
private
LinkedList<
Case>
list;
public
Serpent
(
) {
this
.list =
new
LinkedList<
Case>(
);
}
public
void
calcul
(
) {
// calcul du serpent
}
public
void
affichage
(
Graphics g) {
// dessin du serpent
}
}
Pour dessiner le serpent, il suffit de faire une itération des éléments de la collection et de dessiner l'anneau avec les informations de la case :
public
void
affichage
(
Graphics g) {
// dessin du serpent
for
(
Case box : this
.list) {
g.fillOval
(
box.getX
(
), box.getY
(
), box.getLargeur
(
), box.getHauteur
(
));
}
}
Afin d'améliorer l'aspect graphique, nous activons l'antialiasing :
import
java.awt.Graphics2D;
import
java.awt.RenderingHints;
public
void
affichage
(
Graphics g) {
// activer l'anti-aliasing du dessin
Graphics2D g2 =
(
Graphics2D) g;
g2.setRenderingHint
(
RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
// dessin du serpent
for
(
Case box : this
.list) {
g.fillOval
(
box.getX
(
), box.getY
(
), box.getLargeur
(
), box.getHauteur
(
));
}
}
Il nous faut maintenant intégrer le serpent au modèle du jeu. Ce dernier devra donc posséder une instance de cette classe Serpent. Nous appellerons la méthode d'affichage du serpent depuis la méthode d'affichage du modèle.
import
java.awt.Graphics;
public
class
ModeleDuJeu {
private
Serpent serpent;
public
ModeleDuJeu
(
) {
this
.serpent =
new
Serpent
(
);
}
// le calcul du jeu
public
void
calcul
(
) {
// calcul du serpent
this
.serpent.calcul
(
);
}
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
// affichage du serpent
this
.serpent.affichage
(
g);
}
}
Nous pouvons maintenant créer 3 anneaux qui formeront le serpent de départ :
public
class
Serpent {
...
public
Serpent
(
) {
this
.list =
new
LinkedList<
Case>(
);
this
.list.add
(
new
Case
(
14
, 15
));
this
.list.add
(
new
Case
(
15
, 15
));
this
.list.add
(
new
Case
(
16
, 15
));
}
...
}
Vous pouvez compiler et constater l'affichage du serpent.
L'affichage du serpent
VII. Faire avancer le serpent▲
Le serpent avancera de « case » en « case ». Il suffit donc, pour le faire avancer, d'ajouter au début de notre collection (dans la classe Serpent) la prochaine « case » (suivant la direction de déplacement du serpent), qui sera toujours la tête du serpent et aussi de supprimer de la collection la dernière « case » du serpent (le dernier anneau). C'est pourquoi nous avons choisi la collection LinkedList. Cette collection possède la méthode addFirst(Case) permettant d'insérer une « case » en début de liste, ainsi qu'une méthode removeLast() permettant de supprimer le dernier élément contenu dans la liste.
Les schémas ci-dessous illustrent le déplacement du serpent par le simple fait d'ajouter une nouvelle case en début de collection tout en supprimant la dernière, ainsi que la manipulation de la collection :
Le serpent à un instant t
Le serpent à un instant t + 1
Manipulation de la collection
Ceci suffit à faire avancer le serpent. Nous avons néanmoins impérativement besoin de connaître la direction d'avancement du serpent (vers le haut, le bas, la droite ou la gauche), ainsi que la prochaine « case » sur laquelle se déplacera la tête du serpent.
Pour cela, nous créons une énumération décrivant les quatre directions possibles.
public
enum
Direction {
VERS_LE_HAUT,
VERS_LA_DROITE,
VERS_LE_BAS,
VERS_LA_GAUCHE;
}
La class
La classe Serpent implémentera un nouvel attribut de ce type, que nous mettrons à jour lors d'un changement de direction du serpent, ce qui sera fait plus loin.
public
class
Serpent {
...
private
Direction direction;
public
Serpent
(
) {
this
.list =
new
LinkedList<
Case>(
);
this
.list.add
(
new
Case
(
14
, 15
));
this
.list.add
(
new
Case
(
15
, 15
));
this
.list.add
(
new
Case
(
16
, 15
));
this
.direction =
Direction.VERS_LA_GAUCHE;
}
...
}
Nous pouvons maintenant écrire une méthode qui nous retournera la prochaine « case » sur laquelle sera déplacée la tête du serpent. Cette « case » sera différente suivant la direction du serpent. Nous pouvons nous servir de la méthode getFirst() de la collection pour connaître le premier élément de la collection, soit la tête du serpent. Nous changeons ensuite la coordonnée en fonction de la direction du serpent. Cette méthode sera bien sûr ajoutée dans la classe Serpent :
private
Case getNextcase
(
) {
Case tete =
this
.list.getFirst
(
);
switch
(
this
.direction) {
case
VERS_LE_HAUT:
return
new
Case
(
tete.getIndiceX
(
), tete.getIndiceY
(
) -
1
);
case
VERS_LA_DROITE:
return
new
Case
(
tete.getIndiceX
(
) +
1
, tete.getIndiceY
(
));
case
VERS_LE_BAS:
return
new
Case
(
tete.getIndiceX
(
), tete.getIndiceY
(
) +
1
);
case
VERS_LA_GAUCHE:
return
new
Case
(
tete.getIndiceX
(
) -
1
, tete.getIndiceY
(
));
}
return
null
;
}
Notez que cette prochaine « case » peut très bien être en dehors du plateau de jeu.
Notez aussi que cette méthode participera aussi à savoir si le serpent sort des limites du plateau de jeu, ou s'il se « mord » un anneau, ou bien encore s'il « mange » la grenouille puisque nous pourrons savoir à l'avance sur quelle case la tête du serpent se trouvera.
Il nous est donc facile, maintenant, de faire avancer le serpent. Nous n'avons plus qu'à écrire une méthode dont la tâche sera d'ajouter l'objet du type Case renvoyée par la méthode getNextcase() au début de la collection, puis de supprimer le dernier élément de cette même collection. Cette méthode sera aussi implémentée au sein de la classe Serpent :
private
void
avance
(
) {
// ajoute en tête de liste la case sur laquelle
// le serpent doit se déplacer
this
.list.addFirst
(
getNextcase
(
));
// supprime le dernier élément de la liste
this
.list.removeLast
(
);
}
Il ne nous reste plus qu'à appeler la méthode avance() depuis la méthode de calcul. À chaque cycle, le serpent avancera d'une « case ». Voici pourquoi nous avons utilisé une temporisation très longue depuis le Thread. En modifiant la temporisation, nous augmenterons ou diminuerons la rapidité globale du jeu. Nous verrons plus loin les limitations d'une telle façon de faire.
public
class
Serpent {
...
public
void
calcul
(
) {
// le calcul du serpent
avance
(
);
}
...
}
Vous pouvez compiler et constater que le serpent avance ! Notez que rien ne lui interdit cependant de sortir du plateau de jeu. En effet, les « cases » peuvent très bien posséder des indices inférieurs ou supérieurs au plateau de jeu !
VIII. La gestion de la défaite▲
Dans un premier temps, nous n'allons gérer que la sortie du plateau de jeu. Nous verrons plus loin comment savoir si le serpent « mord » un de ses anneaux. Nous afficherons le traditionnel « game over » si la partie est perdue.
Il serait pratique de savoir si une « case » est valide ou non. C'est-à-dire si elle appartient au plateau de jeu ou non. Nous allons donc écrire une méthode depuis la classe Case qui renverra un boolean à true si la « case » est contenue dans le plateau de jeu, c'est-à-dire que ses indices sont supérieurs ou égaux à zéro et strictement inférieurs au nombre de colonnes en largeur ou au nombre de lignes en hauteur. Si ce n'est pas le cas, la méthode renverra false.
public
class
Case implements
Constantes {
...
// renvoie true si la case est contenue dans le plateau de jeu
public
boolean
estValide
(
) {
return
this
.xIndice >=
0
&&
this
.xIndice <
NBRE_DE_COLONNES
&&
this
.yIndice >=
0
&&
this
.yIndice <
NBRE_DE_LIGNES;
}
}
Il nous suffit d'exploiter cette méthode pour savoir si le serpent sortira du plateau de jeu ou non lors de son prochain déplacement. Nous allons écrire une méthode dans la classe Serpent qui renverra boolean à true si la « case » renvoyée par la méthode Serpent getNextcase() est valide ou non, c'est-à-dire tout simplement en appelant la méthode estValide() de la classe Case. Ajoutez cette méthode dans la classe Serpent :
private
boolean
peutAvancer
(
) {
return
getNextcase
(
).estValide
(
);
}
Depuis la méthode de calcul, nous contrôlons le résultat de cette dernière méthode avant de faire avancer le serpent. Si le résultat est vrai alors nous pouvons faire avancer le serpent. Si le résultat est faux, cela signifie que la tête du serpent va sortir de l'aire du jeu et la partie sera alors perdue.
public
void
calcul
(
) {
// le calcul du serpent
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
}
}
Vous pouvez compiler et constater que le serpent n'avance plus lorsqu'il sort du plateau de jeu.
Le serpent ne peut plus sortir du jeu
Nous devons maintenant pouvoir identifier si le serpent est mort suite à une sortie du plateau. Pour cela nous utilisons un simple attribut boolean. Une méthode nous permettra de connaître cet état depuis le modèle de jeu :
public
class
Serpent {
...
private
boolean
estMort;
...
public
void
calcul
(
) {
// le calcul du serpent
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.estMort =
true
;
}
}
public
boolean
estMort
(
) {
return
this
.estMort;
}
...
}
Pour afficher le traditionnel « game over », nous devons disposer d'un attribut boolean qui pourra être utilisé depuis la méthode d'affichage du modèle. Si cet attribut est à true, cela signifiera que la partie est perdue et que nous devons afficher le « game over ».
public
class
ModeleDuJeu {
private
Serpent serpent;
private
boolean
laPartieEstPerdue;
public
ModeleDuJeu
(
) {
this
.serpent =
new
Serpent
(
);
this
.laPartieEstPerdue =
false
;
}
// le calcul du jeu
public
void
calcul
(
) {
this
.serpent.calcul
(
);
if
(
this
.serpent.estMort
(
)) {
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.laPartieEstPerdue =
true
;
}
}
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
// affichage du serpent
this
.serpent.affichage
(
g);
}
}
Nous pouvons maintenant dessiner le « game over » si cet attribut est vrai. Nous utiliserons un objet FontMetrics, afin de pouvoir centrer le texte :
import
java.awt.Color;
import
java.awt.Font;
import
java.awt.FontMetrics;
import
java.awt.Graphics;
public
class
ModeleDuJeu {
...
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
// affichage du serpent
this
.serpent.affichage
(
g);
if
(
this
.laPartieEstPerdue) {
String str =
"game over"
;
g.setColor
(
Color.RED);
g.setFont
(
new
Font
(
Font.SANS_SERIF, Font.BOLD, 50
));
FontMetrics fm =
g.getFontMetrics
(
);
int
x =
(
g.getClipBounds
(
).width -
fm.stringWidth
(
str)) /
2
;
int
y =
(
g.getClipBounds
(
).height /
2
) +
fm.getMaxDescent
(
);
g.drawString
(
str, x, y);
}
}
}
La partie est perdue
IX. La gestion du clavier▲
La gestion des entrées se fait depuis la méthode de calcul du modèle de jeu. Nous utiliserons un listener pour connaître l'état du clavier. Cependant, le listener se contentera d'indiquer au modèle, via un attribut, la dernière touche que le joueur a pressée. Le listener n'influera pas directement sur le jeu, mais se contentera de seulement collecter l'information et de la spécifier au modèle. Lors de l'appel de la méthode de calcul, nous examinons cette information et en tenons compte, s'il y a lieu.
Le listener utilisé sera un KeyListener. Ce dernier utilise un objet KeyEvent pour spécifier les caractéristiques de l'événement, dont la touche utilisée. Nous allons créer une méthode collectant cette information au sein du modèle :
import
java.awt.Color;
import
java.awt.Font;
import
java.awt.FontMetrics;
import
java.awt.Graphics;
import
java.awt.event.KeyEvent;
public
class
ModeleDuJeu {
...
public
void
gestionDuClavier
(
KeyEvent event) {
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_RIGHT) {
// touche flèche droite
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_LEFT) {
// touche flèche gauche
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_UP) {
// touche flèche haut
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_DOWN) {
// touche flèche bas
}
}
...
}
Les alternatives de cette méthode nous permettent de savoir quelle touche fléchée a été pressée par le joueur.
Nous pouvons maintenant implémenter le KeyListener. Ajoutez le code suivant dans le constructeur de la classe JFenetre :
// le listener gérant les entrées au clavier
content.addKeyListener
(
new
KeyAdapter
(
) {
@Override
public
void
keyPressed
(
KeyEvent e) {
JFenetre.this
.modele.gestionDuClavier
(
e);
}
}
);
Voici les imports correspondant au code ci-dessus :
import
java.awt.event.KeyAdapter;
import
java.awt.event.KeyEvent;
Nous devons aussi nous assurer que le focus sera bien attribué au panel de dessin du jeu et non à la fenêtre. En effet, par défaut, le focus sera attribué à la fenêtre. Par défaut un panel ne prend pas le focus, or notre listener est associé au panel. Pour changer cela, nous utilisons la méthode setFocusable(boolean). Nous demandons que la fenêtre ne puisse pas prendre le focus et l'inverse pour le panel. Ajouter le code suivant dans le constructeur de la classe JFenetre :
// s'assurer du focus pour le listener clavier
setFocusable
(
false
);
content.setFocusable
(
true
);
Chaque fois que le joueur appuiera sur une touche, le modèle de jeu en sera informé. Le modèle vérifiera que c'est bien une touche fléchée qui a été pressée et si c'est le cas, son rôle sera d'en informer le serpent. Ce dernier stockera cette information et lorsque ce sera le moment d'effectuer un calcul, il en tiendra compte pour un éventuel changement de direction. L'attribut demande de type Direction assurera cette fonction de stockage au sein du serpent :
public
class
Serpent {
private
Direction demande;
...
public
void
setDemandeClavier
(
Direction demande) {
this
.demande =
demande;
}
...
}
Nous compléterons donc la méthode de gestion du clavier du modèle en renseignant cette méthode setDemandeClavier(Direction) :
public
void
gestionDuClavier
(
KeyEvent event) {
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_RIGHT) {
// touche flèche droite
this
.serpent.setDemandeClavier
(
Direction.VERS_LA_DROITE);
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_LEFT) {
// touche flèche gauche
this
.serpent.setDemandeClavier
(
Direction.VERS_LA_GAUCHE);
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_UP) {
// touche flèche haut
this
.serpent.setDemandeClavier
(
Direction.VERS_LE_HAUT);
}
else
if
(
event.getKeyCode
(
) ==
KeyEvent.VK_DOWN) {
// touche flèche bas
this
.serpent.setDemandeClavier
(
Direction.VERS_LE_BAS);
}
}
Nous pouvons maintenant examiner l'attribut demande afin de déterminer si le serpent doit tourner ou non. En effet, ce n'est pas parce que le joueur demande à faire tourner le serpent à droite que ce dernier peut le faire. Ainsi le serpent ne peut tourner à droite ou à gauche que s'il se déplace actuellement vers le haut ou le bas. De même il ne peut tourner vers le bas ou le haut que s'il se déplace actuellement vers la droite ou la gauche. Si la demande du joueur est recevable, alors nous changeons le contenu de l'attribut direction, qui, rappelons-le, détermine la direction prise par le serpent à chaque cycle. Toujours dans la classe Serpent, ajoutez cette méthode :
private
void
tourner
(
) {
if
(
this
.demande !=
null
) {
// une touche à été pressée
// le serpent va vers le haut ou le bas
if
(
this
.direction ==
Direction.VERS_LE_HAUT
||
this
.direction ==
Direction.VERS_LE_BAS) {
if
(
this
.demande ==
Direction.VERS_LA_DROITE) {
// la touche droite
// à été pressée
// le serpent tourne à droite
this
.direction =
Direction.VERS_LA_DROITE;
}
else
if
(
this
.demande ==
Direction.VERS_LA_GAUCHE) {
// la touche
// gauche à
// été pressée
// le serpent tourne à gauche
this
.direction =
Direction.VERS_LA_GAUCHE;
}
}
else
{
// le serpent va vers la droite ou la gauche
if
(
this
.demande ==
Direction.VERS_LE_HAUT) {
// la touche haut à
// été pressée
// le serpent tourne vers le haut
this
.direction =
Direction.VERS_LE_HAUT;
}
else
if
(
this
.demande ==
Direction.VERS_LE_BAS) {
// la touche bas
// à été pressée
// le serpent tourne vers le bas
this
.direction =
Direction.VERS_LE_BAS;
}
}
// nous avons tenu compte du clavier, nous le vidons afin de
// forcer le joueur a réappuyé sur une touche pour demander
// une autre direction
this
.demande =
null
;
}
}
Notez le test du null en début de méthode.
Nous pouvons maintenant appeler cette méthode depuis la méthode de calcul du serpent, mais avant d'effectuer le déplacement :
public
void
calcul
(
) {
// le calcul du serpent
tourner
(
);
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.estMort =
true
;
}
}
Cette façon de procéder pourrait permettre la gestion d'un second serpent contrôlé par un autre joueur.
Vous pouvez compiler et constater que le serpent peut désormais être contrôlé. Vous noterez cependant que lorsque le serpent atteint une extrémité, il est possible de le faire tourner et de reprendre le jeu :
Le serpent reste contrôlable
Nous devons simplement interdire l'exécution (totale) du calcul du modèle si la partie est perdue. Ce qui se fait par un simple test de l'attribut laPartieEstPerdue dans la méthode de calcul du modèle du jeu :
public
class
ModeleDuJeu {
...
// le calcul du jeu
public
void
calcul
(
) {
if
(!
this
.laPartieEstPerdue) {
this
.serpent.calcul
(
);
if
(
this
.serpent.estMort
(
)) {
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.laPartieEstPerdue =
true
;
}
}
}
...
}
X. Affichage aléatoire de la grenouille▲
Une grenouille occupe une « case ». Nous allons donc créer une classe Grenouille qui héritera de la classe Case.
public
class
Grenouille extends
Case {
}
Nous écrirons un constructeur par défaut, depuis lequel nous attribuerons des indices aléatoires à la « case ». Pour cela nous utiliserons un objet Random.
import
java.util.Random;
public
class
Grenouille extends
Case {
private
final
static
Random RND =
new
Random
(
);
public
Grenouille
(
) {
super
(
getRandomX
(
), getRandomY
(
));
}
private
static
int
getRandomX
(
) {
return
RND.nextInt
(
NBRE_DE_COLONNES);
}
private
static
int
getRandomY
(
) {
return
RND.nextInt
(
NBRE_DE_LIGNES);
}
}
Une méthode nous permettra de changer aléatoirement ces coordonnées, ce qui sera pratique pour modifier l'emplacement de la grenouille lorsque celle-ci sera « mangée » par le serpent.
public
void
nouvelleGrenouille
(
) {
setIndiceX
(
getRandomX
(
));
setIndiceY
(
getRandomY
(
));
}
Nous écrirons aussi une méthode d'affichage de la grenouille, en l'occurrence un rectangle rouge (dans la classe Grenouille). Nous réduirons de 2 pixels la largeur du rectangle de la grenouille pour une raison esthétique lorsque la grenouille chevauchera le corps du serpent.
public
void
affichage
(
Graphics g) {
g.setColor
(
Color.RED);
g.fillRect
(
getX
(
) +
2
, getY
(
) +
2
, getLargeur
(
) -
4
, getHauteur
(
) -
4
);
}
Sans oublier les imports :
import
java.awt.Color;
import
java.awt.Graphics;
Pour implémenter la grenouille au sein de notre jeu, il suffit d'en définir une depuis notre modèle, et de gérer son affichage.
public
class
ModeleDuJeu {
...
private
Grenouille grenouille;
public
ModeleDuJeu
(
) {
this
.serpent =
new
Serpent
(
);
this
.laPartieEstPerdue =
false
;
this
.input =
null
;
this
.grenouille =
new
Grenouille
(
);
}
...
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
// affichage du serpent
this
.serpent.affichage
(
g);
// affichage de la grenouille
this
.grenouille.affichage
(
g);
// affichage du "game over"
if
(
this
.laPartieEstPerdue) {
String str =
"game over"
;
g.setColor
(
Color.RED);
g.setFont
(
new
Font
(
Font.SANS_SERIF, Font.BOLD, 50
));
FontMetrics fm =
g.getFontMetrics
(
);
int
x =
(
g.getClipBounds
(
).width -
fm.stringWidth
(
str)) /
2
;
int
y =
(
g.getClipBounds
(
).height /
2
) +
fm.getMaxDescent
(
);
g.drawString
(
str, x, y);
}
}
}
Notre jeu affiche désormais une grenouille à une position aléatoire.
Affichage de la grenouille
XI. Le serpent mange la grenouille▲
L'action de « manger » la grenouille n'est pas très différente de celle d'avancer. En effet, quand le serpent « mange » une grenouille, il croît d'un anneau. Cela revient donc seulement à ajouter la « case » suivante en début de liste sans supprimer le dernier anneau. Ajoutez la méthode ci-dessous à la classe Serpent :
private
void
mange
(
) {
// ajoute en tête de liste la case sur laquelle
// le serpent doit se déplacer
this
.list.addFirst
(
getNextcase
(
));
}
Mais nous devons savoir si le serpent est en mesure de « manger » la grenouille comme nous avions eu besoin de savoir si le serpent était en mesure d'avancer. Nous créerons une méthode dont ce sera le rôle. Cela consistera à tester, toujours dans la classe Serpent, si la prochaine « case » sur laquelle le serpent doit se déplacer correspond à celle de la grenouille :
private
boolean
peutManger
(
Grenouille grenouille) {
Case nextCase =
getNextcase
(
);
return
grenouille.getIndiceX
(
) ==
nextCase.getIndiceX
(
)
&&
grenouille.getIndiceY
(
) ==
nextCase.getIndiceY
(
);
}
Nous devons utiliser maintenant ces méthodes depuis la méthode de calcul de la classe Serpent. Mais nous ne disposons pas de l'instance de la grenouille. Nous devons donc modifier la méthode de calcul afin qu'elle accepte un argument de type Grenouille. Si le serpent est en mesure de manger la grenouille, alors nous appelons la méthode mange() et demandons à la grenouille de modifier aléatoirement sa coordonnée, sinon nous vérifions qu'il se déplace ou que c'est une fin de partie. Modifiez la méthode de calcul du modèle :
public
void
calcul
(
Grenouille grenouille) {
// le calcul du serpent
tourner
(
);
if
(
peutManger
(
grenouille)) {
mange
(
);
grenouille.nouvelleGrenouille
(
);
}
else
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.estMort =
true
;
}
}
Nous ne devons pas oublier de passer la grenouille à la méthode de calcul du serpent depuis le modèle :
public
class
ModeleDuJeu {
...
// le calcul du jeu
public
void
calcul
(
) {
if
(!
this
.laPartieEstPerdue) {
this
.serpent.calcul
(
this
.grenouille);
if
(
this
.serpent.estMort
(
)) {
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.laPartieEstPerdue =
true
;
}
}
}
...
}
Le serpent grandit
XII. Le serpent ne peut pas se mordre▲
Nous devons déclencher une fin de partie lorsque le serpent « mord » un de ses anneaux. Nous devons donc enrichir la méthode peutAvancer() de la classe Serpent, puisque c'est d'elle dont dépend le déclenchement d'une fin de partie. Nous pouvons utiliser pour cela la méthode contains(Case) de la collection. Cette méthode renvoie un boolean à true si l'objet Case en argument existe déjà dans la collection. Nous pouvons donc vérifier si la prochaine « case » sur laquelle le serpent se déplacera existe déjà dans la collection. Si tel est le cas, c'est que la tête correspondra à un anneau et que le serpent se mordra.
private
boolean
peutAvancer
(
) {
Case nextCase =
getNextcase
(
);
return
nextCase.estValide
(
) &&
!
this
.list.contains
(
nextCase);
}
Malheureusement, ceci ne fonctionnera pas ainsi. La raison en est que la méthode contains(Case) utilise la méthode equals(Object) pour établir l'équivalence des deux objets. Elle considéra que les objets Case identique sont ceux possédant la même référence et non les mêmes indices (xIndice et yIndice). Nous devons donc indiquer à la méthode de nouveaux critères d'équivalence. Deux « cases » identiques sont des « cases » dont les attributs xIndice et yIndice ont les mêmes valeurs. Pour cela nous devons redéfinir la méthode equals(Object) de la classe Case.
@Override
public
boolean
equals
(
Object obj) {
if
(
obj instanceof
Case) {
Case box =
(
Case) obj;
return
this
.xIndice ==
box.xIndice
&&
this
.yIndice ==
box.yIndice;
}
return
false
;
}
Nous commençons par vérifier que l'objet en argument est bien de type Case. Si tel n'est pas le cas, alors il est certain que les objets ne sont pas identiques. Nous opérons ensuite un cast. Puis renvoyons vrai si les valeurs des attributs sont identiques, sinon nous renvoyons faux.
Vous pouvez constater que maintenant le serpent ne peut plus se « mordre ».
Notez que nous pourrions réécrire la méthode peutManger(Grenouille) de la classe Serpent en utilisant cette méthode equals(Object).
private
boolean
peutManger
(
Grenouille grenouille) {
return
grenouille.equals
(
getNextcase
(
));
}
XIII. Mécanisme de niveau▲
Traditionnellement, ce jeu augmente régulièrement sa difficulté. Ceci est souvent basé sur la comptabilisation du nombre de grenouilles « dévorées » par le serpent. Pour augmenter la difficulté du jeu, de nombreux éléments peuvent intervenir, mais le plus basique est l'augmentation de la vitesse de déplacement du serpent.
Nous devons donc comptabiliser le nombre de grenouilles « mangées ». Nous aurons un nouvel attribut dont ce sera le rôle, dans la classe Serpent. Nous incrémenterons cet attribut chaque fois que le serpent « mangera » une grenouille :
public
class
Serpent {
private
int
eatCount;
...
private
void
mange
(
) {
// ajoute en tête de liste la case sur laquelle
// le serpent doit se déplacer
this
.list.addFirst
(
getNextcase
(
));
// comptabiliser les grenouilles "mangées"
this
.eatCount++
;
}
public
int
getEatCount
(
) {
return
this
.eatCount;
}
...
}
C'est le niveau de jeu qui conditionne la vitesse de déplacement du serpent, et c'est le nombre de grenouille « mangées » qui conditionne le niveau. Nous devons donc calculer ce niveau. Nous fixons arbitrairement l'augmentation du niveau de jeu toutes les 5 grenouilles. Pour cela, il suffit de diviser l'attribut eatCount par 5. Comme le niveau commencera à 1 et non à zéro, nous ajoutons 1. Cette méthode est interne à la classe ModeleDuJeu :
private
int
getNiveau
(
) {
return
(
this
.serpent.getEatCount
(
) /
5
) +
1
;
}
Nous allons, au passage, afficher le niveau sur le plateau de jeu. Modifiez la méthode d'affichage de ModeleDuJeu :
// le dessin graphique du jeu
public
void
affichage
(
Graphics g) {
// affichage du serpent
this
.serpent.affichage
(
g);
// affichage de la grenouille
this
.grenouille.affichage
(
g);
// affichage du "game over"
if
(
this
.laPartieEstPerdue) {
String str =
"game over"
;
g.setColor
(
Color.RED);
g.setFont
(
new
Font
(
Font.SANS_SERIF, Font.BOLD, 50
));
FontMetrics fm =
g.getFontMetrics
(
);
int
x =
(
g.getClipBounds
(
).width -
fm.stringWidth
(
str)) /
2
;
int
y =
(
g.getClipBounds
(
).height /
2
) +
fm.getMaxDescent
(
);
g.drawString
(
str, x, y);
}
// affichage du niveau
g.setColor
(
Color.BLUE);
g.setFont
(
new
Font
(
Font.SANS_SERIF, Font.BOLD, 20
));
g.drawString
(
String.valueOf
(
getNiveau
(
)), 5
, 25
);
}
Maintenant que nous disposons des informations permettant d'évaluer quand la vitesse du serpent doit être augmentée, il nous faut déterminer comment augmenter cette vitesse.
Il y a plusieurs moyens. Le premier que nous allons voir maintenant, est simple et efficace, mais nous enferme dans des limitations en termes de liberté d'animation. La seconde approche permet de régler cet inconvénient, mais est un peu plus complexe.
Le moyen le plus simple pour augmenter la vitesse du serpent est d'augmenter la fréquence des cycles. Ainsi, si au lieu d'effectuer un cycle toutes les 500 millisecondes, nous l'effectuons tous les 250 millisecondes, le serpent se déplacera deux fois plus vite. C'est aussi simple que cela.
Nous devons donc créer une méthode au sein du modèle de jeu, qui calculera le temps de temporisation du Thread en fonction du niveau. Le Thread, lors de sa temporisation, appellera cette méthode.
Nous créons bien sûr cette méthode dans la classe ModeleDuJeu, puisqu'elle doit tenir compte du niveau. Suivant le niveau, nous renvoyons un temps d'attente de plus en plus court, ce qui permettra de faire varier la vitesse du serpent. Notez qu'au-delà du niveau 9, ce temps sera identique :
public
int
getTemporisation
(
) {
switch
(
getNiveau
(
)) {
case
1
:
return
500
;
case
2
:
return
400
;
case
3
:
return
350
;
case
4
:
return
300
;
case
5
:
return
250
;
case
6
:
return
200
;
case
7
:
return
160
;
case
8
:
return
120
;
case
9
:
return
80
;
default
:
return
50
;
}
}
Nous appelons cette méthode depuis le Thread dans la classe JFenetre :
// Créer un thread infini
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
// à chaque fois que la boucle est exécutée, la
// méthode de calcul du jeu est appelée.
// Comme la boucle est infinie, la méthode de calcul
// sera appelée en cycle perpétuel.
JFenetre.this
.modele.calcul
(
);
// demander à l'EDT de redessiner le conteneur,
// ce qui aura pour effet de dessiner notre jeu
content.repaint
(
);
// temporisation
try
{
Thread.sleep
(
JFenetre.this
.modele.getTemporisation
(
));
}
catch
(
InterruptedException e) {
//
}
}
}
}
);
Vous pouvez compiler et constater que le serpent devient de plus en plus rapide à chaque niveau de jeu.
La vitesse varie suivant le niveau
XIV. Les limites de notre procédé▲
Cette façon de gérer notre temporisation offre l'avantage d'être extrêmement simple et de plus parait adaptée au jeu. Cependant, cette manière de procéder ne nous offre pas la possibilité d'animer d'autres éléments à des fréquences d'animation plus élevées, par exemple, de faire en sorte, qu'à l'apparition des grenouilles, le rectangle effectue une rotation sur lui-même et ce, à un rythme régulier et constant quel que soit le niveau du jeu.
Pour pouvoir effectuer ce genre de chose nous devons revoir totalement notre façon de faire. Cependant, cela n'engendrera pas de profonde modification. Nous devons gérer le jeu d'une manière un peu plus évoluée.
Le principe est d'animer les cycles (calcul/affichage) du jeu à une vitesse élevée et constante. Nous chercherons par exemple à obtenir environ 40 cycles par seconde (40 FPS). Cela nous permettra d'effectuer des calculs et un affichage précis et rapide de certains éléments (l'animation de la grenouille). Nous devrons cependant, pouvoir temporiser les cycles de calcul/affichage du serpent d'une autre manière que par la temporisation du Thread. Cela peut être assez facilement fait par un système de compteur.
Un compteur comptabilise le nombre de cycles effectués et lorsqu'il atteint un certain seuil, nous faisons alors un cycle de calcul/affichage du serpent et réinitialisons alors ce compteur à zéro et ainsi de suite. En faisant varier le seuil, nous faisons varier la vitesse de déplacement du serpent.
Le prochain chapitre décrira en détail comment implémenter ce système et parvenir à animer les grenouilles.
XV. Animer à 40 FPS▲
Commençons par animer le jeu à une vitesse constante et élevée, soit 40 FPS. Nous devons donc temporiser le Thread toutes les 25 millisecondes (1 seconde / 40 FPS = 1000 millisecondes / 40 = 25 millisecondes). Nous définirons cette valeur dans une constante puisque la vitesse d'animation globale du jeu ne variera plus.
public
interface
Constantes {
/**
* nombre de colonnes de la surface de jeu
*/
public
final
static
int
NBRE_DE_COLONNES =
30
;
/**
* nombre de lignes de la surface de jeu
*/
public
final
static
int
NBRE_DE_LIGNES =
30
;
/**
* dimension d'une case en pixels
*/
public
final
static
int
CASE_EN_PIXELS =
15
;
/**
* Délai d'animation du jeu
*/
public
final
int
DELAY =
25
;
}
Nous utilisons ensuite cette valeur avec notre Thread, depuis notre classe JFenetre :
// Créer un thread infini
Thread thread =
new
Thread
(
new
Runnable
(
) {
@Override
public
void
run
(
) {
while
(
true
) {
// boucle infinie
// à chaque fois que la boucle est exécutée, la
// méthode de calcul du jeu est appelée.
// Comme la boucle est infinie, la méthode de calcul
// sera appelée en cycle perpétuel.
JFenetre.this
.modele.calcul
(
);
// demander à l'EDT de redessiner le conteneur,
// ce qui aura pour effet de dessiner notre jeu
content.repaint
(
);
// temporisation
try
{
Thread.sleep
(
DELAY);
}
catch
(
InterruptedException e) {
//
}
}
}
}
);
Supprimez la méthode getTemporisation() de la classe ModeleDuJeu.
Bien évidemment, notre serpent va maintenant bien trop vite, au double de la vitesse du niveau le plus élevé que nous avions précédemment mis en place. Nous allons tout de suite voir comment temporiser autrement cette vitesse que par la temporisation du Thread.
XVI. Un compteur pour temporiser▲
Le principe est simple. Nous utilisons un incrément qui comptabilisera le nombre de cycles passés. Si ce compteur équivaut à un certain seuil déterminé à l'avance, c'est qu'il est temps de déplacer le serpent, sinon nous attendons. Le seuil varie en fonction du niveau. Au plus ce seuil est bas, au plus le serpent se déplace rapidement.
Cela se passera au sein de la classe Serpent, parce que cela ne concerne que le serpent, le modèle n'a pas à savoir quand ce serpent doit être animé/déplacé.
Commençons par créer la méthode qui renverra ce seuil du compteur en fonction du niveau :
private
int
getThresholdCounter
(
int
niveau) {
switch
(
niveau) {
case
1
:
return
20
;
case
2
:
return
16
;
case
3
:
return
14
;
case
4
:
return
12
;
case
5
:
return
10
;
case
6
:
return
8
;
case
7
:
return
6
;
case
8
:
return
4
;
case
9
:
return
3
;
default
:
return
2
;
}
}
Au niveau 1, nous attendrons donc 20 cycles, soit 500 ms (20 cycles de 25 ms). Au niveau 5, nous attendrons donc 10 cycles, soit 250 ms (10 cycles de 25 ms).
Comme nous devrons exploiter cette méthode depuis la méthode de calcul du serpent, il nous faudra connaître le niveau du jeu :
public
void
calcul
(
Grenouille grenouille, int
niveau) {
// calcul du serpent
tourner
(
);
if
(
peutManger
(
grenouille)) {
mange
(
);
grenouille.nouvelleGrenouille
(
);
}
else
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.estMort =
true
;
}
}
Puis nous transmettons au serpent le niveau du jeu depuis le modèle du jeu :
public
class
ModeleDuJeu {
...
// le calcul du jeu
public
void
calcul
(
) {
if
(!
this
.laPartieEstPerdue) {
// calcul du serpent
this
.serpent.calcul
(
this
.grenouille, getNiveau
(
));
if
(
this
.serpent.estMort
(
)) {
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.laPartieEstPerdue =
true
;
}
}
}
...
}
Nous pouvons maintenant implémenter notre compteur dans la méthode de calcul de la classe Serpent.
Nous créons un attribut qui comptabilisera les cycles. Depuis la méthode de calcul, nous incrémentons de 1 ce compteur. Nous testons si ce compteur est égal au seuil correspondant au niveau. Si ce n'est pas le cas, nous ne calculons rien pour le serpent, qui restera donc immobile. Si ce seuil est atteint, nous remettons le compteur à zéro afin de permettre à nouveau l'évaluation du seuil dès le prochain cycle et nous permettons le calcul du déplacement du serpent.
public
class
Serpent {
...
private
int
moveCounter;
public
void
calcul
(
Grenouille grenouille, int
niveau) {
// incrémenter le compteur
this
.moveCounter++
;
// vérifier qu'il est temps d'animer le serpent
if
(
this
.moveCounter >=
getThresholdCounter
(
niveau)) {
// remettre le compteur à zéro
this
.moveCounter =
0
;
// calcul du serpent
tourner
(
);
if
(
peutManger
(
grenouille)) {
mange
(
);
grenouille.nouvelleGrenouille
(
);
}
else
if
(
peutAvancer
(
)) {
avance
(
);
}
else
{
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.estMort =
true
;
}
}
}
...
}
Comme nous l'avons déjà dit, il y a bien sûr une correspondance entre ces cycles d'attente et un temps. Au premier niveau nous attendons 20 cycles. Or, chaque cycle est effectué toutes les 25 millisecondes. Au premier niveau le serpent sera donc animé toutes les 500 millisecondes (20 cycles * 25 millisecondes = 500 millisecondes).
Vous pouvez compiler et constater qu'en fait il n'y a pas de changement visible. Mais notre jeu tourne désormais à 40 FPS, ce qui rend les animations rapides.
Pour vous persuader, si besoin est, que notre jeu affiche environ 40 images par seconde, vous pouvez temporairement modifier le code du conteneur dans la classe JFenetre, comme ci-dessous. Cela affichera un compteur de cycle sur le jeu. Vous le verrez défiler très rapidement alors que le serpent avance lentement.
final
JPanel content =
new
JPanel
(
) {
private
int
frame =
0
;
private
long
time =
System.currentTimeMillis
(
);
@Override
protected
void
paintComponent
(
Graphics g) {
super
.paintComponent
(
g);
this
.frame++
;
long
t =
System.currentTimeMillis
(
);
// affichage du modèle du jeu
JFenetre.this
.modele.affichage
(
g);
g.setFont
(
new
Font
(
Font.DIALOG, Font.PLAIN, 12
));
g.setColor
(
Color.BLACK);
g.drawString
(
String.valueOf
(
this
.frame), 5
, 45
);
if
(
t -
this
.time >=
1000
) {
this
.time =
t;
this
.frame =
0
;
}
}
}
;
XVII. Animer la grenouille▲
Animer la grenouille va être très simple. Nous reprenons exactement le même principe de calcul/affichage que pour le modèle au sein de la classe Grenouille. Le modèle appellera la méthode de calcul de la grenouille depuis sa méthode de calcul et l'affichage de la grenouille depuis la méthode d'affichage (ce qui est déjà le cas). Notez que cette fois-ci, contrairement à la section précédente, c'est une implémentation plus pertinente.
L'animation de la grenouille consistera en une rotation sur elle-même. Nous aurons donc besoin d'un attribut numérique entier, qui représentera l'angle (en degrés) de la rotation. La méthode de calcul incrémentera cet angle. Ajoutez l'attribut et la méthode ci-dessous à la classe Grenouille :
private
int
angle;
public
void
calcul
(
) {
// incrémentation de l'angle de 4 degrés
this
.angle +=
4
;
}
Nous ne devons pas oublier de réinitialiser l'angle à zéro lors de la demande d'une « nouvelle grenouille » :
public
void
nouvelleGrenouille
(
) {
setIndiceX
(
getRandomX
(
));
setIndiceY
(
getRandomY
(
));
this
.angle =
0
;
}
Pour dessiner le rectangle avec une rotation, nous utiliserons un objet de type AffineTransform. Nous référencerons en premier lieu l'objet AffineTransform initial, afin de le restituer après la rotation, sans quoi c'est l'ensemble de l'affichage suivant qui serait affecté par la rotation. Ensuite nous affectons un nouvel objet en paramétrant une rotation de la valeur de notre attribut angle. Puis nous restituons l'objet initial. Modifiez la méthode d'affichage de la classe Grenouille :
import
java.awt.Color;
import
java.awt.Graphics;
import
java.awt.Graphics2D;
import
java.awt.geom.AffineTransform;
import
java.util.Random;
public
class
Grenouille extends
Case {
...
public
void
affichage
(
Graphics g) {
Graphics2D g2 =
(
Graphics2D) g;
// objet initial
AffineTransform tr =
g2.getTransform
(
);
g.setColor
(
Color.RED);
// rotation
g2.setTransform
(
AffineTransform.getRotateInstance
(
Math.toRadians
(
this
.angle),
getX
(
) +
(
getLargeur
(
) /
2
),
getY
(
) +
(
getHauteur
(
) /
2
)));
g.fillRect
(
getX
(
) +
2
, getY
(
) +
2
, getLargeur
(
) -
4
, getHauteur
(
) -
4
);
// annulation de la rotation
g2.setTransform
(
tr);
}
...
}
Nous n'avons plus qu'à appeler cette méthode de calcul depuis la méthode de calcul du modèle pour que la grenouille soit animée :
public
class
ModeleDuJeu {
...
// le calcul du jeu
public
void
calcul
(
) {
if
(!
this
.laPartieEstPerdue) {
// calcul de la grenouille
this
.grenouille.calcul
(
);
// calcul du serpent
this
.serpent.calcul
(
this
.grenouille, getNiveau
(
));
if
(
this
.serpent.estMort
(
)) {
// la partie est perdue car le serpent
// a atteint les limites du plateau de jeu
this
.laPartieEstPerdue =
true
;
}
}
}
...
}
Vous pouvez compiler et constater l'animation de la grenouille, faite à vitesse constante, quelle que soit la vitesse du serpent.
Si nous voulons faire une unique et complète rotation de la grenouille et non une rotation perpétuelle, alors il suffit de ne pas faire dépasser la valeur 360 à l'attribut angle :
public
void
calcul
(
) {
// incrémentation de l'angle de 4 degrés
this
.angle +=
4
;
// ne plus poursuivre la rotation après une
// rotation complète
if
(
this
.angle >
360
) {
this
.angle =
360
;
}
}