Making a 2D animation system in C++

I spent this week learning about animation in 2D as I wanted to create an advanced system for a custon engine in C++. This blog is going to cover requirements, research, and the implementation for the system.

Content

  1. 1.0 - The Goal
    1. 1.1 - Requirements
  2. 2.0 - The Existing Solution
    1. 2.1 - The Code
      1. 2.1.1 - Animation.h
      2. 2.1.2 - AnimationComponent.h
      3. 2.1.3 - AnimationManager.h
      4. 2.1.4 - AnimationManager.cpp
      5. 2.1.5 - Code Explained
    2. 2.2 - Conclusion of Current System
  3. 3.0 - Looking at Existing Titles
    1. 3.1 - Terraria
    2. 3.2 - Core Keeper
  4. 4.0 - The New System
    1. 4.1 - Hot-reloading Animations
      1. 4.1.1 - Loading Animations from files
      2. 4.1.2 - Reloading at Runtime
    2. 4.2 - Designing The System
      1. 4.2.1 - Quick Game Additions
      2. 4.2.2 - Designing Layers
      3. 4.2.3 - Designing Events
    3. 4.3 - Implementing The New System
      1. 4.3.1 - Adding Additional Layers
      2. 4.3.2 - Setting Active Layers and Objects
      3. 4.3.3 - Rendering Layers
      4. 4.3.4 - Setting up Animation Events
      5. 4.3.5 - Registering to Events
      6. 4.3.6 - Triggering Event Callbacks
  5. 5.0 - Conclusion and Showcase
    1. 5.1 - Showcase
    2. 5.2 - Conclusion

1.0 - The Goal

Animation in 2D sounds fairly simple. You cycle through some sprites and that is it. However, when you want to expand your game, this simple solution can lead to some problems. For instance, we have a 2D RPG game where you can equip different helmets, hats, chestplates, capes, or even swap hairstyles. How do you at runtime swap out the animation to support all of this whilst also maintaining a decent workflow. The last thing you want to do is draw every sprite combination.

The goal is to have a system that can support different layouts for armor and accessories. I also want this system to introduce events so I can trigger certain game parts during an animation. E.g. dealing damage when a sword swings and not during anticipation. Finally, I want to be able to hot-reload animations for quicker iteration. So to recap, here is a list of requirements:

1.1 - Requirements

  • Create an animation system to play 2D animations on sprites.
  • Introduce layers so we can swap out individual parts of animations.
  • Sync up all layers so that the animation plays correctly when changed.
  • Events for gameplay hooks.
  • Make the animations able to be hot-reloaded.
  • Easily expandable for tools such as animation editors.

2.0 - The Existing Solution

For this animation system, I will be using a 2D C++ engine made by myself and Joshua Mobley. Below is a video demonstrating what the engine currently features. This engine includes rendering, an event system, player movement, mod support, logging, simple UI, tilemaps, simple 2D animation, basic overlap checks, and a debug menu bar. This engine is built using OpenGL, GLFW, and glm.

2.1 - The Code

The above video showcases a player moving around a level and interacting with a chest. When walking, the player uses a different animation. This works by swapping through different individual sprites when a frame time is hit. Firstly, we define the animation as follows:

