Développement de jeux avec MonoGame - Partie II

La gestion des collisions

HECHT Franck

Dans la première partie de cette suite de tutoriels, nous avons vu comment bien démarrer un projet avec cette superbe bibliothèque qu'est MonoGame, de l'installation au démarrage d'un projet de jeu Pacman ! Bien sûr, le but de cette série d'articles n'est pas de faire le jeu complet, mais de vous aider à mettre en place les briques de base que tout jeu demande (gestion de collision, décision de direction pour l'Intelligence Artificielle…).

Dans cette seconde partie, nous allons voir comment gérer la détection de collision entre notre personnage et son environnement. La technique utilisée ici sera celle du « Pixel Perfect » donc une détection de collision au pixel près.

Commentez

- Titre : Développement de jeux avec MonoGame - Partie II

- Auteur : HECHT Franck

- Parution : 16 novembre 2013

- Public visé :

- 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 HECHT Franck. 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. Création de la classe : Collision

- II. Modification de la classe : GameObject

- III. Modification de la classe : Player

- IV. Modification de la classe : World

- V. Modification de la classe : MyPacman

- VI. Gestion des collisions

- VII. Résumé

- VIII. Code source

- IX. Remerciements

I - Création de la classe : Collision

Nous allons pour le moment faire une coquille vide, mais indispensable, car à partir de cette classe, nous allons modifier les autres en conséquence. Comme d'habitude, faites un clic droit sur le dossier Core et ajoutez une classe du nom de Collision.

Cette classe est particulière, ce sera une classe statique qui n'aura donc pas besoin d'être instanciée. Pour cela, changez :

class Collision

en :

public static class Collision

Ceci étant, il va nous falloir indiquer explicitement au compilateur que nos classes GameObject, World, Player et Enemies sont publiques. Vous pouvez d'ores et déjà le faire ! C'est fait de manière implicite par le compilateur, mais en ajoutant cette classe statique il nous faut le préciser de façon explicite.

Dans la classe GameObject, copiez l'énumération Direction et insérez-la dans notre classe Collision et ajoutez-y en haut de la liste une constante :

NONE = -1,

Ajoutez maintenant ces deux méthodes statiques :

private static Color GetColorAt(GameObject gameObject, World world)
{
}

public static bool Collided(GameObject gameObject, World world)
{
}

La première méthode sert à récupérer la couleur d'un pixel à une position donnée et la seconde nous permettra de déterminer si notre personnage est en collision et en conséquence il sera nécessaire de changer d'orientation. Ces deux méthodes ont la même signature, car Collided appellera GetColorAt. Nous en avons fini pour le moment avec cette classe, nous allons passer à l'adaptation des autres classes.

II - Modification de la classe : GameObject

Comme je l'ai dit dans le précédent tutoriel, j'écris en étudiant MonoGame et en créant le programme, vous avancerez tout comme moi j'ai avancé… Donc attendez-vous à devoir modifier pas mal de fois les classes !

Une chose est importante à savoir pour que vous ne vous posiez pas trop de questions lors de la modification de cette classe et des autres. Pour pouvoir tester si notre personnage entre en collision avec un mur (les pixels bleus dans notre monde actuel), il faut bien sûr que ce personnage puisse connaître le monde qui l'entoure et pour cela, il va nous falloir un accès au monde chargé. Nous allons donc commencer par ajouter une variable au début de notre classe :

public World world;

Ensuite, vu que nous avons copié l'énumération Direction présente dans cette classe dans notre classe statique, il va falloir l'enlever sinon il y aura ambiguïté et cela empêchera les comparaisons dont nous avons besoin par la suite. De plus, deux énumérations identiques ne servent pas à grand-chose ! Modifions maintenant notre variable juste en dessous comme ceci :

public Collision.Direction direction;

Étant donné que nous avons déplacé notre énumération, il convient de modifier chaque partie de code faisant référence à cette variable, ce que vous pouvez faire dans la méthode UpdateFrame, exemple :

case Collision.Direction.TOP:

Passons aux constructeurs avec arguments. Comme il nous faut une référence vers le monde, il faut ajouter un quatrième argument et initialiser notre variable, ce que nous faisons ici :

public GameObject(int totalAnimationFrames, int frameWidth, int frameHeight, World world)
{
_totalFrames = totalAnimationFrames;
_frameWidth = frameWidth;
_frameHeight = frameHeight;
this.world = world;
}

On ajoute le quatrième argument qui pointe vers le monde et nous initialisons également notre variable d'instance à la fin de la méthode.

III - Modification de la classe : Player

Commençons par ajouter une propriété :

private Collision.Direction _collidedDirection;
public Collision.Direction collidedDirection
{
get { return _collidedDirection; }
set { _collidedDirection = value; }
}

Cette propriété servira à pouvoir changer de direction tout en étant en collision dans une autre. C'est une façon de faire que j'ai imaginée, elle n'est peut-être pas optimale (vous le verrez dans le reste de l'article) mais au moins ça fonctionne ! Modifions ensuite notre constructeur :

public Player(int totalAnimationFrames, int frameWidth, int frameHeight, World world)
: base(totalAnimationFrames, frameWidth, frameHeight, world)
{
direction = Collision.Direction.RIGHT;
frameIndex = framesIndex.RIGHT_1;
_collidedDirection = Collision.Direction.NONE;
}

On ajoute le fameux quatrième argument pour pouvoir remonter les informations sur le terrain que nous utiliserons plus loin. On modifie l'affectation de la direction, souvenez-vous, nous avons déplacé notre énumération. Puis pour finir, on affecte une valeur par défaut à notre direction de collision, pour le moment on n'a pas encore bougé donc on ne fait rien ! Pour la méthode Move c'est plus radical, supprimez tout le contenu et ajoutez celui-ci :

if (state.IsKeyDown(Keys.Z))
{
direction = Collision.Direction.TOP;

if (!Collision.Collided(this, world))
{
if (collidedDirection != Collision.Direction.TOP)
{
collidedDirection = Collision.Direction.NONE;
Position.Y -= 1;
}
}
}
if (state.IsKeyDown(Keys.Q))
{
direction = Collision.Direction.LEFT;

if (!Collision.Collided(this, world))
{
if (collidedDirection != Collision.Direction.LEFT)
{
collidedDirection = Collision.Direction.NONE;
Position.X -= 1;
}
}
}
if (state.IsKeyDown(Keys.S))
{
direction = Collision.Direction.BOTTOM;

if (!Collision.Collided(this, world))
{
if (collidedDirection != Collision.Direction.BOTTOM)
{
collidedDirection = Collision.Direction.NONE;
Position.Y += 1;
}
}
}
if (state.IsKeyDown(Keys.D))
{
direction = Collision.Direction.RIGHT;

if (!Collision.Collided(this, world))
{
if (collidedDirection != Collision.Direction.RIGHT)
{
collidedDirection = Collision.Direction.NONE;
Position.X += 1;
}
}
}

Décortiquons un peu tout cela ensemble en commençant par la gestion d'une touche (prenons la touche D). On assigne la direction comme avant sinon on ferait du sur-place, ensuite on appelle notre méthode statique Collided. Si elle renvoie false comme le test qu'on fait ici, nous ne sommes pas en collision. Entre en jeu maintenant notre variable collidedDirection ! Voyons à quoi elle va nous servir réellement !

Si par exemple, on se dirige vers la droite, tout droit vers le mur, d'un coup on s'arrête. Normal me diriez-vous. Pour pouvoir changer de direction, il faut détecter si la touche qu'on enfonce est différente par rapport à la direction où on a réalisé notre récente collision. C'est ce que permet cette variable ! Avec un simple test, si on prend le test de la touche Q pour la gauche, on teste si la touche est différente et vu que c'est le cas (on avait une collision par la droite), on change notre variable de collision à sa valeur par défaut NONE puis on décrémente notre position X vu que nous nous dirigeons vers la gauche ! C'est tout simple non ?

IV - Modification de la classe : World

