21 sept. 2007

Exploration de XNA : Entrées utilisateur, introduction et gestion de la souris

Nous savons afficher un décor et des sprites, il est maintenant temps de s'attacher à rendre tout ça un peu plus interactif, c'est à dire que nous devons nous intéresser aux entrées de l'utilisateur.

xna_logo.png [Télécharger Visual C# 2005 Express Edition] [Télécharger XNA Game Studio 1.0 Express Edition] [Télécharger le Service Pack 1 pour VC# 2005 EE] [TorqueX, de GarageGames.com] [XNA Resources] [Forum MSDN XNA Game Studio Express] [GameDev.Net - Plenty of 1s and 0s]

Introduction

Un programme XNA peut gérer 3 types d'entrées utilisateurs différents :

  • Le clavier (sur PC, mais aussi sur XBox360 puisque celle-ci vous permet de connecter un clavier dédier)
  • La souris (sur PC uniquement)
  • Jusqu'à 4 contrôleurs XBox360[1] (sur XBox360 bien évidemment, mais aussi sur PC puisque Microsoft a mis à disposition du public les drivers de ces contrôleurs).

Cette (courte) série d'articles a pour but de soulever un certain nombre de problèmes dans l'espoir de démystifier la gestion des entrées utilisateur dans un jeu XBox360 ou sur PC.

Acquisition en mode immédiat

Avant même de plonger au niveau du code, le premier point à noter est que la plupart des acquisitions sont immédiates. Ainsi, lorsqu'on demande l'état d'un périphérique d'entrée, c'est son état courant que l'on obtient et non pas des informations relatives à son historique d'utilisation. Si nous demandons l'état d'une touche du clavier ou d'un bouton sur le contrôleur XBox, nous savons s'il est appuyé ou non - mais pas depuis combien de temps. En fait, nous ne savons même pas si une requête d'état a été effectuée auparavant. Si cela ne pose guère de problème si manipule la souris ou le contrôleur mais nous verrons plus tard que cette particularité peut être relativement ennuyeuse dès lors qu'on souhaite interroger le clavier.

Pourquoi cela ? Tout simplement parce que dans le but de simplifier au maximum la gestion interne des périphériques, XNA (en fait, DirectX dans ce cas) passe outre les traitements qui sont habituellement effectués par Windows pour avoir un accès brut aux données. Dans la plupart des cas, c'est une aubaine - car le programmeur, travaillant avec des informations bas-niveau, y gagne énormément en flexibilité. Mais la contrepartie est que si le programmeur souhaite effectuer un traitement un peu avancé, alors il devra le faire par lui-même.

Aperçu de la librairie Xna.Framework.Input

Le namespace Xna.Framework.Input définit 3 classes statiques (c'est à dire des classes dont toutes les méthodes sont statiques - nous n'avons pas besoin de créer des instances de ces classes) et un certain nombre de structures et d'énumérations. Chacune de ces 3 classes possède une méthode GetState() qui permet de récupérer un instantané de l'état du périphérique sous-jacent.

Ainsi, Input.Mouse.GetState() renvoie une sructure Input.MouseState qui contient l'état des boutons et la position courante de la souris. Input.Keyboard.GetState() retourne une structure Input.KeyboardState qui contient l'état des différentes touches du clavier, et Input.GamePad.GetState() (qui prend en paramètre une énumération du type PlayerIndex) nous donne l'état du contrôleur d'un joueur particulier.

Sautons maintenant dans le vide intersidéral pour comprendre le fonctionnement des différents périphériques.

La souris

Rappelez-vous, la gestion de la souris n'est disponible que sous Windows - la console XBox360 n'a pas d'interface pour contrôler la souris.

Sous Windows, le curseur de la souris peut être affiché ou masqué en modifiant la propriété Game.IsMouseVisible (un booléen mis à true pour rendre le curseur visible).

La classe Input.Mouse qui permet l'interfaçage avec la souris définit les méthodes et propriétés suivantes :

  • public static MouseState GetState()
  • public static SetPosition(int x, int y)
  • @@publis static IntPtr WindowHandle { get; set; }

Si la propriété WindowHandle n'a que peu d'intérêt (elle permet entre autres de savoir à quelle fenêtre Windows la souris est attachée), les deux autres méthodes statiques nécessites un peu d'explications.

SetPosition(int x, int y) permet de forcer la souris à une position (x,y) définie à partir du coin haut-gauche de la fenêtre. Dans la plupart des cas, vous n'en aurez pas besoin mais certains types de jeu peuvent bénéficier d'une bonne gestion de la souris - c'est notamment le cas des First Person Shooter : a chaque frame, la souris est repositionnée au centre de l'écran ; à la frame suivante, on récupère la position de la souris, ce qui nous permet d'avoir le delta de mouvement qui peut alors être utilisé pour effectuer d'autres calculs (par exemple, pour modifier la caméra).

De manière générale, à chaque fois qu'un jeu peut bénéficier des mouvements de la souris sans afficher le curseur de la souris alors cette méthode peut avoir son utilité. Dans le cas d'un jeu ou d'un programme en mode fenêtré, ne pas utiliser cette méthode signifie qu'on autorise le joueur à sortir le curseur de la souris de la fenêtre[2].

La dernière méthode est bien sûr celle qui nous intéresse en premier lieu : il s'agit de GetState() qui renvoie une structure MouseState. Cette structure définit un certain nombre de propriétés accessibles en lecture :

  • LeftButton, MiddleButton et RightButton stockent l'état des boutons principaux de la souris (gauche, milieu et droit) sous la forme d'une valeur énumérée du type ButtonState. Cette énumération définit deux états distincts : ButtonState.Pressed informe que le bouton est appuyé, et ButtonState.Released qui informe que le bouton n'est pas appuyé.
  • XButton1 et XButton2 sont aussi des BouttonState. Certaines souris ont 5 boutons, voire plus. Les boutons X1 et X2 sont généralement ceux employés pour al navigation web (page précédente, page suivante). Si votre souris est de ce type, il peut être intéressant de vérifier à quel bouton physique ces boutons logiques correspondent.
  • X et Y représente les coordonnées actuelles du curseur de la souris. Pour une fois, il s'agit non pas de valeurs instantanées mais de valeurs accumulées. Ces valeurs sont relatives au coin haut-gauche de la fenêtre de rendu XNA.
  • ScrollWheelValue est aussi une propriété accumulée qui stocke la valeur associée à la roulette de la souris - si la souris en a une. Cette valeur initialisée à 0 lorsque le programme est lancé.

Connaître l'état du périphérique est déjà bien - mais dans certains cas il peut être avantageux de connaître aussi l'état précédent du périphérique. On peut se servir du fait que MouseState est une structure - dans le jargon C#, cela signifie que MouseState est un type valeur et non pas un type référence. Assigner une instance de cette structure à une autre instance copie donc tous les champs. La conclusion s'impose d'elle-même : si je souhaite garder l'historique des états de la souris, il me suffit de garder une copie des instances de MouseState que j'ai obtenu.

C'est particulièrement important si je souhaite obtenir des informations incrémentales en provenance de la roulette : puisque MouseState.ScrollWheelValue est une valeur accumulée, je n'ai pas d'autre choix que de connaître l'état précédent de la roulette si je veux en déduire le mouvement effectué par l'utilisateur depuis la capture précédente de l'état.

Le code

J'ai étendu la librairie Pawn pour intégrer la classe Pawn.Input.Mouse qui encapsule la gestion de la souris. Cette classe permet à l'application cliente d'enregistrer des évènements qui seront générés lorsque l'un des boutons de la souris passera de l'état ButtonState.Pressed à l'état ButtonState.Released - et vice-versa. Elle garde en outre en mémoire l'état précédent de la souris afin de calculer les deltas de coordonnées (X, Y, roulette) depuis la mise à jour précédente. Et finalement, Il est possible de capturer la souris - le curseur sera alors fixé à la position demandée à chaque mise à jour.

Voici le code source de cette classe - sans les directives using, mais vous devriez être capable de les retrouver n'est-ce pas ? =P.

namespace Pawn.Input
{
  public delegate void MouseEventDelegate(ref MouseState state);
public class Mouse { MouseState previousState; MouseState currentState; bool needInit = true; bool captured = false; int pinX = 0; int pinY = 0;
public event MouseEventDelegate OnLButtonDown; public event MouseEventDelegate OnLButtonUp; public event MouseEventDelegate OnRButtonDown; public event MouseEventDelegate OnRButtonUp; public event MouseEventDelegate OnMButtonDown; public event MouseEventDelegate OnMButtonUp; public event MouseEventDelegate OnXButton1Down; public event MouseEventDelegate OnXButton1Up; public event MouseEventDelegate OnXButton2Down; public event MouseEventDelegate OnXButton2Up;
//constructor public Mouse() { }
public void Capture(int pinX, int pinY) { captured = true; this.pinX = pinX; this.pinY = pinY; }
public void ReleaseCapture() { captured = false; }
public void Update() { previousState = currentState;
currentState = Microsoft.Xna.Framework.Input.Mouse.GetState();
if (needInit) { previousState = currentState; }
// verify if I have to fire any event FireEvent(currentState.LeftButton, previousState.LeftButton, OnLButtonDown, OnLButtonUp); FireEvent(currentState.RightButton, previousState.RightButton, OnRButtonDown, OnRButtonUp); FireEvent(currentState.MiddleButton, previousState.MiddleButton, OnMButtonDown, OnMButtonUp); FireEvent(currentState.XButton1, previousState.XButton1, OnXButton1Down, OnXButton1Up); FireEvent(currentState.XButton2, previousState.XButton2, OnXButton2Down, OnXButton2Up);
if (captured) { Microsoft.Xna.Framework.Input.Mouse.SetPosition(pinX, pinY); } }
public int X { get { return currentState.X; } } public int Y { get { return currentState.Y; } } public ButtonState LeftButton { get { return currentState.LeftButton; } } public ButtonState RightButton { get { return currentState.RightButton; } } public ButtonState MiddleButton { get { return currentState.MiddleButton; } } public ButtonState XButton1 { get { return currentState.XButton1; } } public ButtonState XButton2 { get { return currentState.XButton2; } } public int ScrollWheel { get { return currentState.ScrollWheelValue; } }
public int ScrollWheelIncr { get { return currentState.ScrollWheelValue - previousState.ScrollWheelValue; } }
public int XIncr { get { return currentState.X - previousState.X; } } public int YIncr { get { return currentState.Y - previousState.Y; } }
private void FireEvent(ButtonState current, ButtonState previous, MouseEventDelegate fireDown, MouseEventDelegate fireUp) { if (fireDown != null && current == ButtonState.Pressed && previous == ButtonState.Released) { fireDown(ref currentState); } if (fireUp != null && current == ButtonState.Released && previous == ButtonState.Pressed) { fireUp(ref currentState); } } } }

On remarquera la méthode privée Mouse.FireEvent qui appelle les delegate associés à l'évènement OnButtonDown et OnButtonUp pour chaque bouton défini. On retrouvera un traitement similaire pour le clavier et les contrôleurs XBox.

Un problème

Le code ci-dessus fonctionne relativement bien - à condition d'appeler la méthode Mouse.Update() régulièrement. Mais que se passe-t-il si on perd trop de temps entre deux appels ? Prenons un cas extrême : il s'écoule une seconde pleine entre deux appels successifs à cette méthode. Entre ces deux appels, l'utilisateur appuie dur l'un des boutons de la souris puis le relâche. Puisque nous n'avons qu'un instantané de l'état de la souris, il nous est impossible de le découvrir lorsque nous récupérons enfin le nouvel état - le bouton n'est plus appuyé, et l'entrée utilisateur est définitivement perdue.

La solution la plus simple à ce problème est de récupérer l'état de la souris le pus souvent possible. Cependant, dans le cadre d'un jeu fonctionnant avec une seule thread, nous sommes limités dans notre approche puisque nous ne pouvons pas aller plus vite que le rendu. Pour régler définitivement ce problème, on peut penser que délocaliser la gestion des entrées utilisateurs dans une thread à part qui tournera à plein régime sera suffisant[3]. Dans la pratique et dans la plupart des cas, le problème sera effectivement résolu - enfin, les symptômes ne seront plus apparents. Mais il y aura toujours un risque pour que même cette thread soit ralentie et que certaines entrées utilisateurs soient manquées par le logiciel.

Il existe une solution à ce problème - mais elle ne fonctionne que sous Windows - ce qui n'est pas catastrophique dans ce cas particulier puisque la souris ne fonctionne pas sur Xbox. Toutefois, le problème décrit ne se limite pas à la souris, il touche tous les périphériques d'entrée, clavier et contrôleur inclus. Puisqu'il est possible de traiter le problème lié au clavier avec la même solution j'aborderai celle-ci dans l'article de la série qui lui sera associé. Pour ce qui est du contrôleur, j'ai peur que la solution Windows ne puisse pas lui être appliquée. Dans ce cas, on en est réduit à vérifier son état le plus souvent possible en espérant ne pas manquer les entrées utilisateur.

Notes

[1] filaires ou sans fils

[2] Attention messieurs les designers prolixes : en mode fenêtré, il est nécessaire de laisser à l'utilisateur le contrôle du curseur de la souris, où tout au moins de lui donner le moyen de contrôler celui-ci aisément. Si je fixe le curseur au centre de l'écran et que soudain l'utilisateur souhaite effectuer une autre tâche à l'extérieur de l'écran de jeu (par exemple activer une autre application avec sa souris), il doit pouvoir le faire.

[3] calcul rapide : si une thread tourne à 60hz, les entrées utilisateurs sont récupérées toutes les 16 millisecondes ; on peut espérer que cela sera suffisamment rapide pour capter toutes les actions d'un utilisateur

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet