Thursday, 6 October 2011

XNA/Android cross-platform and strongly-typed access to game contents


In this previous post, we already showed how to use T4 templates for several use cases, with a special focus on Resource Files (ResX). Now we are going to complete that post with another one, focused specifically on Content management.
What we want to achieve is an elegant, cross-platform, and strongly-typed way of accessing contents in our projects.

The XNA approach to contents

XNA identifies contents with Asset Names, in the form of strings, but it doesn’t offer any form of strong-typed access, what is very bug-prone, because if you misspell the name of an asset, you won’t notice until runtime, or you won’t notice ever…

The Android approach to contents

Android already offers strongly-typed access to contents that are placed below the “Resources” special folder. Unfortunately, there are a lot of limitations for the contents inside that folder. One of the most evident (and stupid), is that contents cannot be re-arranged into subfolders, what makes it almost un-usable for medium-big projects. Besides that, the kind of access Android gives to that folder is through INT identifiers, what conflicts with the XNA way of doing this (which uses Asset names).
One of the possible solutions is to move our contents to the “Assets” folder, where things can be arranged arbitrarily, and where assets are identified with a string, very much like in XNA. Too bad that Android doesn’t offer strongly-typed access to that folder…

What we want to achieve

1.- We want to be able to arrange our contents in sub-folders, so in Android, we will have to go to the Assets approach, instead of the Resources one.
2.- That solves also the unification of types when identifying assets. In both cases (XNA and Android), we will be using Asset Names as strings.
3.- In both sides we will need to provide a strongly-typed way of accessing contents.
4.- We want the exact same interface that is finally published outwards, so that every piece of code that uses our strongly-typed classes, write the exact same code no matter which platform we are coding on.

An implementation using again T4 templates

Again, we will write down two different T4 templates, one for XNA and one for Android. Both of them will have to do merely the same, but with some minor differences. Let’s see them:

Example: XNA T4 Template to give strongly-typed access to contents

This template will be placed wherever we want to use it. It can be in the main XNA Game project, or in a library project shared all around. Basically, it will search inside the Visual Studio solution for the Game’s Content Project. Once found, it will iterate recursively through the file and folder structure of the project, generating classes that give strong-typed access to each Asset.
Imagine we have the following structure in our contents project:
image
We want to get an output like the following:
image
As you can see, we will use namespaces to represent the tree-structure of folders in the content project. Once we find a folder with one or more content files, we will create a class named “Keys” that will hold properties to access asset names. We will also create an enumeration with all the assets found at that level. This way, we also allow to navigate through the contents tree, if needed.

The code

The XNA template that generates that, is the following:
<#
//  --------------------------------------------------------------------------------------------------------------
//  Template: Generates C# code to give strongly-typed access to Asset files
//  Author: Inaki Ayucar
//  Website: www.graphicdna.net
//  Based on the work of: http://blog.baltrinic.com
//  -----------------------------------------------------------------------------------------------------------
#>
<#@ template debug="true" hostspecific="true" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Xml" #>
<#@ assembly name="Microsoft.VisualStudio.Shell.Interop.8.0" #>
<#@ assembly name="EnvDTE" #>
<#@ assembly name="EnvDTE80" #>
<#@ assembly name="VSLangProj" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Xml" #>
<#@ import namespace="Microsoft.VisualStudio.Shell.Interop" #>
<#@ import namespace="EnvDTE" #>
<#@ import namespace="EnvDTE80" #>
<#@ import namespace="Microsoft.VisualStudio.TextTemplating" #>
<#  // ------------------------------------------------------------------------------------------------------
    // Get global variables
    // ------------------------------------------------------------------------------------------------------
    var serviceProvider = Host as IServiceProvider;
    if (serviceProvider != null)
        Dte = serviceProvider.GetService(typeof(SDTE)) as DTE;
 
 
    // Fail if we couldn't get the DTE. This can happen when trying to run in TextTransform.exe
    if (Dte == null)
        throw new Exception("T4MVC can only execute through the Visual Studio host");
 
 
    Project = GetXNAContentsProject(Dte);
    if (Project == null)
    {
        Error("Could not find XNA Content Project.");
        return"XX";
    }   
    Project prjT4 = GetProjectContainingT4File(Dte);
    if (prjT4 == null)
    {
        Error("Could not find Template's project");
        return"XX";
    }   
 
     AppRoot = Path.GetDirectoryName(Project.FullName) + '\\';
     RootNamespace = prjT4.Properties.Item("RootNamespace").Value.ToString();
    // --------------------------------------------------------------------------------------------------