Note: To keep everything simple and concise, includes won't be part of code blocks.

  • 2.1.1 - Animation.h

    class Animation
    {
    public:
        Animation(std::vector<Texture2D*>& textures, const std::string& name, const float frameTime, const int cellWidth, const int cellHeight, const int frameCount, const bool loop)
            : textures(textures)
            , name(name)
            , frameTime(frameTime)
            , cellWidth(cellWidth)
            , cellHeight(cellHeight)
            , frameCount(frameCount)
            , loop(loop)
            {
                numberOfTextures = textures.size();
                index = 0;
            }
    
        std::vector<Texture2D*> textures;
        std::string name;
        float frameTime;
        int cellWidth;
        int cellHeight;
        int frameCount;
        bool loop;
    
    private:
        size_t numberOfTextures;
        int index;
    };
    
    
  • 2.1.2 - AnimationComponent.h

    class AnimationComponent : public Component
    {
    public:
        virtual void Start() override; // Calls a registered event that AnimationManager listens to
        virtual void OnDestroy() override; // Calls an unregistered event that AnimationManager listens to
    
    public:
        Animation* currentAnimation;
        float currentFrameTime = 0;
        int currentFrameIndex = 0;
        bool playOnStart = true;
        bool shouldPlay = false;
    };
    
  • 2.1.3 - AnimationManager.h

    class AnimationManager
    {
    public:
        static void Initialize();
        static void Update();
        static void Cleanup();
    
        static void RegisterAnimation(const std::string& animationName, Animation* animation);
        static void Play(std::shared_ptr<GameObject> gameObject, const std::string& animationName);
    
    private:
        static void OnAnimationComponentStarted(AnimationComponentStartedEvent* event);
        static void OnAnimationComponentPendingDestroy(AnimationComponentPendingDestroyEvent* event);
    
    private:
        inline static std::vector<AnimationComponent*> animationComponents = std::vector<AnimationComponent*>();
        inline static std::map<std::string, Animation*> animations = std::map<std::string, Animation*>();
    
        inline static uint32_t onAnimationComponentStartedIndex;
        inline static uint32_t onAnimationComponentPendingDestroyIndex;
    };
    
  • 2.1.4 - AnimationManager.cpp

    void AnimationManager::Initialize()
    {
        onAnimationComponentStartedIndex = Events::Subscribe(&AnimationManager::OnAnimationComponentStarted);
        onAnimationComponentPendingDestroyIndex = Events::Subscribe(&AnimationManager::OnAnimationComponentPendingDestroy);
    }
    
    void AnimationManager::Update()
    {
        ZoneScoped; // ZoneScopes are used to profile functions using Tacey
    
        if (SceneManager::IsSceneLoading())
        {
            return;
        }
    
        if (animationComponents.size() <= 0)
        {
            return;
        }
    
        for (int i = 0; i < animationComponents.size(); ++i)
        {
            AnimationComponent* animationComponent = animationComponents[i];
            SpriteComponent* spriteComponent = animationComponent->GetOwner()->GetComponent<SpriteComponent>();
    
            if (animationComponent == nullptr || spriteComponent == nullptr)
                continue;
    
            if (!animationComponent->shouldPlay)
                continue;
    
            Animation* currentAnimation = animationComponent->currentAnimation;
    
            if (currentAnimation == nullptr)
                continue;
    
            if (animationComponent->currentFrameIndex > currentAnimation->frameCount)
            {
                if (!currentAnimation->loop)
                    continue;
            }
    
            if (currentAnimation->textures.size() < 1)
                continue;
    
            animationComponent->currentFrameTime += (float)Time::GetDeltaTime() * 1000.0f;
    
            // Go to next sprite or stop the animation
            if (animationComponent->currentFrameTime > currentAnimation->frameTime)
            {
                // If not looping and we're not at the max index or if we're looping, increase the frame count.
                if (!currentAnimation->loop && animationComponent->currentFrameIndex < currentAnimation->frameCount - 1
                || currentAnimation->loop)
                {
                    animationComponent->currentFrameIndex++;
                }
                
                // If we are at the max frame count and looping, reset animation
                if (animationComponent->currentFrameIndex >= currentAnimation->frameCount && currentAnimation->loop)
                {
                    animationComponent->currentFrameIndex = 0;
                }
    
                // Reset frame count timer.
                animationComponent->currentFrameTime = 0.0f;
            }
    
            if (currentAnimation->textures.size() <= animationComponent->currentFrameIndex)
            {
                continue;
            }
    
            // Set the texture to the right sprite.
            Texture2D* texture = currentAnimation->textures[animationComponent->currentFrameIndex];
            spriteComponent->SetTexture(texture);
        }
    }
    
    void AnimationManager::RegisterAnimation(const std::string& animationName, Animation* animation)
    {
        if (!animations.contains(animationName))
        {
            animations.insert(std::pair<std::string, Animation*>(animationName, animation));
        }
        else
        {
            LOG_WARNING("Tried to register an animation with the name " + animationName + " but it already exists");
        }
    }
    
    void AnimationManager::Cleanup()
    {
        Events::UnSubscribe(&AnimationManager::OnAnimationComponentStarted, onAnimationComponentStartedIndex);
        Events::UnSubscribe(&AnimationManager::OnAnimationComponentPendingDestroy, onAnimationComponentPendingDestroyIndex);
    
        for (auto& [_, animation] : animations)
        {
            delete animation;
        }
    
        animations.clear();
    }
    
    void AnimationManager::Play(std::shared_ptr<GameObject> gameObject, const std::string& animationName)
    {
        if (!animations.contains(animationName))
        {
            LOG_ERROR("Tried to play an animation with the name " + animationName + " but it does not exist");
            return;
        }
    
        AnimationComponent* animationComponent = gameObject->GetComponent<AnimationComponent>();
        SpriteComponent* spriteComponent = gameObject->GetComponent<SpriteComponent>();
    
        if (animationComponent == nullptr)
        {
            return;
        }
    
        if (animations.size() <= 0)
        {
            return;
        }
    
        if (animationComponent->currentAnimation != nullptr
            && animationComponent->currentAnimation->name == animationName
            && animationComponent->currentFrameIndex <= animationComponent->currentAnimation->frameCount - 1)
        {
            return;
        }
    
        animationComponent->currentAnimation = animations[animationName];
        animationComponent->currentFrameTime = 0.0f;
        animationComponent->currentFrameIndex = 0;
        animationComponent->shouldPlay = true;
    }
    
    void AnimationManager::OnAnimationComponentStarted(AnimationComponentStartedEvent* event)
    {
        animationComponents.push_back(event->animationComponent);
    }
    
    void AnimationManager::OnAnimationComponentPendingDestroy(AnimationComponentPendingDestroyEvent* event)
    {
        auto componentToRemoveRange = std::ranges::remove(animationComponents, event->animationComponent);
        animationComponents.erase(componentToRemoveRange.begin(), componentToRemoveRange.end());
    }
    

2.1.5 - Code Explained

I commented the code for the sake of readability here. The idea is simple, we define an animation, register it to the AnimationManager, and then play it using the AnimationComponent. Components are attached to GameObjects, similar to what you may have seen in other engines.

The AnimationComponent keeps track of its own time played using currentFrameTime and the sprite using currentFrameIndex. AnimationManager's update function will then look over all of the components and increase the currentFrameTime. If the currentFrameTime is greater than the animation's frameCount, we increment currentFrameIndex.

2.2 - Conclusion of Current System

When playing, the animation manager will loop over all AnimationComponents and update the component to display the correct sprites. This means that at the moment, we have no concept of layers, or events. All we have is the idea of going through a set of sprites. The code is also not very optimised but that will not be a focus on this blog.

The above does not meet our requirements so as stated before, the goal for this week is to implement a more robust system that features what we need.

3.0 - Looking at Existing Titles

3.1 - Terraria

Terraria is a side-scrolling well-known 2D sandbox game. I wanted to look at how the animations look like to get an understanding of how hair styles and armor was done in the game. For this game as well as the others, I will not look for any decompiled source code a I wanted to challenge myself when designig my own system.

When it comes to animation, Terraria has unique animations for walking, hitting, jumping, and so on. Each piece of armor layers on top of the existing animation. Their weapons are simply attached to the arm when hitting or firing ranged weapons. They do not use individually animated attacks. E.g. the sword isn't animated. This fits their style really well. Everything else seems to be a unique animation. Armor sprites are animated to follow existing animations. The game also has the ability for layers to toggle off other layers. For instance, equipping a helmet hides the player's hair.