Depuis le début de la série, nous n'avons pas du tout travaillé sur cette classe, nous allons y ajouter quelques éléments à partir de maintenant ! Commençons par y importer la base du Framework de MonoGame :

using Microsoft.Xna.Framework;

Je vais vous expliquer un peu le topo avant d'aller plus loin pour que vous compreniez la démarche. Notre gestion de collision, comme je l'ai dit dans le synopsis, sera au pixel près (Pixel Perfect) que j'ai un peu adaptée pour ne pas être trop lourd en algorithme ni trop compliqué en code ! Mais pour mettre en place ce type de collision avec le terrain, il nous faut quoi ? Hé oui, les données des pixels que l'on peut croiser, certes nous avons l'image affichée, mais il n'en reste pas moins qu'un simple affichage, nous ne pouvons pas faire grand-chose avec cela si nous ne disposons pas d'informations supplémentaires. C'est ce que nous allons commencer à mettre en place ici !

Donc, qui dit information de cette envergure dit tableau pour retranscrire les informations de l'image de fond en informations exploitables ! Ajoutez ce tableau dans la classe :

public Color[] colorTab;

Pour effectuer des tests de collisions, il nous faut bien sûr une couleur de référence, ajoutons alors une propriété :

private Color _collisionColor;
public Color collisionColor
{
get { return _collisionColor; }
}

Nous avons utilisé jusque-là le constructeur par défaut, c'en est fini, ajoutons le nôtre pour pouvoir initialiser notre propriété :

public World(Color collisionColor)
{
_collisionColor = collisionColor;
}

V - Modification de la classe : MyPacman

Dernière ligne droite avant d'entrer dans le vif du sujet, mais rendez-vous quand même compte que tout ce que nous avons fait jusqu'ici y participe tout de même à notre gestion de collision donc ce ne sont pas des changements anodins. Commençons par la méthode Initialize, changeons l'initialisation de notre classe Player pour y ajouter la référence vers notre monde :

player = new Player(8, 13, 13, world);

Dans la méthode LoadContent, après le chargement et le placement de l'image de fond, ajoutez ceci :

world.colorTab = new Color[world.Texture.Width * world.Texture.Height];
world.Texture.GetData<Color>(world.colorTab);

Ces deux lignes sont d'une importance capitale ! La première va créer notre tableau que nous avons déclaré dans la classe World. Nous faisons d'un tableau à une dimension un tableau à deux dimensions en multipliant la largeur et la hauteur de l'image. Ce n'est pas très pratique à utiliser pour les accès aux indices, mais nous ferons avec. La seconde instruction permet quant à elle d'initialiser notre tableau grâce à la méthode GetData de la classe Texture. Elle va récupérer les informations de chaque pixel et les stocker à l'endroit adéquat ! C'est la base de notre détection de collision.

VI - Gestion des collisions

Nous y sommes enfin ! Mais rien ne vaut tout d'abord quelques explications sur comment nous allons gérer les collisions, de façon un peu brute et primaire certes, mais ça ne fonctionne pas trop mal. Cela dit, c'est largement perfectible ! Le but de nos collisions, c'est de prendre en compte des points chauds par rapport à nos images, je vous montre cela en… images :

Regardez de près, vous voyez le point rose sur chacune des images ? C'est ce petit pixel qui va être notre point chaud. J'entends par point chaud, la zone qui sera réellement testée pour déterminer si nous sommes en collision avec le décor ou pas ! Ce pixel correspond au centre de l'image du côté de la zone de contact donc par rapport à la direction.

Bon, voyons tout cela en pratique. Dans notre méthode GetColorAt, ajoutez ce code :

Color color = world.collisionColor;

