Programmez un Snake avec Java2D

Michel DOUEZ

Le jeu du « Serpent » (snake) est un des tous premiers jeux vidéo ayant été créé dans les années 1970. Ce célèbre jeu, récemment réapparu avec la téléphonie mobile, est assez simple à programmer, du moins dans sa version originale. Cet article aura pour but de vous guider dans sa réalisation. Il sera aussi un support pour comprendre quelques techniques de base utilisable pour réaliser des jeux vidéo 2D.

- Titre : Programmez un Snake avec Java2D

- Auteur : Michel DOUEZ

- Parution : 1 août 2010

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2013 Michel DOUEZ. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. La fenêtre d'affichage

- III. Le modèle du jeu

- IV. Les cycles du jeu

- V. Les dimensions du jeu

- VI. L'affichage du serpent

- VII. Faire avancer le serpent

- VIII. La gestion de la défaite

- IX. La gestion du clavier

- X. Affichage aléatoire de la grenouille

- XI. Le serpent mange la grenouille

- XII. Le serpent ne peut pas se mordre

- XIII. Mécanisme de niveau

- XIV. Les limites de notre procédé

- XV. Animer à 40 FPS

- XVI. Un compteur pour temporiser

- XVII. Animer la grenouille

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;
}
}