3.2 - Core Keeper

Core Keeper follows a similar approach to terraria. However, instead of the weapon being attached to the rotating arm, the weapons are individually animated, resulting in a more coherent animation. This means that every weapon is probably existing on its own layer with its own animations.

4.0 - The New System

4.1 - Hot-reloading Animations

The first thing I want to get done is hot-reloading animations. This will allow me to iterate over setups faster.

4.1.1 - Loading Animations from files

To set up animations, we first need to convert every Animation into a file so that we can detect when it has changed. Currently, an animation is defined in code with the following setup:

  • Animation Example

    // Idle Animation
    std::vector<Texture2D*> characterTextures;
    characterTextures.push_back(ResourceManager::GetTexture("Player_Idle_1"));
    characterTextures.push_back(ResourceManager::GetTexture("Player_Idle_2"));
    characterTextures.push_back(ResourceManager::GetTexture("Player_Idle_3"));
    characterTextures.push_back(ResourceManager::GetTexture("Player_Idle_4"));
    Animation* idleAnimation = new Animation(characterTextures, "PlayerIdle", 200.0f, 16, 16, 4, true);
    AnimationManager::RegisterAnimation(idleAnimation->name, idleAnimation);
    

This gets very annoying when a lot of animations are implemented. For our system, we want to have many animations per set of armor. Using files, we can make this easier to iterate on. We can also use an editor to make it even easier to modify animations later. For now, we will manually create a JSON file and load it. This engine supports RapidJSON so loading a file shouldn't be hard.

The first thing we do is to make a file new folder called Animations. This file will contain a JSON configuration. From there, we can iterate over the directory and grab anything with .anim extension. For now, we will add a "name" field and load that. This will then log when registered.

