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.