Préambule

L'objectif de ce deuxième cours est d'aborder les principaux problèmes liés à un jeu de plateforme et voir comment structurer le code, notamment pour gérer les différents types de déplacements et les collisions.

 

Pour suivre correctement ce cours, il est conseillé d'ouvrir en parallèle les sources, dont l'archive est téléchargeable [ ici ] 

 

1°/ Les challenges

Dans un jeu 2D à base sprites, ce qui inclus les jeux de plateformes, les deux principaux challenges techniques concernent la détection des collisions et la gestion des mouvements. Ils sont d'ailleurs liés car quand on se déplace, on est amené à entrer en collision, à tomber, ... 

La première chose à faire pour relever ces challenges est de bien définir le comportement des sprites et dans quels cas, on estime qu'il y a collision, que le sprite tombe, qu'il peut bouger librement, etc. Malheureusement, même avec une étape de conception poussée à ce sujet, on tombe souvent lors des tests sur des cas particuliers qui vont par exemple bloquer complètement le sprite, le faire traverser des obstacles, le déplacer de façon irrationnelle, etc. En effet, le résultat observable dépend beaucoup de la façon dont les collisions sont réellement calculées. Parfois, cette façon de calculer amène à des situations "exotiques" qui n'ont pas été prévues lors de la conception. Dans ce cas, il faut soit changer la façon de calculer (= bof), soit se débrouiller pour ne jamais tomber sur ces cas exotiques (= mieux). Et pour cela, c'est plutôt l'expérience dans la structuration du code qui permet de résoudre les comportements anormaux.

L'objectif des sections suivantes est de proposer une solution "générique" à ces challenges dans un cas assez précis, mais pas totalement basique  :

  • les plateformes sont horizontales et fixes,
  • le sprite peut aller à gauche/droite, tomber, sauter (uniquement lorsqu'il n'est pas déjà en train de sauter/tomber).

 

Le fait que les plateformes soient horizontales peut sembler une contrainte très forte. Cependant, le fait qu'un sprite puisse "remonter" une pente inférieure à une certaines inclinaison (descendre est facile) constitue une des challenges de déplacement les plus complexe à résoudre. Il sera juste survolé dans la dernière section bonus et abordé de façon plus complète dans l'article suivant : TD n°3 : un jeu de plateformes - version avancée

Ces différents comportements sont tous initiés pas des interactions du joueur, en l'occurrence en appuyant sur des touches du clavier. La première question à se poser est donc comment gérer correctement le clavier.

 

1.1°/ Gérer le clavier

Pour déplacer un sprite, on suppose que le joueur peut appuyer sur :

  • flèche gauche pour aller à gauche,
  • flèche droite pour aller à droite,
  • espace pour sauter.

De plus, le sprite ne bouge de façon continue que si l'on maintient une touche flèche enfoncée. Autrement dit, dès qu'il n'y a plus de touche enfoncée, le sprite s'arrête. Cette dernière contrainte impose une solution unique pour gérer les touches :

  • maintenir une liste ordonnée des touches actuellement enfoncées. La liste est mise à jour dès qu'une flèche est enfoncée ou relâchée.
  • à chaque frame, le contrôleur principal regarde si flèche gauche/droite est toujours dans cette liste, auquel cas, le sprite est déplacé à gauche/droite.

En effet, si on déclenche le mouvement directement dans le contrôleur de clavier dès que l'on détecte l'appui sur une flèche, le sprite n'aura pas un déplacement régulier :

  • lorsque l'on laisse une touche appuyée, la répétition automatique mise en place par le système d'exploitation laisse généralement un temps plus long entre l'appui initial et la première répétition qu'entre les répétition suivantes. Le sprite a donc une sorte d'à coup lorsqu'il démarre un déplacement.
  • comme c'est le S/E qui gère la répétition, on ne peut pas régler correctement la vitesse de déplacement.

Dans la solution téléchargeable, cette liste et les méthodes qui la manipulent se trouve dans la classe Model :

public class Model {
    ....
    private List<String> keyPressed;
    public Model(int width, int height) {
        ...
        keyPressed = new ArrayList<>();        
    }

    public String getLastKeyPressed() {
        if (keyPressed.isEmpty()) return "";
        return keyPressed.get(keyPressed.size()-1);
    }
    public void addKeyPressed(String lastKey) {
        if (!this.keyPressed.contains(lastKey)) {
            this.keyPressed.add(lastKey);
        }
    }
    public void removeKeyPressed(String lastKey) {
        this.keyPressed.remove(lastKey);
    }
    public int indexKeyPressed(String key) {
         return keyPressed.indexOf(key);
    }
    public boolean isKeyPressed() {
        return !keyPressed.isEmpty();
    }
}

 

Comme on peut le voir, le principe consiste à appeler :

  • addKeyPressed() dès qu'une touche est enfoncée, que ce soit une première fois, ou par répétition. Dans ce cas, on en remet pas la touche dans la liste. Le dernier élément de la liste est donc la dernière touche enfoncée.
  • removeKeyPressed() dès qu'une touche est relâchée.

