Exploration de XNA : les sprites animés | 2 vote(s)
Par Emmanuel Deloget, vendredi 29 juin 2007 à 13:00 :: Exploration de XNA :: permalien #86
Fichier(s) attaché(s) :
Tags: C#, game programming, sprites, xna
Nouveau billet dans notre série consacrée à la plateforme XNA de Microsoft - le sixième. Au cours de cette série, nous étudierons plusieurs aspects du développement de jeux vidéo, tant au niveau de l'architecture qu'au niveau de la programmation elle même - avec au final un but : vous permettre de vous lancer à la poursuite de vos rêves. Enfin, si programmer un jeu est votre rêve.
Les sprites animés : il ne nous reste guère que ce type de sprites à considérer avant de passer à la suite (pas tout à fait quand même). Dans les billets précédents, nous avons étudié les sprites simples, les planches de sprites et les fontes - il est donc temps de croquer un morceau un peu plus sérieux.
Le code correspondant à cet article peut être téléchargé ici.
[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]
De l'utilisation de concepts connus
Ce qu'il faut bien comprendre, c'est que les sprites animés ne sont qu'une utilisation de concepts déjà vu dans les précédents billets. Un sprite animé est un sprite comme les autres - il est donc chargé de la même manière et affiché de la même manière. Ce qui change, c'est la façon dont on va choisir le sprite qu'on souhaite afficher.
Si il est bien sur possible de stocker les animations d'un sprite sur des images séparées, il est préférable - à la fois pour des raisons pratiques et pour des raisons de performance, notamment si le même sprite est affiché plusieurs fois à différents stades de son animation - d'utiliser des planches de sprites. Une planche d'animation est composée de plusieurs images montrant le même personnage ou objet à différents stades de son évolution. Tout comme il est possible de stocker différents sprites sur une même planche, il est aussi possible de stocker les animations de plusieurs sprites sur la même planche.
Voici un planche de sprite typique tirée des Reiner's Tileset, un bibliothèque de sprites libres d'utilisation[1]. Chacune des images de l'animation fait 128x128 pixels.