I modified the AnimationManager to include a new function inside of Initialize called "LoadAnimations". This function will iterate over a folder and check for any .anim file.

  • AnimationManager::LoadAnimations()

    void AnimationManager::LoadAnimations()
    {
        std::string directory = ANIMATION_DIRECTORY; // defined in the .h file as a const string.
    
        for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
        {
            if (entry.path().extension() == ANIMATION_EXTENSION) // defined in the .h file as a const string.
            {
                std::string filepath = entry.path().string();
                std::string directory = filepath.substr(0, filepath.find_last_of("\\/"));
    
                rapidjson::Document document;
                std::ifstream file(filepath);
                std::string json((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
                document.Parse(json.c_str());
    
                if (document.HasParseError())
                {
                    LOG_ERROR("Animation file is not valid JSON");
                    continue;
                }
    
                if (!document.HasMember("name"))
                {
                    LOG_ERROR("Animation file does not have a name");
                    continue;
                }
    
                Animation* anim = new Animation();
                anim->filePath = entry.path().string().c_str();
    
                if (document["name"].IsString())
                {
                    anim->name = document["name"].GetString();
                }
    
                // Load everything else we care for here...
    
                // Finally, register the animation
                RegisterAnimation(anim->name, anim);
            }
        }
    }
    

For this, we use the following file:

  • AnimationTest.anim

    {
        "name": "test"
    }
    

We get the following output:

  • 15:03.59 [Info] [AnimationManager.cpp | Function: RegisterAnimation | Line: 89] Registered anination with the name test

Finally, we can add the other parameters defined in our Animation class. I won't show the code for this as it is a copy/paste of the Json parsing above.

After adding all of the aditional information, this is the file we end up with. We can now delete all of the code above that registered this animation.

  • PlayerIdle.anim

    {
        "name": "PlayerIdle",
        "textures": [
            "Player_Idle_1",
            "Player_Idle_2",
            "Player_Idle_3",
            "Player_Idle_4"
        ],
        "frameTime": 200.0,
        "cellWidth": 16,
        "cellHeight": 16,
        "frameCount": 4,
        "loop": true
    }
    

4.1.2 - Reloading at Runtime

Now that we have animations loaded as files, we can hot-reload them by checking if the file has changed. To do this, we use a library called FileWatch. When the file has changed, we simply re-load the file and apply the new changes. Below is the addition to AnimationManager's initialize function:

  • AnimationManager::Initialize()

    fileWatcher = new filewatch::FileWatch(
        "Animations",
        [](const std::string& path, const filewatch::Event)
        {
            if (!path.ends_with(ANIMATION_EXTENSION))
            {
                return;
            }
    
            std::filesystem::path localPath = std::string("Animations/" + path);
            bool loadedAnimation = false;
    
            for (auto& iter : animations)
            {
                Animation* anim = iter.second;
    
                if (equivalent(anim->filePath, localPath))
                {
                    loadedAnimation = true;
                    anim->needsRecompile = true;
                    break;
                }
            }
    
            if (!loadedAnimation)
            {
                LOG_ERROR_F("ERROR::ANIMATION: Animation not found in AnimationManager | {}", localPath.string());
            }
        }
    );
    

There are two changes to notice here. Firstly, we added 2 new variables to the animation class: bool needsRecompile, and std::filesystem::path filePath. We set the needsRecompile flag to true if the file at the path is no longer identical to the one we look over. This flag is then picked up in AnimationManager::Update where we loop over all animations and reload a file if the flag is set. Below is a video showcasing the ability to hot-reload different parts of an animation:

4.2 - Designing The System

When looking at the examples above, we know that we have to make a choice. What style of animation do we want to go for? This really comes down to preference and for this engine, I have decided to go for the more involved approach of animating every frame of every animation for all layers. The goal is to go for more unique animations which full control will provide us with.

4.2.1 - Quick Game Additions

Before designing how I want layers to work, I wanted to a quick game change. The first thing I did was separate the player's hair from its body so we can have different hair styles. I also exported the hair for every frame in our idle and run animation. Currently, the hair is not used in the game and the player will by default be bald.

4.2.2 - Designing Layers

When looking at existing titles, it is clear that layers are needed. Layers work in the sense that each piece of equipment (or each layer) copies the currently playing animation. For this engine, I want to make sure that the AnimationComponent also dictates the remaining layers. This is to ensure that no de-sync happens between multiple components playing individual animations. For example, we equip a helmet halfway through an existing animation and now we have to sync up two components. Instead, we use one component, and draw everything using the component's data. Layers should also be optional since some parts of the game might not use it. If we have a boss fight, the chances of the boss needing to equip different armor is very low. So how are we going to achieve this? Let's take a look:

The SpriteComponent dictates which sprite we want to draw. As we know, the AnimationComponent then manipulates this as sets the needed texture for the animation. Overall, this part works very well and will remain the same. Layers should be optional so we do not want to make any changes to the definition of the animation. If a layer is added, it should follow the currently playing animation, almost like another animation underneath. However, we do not want another AnimationComponent due to the possibility of de-syncing like I mentioned above. Instead, we will draw the sprites directly on top of the owner based on the current frame index of the animation. The engine I use has a concept of custom drawing, which is the idea of adding anything to spritebatches before the rendering process starts. If you wanted to do this yourself, you could simply call the new draw call after the main render of the owner.

We want to include layers within an animation so that we know which sprite to grab. The active layers should then live on the AnimationComponent along with any object (e.g. hair style 1). The AnimationManager will then find all layers if they exist, grab the texture it needs from the current animation frame index, and then submit the custom draw information.

The illustration above showcases how I want layers to work. For simplicity, the other aspects of the animation are not there. Because the engine loads in everything into memory, I am not concerned about this much data existing per animation. If we wanted to optimise this, we would introduce the idea of async loading different parts when needed. For now, that won't be something I will look into.

Now that we have a concept of how animation layers will work, I then started working on the actual iomplementation.

4.2.3 - Designing Events

Events are essential for ensuring gameplay element trigger at the correct time. The idea is that we insert a callback we want to occur at a certain frame time. Say you have an attack animation and you only want the attack to occur when the sword is in front of you and not during anticipation, an event can be used to call it at the right time. For events, we are going to store functions to event names. I do not want to care for each specific animation since event names could be more generic and call the same logic from different animations. E.g. the hit during a swing. 10 different animations could all have a swing with the same callback. For this to work, we also need to briefly edit our AnimationManager and AnimationComponent to know the total frame time of an aniamtion but that we can already work out from existing values.

We define events in the animation itself. The idea is then that we can register to an event on the AnimationComponent and then use the AnimationManager to trigger any registered callbacks if the current animation reaches an event. To get started, we need to add events to each animation. Below is an illustration showing how that will look in Json.

4.3 - Implementing The New System

With the design ready, we can start the implementation. The first thing I focused on was the layers.

4.3.1 - Adding Aditional Layers

To get started with layers, I first used the concept above to modify our existing animation Json file to include layers. Here is the player's idle animation with a hair layer:

  • PlayerIdle.anim

    {
        "name": "PlayerIdle",
        "textures": [
            "Player_Idle_1",
            "Player_Idle_2",
            "Player_Idle_3",
            "Player_Idle_4"
        ],
        "frameTime": 200.0,
        "cellWidth": 16,
        "cellHeight": 16,
        "frameCount": 4,
        "loop": true,
        "layers": {
          "hair": [
            {
              "style1": [
                "Player_Hair_Style1_Idle_1",
                "Player_Hair_Style1_Idle_2",
                "Player_Hair_Style1_Idle_3",
                "Player_Hair_Style1_Idle_4"
              ]
            }
          ]
        }
    }
    

With the structure ready, we can modify our LoadAnimationFromFile function to include the new elements. The previous animation code would check if we have the element and return if not. Because animations layers are optional, we will only add them if they exist but if not, we simply don't do anything.

Before we load in the new data, we need to append the animation class to store layers, objects, and their associated textures. For this I used a map. Our animation class now looks like this:

  • Animation.h

    // Typedefs exists to avoid confusion of map below
    typedef std::string LayerName;
    typedef std::string ObjectName;
    
    class Animation
    {
    public:
        Animation(std::vector<Texture2D*>& textures, const std::string& name, const float frameTime, const int cellWidth, const int cellHeight, const int frameCount, const bool loop)
        : textures(textures)
        , name(name)
        , frameTime(frameTime)
        , cellWidth(cellWidth)
        , cellHeight(cellHeight)
        , frameCount(frameCount)
        , loop(loop)
        , needsRecompile(false)
        {
            numberOfTextures = textures.size();
            index = 0;
        }
    
    public:
        std::vector<Texture2D*> textures;
        std::map<LayerName, std::map<ObjectName, std::vector<Texture2D*>>> layers;
        std::string name;
        float frameTime;
        int cellWidth;
        int cellHeight;
        int frameCount;
        bool loop;
    
        bool needsRecompile;
        std::filesystem::path filePath;
    
    private:
        size_t numberOfTextures;
        int index;
    
    friend class AnimationManager;
    };
    

The animation class now includes a map of layername to another map of object name to textures. This outlines our Json perfectly. I added some typedefs for the code to be self-explanatory. Just remember that in my case, a layer is a slot such as helmet, whilst objects are what exact helmet we want. E.g. Wooden helmet.

Now that we can store the layers, we can modify our loading function to include them. Here is the following code I added to AnimationManager::LoadAnimationFromFile().

  • AnimationManager::LoadAnimationFromFile(const std::string& filePath)

    // Above is code for loading in the animation (as soon in previous sections)
    ...
    
    // Animation layers
    auto itr = document.FindMember("layers");
    if (itr != document.MemberEnd())
    {
        for (auto& layersObj : itr->value.GetObj())
        {
            std::string layerName = layersObj.name.GetString();
            if (!layers.contains(layerName))
            {
                layers[layerName] = std::map<ObjectName, std::vector<Texture2D*>>();
            }
    
            for (auto& layerMember : layersObj.value.GetArray())
            {
                for (auto& memberObj : layerMember.GetObj())
                {
                    std::vector<Texture2D*> textures;
                    std::string memberName = memberObj.name.GetString();
                    for (auto& layerTexture : memberObj.value.GetArray())
                    {
                        textures.push_back(ResourceManager::GetTexture(layerTexture.GetString()));
                    }
                    layers[layerName][memberName] = textures;
                }
            }
        }
    }
    
    // Finally, register the animation
    RegisterAnimation(filePath, textures, layers, name, frameTime ,cellWidth, cellWidth, frameCount, loop);
    

First of all, that is a lot of auto keywords. I use these when dealing with Json simply because I have not made a reader or writer class yet. Anyway, here we loop through the layers section and find the appropriate data. I won't explain too much here since this is rapidJson calls and dealing with the structure of Json. All we need to know is that it populates our layers section which we then use when registering the animation. Registering adds the layers into the array we defined in Animation.h.

4.3.2 - Setting Active Layers and Objects

Now that layers exist within an animation, we need a way to set them active. I want the AnimationComponent to control this so that we have individual control over it per component. This allows us to only use them when an owner needs it. For this example, I will continue to use the player and hair.

  • AnimationComponent.h

    class AnimationComponent : public Component
    {
    public:
        virtual void Start() override;
        virtual void OnDestroy() override;
    
        void ActivateLayer(const std::string& layerName); // Adds a string entry into the activeLayers vector.
        void DeactivateLayer(const std::string& layerName); // Removes a string entry from the activeLayers vector.
    
        void SetActiveObject(const std::string& layerName, const std::string& objectName); // Sets the object of the active layer in the activeObjects list.
    
    public:
        Animation* currentAnimation;
        std::map<std::string, std::string> activeObjects;
        std::vector<std::string> activeLayers;
        float currentFrameTime = 0;
        int currentFrameIndex = 0;
        bool playOnStart = true;
        bool shouldPlay = false;
    };
    

Layers are now activated and deactivated directly from the component. I have added some comments to explain what the 3 new functions do. As they are quite simple, I do not believe the .cpp is needed. They simply add and remove string entries from the vector. With this change, AnimationComponents now have a link to the different layers and objects. Everything is set up as a string in order to abstract the idea of layers. If I was making this for just one game, I would use IDs or an enum for readability. However, this engine's goal is to stay useful for multiple projects so layers are abstract. For instance, not every game would need a helmet slot but might need hairstyles.

4.3.3 - Rendering Layers

We are almost there! We just need to actually render the data now. As I explained before, I am going to use a custom drawer to insert an entry into our sprite batching system. To do this, I am editing the AnimationManager to loop through layers and add the element if present.

For a bit of context, CustomDraw is a struct that include rendering information such as order, texture, sorting layer, and the vertices to draw. This information is used to add a new spritebatch from the given information. The order and sorting layer determines when to draw the sprite. Like mentioned previously, all of this can be replaced by rendering the layers after the player. Each engine is different with how it renders so this part will differ to what is seen elsewhere. If this system was made in an existing engine such as Unity, you would just have a transform under the player with a SpriteRenderer and manually set the sprite when needed.

  • AnimationManager::UpdateAnimations()

    // The code above this line sets the texture just like before. Nothing has been changed
    ...
    
    // Reset custom draw vertices in order to avoid left over draws from previous frames.
    customDraw->verticies.clear();
    
    // Draw other layers here if needed. Firstly, we go through all enabled layers
    for (std::string& activeLayer : animationComponent->activeLayers)
    {
        auto& animationLayers = animationComponent->currentAnimation->layers;
        // Now we check if the animation and our component cares about the active layer
        if (animationLayers.contains(activeLayer) && animationComponent->activeObjects.contains(activeLayer))
        {
            // Then we check what object we want to deal with. E.g. hair 1
            std::string& activeObjectFromThisLayer = animationComponent->activeObjects[activeLayer];
    
            if (animationLayers[activeLayer].contains(activeObjectFromThisLayer))
            {
                std::vector<Texture2D*>& texturesToDraw = animationLayers[activeLayer][activeObjectFromThisLayer];
                if (texturesToDraw.size() <= animationComponent->currentFrameIndex)
                {
                    LOG_WARNING_F("Tried to select layer frame but the texture was not present. Layer: {}, Frame index: {}", activeLayer, animationComponent->currentFrameIndex);
                    continue;
                }
    
                Texture2D* frameTextureToDraw = texturesToDraw[animationComponent->currentFrameIndex];
    
                customDraw->textureID = frameTextureToDraw->ID;
                customDraw->sortingLayerID = spriteComponent->GetSortingLayer().GetLayerOrder();
                customDraw->sortingOrderID = spriteComponent->GetSortingOrder() - 0.01;
    
                Transform* spriteTransform = spriteComponent->GetOwnerRef()->GetTransform();
                const Vector2& spritePosition = spriteTransform->GetPositionRef();
    
                const Vector2& size = spriteComponent->GetSize();
                Vector2 sizeCopy = size * spriteTransform->GetScale();
                if (spriteComponent->GetFlip())
                {
                    sizeCopy.x *= -1;
                }
    
                const Vector2& pivot = spriteComponent->GetPivot();
                const Color& color = spriteComponent->GetColor();
    
                SpriteComponent::GenerateVerticies(customDraw->verticies, spritePosition, sizeCopy, pivot, color, true);
            }
        }
    }
    

As seen, I loop through every active layer from the AnimationComponent and check if there's an active object present. If so, I then get the texture associated with the current animation and the current index.

The customDrawer is added to the render queue in Initialize. As said above, this is where a custom draw call would be instead after the player is renderered. Because I sort everything after the render queue is set up, I am fine to add it before as it won't be processed yet. The order has the -0.01 because everything is sorted based on the y position, another custom element. Therefore, I have to add a tiny number so that it actually appearas on top of the owner.

With the rendering sorted, all we need to do is head over to the game side, activate the hair layer and set the object to style1 which matches our animation.

  • Player::Start()

    void Player::Start()
    {
        GameObject::Start();
    
        cameraComponent->SetSize(150);
        cameraComponent->SetBackgroundColor(Color(32, 127, 255, 1));
        
        spriteComponent->SetPivot(Pivot::BottomCenter);
        spriteComponent->SetTexture("Player");
        spriteComponent->SetSortingLayer("Player");
        
        // Enable animation layers and set the hair style to style1
        animationComponent->ActivateLayer("hair");
        animationComponent->SetActiveObject("hair", "style1");
        
        circleColliderComponent->drawDebug = true;
        circleColliderComponent->SetRadius(8.0);
        circleColliderComponent->SetOffset(Vector2(0.0, spriteComponent->GetSize().y / 2));
        
        keyDownEventIndex = Events::Subscribe(this, &Player::OnKeyDown);
        keyUpEventIndex = Events::Subscribe(this, &Player::OnKeyUp);
        
        AnimationManager::Play(this->GetPtr(), "PlayerIdle");
        
        SceneManager::RegisterTickable(this->GetPtr());
    }
    

4.3.4 - Setting up Animation Events

Now that layers are working properly, we can look at events. The idea of events is as mentioned that we want to execute callbacks at specific points during an animation. An example of this is footsteps when a player walks.

We start by adding events to our .anim file. I want events to be triggered at any point in the animation so we will have to introduce totalFrameTime into our system. This means that we can add an event at any point. Let's have a look at our animation now:

  • PlayerIdle.anim

    {
        "name": "PlayerIdle",
        "textures": [
            "Player_Idle_1",
            "Player_Idle_2",
            "Player_Idle_3",
            "Player_Idle_4"
        ],
        "frameTime": 200.0,
        "cellWidth": 16,
        "cellHeight": 16,
        "frameCount": 4,
        "loop": true,
        "layers": {
          "hair": [
            {
              "style1": [
                "Player_Hair_Style1_Idle_1",
                "Player_Hair_Style1_Idle_2",
                "Player_Hair_Style1_Idle_3",
                "Player_Hair_Style1_Idle_4"
              ]
            }
          ]
        },
        "events": {
          "testEvent": 
          {
            "time": 100.0
          }
       }
    }
    

Similar to layers, we define events as objects with times. I added a limitation here that events cannot appear twice. This means that for something like footsteps, you would simply have to use two events with different names. They will however be able to use the same callback. Other animations can also use the same name.

The next step should be pretty self-explanatory since we are repeating steps from events. We want to add an entry to our animation class to store this data. However, we also want to mimic this on the AnimationComponent class since the animation itself shouldn't be modified. We therefore make changes to both classes to include the new entry. For now, let's focus on the animation class.

  • Animation.h

    typedef std::string LayerName;
    typedef std::string ObjectName;
    typedef std::string EventName;
    
    class Animation
    {
    public:
        Animation(std::vector<Texture2D*>& textures, const std::string& name, const float frameTime, const int cellWidth, const int cellHeight, const int frameCount, const bool loop)
            : textures(textures)
            , name(name)
            , frameTime(frameTime)
            , cellWidth(cellWidth)
            , cellHeight(cellHeight)
            , frameCount(frameCount)
            , loop(loop)
            , needsRecompile(false)
        {
            numberOfTextures = textures.size();
            index = 0;
        }
        
        ~Animation()
        {
            layers.clear();
            events.clear();
        }
    
    public:
        std::vector<Texture2D*> textures;
        std::map<LayerName, std::map<ObjectName, std::vector<Texture2D*>>> layers;
        std::map<EventName, float> events;
        std::string name;
        float frameTime;
        int cellWidth;
        int cellHeight;
        int frameCount;
        bool loop;
        
        bool needsRecompile;
        std::filesystem::path filePath;
    
    private:
        size_t numberOfTextures;
        int index;
    
    friend class AnimationManager;
    };
    

For the events, we only need to care about the name and time that we introduced in our Json file. We then modify our LoadAnimationFromFile function to take in these new parameters.

  • LoadAnimationFromFile(const std::string& filePath)

    // Code above loads in our data as shown in previous sections
    ...
    
    // Events
    auto itr2 = document.FindMember("events");
    if (itr2 != document.MemberEnd())
    {
        for (auto& eventObj : itr2->value.GetObj())
        {
            std::string eventName = eventObj.name.GetString();
            if (!events.contains(eventName))
            {
                events[eventName] = 0.0;
                auto timeMember = eventObj.value.GetObj().FindMember("time");
                if (timeMember != eventObj.value.MemberEnd())
                {
                    events[eventName] = timeMember->value.GetFloat();
                }
            }
        }
    }
    
    // Register animation underneath
    ...
    

4.3.5 - Registering to Events

With the animation now holding data on events, we need to make it so that we can register onto an event. E.g. the player listening for walking. To do so, we are going to need to add events into the AnimationComponent. Like mentioned, we don't want to modify the animation directly so we use the component in order to give every owner a unique hook to the events.

I was originally going to use our event system for this but I did not want to check for event names every time the event triggered to then delegate the callback task. Instead, I added a new system onto this to register functions onto the AnimationComponent. For this, we are going to need a template so we can call any instance's function

To get started, I created a new struct called AnimationEventData that holds a void* instance, and an std::function<void()>. This is then added into a map on the AnimationComponent where we can manually add entries to the data.

  • AnimationEventData.h

    struct AnimationEventData
    {
        AnimationEventData(void* inInstance, std::function<void()>& inCallback)
            : instance(inInstance)
            , callback(inCallback)
            , hasTriggered(false)
        {}
        
        void* instance;
        std::function<void()> callback;
        bool hasTriggered;
    };
    

  • AnimationComponent.h

    class AnimationComponent : public Component
    {
    public:
        virtual void Start() override;
        virtual void OnDestroy() override;
        
        void ActivateLayer(const std::string& layerName);
        void DeactivateLayer(const std::string& layerName);
        
        void SetActiveObject(const std::string& layerName, const std::string& objectName);
    
    template<class T>
    void RegisterEvent(const std::string& eventName, T* instance, void (T::* callback)())
    {
        std::function<void()> function = std::bind(callback, instance);
        events[eventName].push_back(AnimationEventData(instance, function));
    }
    
    template<class T>
    void UnregisterEvent(const std::string& eventName, T* instance, void (T::* callback)())
    {
        std::vector<AnimationEventData>& eventList = events[eventName];
        for (auto it = eventList.begin(); it != eventList.end();)
        {
            if (it->instance == instance && it->callback.target_type() == typeid(callback))
            {
                it = eventList.erase(it);
            }
                else
            {
                ++it;
            }
        }
        if (eventList.empty())
        {
            events.erase(eventName);
        }
    }
    
    void ResetEvent(const std::string& eventName); // Loops over the vector in the eventName and sets hasTriggered to false.
    
    public:
        Animation* currentAnimation;
        std::map<std::string, std::string> activeObjects;
        std::vector<std::string> activeLayers;
        std::map<std::string, std::vector<AnimationEventData>> events;
        float totalElapsedFrameTime = 0.0f;
        float currentFrameTime = 0;
        int currentFrameIndex = 0;
        bool playOnStart = true;
        bool shouldPlay = false;
    };
    

As we can see, we use the map for the eventName and then a vector of AnimationEventData. I wanted the ability to bind multiple functions/instances to a single event. This is because multiple animations can share the same event name. E.g. swinging a weapon. The callback could deal damage. For this, you would only have to register a single event instead of many. The templated function comes into play because we want to keep track of the instance that registered the event. By storing this information directly in the map, we can avoid checking any strings within callbacks since our system will eventually do it for us.

Finally, we can register a callback within our player. For now, let us just hook a function up to the idle testEvent that we defined in the Json file earlier.

  • Player::Start()

    void Player::Start()
    {
        GameObject::Start();
        
        cameraComponent->SetSize(150);
        cameraComponent->SetBackgroundColor(Color(32, 127, 255, 1));
        
        spriteComponent->SetPivot(Pivot::BottomCenter);
        spriteComponent->SetTexture("Player");
        spriteComponent->SetSortingLayer("Player");
        
        animationComponent->ActivateLayer("hair");
        animationComponent->SetActiveObject("hair", "style1");
        animationComponent->RegisterEvent("testEvent", this, &Player::TestEventCallback); // This callback simply logs
        
        circleColliderComponent->drawDebug = true;
        circleColliderComponent->SetRadius(8.0);
        circleColliderComponent->SetOffset(Vector2(0.0, spriteComponent->GetSize().y / 2));
        
        keyDownEventIndex = Events::Subscribe(this, &Player::OnKeyDown);
        keyUpEventIndex = Events::Subscribe(this, &Player::OnKeyUp);
        onForcePlayerTeleportEvent = Events::Subscribe(this, &Player::OnForcePlayerTeleport);
        
        inventoryComponent->AddItem(ItemType::STICK);
        inventoryComponent->AddItem(ItemType::STICK);
        inventoryComponent->AddItem(ItemType::STONE);
        
        AnimationManager::Play(this->GetPtr(), "PlayerIdle");
        
        SceneManager::RegisterTickable(this->GetPtr());
    }
    

4.3.6 - Triggering Event Callbacks

With the ability to register and unregister events, all we have to do now is call any callback hooked up to the event whenever the event has been hit. To do so, we need to modify our AnimationManager slightly. Firstly, we introduce the concept of totalFrameTime. Currently, we only track the time within a current frame. Because I decided to let an animation event call whenever, we need to know the total length played. We can already work this out based on the currentFrameTime and our currentAnimationIndex.

After adding the totalFrameTime, we can simply loop over any event in the current animation, see if the totalFrameTime is greater than the duration, and then call the callbacks if they are present. We also need the concept of the animation having restarted so we can set hasTriggered to false. Otherwise, events would only ever call once. This would not be ideal for looping animations.

Let us take a look at the final AnimationManager code for updating animations.

  • AnimationManager::UpdateAnmations()

    void AnimationManager::UpdateAnimations()
    {
        if (animationComponents.size() <= 0)
        {
            return;
        }
    
        for (int i = 0; i < animationComponents.size(); ++i)
        {
            AnimationComponent* animationComponent = animationComponents[i];
            SpriteComponent* spriteComponent = animationComponent->GetOwner()->GetComponent<SpriteComponent>();
    
            if (animationComponent == nullptr || spriteComponent == nullptr)
                continue;
    
            if (!animationComponent->shouldPlay)
                continue;
    
            Animation* currentAnimation = animationComponent->currentAnimation;
    
            if (currentAnimation == nullptr)
                continue;
    
            if (animationComponent->currentFrameIndex > currentAnimation->frameCount)
            {
                if (!currentAnimation->loop)
                    continue;
            }
    
            if (currentAnimation->textures.size() < 1)
                continue;
    
            animationComponent->currentFrameTime += (float)Time::GetDeltaTime() * 1000.0f;
            bool didCurrentAnimationRestartThisFrame = false; // Our new bool to see if an animation restarted.
    
            // Go to next sprite or stop the animation
            if (animationComponent->currentFrameTime > currentAnimation->frameTime)
            {
                if (!currentAnimation->loop && animationComponent->currentFrameIndex < currentAnimation->frameCount - 1
                || currentAnimation->loop)
                {
                    animationComponent->currentFrameIndex++;
                }
    
                if (animationComponent->currentFrameIndex >= currentAnimation->frameCount && currentAnimation->loop)
                {
                    animationComponent->currentFrameIndex = 0;
                    didCurrentAnimationRestartThisFrame = true; // Animation restarted. We set the flag here to use later.
                }
    
                animationComponent->currentFrameTime -= currentAnimation->frameTime; // Notice I corrected this. Before, it only reset to 0.
            }
    
            animationComponent->totalElapsedFrameTime = (animationComponent->currentFrameIndex * currentAnimation->frameTime) + animationComponent->currentFrameTime; // Total frame time is calculated here from existing values
    
            if (currentAnimation->textures.size() <= animationComponent->currentFrameIndex)
            {
                continue;
            }
    
            Texture2D* texture = currentAnimation->textures[animationComponent->currentFrameIndex];
            spriteComponent->SetTexture(texture);
    
            customDraw->verticies.clear();
    
            // Draw other layers here if needed. Firstly, we go through all enabled layers
            for (std::string& activeLayer : animationComponent->activeLayers)
            {
                auto& animationLayers = animationComponent->currentAnimation->layers;
                // Now we check if the animation and our component cares about the active layer
                if (animationLayers.contains(activeLayer) && animationComponent->activeObjects.contains(activeLayer))
                {
                    // Then we check what object we want to deal with. E.g. hair 1
                    std::string& activeObjectFromThisLayer = animationComponent->activeObjects[activeLayer];
    
                    if (animationLayers[activeLayer].contains(activeObjectFromThisLayer))
                    {
                        std::vector<Texture2D*>& texturesToDraw = animationLayers[activeLayer][activeObjectFromThisLayer];
                        if (texturesToDraw.size() <= animationComponent->currentFrameIndex)
                        {
                            LOG_WARNING_F("Tried to select layer frame but the texture was not present. Layer: {}, Frame index: {}", activeLayer, animationComponent->currentFrameIndex);
                            continue;
                        }
    
                        Texture2D* frameTextureToDraw = texturesToDraw[animationComponent->currentFrameIndex];
    
                        customDraw->textureID = frameTextureToDraw->ID;
                        customDraw->sortingLayerID = spriteComponent->GetSortingLayer().GetLayerOrder();
                        customDraw->sortingOrderID = spriteComponent->GetSortingOrder() - 0.01;
                        
                        Transform* spriteTransform = spriteComponent->GetOwnerRef()->GetTransform();
                        const Vector2& spritePosition = spriteTransform->GetPositionRef();
                        
                        const Vector2& size = spriteComponent->GetSize();
                        Vector2 sizeCopy = size * spriteTransform->GetScale();
                        if (spriteComponent->GetFlip())
                        {
                            sizeCopy.x *= -1;
                        }
    
                        const Vector2& pivot = spriteComponent->GetPivot();
                        const Color& color = spriteComponent->GetColor();
                        
                        SpriteComponent::GenerateVerticies(customDraw->verticies, spritePosition, sizeCopy, pivot, color, true);
                    }
                }
            }
    
            // Events
            for (auto& [eventName, eventDuration] : currentAnimation->events)
            {
                if (animationComponent->events.contains(eventName))
                {
                    for (auto& animationEventData : animationComponent->events[eventName])
                    {
                        if (animationComponent->totalElapsedFrameTime >= eventDuration)
                        {
                            if (!animationEventData.hasTriggered)
                            {
                                animationEventData.hasTriggered = true;
            
                                auto& callback = animationEventData.callback;
                                callback();
                            }
                        }
                        else if (didCurrentAnimationRestartThisFrame)
                        {
                            animationEventData.hasTriggered = false;
                        }
                    }
                }
            }
        }
    }
    

The last part of the code above is our new addition. We loop over the animation's data and match it with the current AnimationComponent. This ensures that every event is called when needed. Let's have a look at the output from the player now that the idle animation has been hooked up and is calling.

  • Console Output

    15:43.57 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:43.58 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:43.59 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:44.00 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:44.01 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:44.01 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:44.02 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    15:44.03 [Info] [Player.cpp | Function: TestEventCallback | Line: 194] Animation Event Was Triggered
    

And that's it! We have now succesfully gotten layers and events hooked up and can start a proper customisation and gameplay part of any game within this engine.

5.0 - Conclusion and Showcase

That was my 4 days spent trying to come up with a nice animation system for equipment. There is still a lot of optimization to do but I am really happy with how this looks!

5.1 - Showcase

Layers

Below is a video of the layers working. I added a quick debug menu to change layers. It simply uses the SetObject function between style1 and an empty string. It is really nice to see that there is no issue with the syncing of the animation and the layer because we submit the correct index frame.

Events

Below is another video demonstrating animation events. Because the game does not offer sound or any attack animation, the best thing I could think of at the time of demonstrating this was to spawn an item every time the idle animation hits 100ms.

Bonus

I added a helmet to the player and made it so that when you select the helmet, it deactivates the hair layer. This achieves the idea of equipping armor which I originally set as a goal.

5.2 - Conclusion

Having spent 4 days on this (minus the time it took to write this blog in pure HTML. I need a better tool), I do believe I got it working really well! There are obvious areas to expand on, write better code for, and check performance on. At the end of the day, this was my first time expanding on animation outside of the existing solution, and the new system can do a lot! My next step is to go through the existing code and optimise it where I can. Luckily, our engine has a profiler so we can scope out functions and see what's absolutely awful. I imagine the containers I chose will change a lot whenever I take another look at this.

If I was to redo this, I would probably do more research into actual implementations of systems instead of just looking at games. However, I do believe 2D is one of those fields where animation isn't looked at to the same extent as 3D. This doesn't mean there aren't any good resources, I just haven't looked for them.

I also want to start creating more blogs on the process of this engine since it is super enjoyable and is what I will use for future 2D projects.

Contact

Want to get in contact with me?

Email: benjaminnilsson1997@gmail.com

You can also contact me through
social media if preferred.