Ces 2 méthodes sont appelées par le contrôleur de clavier. Les autres méthodes sont appelées par le contrôleur général, lors de l'appel à step() à chaque frame, pour déterminer le comportement du sprite.

 

Quelles touches doit on mettre dans la liste ?

au minimum les touches qui permettent de diriger le sprite, en l'occurrence, flèche gauche/droite, plus éventuellement la touche espace pour sauter. C'est ce qui est fait dans la solution téléchargeable, et cela nécessite que le contrôleur général vérifie au début de chaque frame si espace est effectivement la dernière touche enfoncée ou non.

 

1.2°/ gérer les états du sprite

Maintenant que l'on sait traiter les interactions du joueur, on doit les traduire en déplacement du sprite à l'écran, en gardant à l'esprit les principes MVC.

Pour vérifier si le sprite repose sur le sol, entre en collision avec un obstacle, ou bien peut se déplacer librement, il faut connaître les représentations visuelles du sprite et des obstacles, qui sont stockées dans la partie vue. Cependant, en MVC, cette dernière ne doit pas modifier directement le modèle. La gestion doit donc se faire au niveau du contrôleur général. Si l'on reprend la structure globale de l'exemple du TD n°1, celui-ci contient une méthode step() appelée à chaque frame, pour mettre à jour le modèle et la vue. On va juste lui ajouter le nécessaire pour gérer la détection de sol/collision. Le problème est : dans quel ordre ?

Voici une solution relativement universelle et qui s'adapte facilement à de nombreuses situations et qui permet d'avoir un rendu visuel parfait, c'est-à-dire avec un sprite réellement en contact avec les obstacles et pas en lévitation :

  1. si le sprite n'est pas en train de chuter ou sauter, on détecte la présence d'un sol sous le sprite en déplaçant sa hitbox de 1 pixel vers le bas et en vérifiant s'il y a collision avec un obstacle. Si c'est le cas, le sprite est sur un sol. Sinon, on le met dans un état "tomber" (sans pour l'instant le déplacer).
  2. si le sprite n'est pas déjà en train de tomber ou sauter, on détermine son nouvel état en fonction des touches enfoncées (flèches et espace). Il peut ainsi passer à l'état "bouger à gauche/droite" ou "sauter".
  3. on calcule la future position du sprite, selon son état.
  4. on calcule une liste contenant la position des pixels se trouvant sur le segment entre la future position et l'actuelle (donc à l'envers du chemin parcouru par le sprite)
  5. on parcourt la liste :
    1. s'il n'y a pas de collision lors avec la première position (c.a.d. la future position), alors le sprite peut faire un "plein" déplacement. Dans ce cas, on ne fait rien de plus et on arrête le parcours de la liste.
    2. sinon, on prend la position suivante et on reteste la collision etc. jusqu'à trouver une position sans collision. On signale au sprite que c'est sa "bonne" position et on le met en état collision.
  6. on appelle la méthode update() du modèle du sprite, qui va, selon l'état (collision, tomber, sauter, bouger), appeler des méthodes internes appropriées pour changer la position du sprite.
  7. on appelle la méthode update() de la vue du sprite.

 

Remarques :

  • si à l'étape 1, le sprite passe à l'état "tomber", les étapes 3 et suivantes permettent de calculer immédiatement si son début de chute va provoquer ou non une collision.
  • l'étape 2 est cruciale car elle permet de prendre en compte immédiatement la directive de bouger ou sauter et grâce aux étapes suivantes, de déterminer si le déplacement qui va suivre, va provoquer ou non une collision.
  • les étapes 4 & 5 sont une optimisation qui évite de tester toutes les positions entre la source et la destination du déplacement pendant la frame. Dans certains cas, on obtient une mauvaise détection de collision. En effet, même s'il n'y a pas de collision à la destination, si le sprite se déplace à grande vitesse, il est possible qu'un petit obstacle se trouve entre la destination et la source. Avec l'optimisation, il ne sera pas pris en compte.
  • ces étapes posent aussi problème si le sprite à un mouvement tournant rapide et serré. Dans ce cas, l'approximation de son déplacement par un segment peut être très mauvaise.

 

Ce mécanisme nécessite d'ajouter au modèle du sprite (NB : les noms utilisés sont ceux de la solution) :

  • un attribut state, pour représenter les différents états (inerte, bouger, sauter, tomber, collision)
  • une méthode permettant de calculer la future position en fonction du type de mouvement : getNextPosition()
  • des méthodes permettant d'initier un changement d'état, qui seront appelées par le contrôleur général : setStateMoveLeft()setStateMoveRight(), setStateFall() , setStateJump(), setStateCollision().
  • des méthodes privées permettant de mettre à jour la position du sprite en fonction de son état : move(), fall(), jump().