L'important à noter est qu'au niveau purement physique, nous savons déja gérer les sprites animés. Il ne nous reste qu'une chose à appréhender : comment, une fois chargée, trouver les différentes animations stockées et les jouer devant les yeux ébahis de l'utilisateur de notre programme ?
Encore des méta-données
Avec la juste la planche, il est impossible de savoir à quelle animation correspond quelle image (à moins de mettre en place un système rigide de règles, ce qui est tout à fait possible, mais qui nous empêche d'utiliser tout le potentiel qui s'offre à nous). La conclusion s'impose d'elle même: il nous faut lire quelque part les informations dont nous avons besoin.
Dans le billet précédent, nous avons vu comment lire un fichier XML. Nous allons reprendre la même tactique - notre seul problème ici est que nous n'avons pas d'outils pour générer ce fichier XML; qu'importe, créons le à la main.
J'ai choisi la structure suivante :
<?xml version="1.0"?>
<animations>
<spritesheets>
<spritesheet sheet="0" name="barbarian-walk-se.png"
xcount="4" ycount="2" />
</spritesheets>
<animation name="walk-se" duration="1000">
<frame sheet="0" sprite="0" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="1" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="2" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="3" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="4" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="5" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="6" xdelta="0" ydelta="0" />
<frame sheet="0" sprite="7" xdelta="0" ydelta="0" />
</animation>
</animations>
Les balises spritesheet nous donne le nombre de sprites en X et Y qui sont présents sur la planche de sprites (ce qui nous permettra alors de retrouver la position d'un sprite en fonction de son index). L'attribut name contient le nom de la ressource dans le Content Manager. animation définit une animation et lui associe un nom et une durée. Cette durée est exprimée en millisecondes, et correspond au temps qu'il faut pour afficher les (n) sprites de l'animation pendant une durée égale - chaque sprite est donc affiché pendant d / n millisecondes. Chaque étape de l'animation est représentée par une balise frame, qui associe à cette étape un index de sprite dans l'un des planches. Il est tout à fait possible de compliquer le système et d'assigner à chaque frame une durée ou un temps auquel elle doit être jouée[2]. D'autres données sont assignées à chaque frame: dans notre exemple, on y associé un déplacement (x,y) du sprite, sous la forme d'un delta par rapport à la frame précédente. En effet, la plupart des animations induisent non seulement un mouvement, mais aussi un déplacement. Sans connaissance de ce déplacement, une application ne peut pas faire un rendu correcte de l'animation (pensez par exemple à l'animation d'un personnage qui court : si je ne bouge pas le sprite, il donnera l'impression de courrir sur place; si je le bouge trop ou pas assez, ses pieds donneront l'impression de glisser).
On donnera à chaque animation un nom compréhensible, comme walk ou fight. C'est ce que j'ai fait pour ce fichier barbarian-walk-se.anim correspondant à la planche de sprite précédente, qui est contenu dans le code associé à cet article, projet ReinerAnim.
Puisque vous savez maintenant comment lire les fichiers XML avec le framework XNA, je vous épargne les détails codesque pour aller à l'essentiel : l'interprétation de ces données.
Jouer avec le temps
A l'idée d'animation est nécessairement accolée l'idée de temps qui passe - sans notion de temps, rien ne peut bouger. Lorsqu'on utilise DirectX ou OpenGL, c'est à nous de gérer cette grandeur. Vous l'aurez compris, les développeurs de XNA ont pris les devants et nous fournissent les services dont nous avons besoin sans même que nous ayons à nous en préoccuper.
Si vous vous rappelez le second billet de la série, le framework XNA nous propose de surcharger deux méthodes qui reçoivent en paramètre un objet du type GameTime : il s'agit des méthodes Update() et Draw(). Leurs fonctions respectives sont assez évidentes : la première met à jour le modèle interne de l'application tandis que la seconde effectue le rendu de ce modèle.
Pour bien vous faire comprendre le mécanisme, je dois vous expliquer comment est généré cette instance de GameTime - et sa relation avec Update() et Draw().
Tout se passe dans la boucle principale du jeu - dans la méthode Game.Run(). A chaque itération de la boucle, le framework appelle la fonction Game.Tick(). Celle-ci récupère l'heure système (avec un précision annoncée d'une milliseconde) et appelle successivement Update() puis Draw(). Une variable importante rentre toutefois en compte à ce moment : la valeur de la propriété Game.IsFixedTimeStep. Cette propriété (accessible en lecture et en écriture) définit le comportement de la méthode Game.Tick() : si elle est mise à false, Update() et Draw() seront appelée aussi souvent que possible. Si cette propriété vaut true, Draw() continue d'être appelée aussi souvent que possible, mais Update() est appelée à intervalle régulier (cet intervalle est défini par la valeur de Game.TargetElapsedTime). La raison de ce comportement est simple : sur PC, les différences de hardware existantes ne permettent bien souvent pas de savoir combien de temps sera nécessaire à la mise à jour et au rendu d'une scène. Sur console, ce problème n'existe pas : toutes les consoles sont supposées identiques d'un point de vue matériel, on peut donc connaître avec une grande précision le temps nécessaire à la mise à jour des données et du rendu - il suffit de le mesurer. Bien entendu, cela ne veut pas dire que sur PC il faut initialiser Game.IsFixedTimeStep à false et l'initialiser true sur console. Au final, vous restez libre de vos choix. Fait intéressant si vous travaillez en intervalles fixes : si le laps de temps écoulé entre deux appels successifs à Update() est supérieur à Game.TargetElapsedTime, l'instance GameTime qui sera passée en paramètre lors du prochain appel à Update() aura sa propriété GameTime.IsRunningSlowly initialisée à true - c'est généralement signe qu'il faut alléger la mise à jour ou le rendu.
L'instance de GameTime reflète cette dualité du comportement - elle possède deux groupes de propriétés distinctes. Le premier donne les informations relative au temps passé dans le domaine du jeu - ces informations sont dépendantes de Game.TargetElapsedTime lorsque Game.IsFixedTimeStep est à true. Il s'agit des propriétés GameTime.ElapsedGameTime (temps passé depuis la dernière mise à jour) et GameTime.TotalGameTime (temps passé depuis le lancement du jeu). L'autre groupe donne les valeurs réelles du temps passé - en provenance directe de l'horloge système. Il s'agit de GameTime.ElapsedRealTime et GameTime.TotalRealTime. Toutes ces propriétés sont du type TimeSpan, qui définit des propriétés telles que TimeSpan.Days ou TimeSpan.TotalSeconds (je vous encourage à lire l'aide pour en savoir plus).
Revenons à notre animation de sprites : comment prendre en compte cette information de temps ?
Nous l'avons vu plus haut, toute animation s'exécute dans un temps donné. Puisque c'est à nous de lancer l'animation, nous connaissons donc la date exacte de début. En fonction de la date de début et de la date actuelle, il est aisé de retrouver quelle frame doit être affichée à un moment T - une simple interpolation linéaire nous donne le résultat.
N = nombre de sprites dans l'animation
D = durée de l'animation
T0 = date de début de l'animation
T = date courante
==> sprite_to_display = (int)(N * (T - T0) / D)
Le choix de l'animation dépends quand à lui soit des actions de l'utilisateur, soit de l'état courant du modèle (c'est à dire que l'animation est choisie par le programme en fonction d'un certain nombre de donnée; par exemple, un monstre va se diriger vers le personnage si il l'aperçoit). Puisqu'il ne s'agit pas d'une opération de rendu, la sélection de l'animation va se faire pendant l'exécution de la méthode Update - c'est à dire pendant la mise à jour du modèle.
On obtient donc le pseudo-code suivant :
class Pawn
{
private GameTime timeBegin;
private int spriteCount;
private int animDuration;
private string animName;
public void SelectAnimation(string name, GameTime gameTime)
{
timeBegin = gameTime;
animName = name;
spriteCount = anim.Get(animName).SpriteCount;
animDuration = anim.Get(animName).Duration;
}
public void Draw(GameTime gameTime)
{
int elapsedTime =
gameTime.TotalMilliseconds - timeBegin.TotalMilliseconds;
int spriteToDisplay =
((spriteCount + 1) * elapsedTime) / animDuration;
anim.Get(animName).DrawSprite(spriteToDisplay);
}
}
class MyGame
{
public void Update(GameTime gameTime)
{
pawn.SelectAnimation("anim_name", gameTime);
}
public void Draw(GameTime gameTime)
{
pawn.Draw(gameTime);
}
}
Exemple: ReinerAnim - un barbare qui marche...

