15 avr. 2008

Exploration de XNA : calques de fonctionnalités

Le billet précédent avait pour but de vous aider à mieux structurer votre code. Avant de rebondir sur d'autres points importants du framework XNA, j'aimerais poursuivre un peu dans cette direction, et vous présenter des éléments de micro-architecture qui, je le pense, vous faciliterons la vie lors du développement de votre produit : les calques de fonctionnalités.

Note: cet article, bien que basé sur des notions XNA, présente un concept qu'il est possible d'adapter à tous les autres langages (C++, Java, etc). Ne vous sentez pas limités par le titre du billet ou par les paramètres des méthodes décrites.

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



Définition

Si vous connaissez Photoshop ou un autre logiciel de dessin/retouche photo un peu évolué (il en existe des tonnes; personnellement, j'utilise Paint.Net), vous êtes peut-être familier avec les calques. Pour les autres, en voici une définition simple[1] :

Les calques sont, en infographie, un ensemble de couches empilées les unes au-dessus des autres, dont chacune contient une partie des éléments constituant l'ensemble, ce dernier étant obtenu par la superposition de tous les calques. Dans ce système, chaque élément peut être placé sur un calque différent, si bien que l'on peut décomposer le travail, ce qui le rend plus simple.

Ce billet va s'efforcer de montrer que la vision d'un empilage de calques composés d'éléments simples peut offrir des possibilités intéressantes dans le cadre du développement d'un jeu vidéo, d'autant plus si l'on étends la définition ci-dessus pour y inclure non seulement une représentation graphique mais aussi des niveaux de fonctionnalités.

Pourquoi ?

Parce qu'il est extrêmement rare d'avoir des écrans d'un seul tenant sémantique. La plupart des écrans de jeu présentent une interface, une gestion des entrées utilisateur et d'autres composants peuvent encore s'ajouter à cette courte liste. De plus, on souhaite souvent s'autoriser des effets complexes :

  • un menu s'affiche par dessus une animation vidéo comme sur certains jeu de simulation de courses. Passer au menu suivant ne change pas l'animation en cours.
  • afficher l'inventaire d'un RPG met le jeu en pause, mais certains effets graphiques continuent de s'exécuter (par exemple, les arbres continuent à être animés sous l'effet du vent)

Il est donc nécessaire d'adopter une structure qui permettra de simplifier le développement de ces écrans complexes, sous peine de d'être limité dans son expressivité.

On remarque que la complexité de la structure du jeu est souvent liée à la complexité des états mis en place. Si on incorpore dans notre architecture une gestion d'états seule (sans gestion liée au écrans), on s'interdit un partage simple des données et des comportements entre les états. Il est alors nécessaire de dupliquer de grande portion de code afin d'obtenir les effets souhaités.

Le concept

Bien évidemment, on s'inspire de la définition donnée ci-dessus et du concept de calques tel qu'il est implémenté dans PhotoShop ou Paint.Net.

Calques dans Paint.Net

Le principe de base est le suivant : chaque écran est composé d'un certain nombre de calques. Certains de ces calques ont pour mission d'afficher des informations à l'écran (HUD, fenêtres d'inventaire, menu, etc) dans un ordre prédéterminé. D'autre ont pour mission de gérer ces écrans. D'autres encore ont pour mission d'appliquer des effets graphiques aux calques précédents. On peut librement passer un calque d'un état du jeu vers un autre.

Le but est bien évidemment de créer des calques qui sont globalement indépendant les uns des autres. On s'assure ainsi qu'ils seront réutilisables et que leur mise en œuvre sera simple.

Mise en œuvre : les composants de jeu XNA

Pour une fois, commençons par valider ce qui existe déjà. En effet, le framework XNA définit un ensemble de classes et interfaces qui ont pour fonction de permettre une telle structure dans l'architecture du jeu, via la gestion de composants de jeu (Microsoft.Xna.Framework.GameComponent) et de composants de rendu (Microsoft.Xna.Framework.DrawableGameComponent).

classes de gestion des composants XNA

Au niveau conceptuel, un composant de jeu est soit :

  • GameComponent : un composant effectuant une opération non graphique d'une certaine complexité - par exemple, la lecture des entrées utilisateur ou la mise à jour des informations calculées par le moteur d'intelligence artificielle.
  • DrawableGameComponent : un composant affichant des informations à l'écran - par exemple, la mise à jour et l'affichage d'une minimap dans un RTS.

