D20 RPG – Combatants

In this lesson we will add some visual elements to help make our battle scene more fun to look at.

Overview

This lesson is focused on adding some art assets to represent our combatants. We will add animated sprites to represent both a hero and monster. We will also provide a tile based room for them to fight in. Then I will show some strategies linking the model (an Entity) with its view (a GameObject) without needing to rely on things like Singletons or brute force approaches like FindWithTag.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

Download this unitypackage and import it to your project. It includes a collection of prefabs, scripts and sprites that we can use to play with.

New Sprites

There are three new sprites included. I found them at https://opengameart.org/ courtesy of Calciumtrice.

In addition to the ones I included for this lesson, you can find several other free assets by the same contributor here. I recommend checking out the other heroes: cleric, ranger, rogue, and wizard. You can also get more monsters: goblins, minotaur, orc, skeleton, slime and snake.

Both the warrior (hero) and rat (monster) are animated. They have the same set of animations:

  • idle
  • gesture
  • walk
  • attack
  • death

The consistency in setup allowed me to use the same scripts to manage both.

Combatant View

Open the new script at Assets -> Scripts -> SoloAdventure -> Encounter -> CombatantView. You will see a new class like so:

public class CombatantView : MonoBehaviour
{
    public SpriteRenderer avatar;
    public SpriteRenderer shadow;
    public bool flipDirection;
}

This simple component is attached to both the Hero and Monster prefabs and provides handy references to the elements within its hierarchy such as the avatar (the renderer of the creature itself) and shadow. A flag named flipDirection helps me know which way the art is facing so I can flip it if needed.

public enum CombatantDirection
{
    Left,
    Right
}

The direction the sprite faces is represented as an enum named CombatantDirection. I only care about facing left and right, though if I were making a top-down game I might want other directions as well.

public enum CombatantAnimation
{
    Attack,
    Death,
    Gesture,
    Idle,
    Walk
}

Another enum named CombatantAnimation is used to represent the different animation states that are available in the sprites. I used the same names (attack, death, etc) as were listed on the website.

public interface ICombatantViewSystem : IDependency<ICombatantViewSystem>
{
    void SetLayerOrder(CombatantView view, int value);
    void SetAnimation(CombatantView view, CombatantAnimation animation);
    UniTask PlayAnimation(CombatantView view, CombatantAnimation animation);
}

Next, I added a new system to help manage this new component. SetLayerOrder can be used to make sure that the various sprite renderers will appear in the correct order. I want the combatants that are lower on the tile map to appear above the combatants higher on the tile map so that they appear closer.

SetAnimation is used to apply a looping animation such as the “idle” state.

PlayAnimation is used to apply an animation that I only want to play once such as “attack”. It has a return type of UniTask so that I can await the animation and perform sequential logic as needed. This requires special setup to work correctly. If you take a peek inside one of the animation controllers for the warrior or rat, you can see that the “Attack” state is already configured with an “Exit Time” transition back to the “Idle” state.

Attack Exit Time

public class CombatantViewSystem : ICombatantViewSystem
{
    int attackState = Animator.StringToHash("Attack");
    int deathState = Animator.StringToHash("Death");
    int gestureState = Animator.StringToHash("Gesture");
    int idleState = Animator.StringToHash("Idle");
    int walkState = Animator.StringToHash("Walk");

    public void SetLayerOrder(CombatantView view, int value)
    {
        view.shadow.sortingOrder = -value * 2;
        view.avatar.sortingOrder = -value * 2 + 1;
    }

    public void SetAnimation(CombatantView view, CombatantAnimation animation)
    {
        var hash = HashForState(animation);
        var animator = view.avatar.GetComponent<Animator>();
        animator.CrossFade(hash, 0);
    }

    public async UniTask PlayAnimation(CombatantView view, CombatantAnimation animation)
    {
        var hash = HashForState(animation);
        var animator = view.avatar.GetComponent<Animator>();
        animator.CrossFade(hash, 0);
        while (true)
        {
            await UniTask.NextFrame(animator.GetCancellationTokenOnDestroy());
            if (animator.GetCurrentAnimatorStateInfo(0).shortNameHash != hash)
                break;
        }
    }

    int HashForState(CombatantAnimation animState)
    {
        switch (animState)
        {
            case CombatantAnimation.Attack:
                return attackState;
            case CombatantAnimation.Death:
                return deathState;
            case CombatantAnimation.Gesture:
                return gestureState;
            case CombatantAnimation.Idle:
                return idleState;
            case CombatantAnimation.Walk:
                return walkState;
            default:
                return 0;
        }
    }
}

Here is the implementation for the new system. At the top of the class I cache the hash values for the names of the states. They can be used to manually specify what state I want the animator to play, or to check if the state is currently playing.

SetLayerOrder sets the order for both the avatar and shadow sprite renderers. It multiplies the order by 2 because there are 2 renderers and I want to make sure that even the shadow and avatar are also in the correct order to each other.

SetAnimation grabs a hash that is associated with the given animation enum. It grabs the Animator component from the same object that the avatar is on, and then calls CrossFade to transition to the new state.

PlayAnimation is similar to SetAnimation except that it also has a loop that will continually await the next frame to check if the animator’s current state has changed.

HashForState is a private method that is used by the other methods to associate an animation state with an animation hash.

Combatant UI

Open the script at Assets -> Scripts -> UI -> CombatantUI.

using UnityEngine;
using UnityEngine.UI;

public class CombatantUI : MonoBehaviour
{
    public Slider healthSlider;
    public TMPro.TextMeshProUGUI damageLabel;
}

Within the hierarchy of our combatant prefabs you will find a nested prefab called Combatant UI. It has a canvas to display a health bar and a text label that we can use to present how much damage an attack has inflicted. The script itself is very simple, and merely provides handy reference to the elements inside.

Entity View Provider

You may be wondering, “how will code that knows about an Entity know how to associate it with its View (the combatant GameObject)?” The simple answer is that we simply need a system were you can “set” the association, and then other classes can “get” the association. A Dictionary collection type is ideal for this kind of mapping, and the Entity itself is an ideal Key for the collection while the View can be the Value of the collection.

In this case, I will be making a mapping that is slightly more complex, because I can imagine having multiple views that all represent the same entity. In addition to the combatant in the scene, I could imagine also having story panels with pictures representing whomever is speaking. Since I potentially need multiple views per entity, I would need a multi-level mapping setup. The different “type” of views I have decided to refer to as a “zone”. So in the end, I map from a “zone” to another dictionary that maps from an entity to its view.

Create a new C# script at Assets -> Scripts -> SoloAdventure -> Encounter named EntityViewProvider and add the following:

using System.Collections.Generic;
using UnityEngine;

public enum ViewZone
{
    Combatant
}

I start with defining a new enum to represent the view zone. At the moment I only have one zone, the Combatant, but as I hinted to above, we may decide to add more in the future. Currently, if you have a reference to a combatant you can get a reference to its health bar thanks to the GameObject hierarchy and the ability of getting child components. I could see an argument that it would be better to instead have a mapping directly to a “health bar zone” so that I could further separate myself from Unity architecture. At a minimum this would make unit testing easier, but it could also add flexibility to the way that we build the game.

Add the following:

public interface IEntityViewProvider : IDependency<IEntityViewProvider>
{
    GameObject GetView(Entity entity, ViewZone zone);
    void SetView(GameObject view, Entity entity, ViewZone zone);
}

This is our “system” interface (though in this case I called it a “provider”). To me, the “naming” indicates a certain intention of the use of the class. This class is really just a “middle-man”. It doesn’t really know how to do much by itself. Other classes tell it how to make an association, and other classes then ask for the association so they can “do” things with it.

Add the following:

public class EntityViewProvider : MonoBehaviour, IEntityViewProvider
{
    Dictionary<ViewZone, Dictionary<Entity, GameObject>> mapping = new Dictionary<ViewZone, Dictionary<Entity, GameObject>>();

    public GameObject GetView(Entity entity, ViewZone zone)
    {
        if (!mapping.ContainsKey(zone))
        {
            Debug.LogError(string.Format("No mapping for zone {0}", zone));
            return null;
        }

        var zoneMap = mapping[zone];
        if (!zoneMap.ContainsKey(entity))
        {
            Debug.LogError(string.Format("No mapping for entity {0} in zone {1}", entity.id, zone));
            return null;
        }

        return zoneMap[entity];
    }

    public void SetView(GameObject view, Entity entity, ViewZone zone)
    {
        if (!mapping.ContainsKey(zone))
            mapping[zone] = new Dictionary<Entity, GameObject>();

        if (view)
            mapping[zone][entity] = view;
        else
            mapping[zone].Remove(entity);
    }

    private void OnEnable()
    {
        IEntityViewProvider.Register(this);
    }

