DEV Community

Cover image for 20 Game Challenge: Making a Custom Importer
Brittany Blair
Brittany Blair

Posted on

20 Game Challenge: Making a Custom Importer

If you haven't seen My first 20 game challenge post you can check out the first post here It goes over the progress I made thus far in building my game engine using the MonoGame framework.

This post focuses on creating a custom Importer for the MonoGame Content Pipeline. I have made a bunch of improvements on my game engine in preparation for game 2 that I am super excited to talk about. However, due to the lack of documentation and guides around making a custom pipeline extension, I decided to dedicate a post to this task.

If you're looking for specifically a game 2 of the 20-game challenge update, no worries I am still working on game 2 and will be posting all about it very soon!

I am approaching this tutorial in the same way I made my sprite sheet animation importer but you can follow these steps to make ANY kind of importer you want. Just know that you may have to use different libraries or create different functions depending on what you're importing.

The Problem

For game 2 I made some 2D sprite animations in Marmoset Hexels 3, which allows me to export sprite sheet animations. But once I started trying to make an animation system in my game engine I ran into some quality-of-life trouble.

MonoGame's standard content pipeline handles these sprite sheets in the same way they would any 2D image, but there is so much more information in our sprite sheet. So I would have to hard code a lot of the animation information such as how many frames, the frame rate, the size of the sprite being animated, etc. and make a new object for every animation in a sprite sheet. Multiply that by the number of sprite sheet animations imported and that becomes a ton of work.

So I needed to find a way to import .png files in a way that would let me fill out that information, save it, and load it in the game when it's needed.

For someone whose first career was art, and who is an entirely self-taught programmer this was not an easy task for me. Yeah, I could search GitHub and find someone else's importer or use MonoGame Extended and add that to my project. But that would defeat the purpose of all of this. I want to learn all I can about MonoGame so, I want to make it myself.

Resources

This is a seriously undocumented task and It took me weeks to get what I'm about to show you working. But also It would be a disservice to the community not to go ahead and mention Aristurtle and their channel. They have a video all about making a simple importer that I used to get started on my importer. They consistently upload MonoGame content and help to moderate the MonoGame Discord server, 10/10 resource and awesome human being ( or turtle? )

Step 1 - Make a MonoGame Pipeline Extension Project.

Image description

First step, use the MonoGame Pipeline Extension Template, name it whatever you want to name it. I named mine "SpriteSheetAnimationPipelineExtension". I know it's long-winded but what's done is done. Save it wherever you want to save it.

Step 2 - Take a Look around.

There are 2 files autogenerated for you. the content Processor, and the Content Importer. both with generic names.

Importer1 looks something like this :

using Microsoft.Xna.Framework.Content.Pipeline;

using TImport = System.String;

namespace SpriteSheetAnimationPipelineExtension
{
    [ContentImporter(".txt", DisplayName = "Importer1", DefaultProcessor = "Processor1")]
    public class Importer1 : ContentImporter<TImport>
    {
        public override TImport Import(string filename, ContentImporterContext context)
        {
            return default(TImport);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

When The MonoGame content Pipeline starts the process of building the project this is the first class that is called. Rename the Importer 1 file and class, I name mine "SpriteSheetAnimationImporter"

Processor 1 looks something like this.

using Microsoft.Xna.Framework.Content.Pipeline;

using TInput = System.String;
using TOutput = System.String;

namespace SpriteSheetAnimationPipelineExtension
{
    [ContentProcessor(DisplayName = "Processor1")]
    internal class Processor1 : ContentProcessor<TInput, TOutput>
    {
        public override TOutput Process(TInput input, ContentProcessorContext context)
        {
            return default(TOutput);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The processor class 'Process' function takes in the output of the Importer's 'Import' function. Rename the Processor 1 file and class. I named mine "SpriteSheetAnimationProcessor"

2 other classes will be super important that aren't loaded into the project for us. We are going to make one of them right now. It's a Writer class that will take the output of the Processor classes 'Process' method and write the information to an XML file for us.

Go ahead and create a new class and name it. I named my class "SpriteSheetAnimationWriter". In that class copy down the code I have here. Make sure to change the namespace to match your project's namespace.


using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;


using TInput = System.String;

namespace SpriteSheetAnimationPipelineExtension
{

    [ContentTypeWriter]
    internal class SpriteSheetAnimationWriter : ContentTypeWriter<TInput>
    {
        string _runtimeIdentifier;
        protected override void Write(ContentWriter output, TInput value)
        {

        }

        public override string GetRuntimeReader(TargetPlatform targetPlatform)
        { 
           return default (string);
        }

        public override string GetRuntimeType(TargetPlatform targetPlatform)
        {
            return default(string);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3 - What Information Do We Need Saved?

We need to know what data is important for us to save and some kind of asset type to hold that data. Using our Sprite Sheet animation as an example, let's write down everything we know about a Sprite Sheet animation.

  1. Sprite sheet animations have columns, one for each frame of an animation that is the same width as the sprite
  2. Sprite sheet animations rows, one for each animation and the same height as the sprite.
  3. sprite sheet animations have a width that represents the width of the image in pixels.
  4. Sprite sheet animations have a Height that represents the height of the image in pixels.
  5. In terms of MonoGame a sprite sheet animation has a Texture2D that is referenced to load the image and draw it to the screen.
  6. Sprite sheet animations can have a list of multiple animations.

Now let's write down what we know about animations.

  1. Each animation has a name to describe the animation.
  2. Each animation is located at a specific row on the sprite sheet.
  3. Each animation has a start frame that represents which frame the animation will start at.
  4. Each animation has a frame count that represents the number of frames in the animation.
  5. Each animation has a frame rate, which is used to calculate how many frames are displayed per second.
  6. Each animation has a width that represents the width of the sprite for every frame of the animation.
  7. Each animation has a height that represents the height of the sprite for every frame of the animation.

That's a good amount of info that we can integrate into some classes and class variables. Let's start with the animations by making a new class. I am naming mine "SpriteAnimationAsset".

Then I'm designing the class to have variables for each of the points of info I wrote above. I'm also going to write a constructor.

Also a quick note, you can write whatever information you want in your class to have and make a class in the same way. No need to follow this exactly.

namespace SpriteSheetAnimationPipelineExtension
{
    public class SpriteAnimationAsset
    {
        public string Name
        { get; set; }

        public int Row
        { get; set; }

        public int StartFrame
        { get; set; }

        public int FrameCount
        { get; set; }

        public int FrameRate
        { get; set; }

        public int SpriteWidth
        { get; set; }

        public int SpriteHeight
        { get; set; }

        public SpriteAnimationAsset(string name, int row, int startFrame, int frameCount, int frameRate, int spriteWidth, int spriteHeight)
        {
            Name = name;
            Row = row; 
            StartFrame = startFrame;
            FrameCount = frameCount;
            FrameRate = frameRate;
            SpriteWidth = spriteWidth;
            SpriteHeight = spriteHeight;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can make a class for the Sprite sheet itself. Make a new class, mine is named "SpriteSheetAnimationAsset". Fill the class with some variables that match what we wrote about sprite sheets. You can write a constructor here too if you would like. I decided not to.

Two important notes are that instead of a Texture2D variable, we want to make a Texture2DContent variable. We have to import and load the texture ourselves and this will help us do it. Also, I added a string variable called runtime Identifier, I will explain this later but for now, add one to your class too.

using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using System.Collections.Generic;

namespace SpriteSheetAnimation
{
    public class SpriteSheetAnimationAsset
    {
        public int Rows
        { get; set; }

        public int Columns
        {  get; set; }

        public int Width
        { get; set; }

        public int Height 
        { get; set; }

        public Texture2DContent TextureSheet 
        { get; set; }

        public IReadOnlyCollection<SpriteAnimationAsset> Animations { get; set; } = new List<SpriteAnimationAsset>();

        public string RuntimeIdentifier
        { get; set; }

    }
}
Enter fullscreen mode Exit fullscreen mode

These two new classes are going to be used exclusively as a part of our pipeline extension. So we don't need to add any fancy methods to them. We will get this information in our games using different classes that we will write later.

Step 4 - The Importer class

As I said earlier, this is the very first class that is called upon by the MonoGame content pipeline. Let's start at the top of the script.

using TImport = System.String;

This line of code creates a type alias for the type of object we want to import. The default type is a string, but what we want to import is a SpriteSheetAnimationAsset. To do that we need to set TImport equal to [namespace.className]. Here's what it looks like after I implement this change.

using TImport = SpriteSheetAnimationPipelineExtension.SpriteSheetAnimationAsset

Next let's look at our class.

namespace SpriteSheetAnimationPipelineExtension
{
    [ContentImporter(".txt", DisplayName = "Importer1", DefaultProcessor = "Processor1")]
    public class SpriteSheetAnimationImporter : ContentImporter<TImport>
    {
        public override TImport Import(string filename, ContentImporterContext context)
        {
            return default(TImport);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The line [ContentImporter(".txt", DisplayName = "Importer1, DefaultProcessor = "Processor1")] Is super important, this tells the MonoGame content Pipeline, what file type is compatible, the name of the importer to show in a dropdown list of all importer types, and the name of the processor to show in a dropdown list of all processors types. The image below is an example of where these display names are shown in the MonoGame Content pipeline.

Image description

Let's customize this line to fit our needs. I'm Importing .png files, and I'm going to name the display name the same as my class but with spaces. The Default processor I'm going to name is the same as what I will write in the Processor classes display name.

[ContentImporter(".png", DisplayName = "Sprite Sheet Animation Importer", DefaultProcessor = "Sprite Sheet Animation Processor")]

For the class declaration, it should look something like this public class SpriteSheetAnimationImporter : ContentImporter<TImport> That just means that it is a content importer made to import the content type of our TImport alias.

Now this is the bulk of the processor, overriding the Import method. So just by looking at it. we know that it needs to return the same Type as our TImport alias, and it will be passed in a string for the file name and a ContentImportercontext which we can use for debugging and logging messages.

In this method, we want to create a new SpriteSheetAnimationAsset local variable. This is what we will be returning and passing on to our processor class.
// Make a sprite sheet animation asset.
SpriteSheetAnimationAsset sheetAsset = new SpriteSheetAnimationAsset();

Next, we need to import the .png file as Texture2DContent. otherwise, we won't be able to properly use our sprite sheet animation image in the same ways we would a Texture2D. To do that we need to make a new TextureImporter, find the file path, and import the file as Texture2DContent.

            //Use the texture Importer to get the texture content for the sheet asset.
            TextureImporter textureImporter = new TextureImporter();
            var filePath = Path.Combine(Path.GetDirectoryName(filename), filename);

            context.Logger.LogMessage("importing sprite sheet file : " + filePath); // Log the progress

            Texture2DContent content = (Texture2DContent)textureImporter.Import(filePath, context); // Import the file as Texture2DContent
            content.Name = Path.GetFileNameWithoutExtension(filePath); // Make sure the texture name is correct so it can be loaded with the content.Load<> function in the game.

            // assign the texture content to the texture sheet var.
            sheetAsset.TextureSheet = content;
Enter fullscreen mode Exit fullscreen mode

Finally, Return the SpritesheetAnimation variable we made at the start of the Import Method.

return sheetAsset;

Step 5 - The Processor

Similar to the importer, the processor has a TInput but it also has a TOutput. if you're importing a Text file, CSV, JSON, or any other kind of file. You might be importing a string or system.Json object. Make sure the TInput matches what we used in our Importer because the result of the Input method gets passed straight to the processor.

For the TOutput you can output whatever you want your final output to be. For me, I'm going to have the same TInput and TOutput type, but yours might be different if you aren't following along.

using TInput = SpriteSheetAnimation.SpriteSheetAnimationAsset;
using TOutput = SpriteSheetAnimation.SpriteSheetAnimationAsset;

Next above the class declaration, we have [ContentProcessor(DisplayName = "Processor1")] we need to change the display name of the processor to match the DefaultProcessor we wrote in our Importer.

[ContentProcessor(DisplayName = "Sprite Sheet Animation Processor")]

Now something you might have noticed if you have imported an asset into MonoGame is that in the properties window, there are some options you can edit and adjust. Take a look at the properties shown for a texture importer in the image below.

Processor parameters of the MGCB Editor

These are Processor Parameters and we are going to make some simple parameters for ourselves too. These parameters are written like variables inside of our processor class, with some component model tags. Here's the basic structure.

[DisplayName("Parameter Name here")]
[DefaultValue(static value here)] // optional, you don't need a default value.
[Description("Description of the parameter here")] // optional, you don't need a description.
public string parameter {get;set;} // Define the parameter. So far I know that integers, strings, and booleans can be parameters. There are probably other types too.

You can add as many parameters as you need or want to for your specific asset class needs. But make sure to add a Runtime Identifier we will need it later. Here is what I added to my processor.

        [DisplayName("Sprite Sheet Animations")]
        [DefaultValue(1)]
        [Description("How many animations are in the sprite sheet. 1 row is 1 animation.")]
        public int _rows { get; set; }

        [DisplayName("Sprite Sheet Max Frames")]
        [DefaultValue(2)]
        [Description("What is the maximum number of frames per animation")]
        public int _columns { get; set; }

        [DisplayName("Texture Width")]
        [Description("The total Width in pixels of the sprite sheet")]
        public int _width { get; set; }

        [DisplayName("Texture Height")]
        [Description("The total Height in pixels of the sprite sheet")]
        public int _height { get; set; }

        [DisplayName("Animation Frame Rate")]
        [Description("List a common frame rate for the animation.")]
        public int _frameRate{ get; set; }

        [DisplayName("Animation Names")]
        [Description("Add a list of Names for the animations in order from the top of the sheet to the bottom of the sheet." +
             "Please put a comma between each animation name")]
        public string _AnimationNames { get; set; }

        [DisplayName("Frame Counts")]
        [Description("Enter in the frame count for the animations in order from top to bottom." +
            "Please put a comma between each Frame count.")]
        public string frameCount { get; set; }

        [DisplayName("Sprite Width")]
        [Description("In pixels, write down the width of the sprite for each frame ")]
        public int _spriteWidth { get; set; }

        [DisplayName("Sprite Height")]
        [Description("In pixels, write down the Height of the sprite for each frame")]
        public int _spriteHeight { get; set; }

        [DisplayName("Runtime Identifier")]
        public string identifier { get; set; }
Enter fullscreen mode Exit fullscreen mode

Once I have all my processor parameters defined, we can move on to the Process Method. It outputs our TOutput type alias and takes in our TInput from the Importer, and a ContentProsessorContext we can use to debug and write logs.

First, we want to make sure that the Runtime Identifier (identifier) is not null. It's required for us to move forward.

if(identifier == string.Empty)
{
throw new Exception("No Runtime Type was specified for the content.");
}

Next, if you noticed in my processor parameters, I ask the user to give me lists of items separated with a comma rather than asking for an array or asking for a List. This is because I couldn't get a list or array to show up properly in the MGCB Editor, So I grabbed this string of names and frame counts then I split the strings using the ',' comma as the separator. For the frame count, I need to parse the string version of the int into an actual integer.

                // Split all values that are contained in a string list.
                string[] names = _AnimationNames.Split(',');
                string[] framesStr = frameCount.Split(",");
                int[] frames = new int[framesStr.Length];

                for( int i = 0; i < framesStr.Length; i++)
                {
                    f
Enter fullscreen mode Exit fullscreen mode

Next, I need to take all the information about animations and make a loop that creates a new SpriteAnimationAsset for each row of the sprite sheet and fills out all the necessary information.

                // Create all the sprite animation assets
                List<SpriteAnimationAsset> animations = new List<SpriteAnimationAsset>();
                for(int i = 0; i < _rows; i++)
                {
                    SpriteAnimationAsset anim = new SpriteAnimationAsset(names[i], i, 0, frames[i], _frameRate, _spriteWidth, _spriteHeight);
                    animations.Add(anim);
                }
Enter fullscreen mode Exit fullscreen mode

Then, we need to run the Texture2DContent we set in our Importer through the TextureProcessor and update the Texture2D content of our SpriteSheetAnimationAsset (TInput)

`                // Process the Texture file and assign values to the input variable.
                TextureProcessor TexProcessor = new TextureProcessor();
                var textureContent = TexProcessor.Process(input.TextureSheet, context);
                input.TextureSheet = textureContent as Texture2DContent;`
Enter fullscreen mode Exit fullscreen mode

Finally, We set the variables of our SpriteSheetAnimationAsset (input) and return it as the result.

             input.Rows = _rows;
             input.Columns = _columns;
             input.Width = _width;
             input.Height = _height;
             input.Animations = animations;

             input.RuntimeIdentifier = identifier;

             // Return a Sprite Sheet animation Asset.
             SpriteSheetAnimationAsset result = input;
             return result;
Enter fullscreen mode Exit fullscreen mode

Step 6 The Type Writer

When you hit the Build button in the MCGB Editor, it calls the Process method of your Processer class and passes the output to the Write method of your Type Writer class to compress the information into an XML file.

For this step, it's very important that you write the XML file in an intuitive order because, in the next few steps, we will have to read this XML file and turn it into an asset our game can use while it's running.

Starting at the top, TInput for this class Must be the same as the TOutput of your processor class. otherwise, your 'value' variable will be null and you will get errors when you try to build. Here is mine.

using TInput = SpriteSheetAnimationPipelineExtension.SpriteSheetAnimationAsset;

The [ContentWriter] doesn't need any adjustment so we can move on to the class itself. Add a private string variable called _runtimeIdentifier to the content writer class.

private string _runtimeIdentifier;

Next, we can move on to the Write Method. It has no output and takes in a ContentWriter and our TInput alias type passed to us from the processor. We use the ContentWriter (output) to write the XML version of our SpriteSheetAnimationAsset. I recommend writing the file in the same order in which your variables are laid out in your class definition. Here's how I started.

// Write the file in a specific order because it will be read in this same order.
output.Write(value.Rows);
output.Write(value.Columns);
output.Write(value.Width);
output.Write(value.Height);

Then, because we have a list of SpriteAnimationAssets as a variable for our SpriteSheeteAnimationAsset, we need to loop through all the animations, and write them to the XML doc.

            // Write each animation contained in the sprite.
            foreach(SpriteAnimationAsset anim in value.Animations)
            {
                output.Write(anim.Name);
                output.Write(anim.Row);
                output.Write(anim.StartFrame);
                output.Write(anim.FrameCount);
                output.Write(anim.FrameRate);
                output.Write(anim.SpriteWidth);
                output.Write(anim.SpriteHeight);
            }
Enter fullscreen mode Exit fullscreen mode

Finally, we have to write the Texture2DContent in our asset class. we will use the WriteRawObject method, as it will automatically find and use the TextureTypeWriter for us. the TextureTypeWriter is an internal class so we cannot access it directly.

// Write the texture Object.
output.WriteRawObject((Texture2DContent)value.TextureSheet);

We also set the _runtimeIdentifier to the asset classes runtime identifier variable, but we don't write it to the XML file.

// Save the RuntimeIdentifier
_runtimeIdentifier = value.RuntimeIdentifier;

Moving on to the GetRuntimeReader method, we will have it return the _runtimeIdentifier string variable. To finally explain this. We will have a class in the next step that is used to Read the XML we wrote here and use it in the game.

Basically, if you know what reader you are using you can avoid all the runtime identifier stuff by typing out a fully qualified type path and returning it here in this method. That would look something like "[libraryName.Namespace.Classname],[AssemblyName]". It's notoriously a line of code that will cause you lots of problems. So we are going to be putting the burden of this onto the user to figure out. if you don't want to do it this way, I recommend following along anyway till you find the correct fully qualified path name and then refactor to remove all traces of the RuntimeIdentifier from your code. Otherwise, you have to build this project, reference it in the MCGB Editor, and test it, if there's an error remove the reference, edit the code, build the project again, wash, and repeat till you figure it out.

Onto the final method, the GetRuntimeType. This one is easy, we just return this:
return typeof(SpriteSheetAnimationAsset).AssemblyQualifiedName;

Step 7 - What Do We Use In The Game?

OK, we got the Importer done, we got the Processor done and we got the Writer Done. Now we just need to find a way to read it. First, we are going to make a new project using the MonoGame Library template. We do this, because all this pipeline extension jazz doesn't need to be packaged into our game build.

Image description

Name the project, I'm naming mine "SpriteSheetAnimationContentPipeline", and under 'solution' make sure you change the dropdown to the 'Add to solution' option. Then you should see it in your solution explorer. Inside of this new project, we are going to make 3 new classes.

A SpriteAnimation class, feel free to add some methods for yourself such as an Update or Draw method. It will look pretty similar to our SpriteAnimationAsset class from the Pipeline Extension Project.

namespace SpriteSheetAnimationContentPipeline
{
    public class SpriteAnimation
    {
        public string Name
        { get; private set; }

        public int StartFrame
        { get; private set; }

        public int FrameCount
        { get; private set; }

        public int FrameRate
        {  get; private set; }

        public int SpriteWidth
        { get; private set; }

        public int SpriteHeight
        { get; private set; }

        public SpriteAnimation(string name, int startFrame, int frameCount, int frameRate, int spriteWidth, int spriteHeight)
        {
            Name = name;
            StartFrame = startFrame;
            FrameCount = frameCount;
            FrameRate = frameRate;
            SpriteWidth = spriteWidth;
            SpriteHeight = spriteHeight;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Add a SpriteSheetAnimation class, again you can add any additional methods you would like here.

using Microsoft.Xna.Framework.Graphics;
using System.Collections.Generic;

namespace SpriteSheetAnimationContentPipeline
{
    public class SpriteSheetAnimation
    {
        public int Rows
        { get; private set; }

        public int Columns
        { get; private set; }

        public int Width
        { get { return TextureSheet.Width; } }

        public int Height
        { get { return TextureSheet.Height; } }

        public Texture2D TextureSheet
        { get; private set; }

        public List<SpriteAnimation> Animations { get; private set; }

        public int AnimationIndex
        { get; set; } = 0;

        public SpriteSheetAnimation()
        {
            Rows = 0;
            Columns = 0;
            TextureSheet = null;
            Animations = new List<SpriteAnimation>();
        }
        public SpriteSheetAnimation(int rows, int columns, Texture2D textureSheet, List<SpriteAnimation> animations)
        {
            Rows = rows;
            Columns = columns;
            TextureSheet = textureSheet;
            Animations = animations;
        }

        public SpriteAnimation GetAnimation(int index)
        {
            return Animations[index];
        }

        public List<SpriteAnimation> GetAllAnimations()
        {
            return Animations;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And a SpritesheetAnimationTypeReader class. In this one we will fill out the details together, but go ahead and write the basic framework for it.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;


namespace SpriteSheetAnimationContentPipeline
{

    public class SpriteSheetAnimationTypeReader : ContentTypeReader<SpriteSheetAnimation>
    {
        protected override SpriteSheetAnimation Read(ContentReader input, SpriteSheetAnimation existingInstance)
        {

        }

        private Texture2D ReadTexture2D(ContentReader input, Texture2D existingInstance)
        {

        }

}

Enter fullscreen mode Exit fullscreen mode

Step 8 - The Type Reader

Lets fill out the type reader class together. This class inherits the ContentTypeReader, we replaced T with our SpriteSheetAnimation class meaning that we are outputting a SpriteSheetAnimation from our Read method. The Read method takes in a ContentReader (input) which is our XML file written by our Type Writer class in the Pipeline Extension project, and an existing instance of the SpriteSheetAnimation class.

In the Read method, let's start by creating local variables for rows, columns, width, and height. Mirroring the order in which we wrote to the XML file by reading the lines in the same order.

// Set the result of reading the first 4 lines as int32 to some local variables.
int Rows = input.ReadInt32();
int Columns = input.ReadInt32();
int Width = input.ReadInt32();
int Height = input.ReadInt32();

Next, we can create a loop to go through the number of rows and create one SpriteAnimation per row, adding it to a list.

            List<SpriteAnimation> anims = new List<SpriteAnimation>();
            for (int r = 0; r < Rows; r++)
            {
                string aName = input.ReadString();
                int aRow = input.ReadInt32();
                int aStartFrame = input.ReadInt32();
                int aFrameCount = input.ReadInt32();
                int aFrameRate = input.ReadInt32();
                int aSpriteWidth = input.ReadInt32();
                int aSpriteHeight = input.ReadInt32();

                anims.Add(new SpriteAnimation(aName, aStartFrame, aFrameCount, aFrameRate, aSpriteWidth, aSpriteHeight));
            }
Enter fullscreen mode Exit fullscreen mode

Then we need to read the Texture2DContent as a Texture 2D asset.

IGraphicsDeviceService graphicsDeviceService = (IGraphicsDeviceService)input.ContentManager.ServiceProvider.GetService(typeof(IGraphicsDeviceService));
var device = graphicsDeviceService.GraphicsDevice;
Texture2D sheetTexture = new Texture2D(device, Width, Height);
Texture2D texture = ReadTexture2D(input, sheetTexture);

Finally, we create a SpriteSheetAnimation and return it.

SpriteSheetAnimation AnimSheet = new SpriteSheetAnimation(Rows, Columns, texture, anims);
return AnimSheet;

Moving on to the ReadTexture2D method, we are going to create a local Texture2D variable called output and set it to be null;

Texture2D output = null;

Next we will Try to set the output variable by reading a raw object of type Texture2D. if it throws a not supported exception we will have to create a Texture2D reader and use a different read raw object method overload.

            Texture2D output = null;
            try
            {
                output = input.ReadRawObject<Texture2D>(existingInstance);
            }
            catch (NotSupportedException)
            {
                var assembly = typeof(Microsoft.Xna.Framework.Content.ContentTypeReader).Assembly;
                var texture2DReaderType = assembly.GetType("Microsoft.Xna.Framework.Content.Texture2DReader");
                var texture2DReader = (ContentTypeReader)Activator.CreateInstance(texture2DReaderType, true);
                output = input.ReadRawObject<Texture2D>(texture2DReader, existingInstance);
            }
Enter fullscreen mode Exit fullscreen mode

then outside of the catch, return output;

Step 9 References & Building

Now that the content pipeline library is done right-click your pipeline Extension project ( the first project we made at the beginning) and build it.

Then, you can test things out by making a new MonoGame Desktop GL project and adding it to the solution. Right-click on the new game project go to Add, project reference, and add the ContentPipeline (the second project we made) as a reference.

Image description
Image description

Open up the MGCB Editor and click on 'Content' in the project window and go to the bottom of the properties list. You will see a parameter called references. Click on the empty space next to the name references and it should open up a window to let you add or remove references.

Image description

in the pop-up window click add, and find the pipeline extension project's folder in your file browser. If you built your project you should be able to navigate to pipelineExtenstionProjectName/bin/debug/net6.0/ and see a .dll file select it and hit add. then hit Ok in the reference editing window.

Step 10 Using Your Pipeline Extension

add a new class file to your game call it AnimationReader or whatever you want. It should inherit from your Type Readere class and look like this.

`namespace [your game namespace here]
{

public class AnimationReader : SpriteSheetAnimationTypeReader { }
Enter fullscreen mode Exit fullscreen mode

}`

Open the MGCB Editor and import a Sprite Sheet Animation png or whatever type of file your Importer supports. if it doesn't automatically set the importer and processor to your custom importer and processor, then make sure to set them in the properties window along with all the processor parameters you need.

Image description

For the Runtime Identifier it's going to be "[gameProjectNamespace.WhatYouNamedYourInGameReader],[gameProjectNamespace]"

To load it go back to your game1 file and in the Load method load your SpriteSheetAnimation like this

SpriteSheetAnimation animation = Content.Load<SpriteSheetAnimation>("Squid Walk_V3.spriteanim");

I went a step further and added some logic to animate the sprite for debugging. here's what my Game1 class looks like.

    public class Game1 : Game
    {
        private GraphicsDeviceManager _graphics;
        private SpriteBatch _spriteBatch;

        SpriteAnimation currentAnim;
        private int startFrame = 0;
        private int frameIndex = -1;
        private int EndFrame;
        private float FrameTimer = 0.0f;
        private float FrameDurration;

        private SpriteSheetAnimation animation;
        public Game1()
        {
            _graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
            IsMouseVisible = true;
        }

        protected override void Initialize()
        {
            // TODO: Add your initialization logic here

            base.Initialize();
        }

        protected override void LoadContent()
        {
            _spriteBatch = new SpriteBatch(GraphicsDevice);

            // TODO: use this.Content to load your game content here

            SpriteSheetAnimation animation = Content.Load<SpriteSheetAnimation>("Squid Walk_V3.spriteanim");
            currentAnim = animation.GetAnimation(0);
            EndFrame = currentAnim.FrameCount -1;

            FrameDurration = 1.0f / currentAnim.FrameRate;
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed || Keyboard.GetState().IsKeyDown(Keys.Escape))
                Exit();

            // TODO: Add your update logic here

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            GraphicsDevice.Clear(Color.CornflowerBlue);

            // TODO: Add your drawing code here

            _spriteBatch.Begin();

            animateSprite(gameTime);


            _spriteBatch.End();

            base.Draw(gameTime);
        }

        public void animateSprite(GameTime gt)
        {
            float deltaTime = (float)gt.ElapsedGameTime.TotalSeconds;

            if(FrameTimer <= 0.0f)
            {
                if (frameIndex + 1 > EndFrame)
                {
                    frameIndex = startFrame;
                }
                else
                {
                    frameIndex++;
                }

                FrameTimer = FrameDurration;
            }
            else
            {
                FrameTimer -= deltaTime;
            }

            Rectangle rect = new Rectangle(currentAnim.SpriteWidth * frameIndex, 0, currentAnim.SpriteWidth, currentAnim.SpriteHeight);
            _spriteBatch.Draw(animation.TextureSheet, Vector2.One, rect, Color.White);
        }
    }
Enter fullscreen mode Exit fullscreen mode

When I play the game my character animation plays!

Image description

Conclusion

Best of luck to anyone wanting to make a custom pipeline extension. It can be a real headache but I encourage you to check out the resources section above and see Aristurtle's YouTube if you find yourself stuck you can find a link to the MonoGame discord on all of their videos and join it to ask any questions.

I hope that in the future someone else who is just as confused as I was can find this to be a useful guide. I am already planning to improve upon this to be more functional for game 2 in the 20-game challenge as I am working on a whole animation state system.

The Final code for all of this will be published with the Game 2 update that is coming up very soon!

Top comments (0)