if ((int)gameObject.Position.X >= 0 && (int)gameObject.Position.X < world.Texture.Width
&& (int)gameObject.Position.Y >= 0 && (int)gameObject.Position.Y < world.Texture.Height)
{
switch (gameObject.direction)
{
case Direction.RIGHT:
{
color = world.colorTab[((int)gameObject.Position.X + gameObject.frameWidth) + ((int)gameObject.Position.Y + (gameObject.frameHeight / 2)) * world.Texture.Width];
}
break;
case Direction.LEFT:
{
color = world.colorTab[(int)gameObject.Position.X + ((int)gameObject.Position.Y + (gameObject.frameHeight / 2)) * world.Texture.Width];
}
break;
case Direction.BOTTOM:
{
color = world.colorTab[((int)gameObject.Position.X + (gameObject.frameWidth / 2)) + ((int)gameObject.Position.Y + gameObject.frameHeight) * world.Texture.Width];
}
break;
case Direction.TOP:
{
color = world.colorTab[((int)gameObject.Position.X + (gameObject.frameWidth / 2)) + (int)gameObject.Position.Y * world.Texture.Width];
}
break;
}
}

return color;

Nous commençons par ajouter une variable de type Color que nous initialisons avec la couleur de collision qui est stockée dans notre objet world. Nous poursuivons avec un premier test qui va nous permettre d'éviter des erreurs de dépassement d'indice. On détermine donc si on se trouve bien à l'intérieur de la fenêtre avant de faire quoi que ce soit. Ensuite, prenons l'exemple du premier case dans le switch, nous recherchons le pixel qui correspond au point de collision par rapport à la direction en cours. Ici c'est la direction droite. Certains se poseront sûrement la question, mais c'est quoi tout ce calcul pour accéder à un simple indice ?!

Hé bien… nous avons initialisé le tableau de la manière suivante :

world.colorTab = new Color[world.Texture.Width * world.Texture.Height];

Cette méthode permet de créer en réalité un tableau à deux dimensions dans un tableau à une seule dimension, certes ce n'est pas très pratique, mais c'est comme ça, il me semble qu'il n'y a pas d'autres possibilités dans MonoGame pour récupérer tous les pixels d'un coup comme nous l'avons fait ! Je vous donne la formule de base pour accéder aux indices voulus :

Tab [ x + y * largeur ]

Donc que faisons-nous ? On commence par calculer la position X où se situe notre point chaud (qui se trouve à l'extrémité droite, le point rose sur l'image un peu plus haut) donc la position X courante de l'image plus la largeur de l'image, logique non ? On additionne ensuite à cela, la position Y de notre point plus la hauteur de l'image divisée par deux ce qui, sur notre image précédente, nous fait tomber pile poil sur notre point rose ! Explication en image :

Pour finir, on multiplie le tout par la largeur du monde pour respecter le calcul d'indice que je vous ai montré avant. À partir de maintenant vous êtes capable, normalement, de récupérer les points d'impact en fonction des autres directions ! Passons à la fin, voici la petite méthode Collided :

bool b = false;
Color color = GetColorAt(gameObject, world);

if (color != world.collisionColor)
b = false;
else
b = true;

return b;

Ici rien de compliqué, on appelle notre méthode GetColorAt en lui transmettant les arguments que contient notre méthode Collided. Elle renvoie une couleur que nous testons plus bas. Si la couleur est différente de la couleur de collision enregistrée dans le monde, il n'y pas de collision. On renvoie alors une valeur booléenne, false si on ne touche pas, true si on entre en collision avec le mur !

VII - Résumé

Voilà ! Vous savez tout sur les collisions par pixels ! C'est bien sûr une méthode parmi tant d'autres, une excellente ressource que vous pouvez garder sous le coude est celle-ci : Théorie des collisions. L'auteur nous explique différentes techniques de collisions en passant par la plus simple AABB jusqu'aux techniques les plus avancées et complexes.

La technique de collision que nous avons mise en place ici est perfectible, vous pouvez vous amuser à la modifier.

VIII - Code source

Vous pouvez télécharger ci-après, le code source de la solution complète par rapport à cette seconde partie du tutoriel : franckh.developpez.com/tutoriels/csharp/monogame/part-II/fichiers/Pacman.zip

IX - Remerciements

Un grand merci à LittleWhite pour la relecture et ses remarques lors de l'écriture de ce tutoriel et merci également à ClaudeLELOUP pour sa relecture et correction.