Sunday, November 27, 2011

The XML Content Importer

A while ago, I wrote a quick rundown of XNA's Content Pipeline, the classes needed to extend it and how they work together. However for the most part, one wouldn't need to do all that since the built in XML Importer is so powerful. One can load almost any class through the content pipeline using only XML.
We'll go few some examples to illustrate this.
If you want to follow along with the code, go ahead and make a new XNA Game Project. I named mine XMLPipelineTutorial.
Right click on the Content Project, select Add New Item... and select XML file. I named mine SimpleString.xml because that's what it will load. The following XML should appear:

<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <!-- TODO: replace this Asset with your own XML asset data. -->
  <Asset Type="System.String"></Asset>
</XnaContent>
The Node named Asset has an attribute called Type which is set to "System.String". This is what will be returned when we call the Content Manager's Load method.
What we put inside the Asset tags is the actual content
I've changed the XML to look like this:

<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <Asset Type="System.String">
    Hello World!
  </Asset>
</XnaContent>
Now in Game1.cs I changed the LoadContent() method so it looks like this:
protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch(GraphicsDevice);

    string xmlContent = Content.Load<string>("SimpleString");
    Console.WriteLine(xmlContent);
}
The line Console.WriteLine(xmlContent) is a little trick I use for debugging games when I don't want to bother loading a font and drawing it on the screen. The output appears on the 'Output' window which you can find in Visual Studio via Debug -> Windows -> Output.
Easy enough. We can load arrays of things:

<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <!-- TODO: replace this Asset with your own XML asset data. -->
  <Asset Type="System.Int32[]">
    4 5 1 6 9 4 10 100032
  </Asset>
</XnaContent>
I named this one IntegerArray.xml.
We can load this just as easily.

protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch(GraphicsDevice);

    string xmlContent = Content.Load<string>("SimpleString");
    Console.WriteLine(xmlContent);

    int[] xmlContent2 = Content.Load<int[]>("IntegerArray");
    for (int i = 0; i < xmlContent2.Length; i++)
    {
        Console.WriteLine(xmlContent2[i].ToString());
    }
}
This is all very well and good but doesn't really show off the flexibility. Let's import a Dictionary of Vectors keyed by Rectangles just for kicks.

<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <!-- TODO: replace this Asset with your own XML asset data. -->
  <Asset Type="System.Collections.Generic.Dictionary[Microsoft.Xna.Framework.Rectangle, 
         Microsoft.Xna.Framework.Vector2]">
    <Item>
      <Key>0 12 24 5</Key>
      <Value>5 5.4</Value>
    </Item>
    <Item>
      <Key>12 3 6 2</Key>
      <Value>0 0</Value>
    </Item>
  </Asset>
</XnaContent>
Each KeyValuePair in the Dictionary is put in between Item nodes. We could had done this for the simple array but we don't have to. Each Key and Value is inside a node of the same name. The XML importer uses reflection to figure out what's what (which is why it's so flexible and powerful).
The Keys, being rectangles are just a list of int's representing X, Y, Width and Height respectively and the Values being Vectors are written as two float's X and Y, respectively. For some reason the pipeline expects these to be in this format and won't let me put values between Width/Height tags or X/Y tags. I suppose for convenience.
Now to load and test it out.
protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch(GraphicsDevice);

    Dictionary<Rectangle, Vector2> xmlContent = 
        Content.Load<Dictionary<Rectangle, Vector2>>("VectorRectangleDictionary");

    foreach (Rectangle rectangle in xmlContent.Keys)
    {
        Console.WriteLine(rectangle.ToString());
    }

    foreach (Vector2 vector in xmlContent.Values)
    {
        Console.WriteLine(vector.ToString());
    }
}
And all is as it should be.
But these are all classes from preexisting libraries, one might point out, what if I want to load my own class?
Not a problem! Since the XML importers uses reflection, we can load any class we want through it.
To do this though we must add a Game Library Project to keep any classes we want to load. Then add a reference to that project in the content project.
We need the Library Project because the content project doesn't know anything about the XMLPipelineTutorial namespace so it won't be able to find any types you try to give it and trying to add a reference to it would cause a circular dependency.
Remember to add a reference to the Game Library not only in the content project but also the main game project.
Here's a simple class:
public class GameItem
{
    public string Name = null;
    public double Value;
}
And here's an XML file for loading it:
<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <Asset Type="XMLTutorialData.GameItem">
    <Name>Sword</Name>
    <Value>350</Value>
  </Asset>
</XnaContent>
Make sure the namespace in the Type attribute match yours.
Now we can load it just like any other asset
protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch(GraphicsDevice);

    GameItem item = Content.Load<GameItem>("Item");

    Console.WriteLine(item.Name + " " + item.Value.ToString());
}
And you should see, "Sword 350" in the output window.
There is a subtlety here that's worth pointing out. To illustrate I've defined another GameItem object and loaded it with the same file. Here's the new LoadContent method. See if you can predict the output before running.
protected override void LoadContent()
{
    // Create a new SpriteBatch, which can be used to draw textures.
    spriteBatch = new SpriteBatch(GraphicsDevice);

    GameItem itemOne = Content.Load<GameItem>("Item");
    GameItem itemTwo = Content.Load<GameItem>("Item");

    Console.WriteLine(itemOne.Name + " " + itemOne.Value.ToString());
    Console.WriteLine(itemOne.Name + " " + itemOne.Value.ToString());

    //Changed the name of itemOne...
    itemOne.Name = "Boot";

    //Print out the name of itemTwo.
    Console.WriteLine(itemTwo.Name);
}
What the?! Changing itemOne's name changed itemTwo's name as well! This is because GameItem is a reference object. What we have are two references to the same object. Go ahead and try this with a value type like a single integer or a Vector2 object and you won't get the same behavior.

The importer will by default only deserialize public fields and properties. If you have private fields you'd like to add you must mark them with a [ContentSerializer] attribute and if you have public fields or properties you'd like ignored by the serializer, you must use the [ContentSerializerIgnor] attribute (excluding readonly fields and properties).
So let's change the GameItem class so not everything is public.
public class GameItem
{
    [ContentSerializer]
    string _name = null;
    [ContentSerializer]
    double _value;

    public string Name
    {
        get { return _name; }
    }

    public double Value
    {
        get { return _value; }
    }
}
The Name and Value properties are readonly so they don't require an attribute to ignore them.
Here's the new Item.xml where the node names match the new field names
<?xml version="1.0" encoding="utf-8" ?>
<XnaContent>
  <Asset Type="XMLTutorialData.GameItem">
    <_name>Sword</_name>
    <_value>350</_value>
  </Asset>
</XnaContent>
And this works exactly the same as before except the GameItem class is better encapsulated.
That's all for now. The built in XML importer has a lot of features and is quite flexible. There's a lot more to explore and perhaps I'll go into more in future articles.

Update: I cam across this question in StackOverflow where someone's demonstrated a nice way to print out how exactly the intermediate serializer expects to see your .xml formatted for some specific object. Definitely worth checking out.

No comments :

Post a Comment