Evolutions depuis la version précédente
Comme d'habitude, je code et je design dans la même foulée - ou plus exactement, je design puis je code dans la même foulée. Ce qui ne m'empêche pas de faire faire des erreurs, notamment à cause d'une mauvais vision des fonctionnalités souhaitées. Lorsque je me rends compte que j'ai fait un erreur d'architecture, je la corrige aussitôt - c'est ce que j'ai fait sur la gestion des fontes pour cette version du code.
La version précédente passait par un SpriteLib.SpriteStatic pour implémenter les fontes, en lui fournissant un SpriteSheetLayout compatible avec cette fonte. La nouvelle version ne fait plus dépendre SpriteLib.Font de SpriteLib.SpriteStatic mais ajoute une interface SpriteLib.IFontLayout qui définit l'ensemble des éléments permettant de gérer la fonte. SpriteLib.FontLayoutXmlBmFont implémente cette interface, ce qui lui permet de gérer des fontes éclatées sur plusieurs planches. Vous pouvez voir le résultat dans le code associé à cet article, où je charge une fonte (morpheus_64_regular) qui est réparties sur 3 planches de 256x256 pixels (bien évidemment, il s'agit plus de démontrer cette nouvelle fonctionnalité que d'une utilisation réelle). La fonctionnalité est d'importance, puisqu'elle permet de gérer les fontes Unicode dans des langages moins aisé à appréhender que le français ou l'anglais. Cette nouvelle architecture nous permettra aussi d'étendre les fonctionnalités de la classe SpriteLib.Font dans le futur - par exemple pour gérer le kerning (espacement réduits entre deux caractères consécutifs lorsque ceux-ci sont "compatibles").
Les sprites animés sont implémentés avec une philosophie similaire. La classe SpriteLib.SpriteAnimated référence un objet qui dérive de l'interface IAnimationStorage. Une implémentation de cette interface capable de lire les fichiers .anim décrit plus haut de trouve dans SpriteLib.AnimationStorageXmlAnim (pour le coup, on se sert dans ce cas de SpriteLib.SpriteStatic, mais ce n'est qu'un détail d'implémentation). Je ne vous cache pas cependant qu'il reste du travail à faire sur ces classes - et notamment implémenter deux fonctionnalités importantes : le chainage des animations et la gestion des pauses.
Conclusion
A co(d)eur vaillant rien d'impossible - même si le concept de base parait non trivial, on s'aperçoit après un peu d'étude que ce n'est en fait guère compliqué. En fait, une fois la barrière organisationnelle franchie (stockage, gestion du temps), il ne reste que des concepts très simples (choix d'un sprite dans une animation, et rendu de ce sprite).
Nous avons presque terminé notre tour d'horizon des sprites - maintenant que nous savons les afficher et les animer, il nous faut leur donner un environnement dans lequel s'épanouir. Mais nous verrons ça très prochainement, je vous le promet.
Notes
[1] cette planche a été créée à partir des fichiers contenus dans l'archive du personnage "barbarian" disponible sur Reiner's Tileset. La couche alpha a notamment été rajoutée, et les images séparées ont été mises ensembles pour créer la planche que vous voyez.
[2] ce qui sera très probablement fait dans une version ultérieure
Commentaires
1. Le mardi 26 février 2008 à 17:53, par Martin
2. Le mercredi 27 février 2008 à 11:03, par Emmanuel Deloget
3. Le lundi 3 mars 2008 à 15:26, par Martin
4. Le lundi 3 mars 2008 à 15:43, par Emmanuel Deloget
5. Le lundi 3 mars 2008 à 16:24, par Martin
6. Le vendredi 28 mars 2008 à 17:21, par Martin
7. Le lundi 31 mars 2008 à 19:02, par Emmanuel Deloget
8. Le lundi 31 mars 2008 à 20:10, par Martin
9. Le vendredi 4 avril 2008 à 14:15, par Emmanuel Deloget
10. Le samedi 5 avril 2008 à 00:58, par Martin
11. Le vendredi 18 avril 2008 à 15:21, par OmegaBahamut
12. Le vendredi 18 avril 2008 à 23:59, par Emmanuel Deloget
13. Le samedi 26 avril 2008 à 15:29, par Florian
14. Le lundi 28 avril 2008 à 13:13, par Emmanuel Deloget
:: Fil rss des commentaires de ce billet ::
Ajouter un commentaire