    private void OnDisable()
    {
        IEntityViewProvider.Reset();
    }
}

This is the class that implements our new provider interface. It is a subclass of MonoBehaviour, so we will need to remember to add it to a scene. On the bright side, this means it can handle its own IDependency registration.

The rest of the class is pretty simple. We defined the multi-level mapping as a Dictionary of Dictionaries. When Getting or Setting a mapping, there is a certain amount of handling necessary to make sure that a nested dictionary exists before we try to grab values from it.

Add the following:

public partial struct Entity
{
    public GameObject GetView(ViewZone zone)
    {
        return IEntityViewProvider.Resolve().GetView(this, zone);
    }

    public void SetView(GameObject view, ViewZone zone)
    {
        IEntityViewProvider.Resolve().SetView(view, this, zone);
    }
}

This last snippet is a simple extension on an Entity that allows us to use our new provider without even knowing it exists. This means that my systems are able to be more decoupled – I could easily remove or replace this code with very little effort.

Encounter Scene

Open the Encounter scene. We will add some of the new assets so that we can look at something more interesting than a sky gradient.

  1. Select the Demo game object and attach the EntityViewProvider component to it.
  2. Add an instance of the Assets -> Prefabs -> Grid as a child of the Demo game object.
  3. Select and Edit the Main Camera:
    1. Move it to Position (X:0, Y:2, Z:-10)
    2. Set the Clear Flags to Solid Color
    3. Set the Background color to Black (R:0, G:0, B:0, A:255)
    4. Set the Projection to Orthographic
  4. Add an instance of the Assets -> Prefabs -> Combatants -> Heroes -> Warrior as a child of the Demo game object. Set its Position to (X:-4, Y:0, Z:0)
  5. Add an instance of the Assets -> Prefabs -> Combatants -> Monsters -> Rat as a child of the Demo game object. Set its Position to (X:3, Y:0, Z:0)
  6. Save the scene

Your scene should now look like this:

Combatants in the Encounter Scene

Note that in the future we will load the characters dynamically based on the current Encounter asset. For now, it gives a sneak peak of what we are working toward, while also giving us something to look at while we build up some of the simple mechanics.

If you play the scene, you will also see that the characters are already setup for animation and each will be looping its idle state.

Demo

Create a temporary Demo script. It doesn’t matter where you save it, because you can delete it later. Add the following:

using UnityEngine;

public class Demo : MonoBehaviour
{
    [SerializeField] GameObject heroCombatant;

    private void Start()
    {
        AssociateHero();
        new SomeOtherClass().DamageHero();
    }

    void AssociateHero()
    {
        var hero = ISoloHeroSystem.Resolve().Hero;
        hero.SetView(heroCombatant, ViewZone.Combatant);
    }
}

public class SomeOtherClass
{
    public void DamageHero()
    {
        var hero = ISoloHeroSystem.Resolve().Hero;
        var view = hero.GetView(ViewZone.Combatant);
        var slider = view.GetComponentInChildren<CombatantUI>().healthSlider;
        slider.value = 0.5f;
    }
}

First things first, notice that I have this demo split into two different classes. The demo itself could easily have been done as a single class, but the purpose of the demo is to help demonstrate how a class that is NOT a MonoBehaviour can still use a provider to get access to both the model and view and perform its own duties.

The Demo class itself is still a MonoBehaviour for convenience. I can use the serialized reference to the already instantiated warrior, and can also use the “Start” method to get the demo to run automatically. This script is serving as an example of a class that does the setup portion of the code. In the future, rather than having a serialized reference, the setup code will create its own instance from a prefab and then make the association at that point.

The SomeOtherClass was added as a separate class to help emphasize the idea that this is code that could happen later, at any other time and for any other reason. It is NOT a MonoBehaviour and does not have any reference to anything from Unity. Even still, it can easily grab a reference both to the hero entity, and then to the hero entity’s view, and can act upon it easily.

To try out the Demo, add this script to the Demo GameObject in the “Encounter” scene and then connect the reference on the script to the warrior instance in the scene. Once that is done, save the scene, and run the game from the “LoadingScreen” scene. The result is that the Warrior will appear to have entered combat with only half of his health.

Summary

In this lesson we dressed up the Encounter scene by adding a tile based level map and sprites for our combatants. We then looked at a way to provide access to a view from any entity that has one associated with it. We created a small demo script that showed the basic idea and made it so that when the encounter loads, the hero will appear to be at half of his health.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *