Etape 6 – Evolution des joueurs
1 Introduction
Cette étape, la dernière de la première partie du projet, a un seul but : terminer la gestion de l'évolution de l'état du jeu en faisant correctement évoluer les joueurs.
Même si cette évolution n'est pas totalement triviale, cette étape est néanmoins volontairement plus courte que les précédentes, afin de vous donner le temps de rattraper un petit retard éventuel avant le rendu testé.
Cette étape devra être rendue deux fois : d'abord pour le rendu testé (avant le 8 avril à 17h30) puis pour le rendu intermédiaire (avant le 15 avril à 17h30). Ces deux rendus valant 80 et 78 points respectivement, ne les manquez sous aucun prétexte !
1.1 Evolution des joueurs
De tous les éléments du jeu, les joueurs sont ceux dont l'évolution est la plus complexe, car plusieurs de leurs caractéristiques évoluent :
- leur position dirigée, c-à-d leur couple (position, direction de regard),
- leur couple (nombre de vies, état),
- leurs capacités, p.ex. le nombre maximum de bombes qu'ils peuvent déposer, ou la portée de celles-ci.
La position dirigée d'un joueur évolue en fonction de plusieurs facteurs :
- les événements de changement de direction, qui se produisent lorsqu'un joueur (humain) désire faire avancer son joueur dans une autre direction, ou l'arrêter,
- le plateau, dont les murs arrêtent le déplacement du joueur,
- les bombes, qui arrêtent également les joueurs mais de manière différente que les murs.
Quant au couple (nombre de vies, état), il évolue en fonction des particules d'explosion, susceptibles de faire perdre une vie à un joueur, pour peu qu'il soit vulnérable.
Ces différents aspects sont détaillés ci-dessous.
1.1.1 Evénements de changement de direction
Il y a cinq événements de changement de direction possibles : un par direction (nord, sud, est, ouest), chacun d'entre eux exprimant le désir du joueur de se déplacer dans cette direction ; et un dernier qui exprime le désir du joueur de s'arrêter.
La gestion de ces événements n'est toutefois pas aussi simple qu'il n'y paraît, car un joueur ne peut pas changer de direction de manière arbitraire : s'il désire se déplacer dans la direction opposée à sa direction actuelle, il peut le faire immédiatement ; sinon (c-à-d s'il désire soit s'arrêter, soit changer pour une direction perpendiculaire), il doit d'abord atteindre la prochaine sous-case centrale avant de pouvoir le faire.
Cela ne signifie par pour autant que les événements de changement de direction perpendiculaires sont ignorés si un joueur ne se trouve pas sur une sous-case centrale. Si tel était le cas, le jeu serait très difficile, car pour effectuer un changement de direction perpendiculaire, un joueur humain devrait presser la touche correspondant durant le vingtième de seconde durant lequel son joueur se trouve sur la sous-case centrale.
Dès lors, les événements de changement de direction, même perpendiculaires, sont acceptés à n'importe quel moment, et modifient toujours la position future du joueur, mais parfois de manière non triviale.
Cela est illustré sur l'image ci-dessous, qui symbolise un joueur se trouvant sur la sous-case rouge et se déplaçant vers le nord. Initialement, étant donné qu'il se déplace vers le nord, ses positions futures sont les sous-cases au nord de celle qu'il occupe, grisées sur la partie gauche de l'image.
Si ce joueur exprime (via un événement de changement de direction) le souhait de se déplacer vers l'est, ses positions futures sont changées pour que, dans un premier temps, il continue sur sa lancée jusqu'à la sous-case centrale, et qu'ensuite seulement il parte vers l'est. Ses positions futures deviennent alors celles qui sont grisées sur la partie droite de l'image.
Cet exemple illustre l'intérêt de représenter les positions futures d'un joueur par une séquence de positions — plutôt qu'à travers une vitesse, par exemple — car celle-ci peut être aussi complexe que nécessaire, et donc exprimer naturellement des déplacements non linéaires tels que celui-ci.
Une information manque sur l'image ci-dessus : la direction de regard du joueur. Celle-ci est toujours la direction de la prochaine sous-case. Ainsi, dans la partie droite de l'image, le joueur regarde au nord tant et aussi longtemps qu'il n'a pas atteint la sous-case centrale jaune. Dès celle-ci atteinte, il se tourne vers l'est. Notez qu'il est néanmoins nécessaire de stocker séparément la direction du regard, car elle ne peut pas toujours être déduite de ses positions futures : un joueur arrêté a une séquence de positions futures constante, mais regarde néanmoins dans l'une des quatre directions.
1.1.2 Blocage du joueur
Comme nous l'avons vu à l'étape 3, un joueur ne peut se déplacer que s'il est invulnérable ou vulnérable (et pas mourant ou mort). Cela dit, même s'il est dans un état le lui permettant, il ne pourra pas toujours effectivement se déplacer, deux éléments du jeu pouvant le bloquer : les murs et les bombes.
Un joueur est bloqué par un mur lorsqu'il se trouve sur la sous-case centrale d'une case et tente de la quitter en direction d'une case voisine occupée par un mur et qui ne peut donc l'accueillir.
Un joueur est bloqué par une bombe lorsqu'il se trouve à une distance de 6 de la sous-case centrale d'une case occupée par une bombe, et se dirige vers la sous-case centrale.
L'image ci-dessous illustre les cinq sous-cases d'une case sur lesquelles un joueur peut être bloqué. Les sous-cases grisées sont les seules sur lesquelles le joueur peut se déplacer, en fonction de ce qui a été dit plus haut. La sous-case centrale, en jaune, est celle sur laquelle il reste bloqué si le bloc occupant la case voisine ne peut l'accueillir. Les quatre sous-cases en rouge sont celles sur lesquelles il reste bloqué si la case est occupée par une bombe et le joueur se dirige en direction de la sous-case centrale.
Dans les deux cas, le blocage ne provoque pas l'arrêt définitif du joueur, seulement l'interruption temporaire de l'évolution de sa position (dirigée). C'est-à-dire que si l'obstacle qui le bloque — bloc ou bombe — disparaît, le joueur reprend son déplacement normalement.
Une fois encore, l'intérêt de représenter la position future d'un joueur par une séquence apparaît. Pour le bloquer, il suffit de ne pas faire évoluer cette séquence, qui garde néanmoins toutes les informations qu'elle contient quant à sa position future. Ainsi, un joueur se déplaçant vers le nord, bloqué par une bombe mais désirant se diriger vers l'est aura une séquence des positions futures similaire à celle de la partie droite de la figure 1. Une fois la bombe explosée, et à supposer qu'il lui reste une vie, il suivra cette trajectoire en L comme prévu.
1.1.3 Evolution de l'état
L'évolution de la paire (nombre de vies, état) est nettement plus simple que celle de la position : lorsqu'un joueur est atteint par une particule d'explosion et qu'il est vulnérable, alors il devient mourant puis résuscite s'il lui reste alors encore une vie, et meurt définitivement sinon.
Notez que pour déterminer si un joueur est atteint par une particule d'explosion, on utilise sa nouvelle position, calculée de la manière décrite ci-dessus !
1.1.4 Evolution des capacités
Lorsqu'un joueur consomme un bonus, ses capacités sont augmentées par celui-ci, en tenant compte des limites des différents bonus (p.ex. une portée maximale de 9 pour le bonus portée).
1.1.5 Ordre d'évolution
Etant donné que plusieurs éléments de l'état du joueur (au sens large, incluant p.ex. sa position dirigée) évoluent et dépendent les uns des autres, il est nécessaire de savoir dans quel ordre se fait cette évolution. La liste ci-dessous spécifie donc cet ordre :
- une nouvelle séquence de position dirigée est calculée en fonction de l'événement de changement de direction associé au joueur, s'il y en a un (sinon, cette séquence ne change pas),
- la (éventuellement nouvelle) séquence de positions dirigée évolue si le joueur peut se déplacer, selon ce qui a été décrit plus haut,
- l'état du joueur évolue, en fonction de sa nouvelle position, déterminée précédemment,
- les capacités du joueur évoluent, en fonction du bonus qu'il a éventuellement consommé.
2 Mise en œuvre Java
2.1 Classe Sq
Avant de présenter la mise en œuvre de cette étape, il convient de présenter les dernières méthodes de la classe Sq
utiles à ce projet.
2.1.1 Sq.takeWhile
La méthode takeWhile
prend en argument un prédicat et retourne le plus long préfixe de la séquence dont tous les éléments satisfont le prédicat. Ce dernier est généralement donné sous forme d'une lambda expression.
Par exemple, comme nous l'avons vu dans l'énoncé de l'étape 3, la séquence infinie des puissances de deux peut s'obtenir au moyen de la méthode iterate
utilisée ainsi :
Sq.iterate(1, u -> 2 * u)
Au moyen de la méthode takeWhile
, il est facile d'en obtenir la séquence (finie) des puissances de deux inférieures à 100 :
Sq.iterate(1, u -> 2 * u).takeWhile(u -> u < 100)
Sans surprise, cette séquence est composée des éléments [1, 2, 4, 8, 16, 32, 64].
2.1.2 Sq.findFirst
La méthode findFirst
prend en argument un prédicat et retourne le premier élément de la séquence qui le satisfait, ou lève l'exception NoSuchElementException
si la séquence est finie et aucun élément ne satisfait le prédicat.
Par exemple, pour trouver la première puissance de deux supérieure à 100, on peut utiliser la méthode findFirst
ainsi :
Sq.iterate(1, u -> 2 * u).findFirst(u -> u > 100)
et on obtient logiquement 128.
2.2 Classe GameState
Etant donné sa complexité, il est conseillé de confier l'évolution des joueurs à (au moins) une fonction privée et statique ne s'occupant de rien d'autre. Comme nous l'avons dit dans l'énoncé de l'étape précédente, cette fonction pourrait avoir la signature suivante :
List<Player> nextPlayers(List<Player> players0, Map<PlayerID, Bonus> playerBonuses, Set<Cell> bombedCells1, Board board1, Set<Cell> blastedCells1, Map<PlayerID, Optional<Direction>> speedChangeEvents)
La table associative playerBonuses
associe à chaque identité de joueur le bonus obtenu par le joueur en question. Elle est construite par la méthode next
, qui résoud les conflits dans l'ordre de priorité correspondant au coup d'horloge actuel.
La table associative speedChangeEvents
contient quant à elle les événements de changement de direction pour les joueurs désirant changer de direction. A l'identité de chaque joueur désirant changer de direction elle associe soit une direction (N
, E
, S
ou W
), soit la valeur optionnelle vide si le joueur désire s'arrêter.
Attention : étant donné que la méthode nextPlayers
ne doit pas résoudre de conflits entre les joueurs, la liste des joueurs qu'on lui passe n'est pas triée par priorité.
2.2.1 Evolution de la position
L'évolution de la position dirigée du joueur est de loin l'aspect le plus complexe de la méthode nextPlayers
. Ne vous lancez pas dans sa programmation avant d'avoir une idée claire de ce que vous voulez faire !
Pensez à vous aider de la méthode findFirst
des séquences pour trouver la position de la prochaine sous-case centrale atteinte par le joueur, et la méthode takeWhile
pour obtenir la trajectoire qui y mène. Etant donné que les séquences sont immuables, il n'y a effectivement aucun risque à « lire le futur » de cette manière.
De plus, pensez à utiliser judicieusement les méthodes des classes écrites dans le cadre des étapes précédentes, comme p.ex. canHostPlayer
de Block
, etc.
2.3 Tests
Cette étape n'introduisant pas de nouvelle entité publique, aucun fichier de vérification de noms n'est nécessaire, donc fourni.
En plus des tests unitaires, nous vous conseillons d'écrire un petit programme principal simulant une partie entre des joueurs se comportant aléatoirement et d'afficher l'évolution de cette partie à l'écran au moyen de la classe GameStatePrinter
écrite précédemment. Dans ce but, nous mettons à votre disposition une archive Zip contenant une classe RandomEventGenerator
capable de générer des événements aléatoires.
L'animation ci-dessous montre un extrait d'une partie entre des joueurs dont le comportement est simulé par une instance de cette classe créée ainsi :
new RandomEventGenerator(2016, 30, 100)
Le programme produisant cet affichage est très simple : il commence par créer un état de jeu initial utilisant, entre autres, le plateau de jeu donné à l'étape 2, puis boucle tant et aussi longtemps que la partie n'est pas terminée. A chaque itération de la boucle, il calcule le prochain état du jeu (au moyen de GameState.next
, à qui il passe les événements aléatoires produits par RandomEventGenerator
), l'affiche à l'écran (au moyen de GameStatePrinter
) et attend 50 ms (au moyen de la méthode Thread.sleep
).
Pour afficher l'état de la partie ci-dessus, une version de GameStatePrinter
plus évoluée que celle que nous vous avons fourni a été utilisée. En plus d'afficher des informations sur les joueurs, elle efface l'écran avant chaque affichage et utilise des couleurs pour améliorer la lisibilité. Notez que la position des joueurs est représentée par les coordonnées de la case, et la distance à la sous-case centrale.
En elles-mêmes, ces améliorations ne sont pas très difficile à réaliser, mais elles nécessitent de lancer le programme depuis un terminal capable d'afficher plusieurs couleurs, ce que la console intégrée à Eclipse ne peut faire. Quelques explications à ce sujet sont données ci-après, suivies par des indications concernant l'utilisation de couleurs.
2.3.1 Exécution depuis un terminal
Lorsqu'un programme est lancé depuis Eclipse et qu'il affiche du texte à l'écran, généralement au moyen de la méthode println
appliquée à l'objet System.out
, celui-ci est affiché directement dans une fenêtre d'Eclipse appelée la console. Même si ce comportement est généralement souhaitable et agréable pour l'utilisateur, il présente le défaut que la console d'Eclipse est très limitée, et il n'est par exemple pas possible de changer la couleur du texte qu'elle affiche.
Dès lors, pour pouvoir obtenir un affichage similaire à celui présenté plus haut, il vous faut lancer votre programme non pas depuis Eclipse mais depuis un terminal. Cela n'est pas très difficile, et sera fort utile à la fin du projet, raison pour laquelle nous vous conseillons d'apprendre dès maintenant les manipulations requises.
Pour commencer, il vous faut lancer un programme de terminal de votre choix, afin de pouvoir y entrer des commandes. Au besoin, vous pouvez relire le document Environnement de travail du semestre précédent. Le programme terminal lancé, vous pouvez commencer par taper la commande java
pour vous assurer que Java est correctement installé sur votre ordinateur. En faisant cela, vous devriez voir apparaître dans le terminal un message d'aide ressemblant à ceci :
% java Syntaxe : java [-options] class [args...] (beaucoup d'autres lignes omises)
Si Java est correctement installé, vous pouvez maintenant vous placer, au moyen de la commande cd
, dans le répertoire contenant votre projet. En tapant la commande ls
, vous devriez voir que ce répertoire contient au moins les répertoires src
, test
et bin
. Le premier contient le code de votre projet, le second les tests, le troisième la version compilée de votre projet.
Pour lancer votre programme, il vous faut utiliser la commande java
, en lui passant deux informations :
- les endroits où trouver les versions compilées de votre projet et de la classe
Sq
que nous vous avons fournie, - le nom de la classe principale à exécuter (celle contenant la méthode
main
).
En admettant que vous ayez placé le fichier sq.jar
que nous vous avons fourni dans un sous-répertoire nommé lib
de votre projet, et que la classe principale que vous désirez lancer s'appelle RandomGame
et se trouve dans le paquetage ch.epfl.xblast.server.debug
, la commande à entrer est la suivante :
java -classpath lib/sq.jar:bin \ ch.epfl.xblast.server.debug.RandomGame
(que vous pouvez aussi écrire sur une seule ligne, en supprimant alors le \
à la fin de la première ligne).
Attention : sur Windows, vous devrez remplacer le deux-points (:
) dans l'argument passé à l'option -classpath
par un point-virgule (;
).
L'argument -classpath
dit à la commande java
où trouver les fichiers classe (dont le nom se termine par .class
) pour le projet. Ici, on spécifie d'une part de chercher dans le fichier sq.jar
, qui contient la version compilée de la classe Sq
, et également dans le répertoire bin
, qui contient la version compilée (par Eclipse) de votre projet.
Si tout se passe bien, votre programme démarrera. Sinon, appelez un assistant, les problèmes potentiels étant trop dépendants du système utilisé pour être décrits ici.
2.3.2 Affichage évolué
Un certain nombre de programmes de terminal (p.ex. ceux fournis avec Linux ou OS X) peuvent être contrôlés au moyen de séquences spéciales de caractères, appelées séquences d'échappement ANSI (ANSI escape sequences), bien décrites sur leur page Wikipedia.
Une séquence d'échappement ANSI n'est rien d'autre qu'une séquence particulière de caractères que le terminal interprète comme une commande plutôt que de l'afficher à l'écran. L'extrait de programme ci-dessous utilise deux de ces séquences pour afficher le texte Un mot en rouge… en mettant le mot rouge en rouge :
1: String red = "\u001b[31m"; 2: String std = "\u001b[m"; 3: String s = "Un mot en " + red + "rouge" + std + "…"; 4: System.out.println(s);
Les variables red
et std
contiennent chacune une séquence d'échappement ANSI. La première fait passer la couleur du texte au rouge, la seconde rétablit la couleur par défaut. Même si cela n'est pas évident de prime abord, la chaîne red
est composée de 5 caractères, qui sont :
- le caractère ESC, représenté en Java par
\u001b
, - le caractère
[
, - le caractère
3
, - le caractère
1
, - le caractère
m
.
Les deux premiers caractères constituent ce que l'on appelle le CSI, pour control sequence introducer. Toutes les séquences d'échappement ANSI commencent par ces deux caractères, et ceux qui suivent donnent la commande. Ici, la commande est 31
, qui change la couleur du texte en rouge (voir la page Wikipedia), et le m
qui suit indique la fin de la commande.
Si l'extrait de programme ci-dessus est exécuté depuis Eclipse, qui ne reconnaît pas les séquences d'échappement ANSI, le résultat est décevant :
Un mot en [31mrouge[m…
Par contre, si on l'exécute depuis un terminal reconnaissant les séquences d'échappement ANSI, on obtient quelque chose de beaucoup plus satisfaisant :
Un mot en rouge…
3 Résumé
Pour cette étape, vous devez :
- terminer la classes
GameState
en fonction des spécifications données plus haut, - tester votre code,
- documenter la totalité des entités publiques que vous avez définies,
- rendre votre code au plus tard le 8 avril 2016 à 17h30, via le système de rendu.
Attention : ce rendu est le rendu testé, sur lequel les tests unitaires seront exécutés, et un total de 80 points lui est attribué. Il est donc très important que vous l'effectuiez à temps, car comme d'habitude, aucun rendu ne sera accepté en retard, quelle qu'en soit la cause.
Pour éviter toute mauvaise surprise, effectuez un premier rendu en début de semaine, même si votre programme est encore incomplet, puis effectuez d'autres rendus par la suite au fur et à mesure de votre avancement.