Après avoir quelques heures à me triturer les méninges sur un ContentImporter perso pour Pull N' Bounce, je vais partager avec vous ma façon de faire. En espérant que ça vous fasse économiser un peu de temps. J'espère ne pas me planter dans ce que je vais raconter, mais n'étant pas parfaitement expert dans ces technologies, je vous invite à consulter des sources plus précises si vous souhaitez approfondir le sujet.
XNA et les ressources extérieures
XNA a ceci de particulier qu'il n'accepte pas facilement de charger des fichiers de ressources "bruts". Il n'est pas par exemple pas possible de fournir les fichiers de modèles 3D directement avec l'exécutable, et de les charger à la volée, laissant ainsi la possibilité à l'utilisateur de modifier ce modèle. A la place, il faut référencer les différentes ressources dans le projet de développement, et laisser travailler le ContentProcessor pour générer des fichiers XNB. Ces fichiers XNB peuvent poser problème, puisqu'ils ne sont pas modifiables à la volée.
Quand on travaille sur un projet Windows, qui ne sera pas porté sur XBox, il est possible de passer outre cette limitation dans certains cas. Par exemple, pour charger des fichiers de données (sauvegardes, niveaux, configuration, ...) écrits dans un format personnalisé (binaire, texte, xml, ...), on peut facilement utiliser les fonctions de lecture de fichier du framework .NET. De même, le chargement de textures peut se faire par la fonction Texture2D.LoadFromFile(), qui va s'occuper d'aller chercher un fichier de texture brut (PNG, BMP, JPG) pour le charger en mémoire, sans passer par la compilation en XNB. J'utilise ces deux méthodes pour Blind Shark, qui utilise de nombreux éléments non-compatibles avec la XBox.
Par contre, pour un projet XBox, tout doit se trouver dans le projet de développement, même le plus simple fichier de configuration. Or, par défaut, on ne trouve qu'un nombre réduit de types de ressources référencées par le ContentProcessor. Et si on veut utiliser un élément personnalisé (comme un fichier de configuration en XML), il faut programmer son propre ContentImporter.
Configuration en XML
Avant d'attaquer le ContentImporter, un petit mot sur le fichier que nous allons charger. Ce sera un simple fichier de configuration en XML, qui permettra de charger différentes données. J'utilise cette méthode pour la génération des niveaux de Pull N' Bounce, mais n'importe quelle donnée paramétrable peut être chargée de cette façon, comme des stats de monstres, la configuration d'un effet, ...
J'ai choisi le XML pour son extensibilité. A l'heure actuelle, le fichier permettant de construire mon niveau est assez simple, puisqu'il référence les blocs à placer, les switchers et la position de la balle. Mais j'ai déjà en tête d'autres éléments, ainsi que des niveaux de forme et taille différentes. Et le XML, quand on sait correctement s'en servir, permet de facilement étendre un fichier avec de nouvelles options, sans pour autant casser l'existant. En plus, il est assez lisible pour être facilement édité par un humain. Voilà pour le choix du XML, qui n'est cependant pas à utiliser à toutes les sauces. Pour Blind Shark, j'ai ainsi utilisé à la fois du XML, une base de données SQLite, et un format binaire perso.
Le projet de Jeu
Pour commencer, on va partir d'un projet XNA standard pour Windows. Pour éviter de trop se disperser, le projet consistera uniquement à afficher une texture à différents endroits de l'écran, à des tailles différentes également. On va créer une classe qui nous permettra de gérer ça : SpriteDisplay.cs. On peut la mettre à la racine de notre projet. La classe permet de stocker une position et une taille, qui nous permettront de réaliser l'affichage d'une texture.
using System; using System.Collections.Generic; using System.Linq; using System.Text;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace DemoContentImporter public class SpriteDisplay #region Variables Vector2 _vLocation; Vector2 _vSize; #endregion #region Constructors public SpriteDisplay() _vLocation = Vector2.Zero; _vSize = Vector2.One; #endregion #region Accessors public Vector2 Location get return _vLocation; set _vLocation = value; public Vector2 Size get return _vSize; set _vSize = value; private Rectangle DrawRectangle get return new Rectangle((int)_vLocation.X, (int)_vLocation.Y, (int)_vSize.X, (int)_vSize.Y); #endregion #region Display public void Display(SpriteBatch spriteBatch, Texture2D texture, Color color) spriteBatch.Draw(texture, DrawRectangle, color); #endregionPour stocker tout ça, on pourrait utiliser une List (on va le faire, en fait), mais souvent d'autres informations viendront se mélanger avec ça. Une petite classe Level va nous aider à contenir tout ça.
using System; using System.Collections.Generic; using System.Linq; using System.Text;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; namespace DemoContentImporter public class Level #region Variables ListOn va ajouter une texture au projet, qu'on nommera "blank" (c'est une image blanche d'un pixel sur un). La classe principale Game1 va se voir ajouter deux objets. La texture à afficher, pour commencer, qu'on chargera dans la méthode LoadContent(), et un Level, qu'on garnira à la main pour le tester.
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Audio; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.GamerServices; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Microsoft.Xna.Framework.Media; using Microsoft.Xna.Framework.Net; using Microsoft.Xna.Framework.Storage;
namespace DemoContentImporter public class Game1 : Microsoft.Xna.Framework.Game GraphicsDeviceManager graphics; SpriteBatch spriteBatch; Level _level; Texture2D _texBlank; public Game1() graphics = new GraphicsDeviceManager(this); Content.RootDirectory = "Content"; _level = new Level(); protected override void Initialize() base.Initialize(); _level.Color = Color.PowderBlue; SpriteDisplay sprite = new SpriteDisplay(); sprite.Location = new Vector2(10, 10); sprite.Size = new Vector2(100, 200); _level.AddSprite(sprite); sprite = new SpriteDisplay(); sprite.Location = new Vector2(45, 250); sprite.Size = new Vector2(100, 100); _level.AddSprite(sprite); sprite = new SpriteDisplay(); sprite.Location = new Vector2(500, 100); sprite.Size = new Vector2(10, 10); _level.AddSprite(sprite); protected override void LoadContent() // Create a new SpriteBatch, which can be used to draw textures. spriteBatch = new SpriteBatch(GraphicsDevice); _texBlank = Content.LoadLe projet fonctionne, et affiche trois rectangles colorés... Ouais ! Bon, comme c'est pour une démo, c'est pas grave. On va maintenant faire le projet XBox. Comme on a fait attention à n'utiliser que du XNA, le projet XBox va être très simple à générer. Faites un clic-droit sur le projet dans l'arborescence de la solution, et sélectionnez "Create Copy of Project for XBox 360". Un nouveau projet apparaît dans la solution, similaire au projet de départ, excepté pour l'icône associée. En fait, il s'agit d'un véritable clone, qui utilise les mêmes fichiers que le projet Windows. Très pratique, car cela permet de ne pas se soucier des modifications entre les projets, puisqu'ils partagent (presque) tout : ressources dans Content, fichiers sources, ... En compilant, un petit warning vous avertira d'une référence impossible à résoudre. Et oui, les projets XBox 360 n'ont pas accès à tous les namespaces, et System.Data fait partie de ceux-là . Supprimez la référence (qui est affichée avec un warning). Déployez-le pour test sur votre XBox 360. Ca fonctionne impeccable.
Juste pour info, j'ai renommé ce projet "DemoContentImporter 360", pour plus de lisibilité.
Le ContentImporter
Le code présent dans le fonction Initialize() est bien sympa, mais si on pouvait charger dynamiquement notre objet Level, ce serait bien plus sympa. C'est ce que nous allons faire ici. Dans la solution courante, créons un nouveau projet de type "Content Pipeline Extension Library". On va le nommer "ContentPipelineExtension". Commençons par supprimer le fichier "ContentProcessor1.cs".
Ce projet servira à générer les fichiers XNB. Il faut donc rajouter un fichier de type "Content Type Writer". Le fichier généré est presque complet. Commençons par le renommer en LevelWriter, puisque c'est ce dont il s'occupera. A la fin de la liste des déclarations "using", on trouve un "using TWrite = System.String". Remplaçons le type string par notre propre type Level :
using TWrite = DemoContentImporter.Level;
La classe Level est inconnue. Rajoutons-la dans le projet, ainsi que la classe SpriteDisplay (Copier puis Coller depuis le projet de départ). Il faut encore modifier les deux fonctions du ContentTypeWriter. Write() permet d'écrire le fichier XNB, GetRuntimeReader() renvoie lui une indication sur le TypeReader, que nous n'avons pas encore défini. On le laisse en friche pour l'instant. Concernant Write(), il faut maintenant réfléchir à ceux que l'on veut écrire. Et bien, la couleur pour commencer, puis la liste des SpriteDisplay.
protected override void Write(ContentWriter output, TWrite value) output.Write(value.Color); output.WriteObject>(value.Sprites);
La première fonction Write() accepte un paramètre de type Color. Une bonne vingtaine de types sont supportés, et peuvent ainsi être directement écrits. Pour notre liste, on doit utiliser WriteObjet, et spécifier le type de notre liste. Comment le fichier va-t-il faire pour générer le fichier à partir de notre liste de SpriteDisplay ? Et bien, il ne saura pas le faire. Il faut donc réaliser un Writer pour SpriteDisplay également.
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Content.Pipeline; using Microsoft.Xna.Framework.Content.Pipeline.Graphics; using Microsoft.Xna.Framework.Content.Pipeline.Processors; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;
// TODO: replace this with the type you want to write out. using TWrite = DemoContentImporter.SpriteDisplay; namespace DemoContentImporter [ContentTypeWriter] public class SpriteDisplayWriter : ContentTypeWriterNotre ContentImporter est terminé ! (ou presque). Il manque le Reader. Et puis, au niveau conception, le fait d'avoir les classes Level et SpriteDisplay dans deux projets différents n'est pas très judicieux. Si on oublie de changer une modification dans les deux projets, on risque de sérieux problèmes.
Le projet des ressources partagées
Un nouveau projet va nous aider à gérer tout ça. Créez un nouveau projet dans la solution, de type "Windows Game Library", et appelez-le "SharedData". La classe auto-générée ne nous sert à rien, on va la supprimer. Par contre, avant d'oublier, on va ajouter les deux classes Level et SpriteDisplay dans ce projet. Ce projet nous servira de liaison entre le ContentImporter et le projet du jeu lui-même. Supprimons les classes Level et SpriteDisplay de ces deux projets.
Les erreurs arrivent ! Ce qui est normal, puisque la liaison n'est pas encore faite. Pour ce faire, commençons par le projet DemoContentImporter. Clic droit sur les références, puis "Ajouter une référence". L'onglet qui nous intéresse est celui des "Projets". La liste des projets de la solution apparaît. Sélectionnons "SharedData", et plusieurs erreurs ont disparu. Faisons la même chose sur le projet ContentPipelineExtension. Il reste une erreur, qui se trouve dans le projet pour la 360. Malheureusement, on ne peut pas réaliser l'association avec SharedData, puisque d'un côté on a un projet Windows, et de l'autre un projet XBox 360.
Réalisons une copie du projet SharedData vers un projet XBox 360, comme précédemment pour le jeu. Là aussi, renommons-le, en "SharedData 360". Comme précédemment, tous les fichiers des deux projets sont partagés, ce qui va nous éviter énormément d'ennuis. Ajoutons "SharedData 360" au projet de jeu pour la XBox. Compilation OK !
Le Reader
La méthode de lecture du fichier XNB n'existe pas encore. Nous allons la créer dans le SharedData, car elle sera utilisée par le projet, et par le ContentImporter. Ajoutons un fichier de type "Content Type Reader" au projet, et nommons-le LevelReader.cs. Comme pourle writer, une déclaration using particulière est à renseigner en en-tête de fichier :
using TRead = DemoContentImporter.Level;
Il reste la fonction de lecture Read() à renseigner. La méthode est ici l'inverse de la méthode write. Il faut donc suivre le même ordre pour retrouver nos types de données :
protected override TRead Read(ContentReader input, TRead existingInstance) existingInstance = new TRead(); existingInstance.Color = input.ReadColor(); List>(); for (int i = 0; i < list.Count; i++) existingInstance.AddSprite(list[i]); return existingInstance;
Notre objet Level est bien construit. Par contre, comme pour le Writer, il va falloir créer un Reader pour SpriteDisplay :
using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics;
// TODO: replace this with the type you want to read. using TRead = DemoContentImporter.SpriteDisplay; namespace DemoContentImporter public class SpriteDisplayReader : ContentTypeReaderSi vous vous rappelez du Writer, il y avait une fonction qui concernait le Reader, mais qu'on n'avait pas touché. On va y retourner. Ouvrez les fichiers LevelWriter et SpriteDisplayWriter du projet ContentPipelineExtension. Voici le contenu des deux fonctions, qui ne font que référencer le Reader associé :
public override string GetRuntimeReader(TargetPlatform targetPlatform) return typeof(DemoContentImporter.LevelReader).AssemblyQualifiedName;
// ***** public override string GetRuntimeReader(TargetPlatform targetPlatform) return typeof(DemoContentImporter.SpriteDisplayReader).AssemblyQualifiedName;Le fichier de données
On approche du bout, mais on n'a toujours pas notre fichier XML de départ. Pour créer son squelette de départ, on va utiliser une fonction utilisant un XmlSerializer. On pourra ensuite l'éditer manuellement. Modifiez la fonction Update de Game1.cs ainsi :
if WINDOWS using System.Xml; using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate; #endif //****** protected override void Update(GameTime gameTime) // Allows the game to exit if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed) this.Exit(); #if WINDOWS if (Keyboard.GetState().IsKeyDown(Keys.Space)) Level level = new Level(); level.Color = Color.PowderBlue; SpriteDisplay sprite = new SpriteDisplay(); sprite.Location = new Vector2(10, 10); sprite.Size = new Vector2(100, 200); level.AddSprite(sprite); sprite = new SpriteDisplay(); sprite.Location = new Vector2(45, 250); sprite.Size = new Vector2(100, 100); level.AddSprite(sprite); sprite = new SpriteDisplay(); sprite.Location = new Vector2(500, 100); sprite.Size = new Vector2(10, 10); level.AddSprite(sprite); XmlWriterSettings xmlSettings = new XmlWriterSettings(); xmlSettings.Indent = true; using (XmlWriter xmlWriter = XmlWriter.Create("Level01.xml", xmlSettings)) IntermediateSerializer.Serialize(xmlWriter, level, null); #endif base.Update(gameTime); Le paramètre de génération WINDOWS permet d'ignorer ce code dans le projet XBox. En effet, le namespace Microsoft.Xna.Framework.Pipeline n'est pas disponible sur XBox 360. D'ailleurs, il n'est pas ajouté par défaut dans les références du projet, et il faut le rajouter pour le projet Windows. Lançons le jeu sur Windows maintenant, et pressons sur espace.
Dans le répertoire bin\x86\Debug du projet se trouve maintenant un fichier Level01.xml, qui contient les données de notre niveau. On peut l'éditer sans problèmes, mais il faut veiller à respecter le formatage des valeurs.
Ajoutons ce fichier au Content de notre projet, puisque c'est ce que nous voulons obtenir au final, et lançons la compilation. Deux erreurs sont lancées. POur commencer, on peut remarquer que le fichier, ajouté dans un des deux projets de jeu, est bien présent dans les deux projets. Ca c'est bien. Maintenant, l'erreur nous dit que le type Level n'est pas trouvé.
En effet, le dossier Content est en fait un petit projet à part, qui dispose aussi de ses références. Et ce sont ces références qui sont utilisées pour générer les fichiers XNB. Rajoutons dans les références de Content notre projet COntentPipelineExtension. Ce coup-ci, la compilation a fonctionné, et notre fichier Level01.xnb a été généré.
Dans la fonction Initialze() de Game1, supprimons tout le chargement de _level. A la place, direction la fonction LoadContent(), dans laquelle on chargera notre Level depuis le fichier :
protected override void LoadContent() // Create a new SpriteBatch, which can be used to draw textures. spriteBatch = new SpriteBatch(GraphicsDevice); _texBlank = Content.Load
Conclusion
Que ce soit sur le PC, ou sur le XBox 360, le projet tourne à la perfection. On peut facilement rajouter de nouveaux niveaux à l'aide de fichiers XML, ou éditer les niveaux présents. Le ContentImporter peut être encore amélioré, bien sûr, et je n'ai pas encore effleuré le ContentProcessor. Mais cette première étape est déjà très importante.
J'ai passé pas mal de temps à triturer les projets, les références, à déplacer les fichiers d'un projet à l'autre, pour finalement réussir à obtenir ce résultat. J'espère qu'il vous servira dans vos projets !