Pour la partie contrôle, il faut ajouter :

  • une méthode pour détecter la présence d'un sol : checkFloor()
  • la méthode pour détecter les collisions : checkCollision() plus les méthodes pour calculer la position des pixels (par ex, avec l'algorithme de bresenham).

 

1.3°/ Gérer la gravité

Pour que le sprite chute/saute de façon un minimum réaliste, il faut que le chute s'accélère, et que la vitesse du saut diminue jusqu'à son apogée, avant d'augmenter. Cela peut se faire avec des équations balistiques compliquées, ou en comptant tout simplement sur la gravité. Le principe pour le saut :

  • au moment où le saut commence, on donne au sprite une vitesse en Y négative (car Y=0 est vers le haut de l'écran) et une vitesse en X inférieure, égale ou supérieure à 0 selon la direction du saut.
  • à chaque frame, on ajoute la vitesse en Y à la position actuelle en Y du sprite, puis on ajoute une valeur positive nommé gravité, à la vitesse en Y,

Exemple, avec speedY = -20, gravity = 5, y = 100 :

  • frame 0 : y = 100
  • frame 1 :  y = 100-20 = 80. speedY = -20+5 = -15
  • frame 2 : y = 80-15 = 65. speedY = -15+5 = -10
  • frame 3 : y = 65-10 = 55. speedY = -10+5 = -5
  • frame 4 : y = 55-5 = 50. speedY = -5+5 = 0
  • frame 5 : y = 50-0 = 50. speedY = 0+5 = 5
  • frame 6: y = 50+5 = 55. speedY = 5+5 = 10.
  • etc.

On remarque que l'on obtient bien une position en Y qui suit une parabole.

Pour la chute, le principe est le même excepté que l'on initialise la vitesse en Y à 0.

 

2°/ Bonus

 

2.1°/ tomber du bord

La solution proposée va un peu plus loin dans la gestion des mouvements du sprite. En effet, quand le sprite est au bord d'une plateforme, si plus de sa moitié gauche/droite est dans le vide, il va glisser puis finalement tomber de la plateforme. Pour cela, il suffit de créer une méthode de détection de collision qui détecte dans quel "cadran" du sprite se produisent les collisions. Le terme quadrant reflète le fait que l'on découpe le sprite en 4 parties. Dans le cas présent, le découpage est effectué en coupant la forme du sprite (à savoir un disque) en quatre quartier égaux, grâce à 2 diamètres, l'un étant horizontal et l'autre vertical.

Dans le code, cette méthode s'appelle checkQuadrantCollision(). Elle renvoie un entier dont seuls les 4 premiers bits sont utilisés :

  • si le bit 0 est à 1, cela indique qu'il y a collision avec le quadrant haut-droit.
  • si le bit 1 est à 1, cela indique qu'il y a collision avec le quadrant haut-gauche.
  • si le bit 2 est à 1, cela indique qu'il y a collision avec le quadrant bas-gauche.
  • si le bit 3 est à 1, cela indique qu'il y a collision avec le quadrant bas-droit.

Par exemple, si la méthode renvoie la valeur 12, cela veut dire qu'il y a collision dans les deux quadrants du bas.

 

Pour faire glisser le sprite quand il est au bord d'une plateforme, il suffit de vérifier si cette méthode renvoie 4 ou 8. Dans ce cas, on va simplement déplacer le sprite légèrement à gauche/droite et le placer en état de chute. Cependant, cette méthode n'est pas parfaite. Normalement, il faudrait vérifier que l'on peut effectivement déplacer légèrement le sprite sans provoquer de collision. Sinon, on risque d'avoir des surprises.

Le code pour réaliser ces opérations se trouve dans checkFloor().

 

2.2°/ la glissade

La classe View contient le code définissant les plateformes. La ligne 45 en commentaire, permet de changer l'inclinaison de la plateforme du bas. En mettant une inclinaison suffisante (par exemple 20°), le sprite va naturellement glisser à droite vers le bas. Cela est du au fait que checkFloor() calcule qu'il y a contact uniquement dans le quadrant bas-gauche, donc il décale le sprite à droite et le met en état de chute. Le seul problème de cette solution simple est qu'il est impossible de sauter lorsque l'on chute. Si l'on voulait autoriser le saut, il faudrait simplement ajouter un état à ManModel, représentant le fait de glisser.

 

2.3°/ remonter la pente

Si on met une inclinaison faible, par exemple 5°, le sprite ne glisse pas. En revanche, il ne peut se déplacer vers la gauche que de quelques pixels. C'est normal car l'inclinaison génère des marches de 1 pixels qui conduisent à détecter une collision, donc à bloquer le sprite. Pour permettre au sprite de remonter, il existe des solutions plus ou moins complexes, par exemple en se basant sur la pente de l'obstacle (si on y a accès). Cependant, on peut faire beaucoup plus simple. Il suffit de calculer la hauteur des intersections entre le sprite et les obstacles tout au long du chemin de pixel entre la position actuelle et la position finale. Dès que cette hauteur devient "trop" importante, on déplace le sprite vers la gauche/droite, à la position courante en X ainsi trouvée, mais en le remontant en Y de la hauteur de l'intersection.

Cela pourrait par exemple se faire en changeant la valeur renvoyée par checkCollision(). Au lieu d'un simple booléen, on pourrait renvoyer la hauteur de l'intersection.