#>
// ------------------------------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
// ------------------------------------------------------------------------------------------------------
using System.Threading;
 
 
<#
    try
    {
        // We are storing in a List<ResourceEntry> (declared below) a list with all string entries of
        // all files found matching our search criteria
        AllEntries = new Dictionary<string, List<AssetFileInfo>>();
 
        string projectFileName = Path.GetFileName(Project.FullName);
        string projectFullPath = Project.FullName.Substring(0, Project.FullName.Length - projectFileName.Length);
 
        // Find files on our project that match our search criteria (recursively), and store every string
        // entry on those files
        FindResourceFilesRecursivly(projectFullPath, Project.ProjectItems, "");
 
 
        foreach(string path in AllEntries.Keys)
        {
            if(path == null || path == "")
                continue;
 
            List<string> enumNames = new List<string>();
 
            string aux = path;
 
            // To avoid conflict names with namespaces, the class names will always be "Keys"
            string className = "Keys";
 
            if(aux.EndsWith("\\"))
                aux = aux.Remove(aux.Length - 1, 1);
            string pathNameSpace = aux.Replace("\\", ".");
 
            // Start of namespace
            if(pathNameSpace != "")
                WriteLine(string.Format("namespace {0}.Assets.{1}", RootNamespace, pathNameSpace));
            else WriteLine(string.Format("namespace {0}.Assets", RootNamespace));
            WriteLine("{");
 
            // Start of class
            WriteLine(string.Format("\tpublic class {0}", className));
            WriteLine("\t{");
            foreach(AssetFileInfo info in AllEntries[path])
            {  
                string filenameWithoutExt= Path.GetFileNameWithoutExtension(info.File);
                WriteLine(string.Format("\t\tpublic static string {0}", filenameWithoutExt));
                WriteLine("\t\t{");
                WriteLine(string.Format("\t\t\tget  {{ return \"{0}\"; }}", info.AssetName.Replace(@"\", @"\\") ));
                WriteLine("\t\t}");
 
                enumNames.Add(filenameWithoutExt);
            }
 
            // Start of Enum
            WriteLine("\t\tpublic enum eKeys");
            WriteLine("\t\t{");
            foreach(string enumname in enumNames)
                WriteLine(string.Format("\t\t\t{0},", enumname));
            // Close enum
            WriteLine("\t\t}");
 
 
            // Close class
            WriteLine("\t}");
 
 
 
            // Close namespace
            WriteLine("}");
        }
 
    }
    catch(Exception ex)
    {
        Error(ex.ToString());
    }
#>
 
 
<#+ // --------------------------------------------------------------------------------------------------------
    // Class feature control block:
    // Remarks: Identified by the #+ mark, allows to define variables, methods, etc
    // --------------------------------------------------------------------------------------------------------
    const string Kind_PhysicalFolder = "{6BB5F8EF-4483-11D3-8BCF-00C04F8EC28C}";
    bool AlwaysKeepTemplateDirty = true;
    static DTE Dte;
    static Project Project;
    static string AppRoot;
    static string RootNamespace;
    static Dictionary<string, List<AssetFileInfo>> AllEntries;
    static List<string> SupportedExtensions = new List<string>() {".dds", ".png", ".bmp", ".tga", ".jpg"};
 
    /// <Summary>
    /// FindResourceFilesRecursivly
    /// Remarks: Searches recursively in the files of our project, for those which are in the same folder
    /// that this file, or below, and that have extensions included in the supported extension list
    /// </Summary>
    void FindResourceFilesRecursivly(string pProjectFullPath, ProjectItems items, string path)
    {
        string assetRelativePath = path.TrimStart(new char[1]{'.'});
        assetRelativePath = assetRelativePath.Replace('.', '\\');
        foreach(ProjectItem item in items)
        {       
            if(item.Kind == Kind_PhysicalFolder)
                FindResourceFilesRecursivly(pProjectFullPath, item.ProjectItems, path+"."+item.Name);
            else
            {               
                // check if extension is supported
                string extension = Path.GetExtension(item.Name).ToLowerInvariant();
                if(SupportedExtensions.Contains(extension))
                {
                    string itemFileName = item.FileNames[0];
                    if(itemFileName == null)
                        continue;
 
 
                    AssetFileInfo info = new AssetFileInfo();
 
                    info.AssetName = itemFileName.Remove(0, pProjectFullPath.Length);
 
                    // XNA require que los asset names no tengan extensión
                    info.AssetName = info.AssetName.Substring(0, info.AssetName.Length - extension.Length);
 
                    info.File = item.Name;
                    info.Path = itemFileName.Substring(0, itemFileName.Length - item.Name.Length);
 
                    if(!AllEntries.ContainsKey(assetRelativePath))
                        AllEntries.Add(assetRelativePath, new List<AssetFileInfo>());
 
                    AllEntries[assetRelativePath].Add(info);
                }
            }
        }
    }
    /// <Summary>
    /// GetXNAContentsProject
    /// Remarks: http://www.codeproject.com/KB/macros/EnvDTE.aspx
    /// </Summary>
    Project GetXNAContentsProject(DTE dte)
    {
        foreach(Project prj in dte.Solution.Projects)
        {
            // XNA Content projects define this property. Use it to identify the project
            if(!HasProperty(prj.Properties,
                 "Microsoft.Xna.GameStudio.ContentProject.ContentRootDirectoryExtender.ContentRootDirectory"))
                continue;
 
            return prj;         
        }    
        return null;
    }
     /// <Summary>
    /// GetProjectContainingT4File
    /// Remarks:
    /// </Summary>
    Project GetProjectContainingT4File(DTE dte)
    {
 
        // Find the .tt file's ProjectItem
        ProjectItem projectItem = dte.Solution.FindProjectItem(Host.TemplateFile);
 
        // If the .tt file is not opened, open it
        if (projectItem.Document == null)
            projectItem.Open(Constants.vsViewKindCode);
 
        if (AlwaysKeepTemplateDirty) {
            // Mark the .tt file as unsaved. This way it will be saved and update itself next time the
            // project is built. Basically, it keeps marking itself as unsaved to make the next build work.
            // Note: this is certainly hacky, but is the best I could come up with so far.
            projectItem.Document.Saved = false;
        }
 
        return projectItem.ContainingProject;
    }
    /// <Summary>
    /// Struct: ResourceEntry
    /// Remarks: Stores information about an entry in a resource file
    /// </Summary>
    private bool HasProperty(Properties properties, string propertyName)
    {
        if (properties != null)
        {
            foreach (Property item in properties)
            {
                //WriteLine("// " + item.Name);
                if (item != null && item.Name == propertyName)
                    return true;
            }
        }
        return false;
    }
    /// <Summary>
    /// Struct: ResourceEntry
    /// Remarks: Stores information about an entry in a resource file
    /// </Summary>
    struct AssetFileInfo
    {               
        public string AssetName {get;set;}
        public string Path { get; set; }
        public string File { get; set; }
    }  
#>

Usage

One the T4 template is included on your solution, the way of accessing Assets in XNA is like the following:
Content.Load<Texture2D>(GDNA.PencilBurst.Assets.Textures.UI.Keys.circleT);
All absolutely strong-typed, much less bug-prone.

Android version

Once you have that as a start point, developing the Android version is pretty straight-forward. You just need to change a couple of things:
  • Instead of searching for the Contents Project inside your solution, you will need to search for the main project, and look for the “Assets” subfolder.
  • Asset names do include file extensions in Android, so be sure to not remove them (like in XNA)
  • Also, asset names will need to replace ‘\’ for ‘/’, so you can correctly invoke:
stream = mContext.Assets.Open(pAssetName);
And that´s all !!!

No comments: