Préambule
L'objectif de ce troisième article est de présenter comment résoudre le challenge du TD n°2 qui a été laissé de côté : les obstacles inclinés.
Pour suivre correctement ce cours, il est conseillé d'ouvrir en parallèle les sources, dont l'archive est téléchargeable [ ici ]
1°/ Un nouvel état : la glissade
Dans le TD n°2, si on incline suffisamment une des plateformes, le sprite se met à glisser. C'est un effet de bord de la mise en place de la glissade lorsque l'on arrive près du bord d'une plateforme. Le problème de cette solution est qu'elle consiste à décaler le sprite de quelques pixels puis de le mettre en chute verticale. Il va donc tomber de quelques pixels, s'arrêter (puisqu'il y a un obstacle), puis repartir en glissade, etc. Le mouvement résultant est donc haché car le sprite n'arrête pas de bouger puis s'arrêter. Selon la pente, on peut même voir le sprite faire un petit mouvement ondulatoire. Enfin, même si la pente est très accentuée, le sprite ne glisse pas forcément plus vite qu'avec une pente moindre.
La solution globale à ce problème est simple :
- le sprite ne doit pas forcément être arrêté dès qu'il y a contact avec un obstacle et si cela ne l'arrête pas, il conserve sa vitesse acquise et part en glissade. NB : dans un système encore plus réaliste, on calculerait l'énergie perdue (donc la vitesse) à cause du choc.
- lorsque le sprite doit chuter, il le fait avec sa vitesse actuelle, qui n'est pas forcément nulle, ni forcément verticale.
Cela suppose d'ajouter un nouvel état au sprite : "glissade" et donc, TOUTES les conditions dans lesquelles le sprite passe, reste ou sort de glissade. Malheureusement, ce n'est pas un problème très simple à résoudre, surtout quand les pentes évoluent et si on veut effectivement créer un effet de glissade proportionnel à la pente.
Pour simplifier le codage, il convient donc d'avoir une vue d'ensemble du comportement du sprite et de fixer des conditions précises pour entrer/rester/sortir des états possibles.
Dans cet article, le sprite peut être :
- immobile,
- en mouvement latéral,
- en chute,
- en saut,
- en glissade.
Dans le code source, il existe un 6ème état : bloqué, qui est temporaire. Il est seulement utilisé par le contrôleur pour signifier au modèle qu'au cours de la frame courante, le sprite est devenu bloqué par un obstacle et qu'il faut le mettre à une position bien précise. Quand le modèle du sprite se met à jour, le sprite est effectivement déplacé à cette position, puis il passe en état immobile. Un sprite ne commence donc ni ne termine une frame dans l'état bloqué.
2°/ Gestion des collisions
2.1°/ CollisionData et CollisionSummary
Toute la gestion des états exposée dans la section suivante repose sur la capacité à détecter des collisions. Les principes utilisés ici sont les mêmes que dans les articles précédents : on utilise la méthode Shape.intersect() pour obtenir l'intersection entre 2 objets Shape, sous entendu le sprite et un obstacle.
Pour rendre le code plus compact/lisible, la zone d'intersection (qu'elle soit vide ou non) est stockée dans un objet ColllisionData. Pour rassembler les informations de toutes les collisions, on utilise un objet CollisionSummary, contenant une liste de CollisionData. Chaque fois que l'on ajoute une collision à la liste, on demande au préalable à la vue du sprite de calculer les quadrants dans lesquels se produisent la collision.
Une fois que tous les objets collisions ont été ajoutés, la classe CollisionSumary contient des méthodes pour tester dans quels quadrants il y a collision, s'il y a collision dans au moins un quadrant du haut/bas, etc.
NB : cette façon de collecter les informations de collision n'est pas générique et n'est pas réellement utilisable lorsque différents sprites se déplacent.
2.2°/ Filtrer les collisions
Au lieu de tester tous les obstacles possibles, le code source met en place une stratégie de filtrage des obstacles qui seront potentiellement impliqués dans une collision pour la frame courante. Pour cela, il suffit de définir la zone maximale que peut occuper le sprite entre le début et la fin de la frame, en fonction de l'état courant et des états futurs possibles.
Dans ce but, un attribut filterArea a été ajouté à la classe ManLook. C'est une instance de Rectangle, non ajouté à la scène donc invisible (comme les hitbox). A chaque début de frame, le contrôleur appelle une méthode de ManLook dont le rôle est de mettre à jour les coordonnées + dimension de filterArea. Ensuite, le contrôleur vérifie pour chaque obstacle s'il y a collision avec le sprite. Si ce n'est pas le cas, l'obstacle ne sera pas utilisé lors des tests de collisions de la frame courante.
NB : Il existe d'autres stratégies pour réduire les tests de colllision, notamment les quad-tree qui sont les plus connus/utilisés.
3°/ Gestion des états
Le principe du contrôleur général est de prendre l'état courant du sprite en début de frame, et en fonction des touches enfoncées et de cet état, de déterminer son prochain état et sa prochaine position. On doit donc définir toutes les transitions possibles entre le début et la fin de frame, comment sont testées ces transitions, et éventuellement dans quel ordre. En effet, au cours d'une même frame, il est possible que le sprite passe par différents états. Par exemple, si le sprite est immobile au début de la frame, qu'aucune touche n'est enfoncée, on doit tester s'il repose de façon stable sur un sol. Si ce n'est pas le cas, il peut passer à l'état glissade. Dans ce cas, vu que cela provoque un déplacement, il faut tester si la glissade est possible. Si ce n'est pas le cas, alors le sprite passe à l'état bloqué, puis immobile. Au final, entre le début et la fin de frame, il aura changé 3 fois d'état, malgré le fait qu'il commence et termine dans l'état immobile. L'ordre dans lequel on teste les conditions est donc très important.
Ces conditions peuvent être dictées par la physique, ou tout simplement par le gameplay voulu par les développeurs, qui ne sera pas forcément très réaliste mais plus adapté au jeu, plus réactif, etc. Par exemple, dans cet article, le sprite est une boule. On s'attend donc à ce qu'elle roule vers le bas en cas de pente négative. Qu'arrive-t-il quand elle heurte une surface horizontale après un saut ou une chute non verticale ? Dans la plupart des jeux de plateforme, le sprite d'arrête, quelle que soit la forme qu'il a. Ce n'est pas du tout réaliste mais utile au gameplay et surtout bien plus simple à gérer : un sprite qui bouge tout seul avec inertie + frottements est plutôt compliqué à modéliser.
Du point de vue algorithmique, ces conditions imposent toutes de détecter des collisions. Si l'on veut le faire finement, cela doit être au pixel près. On peut donc utiliser l'algorithme de bresenham pour déterminer le chemin entre la position actuelle et la future position, en fonction du type de mouvement. En suivant ce chemin, on détermine s'il y a collision ou non, et si oui, on vérifie les conditions pour changer éventuellement l'état du sprite.
La détection des collisions utilisée dans le code repose sur les mêmes principes que dans l'article précédent, avec notamment la prise en compte du quadrant dans lequel a lieu une collision. Pour rendre le code plus compact/lisible, la zone de collision avec un obstacle est stockée dans un objet CollisionData. Comme le sprite est
Dans les sections suivantes, les conditions sont exposées globalement dans l'ordre de test. Des remarques viennent expliquer cet ordre et/ou comment sont faits les tests.
Sauf mention d'un OU en début de condition, les conditions doivent toutes être réunies.
3.1°/ immobile
1- conditions pour passer en mouvement latéral :
- la touche gauche/droite est enfoncée au début de la frame mais pas espace.
2 - conditions pour passer en saut :
- la touche espace est appuyée au début de la frame, et éventuellement les touches gauche/droite.
3 - conditions pour rester en immobile :
- aucune touche n'est enfoncée au début de la frame
- le sprite repose de façon stable sur un sol.
4 - conditions pour passer en chute :
- le sprite n'a pas changé d'état à cause des conditions 1 & 2
- le sprite ne repose sur aucun obstacle.
5 - conditions pour passer en glissade :
- le sprite n'a pas changé d'état à cause des conditions 1 & 2.
- le sprite repose sur un obstacle de façon instable.
Remarques :
- les deux premières conditions sont prioritaires sur les deux dernières. Elles doivent donc être traitées avant dans le code.
- pour tester si le sprite repose sur un sol stable, on déplace sa hitbox de 1 pixel vers le bas. Il chute s'il n'est pas en collision dans ses 2 quadrants du bas.
- pour tester l'instabilité, on utilise le même principe. Si le sprite est en collision uniquement avec le quadrant bas-gauche, il glisse vers la droite. S'il est en collision avec le quadrant bas-droite, il glisse vers la gauche.
3.2°/ mouvement latéral
1 - conditions pour passer en immobile :
- aucune touche n'est enfoncée
2 - conditions pour passer en saut :
- la touche espace est appuyée, et éventuellement les touches gauche/droite.
3 - conditions pour rester en mouvement latéral :
- la touche gauche/droite est enfoncée mais pas espace.
- la pente instantanée au cours du déplacement du sprite n'excède pas 45° vers le haut ou le bas.
4 - conditions pour passer en glissade :
- le sprite est passé en immobile du fait de la condition 1.
5 - conditions pour passer en chute :
- le sprite n'a pas changé d'état, à cause des conditions 1 & 2.
- la pente instantanée au cours du déplacement excède 45° vers le bas.
6 - conditions pour passer en bloqué :
- le sprite n'a pas changé d'état, à cause des conditions 1 & 2.
- la pente instantanée au cours du déplacement du sprite excède 45° vers le haut.
En l'absence de pente, un mouvement latéral correspond à déplacer le sprite en {x,y} de P pixels à gauche/droite, donc vers la position {x+P,y}. On peut considérer P comme la "vitesse" actuelle du sprite. Dès lors qu'il y a une pente, le sprite va se retrouver dans une position entre {x+P,y+P} et {x+P,y-P}, ou bien être bloqué/en chute.
Le choix d'une pente maximale de 45° permet de beaucoup simplifier les tests. En effet, 45° correspond simplement à un pixel plus haut ou plus bas que le précédent.
Pour connaître sa position/état final, il suffit de suivre l'algorithme suivant :
height = 0
pour i de 1 à P (ou de -1 à -P pour aller à gauche) :
C1 = tester collision en {x+i,y+height}
si C1 = quadrant bas :
tester collision en {x+i,y+height-1}
si collision => bloqué
sinon height--
sinon :
C2 = tester collision en {x+i,y+height+1}
si C1 = quadrant haut et C2 = quadrant-bas => bloqué
sinon si C1 = aucun quadrant :
tester collision en {x+i, y+height+2}
si aucune colllision => chute avec une vitesse en X = i-1
sinon height++
fsi
fsi
fpour
Remarque : quand le sprite est bloqué ou bien en chute, sa position finale est {x+i-1,y+height}, c'est-à-dire l'avant dernière position testée, dans laquelle le sprite n'était pas en collision.
3.3°/ chute
1 - conditions pour passer en immobile :
- si au cours de la frame, le sprite est passé en état bloqué.
2 - conditions pour passer en saut :
- impossible
3 - conditions pour passer en mouvement latéral :
- impossible
4 - conditions pour passer en glissade :
- le sprite entre en collision dans le quadrant bas-gauche au cours de sa chute et le sprite tombe verticalement ou vers la droite,
- OU le sprite entre en collision dans le quadrant bas-droite au cours de sa chute et le sprite tombe verticalement ou vers la gauche,
5 - conditions pour rester en chute :
- le sprite n'entre pas en collision
6 - conditions pour passer en bloqué :
- le sprite entre en collision dans les deux quadrants du bas.
- OU le sprite entre en collision dans le quadrant bas-gauche et tombe vers la gauche.
- OU le sprite entre en collision dans le quadrant bas-droite et tombe vers la droite.
Remarques :
- quand le sprite passe en glissade, il conserve la vitesse en x/y acquise au cours de la chute.
- quand le sprite est bloqué, sa position finale est l'avant dernière position testée, pour laquelle le sprite n'était pas en collision.
3.4°/ saut
1 - conditions pour passer en immobile :
- si au cours de la frame, le sprite est passé en état bloqué.
2 - conditions pour rester en saut :
- aucune collision
3 - conditions pour passer en mouvement latéral :
- impossible
4 - conditions pour passer en glissade :
- le sprite entre en collision dans le quadrant bas-gauche au cours de sa chute et le sprite tombe verticalement ou vers la droite,
- OU le sprite entre en collision dans le quadrant bas-droite au cours de sa chute et le sprite tombe verticalement ou vers la gauche,
5 - conditions pour passer en chute :
- le sprite entre en collision dans le quadrant haut.
6 - conditions pour passer en bloqué :
- le sprite entre en collision dans les deux quadrants du bas.
- OU le sprite entre en collision dans le quadrant bas-gauche et se dirige vers la gauche.
- OU le sprite entre en collision dans le quadrant bas-droite et se dirige vers la droite.
Remarques :
- quand le sprite passe en glissade, il conserve la vitesse en x/y acquise au cours du saut.
- quand le sprite est bloqué, sa position finale est l'avant dernière position testée, pour laquelle le sprite n'était pas en collision.
- comme les conditions sont quasi similaires avec celles de la chute, on peut faire une seule méthode pour tester toutes ces conditions.
3.5°/ glissade
1 - conditions pour passer en immobile :
- si au cours de la frame, le sprite est passé en état bloqué.
2 - conditions pour passer en saut :
- la touche espace est appuyée au début de la frame, et éventuellement les touches gauche/droite.
3 - conditions pour passer en mouvement latéral :
- la touche gauche/droite est enfoncée au début de la frame mais pas espace.
4 - conditions pour passer en bloqué :
- le sprite entre en collision dans les deux quadrants du bas.
- OU le sprite entre en collision dans un des quadrants du hat.
- OU le sprite entre en collision dans le quadrant bas-gauche et se dirige vers la gauche.
- OU le sprite entre en collision dans le quadrant bas-droit et se dirige vers la droite.
- OU il est impossible de trouver une position sans collision alors que l'on suit le chemin de pixels (cf. remarques)
5 - conditions pour passer en chute :
- le sprite parcourt le chemin de pixels sans être bloqué,
- à la fin de ce parcours, il n'y a pas de collision vers le bas en testant jusqu'à la position qu'il aurait en chute libre.
6 - conditions pour rester en glissade :
- le sprite n'est jamais bloqué ni ne chute à la fin du parcours.
Remarques :
- quand le sprite est bloqué, sa position finale est l'avant dernière position testée, pour laquelle le sprite n'était pas en collision.
- quand le sprite est en chute, sa position finale en x est la dernière position testée, et en y, la position maximale qu'il aurait eu en chute libre.
-
dans le code source, la position future est calculée à partir de la vitesse et de la pente à la frame précédente. La pente permet d'obtenir l'accélération auquelle est retranchée une constante représentant la friction. Cela permet d'obtenir un effet de ralentissement du sprite quand il glisse.
Les conditions de glissade sont les plus complexes à tester car elles doivent tenir compte du fait qu'au début d'une frame la vitesse en x/y va définir un chemin de pixels éventuellement très différent de la pente réelle.Il faut donc retrouver la pente réelle et positionner le sprite le long de cette pente. Cette recherche doit tenir compte de cas épineux, d'où la difficulté.
Quand la pente est plus faible que la vitesse en x/y initiale de la glissade, à chaque pixel du chemin, il faut chercher une position sans collision. La direction de cette recherche doit tenir compte de la vitesse en x/y. En effet, si vy > vx, alors le chemin de pixel va plus vers le bas. Il faut donc rechercher les positions sans collision à droite/gauche, selon le signe de vx. En revanche, si vx > vy alors, il faut chercher les positions vers le haut.
Au cours de cette recherche, principalement quand c'est vers la gauche/droite, on va potentiellement beaucoup modifier la position du sprite par rapport au chemin initial. Cette modification peut être suffisamment importante pour que la position finale du sprite soit irréaliste par rapport à la vitesse finale prévue. Par exemple, supposons un chemin prévu vertical sur 10 pixels, et qu'au 3ème pixel, on tombe sur une pente de 30° vers le bas droite. Il y a donc collision et on cherche à droite la première position sans collision, qui devrait se trouver en gros 2-3 pixels plus loin. Idem pour tout le reste du chemin. Cela implique qu'à la fin du chemin, la position finale en y sera celle prévue, mais celle en x sera en gros 20 pixels à droite. Cela implique que la vitesse absolue en fin de chemin sera sqrt(10*10+20*20) = 22. Ce n'est pas possible ! La vitesse maximale atteignable est celle définie par la longueur du chemin, en l'occurence 10. En conclusion, lorsque l'on parcourt le chemin, il faut également vérifier que la nouvelle position ne conduit pas à dépasser la vitesse maximale. Sinon, on arrête le parcours et on positionne le sprite à la position précédente, en le laissant à l'état glissade.
Quand la pente est plus grande que la vitesse en x/y initiale, il n'y aura pas de collisions et le sprite se retrouvera "en l'air" à la fin de la glissade. Dans ce cas, il faut chercher si le sprite va rester "collé" à la pente, ou bien va entrer en chute. Une solution est de calculer la position maximale en y comme si le sprite était en chute libre au lieu d'une glissade. Dans tous les cas, le sprite ne peut pas aller plus bas. Si à la fin de la glissade, on cherche une collision vers le bas et qu'elle intervient avant cette position maximale, alors le sprite est déplacé à cette position-1. Sinon, il chute.
Fort heureusement, si la pente change au cours de la glissade, les deux cas précédents permettent d'en tenir compte.
4°/ Améliorations possibles
Comme énoncé en section 2, la collecte des informations de collision devrait être plus générique. Cela pourrait se faire grâce à une classe interface qui serait implémentée par toutes les classe de Look nécessitant une détection de collision. Cette interface contiendrait les méthodes permettant de tester une collision (NB : on pourrait donc utiliser un autre principe que Shape.intersect() ), de trouver les quadrants, les hauteurs/largeurs des zones de collision, etc.
A ce propos, on pourrait ajouter une gestion plus complexe des chocs pour ralentir plus ou moins le sprite en cas de collision. Par exemple, si le sprite chute vers la droite et qu'il entre en collision dans son quadrant bas-gauche, on peut calculer la "largeur" de la zone de collision par rapport au bord gauche du sprite. Cette largeur représente en quelque sorte la largeur de l'impact et la perte de vitesse qui en découle. En effet, si l'impact à lieu plutôt sur le côté gauche d'une bille, on peut dire qu'elle glissera plus facilement que si elle heurte un obstacle plutôt sous elle, près du centre. Traduit en code, la perte de vitesse est proportionnelle à la largeur de l'impact.
La glissade s'arrête dès que le sprite arrive en position stable. On pourrait reprendre le système du mouvement latéral pour qu'elle puisse continuer son mouvement, mais en ralentissant proportionnellement à la pente qu'il suit. Cela pourrait même permettre de remonter une pente si la vitesse en suffisante.