L'idée maitresse des composants est la segmentation des tâches : un composant a une responsabilité, et ne déborde pas de celle-ci - c'est l'application du principe de responsabilité unique. Il est donc très souvent réutilisable, bien que ça ne soit pas strictement nécessaire (dans une architecture basée sur des composants, on aura des composants réutilisables et des composants qui sont spécifique au jeu en cours de création).

Créer un composant de jeu est une tâche triviale : il suffit d'hériter de la classe DrawableGameComponent ou de la classe GameComponent, selon que l'on désire utiliser les fonctions d'affichage ou pas. Par exemple, un composant dont la fonction est d'afficher un fond défilant héritera de DrawableGameComponent, tandis qu'un composant qui met à jour le modèle physique du jeu héritera de GameComponent (puisqu'il n'a pas d'affichage à faire).

C'est le fait de pouvoir lier plusieurs composants entre eux via une collection (GameComponentCollection) qui permet de réaliser un système de gestion de calques de fonctionnalités.

Alors, comment fait-on ? C'est en fait très simple : on définit des composants de rendu et on les ajoute dans une collection de composants. Des actions ultérieures sur ces composants permettent alors de mettre à jour les calques et de les dessiner à l'écran si besoin. Ces opérations s'effectuent en parcourant la liste des composants et en appelant leurs méthodes Update() et Draw().

Le parcours des composants n'est cependant pas trivial : l'ordre dans lequel ces composants doivent être traités n'est pas extrinsèque aux composants (comprennez que les composants fixent l'ordre dans lequel ils doivent être traités). On ne pas pas juste parcourir la collection et effectuer les traitement dans l'ordre que celle-ci défini. Pire, l'ordre de mise à jour et l'ordre de rendu peuvent être différents. Une autre limitation importante : à l'heure actuelle, seule la classe Microsoft.Xna.Framework.Game implémente la gestion des composants - il aurait été agréable de voir le framework définir une classe intermédiaire pour pouvoir réutiliser ce mécanisme à d'autres fins.

D'ailleurs, comment sont-ils gérés ces GameComponent ? Tout se passe principalement dans les méthodes Game.Update() et Game.Draw(), qui sont appelées dans vos classes dérives Game1:

class Game1 : Microsoft.Xna.Framework.Game
{
  // ...
protected override void Update(GameTime gameTime) { // -- votre code -- // parcours et mise à jour des GameComponent // dans l'ordre spécifié par les GameComponent.UpdateOrder base.Update(gameTime); }
protected override void Draw(GameTime gameTime) { // -- votre code -- // parcours et dessin des DrawableGameComponent // dans l'ordre spécifié par les DrawableGameComponent.DrawOrder base.Draw(gameTime); }
// ... }

Les méthodes Game1.Initialize() et Game1.LoadContent() ont aussi un effet sur la gestion des composants.

Ce qui n'est pas sans poser un problème d'architecture : tous les GameComponent enregistrés sont traités par ces fonctions. Si certains d'entre eux sont spécifiques à un état particulier et que l'on ne souhaite pas les traiter, il faut penser à les désactiver lorsqu'on quitte cet état, et à les réactiver ensuite. De plus, puisque la méthode GameComponent.Initialize() n'est appelée que lors de l'appel de Game.Initialize(), cela signifie que tous les GameComponent que nous souhaitons traiter doivent être connus à ce moment, sous peine de devoir nous charger nous même de leur initialisation. D'ailleurs, la même remarque vaut pour DrawableGameComponent.LoadContent() - ce qui, dans un sens, nous empêche de contrôler le chargement des ressources[2].

Une solution?

Vous l'aurez compris, les composants de jeu nous offre exactement ce dont nous avons besoin, à ceci prêt que nous ne voulons pas qu'ils soient directement gérés par le framework XNA, qui impose trop de sémantique sur leur utilisation. De fait, nous aimerions pouvoir utiliser ces interfaces sans pour autant nous priver d'une once du contrôle que nous pouvons avoir sur elles.

La solution passe par un point chaud : le parcours des GameComponent dans une GameComponentCollection. Pour cela, une solution simple (et naïve, certes) : reconstruire la collection après l'avoir parcouru en se servant de GameComponent.UpdateOrder/GameComponent.Enabled ou DrawableGameComponent.DrawOrder/DrawableGameComponent.Visible (selon le cas).

Ce n'est certes pas une approche optimale - mais il est impossible de garantir que la collection des composants est triée dans l'ordre qui nous intéresse. Il est même possible de démontrer que ce n'est que très rarement le cas : en fait, dès lors qu'il existe deux DrawableGameComponent dont les DrawOrder et UpdateOrder sont dans un ordre inverse, la collection ne peut pas être triée.

Vous avez probablement pensé à utiliser une SortedList<> ou un SortedDictionary<> - mais ce n'est hélas pas possible, car ces conteneurs présupposes l'unicité de la clef - ce que nous ne pouvons pas affirmer (notre clef étant l'ordre de mise à jour ou de dessin, il est possible que deux composants de rendu ait le même ordre de dessin). Nous n'avons guère le choix: il nous faut par un conteneur simple, et gérer nous même le tri des valeurs. For heureusement, le framework .Net nous fourni l'ensemble des fonctions dont nous avons besoin :

  • un constructeur de List<T> prend en paramètre une instance de IEnumerable<T>, dont dérive la classe GameComponentCollection.
  • nous pouvons ensuite appeler List<T>.RemoveAll() (avec un Predicate<T>) pour supprimer les composants qui ne nous interessent pas dans la liste
  • et terminer par un appel à List<T>.Sort() (avec un Comparison<T>) pour trier la liste.

Au final, on obtient une liste triée composée uniquement des éléments que nous devons considérer. Nous pouvons alors parcourir cette boucle avec foreach[3].

public sealed class GameComponentManager
{
  private GameComponentCollection components;
private static bool IsUpdatable(IGameComponent o) { if (o is GameComponent) { GameComponent component = (GameComponent)o; return component.Enabled; } return false; }
private static int CompareUpdatable(IGameComponent a, IGameComponent b) { if (a is GameComponent && b is GameComponent) { GameComponent c1 = (GameComponent)a; GameComponent c2 = (GameComponent)b; return c1.UpdateOrder - c2.UpdateOrder; } // when one of a and b or both are not game components, // we make no decision return 0; }
private List<IGameComponent> GetComponents(Predicate<IGameComponent> predicate, Comparison<IGameComponent> comparison) { List<IGameComponent> list = new List<IGameComponent>(components); list.RemoveAll(predicate); list.Sort(comparison); return list; }
public GameComponentCollection Components { get { return components; } }
public GameComponentManager(GameComponentCollection components) { this.components = components; }
public void Update(GameTime gameTime) { List<IGameComponent> list = GetComponents(IsUpdatable, CompareUpdatable); foreach (IGameComponent o in list) { GameComponent component = (GameComponent)o; component.Update(gameTime); } } }

L'architecture présentée dans le billet XNA précédent est légèrement modifiée pour ajouter une classe héritée de Pawn.Model.State qui est basée sur une gestion de composants de jeu (cliquez sur l'image pour l'agrandir):

Conclusion

Avec la connaissance des sprites que vous avez déjà, la capture des entrées utilisateur et la gestion des états présentée dans l'article XNA précédent et cet article, vous êtes presque fin prêt pour vous attaquer au cœur du problème : réaliser un petit jeu 2D par vous même. Nous n'avons toutefois pas fini notre travail - il reste en effet de multiples points à aborder avant de prétendre maitriser le framework XNA. Mais normalement, vous avez déjà acquis les bases nécessaires et suffisantes.

Cette série va donc continuer - pour mon plus grand bonheur. A bientôt !

Notes

[1] source: wikipedia

[2] ce qui n'est pas catastrophique dans un petit jeu. Mais dès lors que vous atteignez les 100Mo de ressources graphiques, le fait de devoir tout charger au début n'est pas véritablement une bonne solution.

[3] Le code correspondant a été simplifié en omettant les traitements de Draw() et Initialize(); ces modifications sont laissées à au lecteur à titre d'exercice...

Ajouter un commentaire

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

Fil des commentaires de ce billet