A Stat System for Unity part 2

Posted by : on (Updated: )

Category : Unity

Introduction

You can check the GitHub repository with the final code here: Stat System

In the last post, I created the Stat class, so that it can make the necessary calculations for the flat, additive and multiplicative modifiers in the correct order. The way the Stat class was implemented, has some architectural problems: Our code is not easy to extend. To add a new type of modifier, we have to change the code in the class, not only to add this modifier by creating a new list that can hold it, but to also create a new method that is responsible for the calculation of that modifier and to change those switch statements so that we can have the ability to add/remove it form the list. Finally, we also have to take care to change the CalculateModifiedValue method that calls this new calculation method while adding that call in the appropriate place so that we can get the order of calculations correctly. This is very error-prone and time-consuming.

Ideally, we want to be able to adhere to the Open Closed Principle. We want to be able to create new modifiers and add them to our system to be calculated in the desired order, without having to touch already written code. Our system should be extendable, even if when someone is using it, doesn’t have access to our code. For example even if had compiled our code in a DLL, it should still be possible to add new functionality to it.

In this post, I let’s do this, by applying The Single Responsibility Principle, to separate our Stat class, use The Dependency Inversion Principle and The Liskov Substitution Principle to have a common interface to leverage polymorphism so that we can remove those switch statements and have a base class that contains all the common functionality so any new type of modifier we want to create needs only a single method, the method that describes how every modifier of that type, affects the value of our stat.

Creating a Common Interface

We know that each type of modifier, can do the same things, the way it does those things is different. Let’s create an interface that describes what the operations of a modifier type can do. We can see by looking at the Stat class, that a modifier type can be added, removed and calculated.

We can also see that we need a list that will hold all the modifiers of that type, so we need a method that can return that list of modifiers. Finally, as I described last week, the method that does the calculations on our stat, needs one or both of the following stat data: its base value and its current value. With this information, we can easily create our interface:

public interface IModifiersOperations
{
   void AddModifier(Modifier modifier);
   bool TryRemoveModifier(Modifier modifier);
   List<Modifier> GetAllModifiers();
   float CalculateModifiersValue(float baseValue, float currentValue);
}

Our different modifier operations classes, should follow that interface, but if we do that, we can see that we have to repeat some code.

Creating a Base Class

By creating a base class, we can avoid repeating writing code with the same behavior in every implementation. Every implementation of our modifier operations, will have a list that contains all the modifiers and will add and remove modifiers to that list in the same way.

Here is our base class:

public abstract class ModifierOperationsBase : IModifiersOperations
{
   protected readonly List<Modifier> Modifiers;

   protected ModifierOperationsBase(int capacity) => Modifiers = new List<Modifier>(capacity);
   protected ModifierOperationsBase() => Modifiers = new List<Modifier>(4);

   public virtual void AddModifier(Modifier modifier)
   {
      CheckListCapacity(Modifiers, modifier.Type);
      Modifiers.Add(modifier);
   }

   public virtual bool TryRemoveModifier(Modifier modifier) => Modifiers.Remove(modifier);

   public virtual List<Modifier> GetAllModifiers() => Modifiers;

   public abstract float CalculateModifiersValue(float baseValue, float currentValue);

   [Conditional("UNITY_EDITOR")]
   private static void CheckListCapacity(List<Modifier> modifiersList, ModifierType type)
   {
#if UNITY_EDITOR
      if(modifiersList.Count == modifiersList.Capacity)
         Debug.LogWarning($"Resize of {type} modifiers List! Consider initializing the list with a higher capacity.");
#endif
   }
}

The only extra thing I have added here is the CheckListCapacity method. This method, is only for use in the editor, and is responsible for notifying us, for any resize of our lists. This is useful, for creating our initial lists with a capacity that won’t create any garbage depending on the number of modifiers of each type that can simultaneously exist in a stat in our game.

Creating the Concrete Implementations

After that, inheriting from the base class will allow us to just concentrate on the thing that is different on each modifier type: the way that it is calculated. Here are the implementations for our three basic modifier types. Flat, additive and multiplicative.

public sealed class FlatModifierOperations : ModifierOperationsBase
{
    internal FlatModifierOperations(int capacity) : base(capacity) { }

    public override float CalculateModifiersValue(float baseValue, float currentValue)
    {
        float flatModifiersSum = 0f;

        for (var i = 0; i < Modifiers.Count; i++)
            flatModifiersSum += Modifiers[i];

        return flatModifiersSum;
    }
}

private sealed class AdditiveModifierOperations : ModifierOperationsBase
{
   internal AdditiveModifierOperations(int capacity) : base(capacity) { }

   public override float CalculateModifiersValue(float baseValue, float currentValue)
   {
      float additiveModifiersSum = 0f;

      for (var i = 0; i < Modifiers.Count; i++)
         additiveModifiersSum += Modifiers[i];

      return baseValue * additiveModifiersSum;
   }
}

private sealed class MultiplicativeModifierOperations : ModifierOperationsBase
{
   internal MultiplicativeModifierOperations(int capacity) : base(capacity) { }

   public override float CalculateModifiersValue(float baseValue, float currentValue)
   {
      float calculatedValue = currentValue;

      for (var i = 0; i < Modifiers.Count; i++)
         calculatedValue += calculatedValue * Modifiers[i];

      return calculatedValue - currentValue;
   }
}

Only the CalculateModifiersValue implementation is needed.

For flat modifiers, we add them and return their sum.

For additive modifiers, we add them and return their sum multiplied by the stats base value.

Finally, for the multiplicative modifiers, the first modifier is multiplied by the current value and each one after that with the resulting value. In the end, we subtract the initial current value, so the result will be the amount that needs to be added to our stat’s current value.

Creating A Collection For Our Modifier Operations

Our Stat class needs a list for each modifier type, and it also needs a way to know the order that it should perform the calculations between our different modifier types. Besides that, each stat class should have its own instance of each Modifier Operations type, as each one holds a list with modifiers applicable only to that Stat instance.

Here is the method, that returns new instances of all of our three modifier types:

internal Dictionary<ModifierType, Func<IModifiersOperations>> GetModifierOperations(int capacity)
{
    _modifierOperationsDict[ModifierType.Flat] = () => new FlatModifierOperations(capacity);
    _modifierOperationsDict[ModifierType.Additive] = () => new AdditiveModifierOperations(capacity);
    _modifierOperationsDict[ModifierType.Multiplicative] = () => new MultiplicativeModifierOperations(capacity);

    _modifiersCollectionHasBeenReturned = true;
         
    return _modifierOperationsDict;
}

The only thing that is added here, is the _modifiersCollectionHasBeenReturned flag. This flag is needed, because we will also create a method to be able to add new modifier types with their operations. We want to disallow the addition of any new modifiers, after any initialization of our Stat class.

Our Stat class, will call at each initialization in its constructor the GetModifierOperations method, so that it can know how to perform the calculations for each type. This flag guarantees, that we can have a check that denies any new addition of new types of modifiers after the Stat class has been instantiated at least once.

Speaking of adding new modifier types, here is the method that does that:

internal ModifierType AddModifierOperation(int order, Func<IModifiersOperations> modifierOperationsDelegate)
{
    if (_modifiersCollectionHasBeenReturned)
        throw new InvalidOperationException("Cannot change collection after it has been returned");
         
    var modifierType = (ModifierType)order;

    if (modifierType is ModifierType.Flat or ModifierType.Additive or ModifierType.Multiplicative)
        Debug.LogWarning("modifier operations for types flat, additive and multiplicative cannot be changed! Default operations for these types will be used.");

    _modifierOperationsDict[modifierType] = modifierOperationsDelegate;

    return modifierType;
}

After some checks that disallow the adding of new types after the collection has been returned and a warning that the basic three modifiers cannot be overriden, we just cast the order int to a ModifierType and add to our dictionary the delegate of the class that has its Modifier Operations.

This warning is the reason that in the GetModifierOperations method, we use the assignment operator for our dictionary instead of the add method. Even if someone tries to overwrite our default implementations in the AddModifierOperation method, these will be overwritten again in the GetModifierOperations method.

These two methods are the only methods our ModifierOperationsCollection class needs. Here is the whole class:

internal sealed class ModifierOperationsCollection
{
    private readonly Dictionary<ModifierType, Func<IModifiersOperations>> _modifierOperationsDict = new();
    private bool _modifiersCollectionHasBeenReturned;
      
    internal ModifierType AddModifierOperation(int order, Func<IModifiersOperations> modifierOperationsDelegate)
    {
        if (_modifiersCollectionHasBeenReturned)
            throw new InvalidOperationException("Cannot change collection after it has been returned");
         
        var modifierType = (ModifierType)order;

        if (modifierType is ModifierType.Flat or ModifierType.Additive or ModifierType.Multiplicative)
            Debug.LogWarning("modifier operations for types flat, additive and multiplicative cannot be changed! Default operations for these types will be used.");

        _modifierOperationsDict[modifierType] = modifierOperationsDelegate;

        return modifierType;
    }

    internal Dictionary<ModifierType, Func<IModifiersOperations>> GetModifierOperations(int capacity)
    {
        _modifierOperationsDict[ModifierType.Flat] = () => new FlatModifierOperations(capacity);
        _modifierOperationsDict[ModifierType.Additive] = () => new AdditiveModifierOperations(capacity);
        _modifierOperationsDict[ModifierType.Multiplicative] = () => new MultiplicativeModifierOperations(capacity);

        _modifiersCollectionHasBeenReturned = true;
         
        return _modifierOperationsDict;
    }
}

The Modifier Type enum

To give some room for the addition of new method operations between our existing ones, we can change our enum with some default values:

public enum ModifierType
{
   Flat = 100,
   Additive = 200,
   Multiplicative = 300
}

The final Stat class

Finally, after our refactoring our Stat class looks like this:

[Serializable]
public sealed class Stat
{
   private const int DEFAULT_LIST_CAPACITY = 4;
   private const int DEFAULT_DIGIT_ACCURACY = 2;
   internal const int MAXIMUM_ROUND_DIGITS = 8;

   [SerializeField] private float baseValue;

   [SuppressMessage("NDepend", "ND1902:AvoidStaticFieldsWithAMutableFieldType", Justification="Cannot mutate after Instantiation of Stat, will throw.")]
   [SuppressMessage("NDepend", "ND1901:AvoidNonReadOnlyStaticFields", Justification="Not readonly so that it can be called from Init() for reset.")]
   private static ModifierOperationsCollection _ModifierOperationsCollection = new();

   private readonly int _digitAccuracy;
   private readonly List<Modifier> _modifiersList = new();
   private readonly SortedList<ModifierType, IModifiersOperations> _modifiersOperations = new();

   private float _currentValue;
   private bool _isDirty;

   [SuppressMessage("NDepend", "ND1701:PotentiallyDeadMethods", Justification="Needed for Unity's disable domain reload feature.")]
   [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
   private static void Init() =>  _ModifierOperationsCollection = new();
   
   public Stat(float baseValue, int digitAccuracy, int modsMaxCapacity)
   {
      this.baseValue = baseValue;
      _currentValue = baseValue;
      _digitAccuracy = digitAccuracy;

      InitializeModifierOperations(modsMaxCapacity);

      // local method
      void InitializeModifierOperations(int capacity)
      {
         var modifierOperations = _ModifierOperationsCollection.GetModifierOperations(capacity);

         foreach (var operationType in modifierOperations.Keys)
            _modifiersOperations[operationType] = modifierOperations[operationType]();
      }
   }
   public Stat(float baseValue) : this(baseValue, DEFAULT_DIGIT_ACCURACY, DEFAULT_LIST_CAPACITY) { }
   public Stat(float baseValue, int digitAccuracy) : this(baseValue, digitAccuracy, DEFAULT_LIST_CAPACITY) { }

   public float BaseValue
   {
      get => baseValue;
      set
      {
         baseValue = value;
         _currentValue = CalculateModifiedValue(_digitAccuracy);
         OnValueChanged();
      }
   }

   public float Value
   {
      get
      {
         if (IsDirty)
         {
            _currentValue = CalculateModifiedValue(_digitAccuracy);
            OnValueChanged();
         }

         return _currentValue;
      }
   }

   private bool IsDirty
   {
      get => _isDirty;
      set
      {
         _isDirty = value;
         if (_isDirty)
            OnModifiersChanged();
      }
   }

   public event Action ValueChanged;
   public event Action ModifiersChanged;

   public void AddModifier(Modifier modifier)
   {
      IsDirty = true;
      _modifiersOperations[modifier.Type].AddModifier(modifier);
   }

   public static ModifierType NewModifierType(int order, Func<IModifiersOperations> modifierOperationsDelegate)
   {
      try
      {
         return _ModifierOperationsCollection.AddModifierOperation(order, modifierOperationsDelegate);
      }
      catch
      {
         throw new InvalidOperationException("Add any modifier operations before any initialization of the Stat class!");
      }
   }

   public IReadOnlyList<Modifier> GetModifiers()
   {
      _modifiersList.Clear();

      foreach (var modifiersOperation in _modifiersOperations.Values)
         _modifiersList.AddRange(modifiersOperation.GetAllModifiers());

      return _modifiersList.AsReadOnly();
   }

   public IReadOnlyList<Modifier> GetModifiers(ModifierType modifierType) => _modifiersOperations[modifierType].GetAllModifiers().AsReadOnly();

   public bool TryRemoveModifier(Modifier modifier)
   {
      var isModifierRemoved = false;

      if (_modifiersOperations[modifier.Type].TryRemoveModifier(modifier))
      {
         IsDirty = true;
         isModifierRemoved = true;
      }

      return isModifierRemoved;
   }

   public bool TryRemoveAllModifiersOf(object source)
   {
      bool isModifierRemoved = false;

      for (int i = 0; i < _modifiersOperations.Count; i++)
      {
         if (TryRemoveAllModifiersOfSourceFromList(source,
                _modifiersOperations.Values[i].GetAllModifiers()))
         {
            isModifierRemoved = true;
            IsDirty = true;
         }
      }

      return isModifierRemoved;

      // local method, static guarantees that it won't be allocated to the heap
      // (It is never converted to delegate, no variable captures)
      static bool TryRemoveAllModifiersOfSourceFromList(object source, List<Modifier> listOfModifiers)
      {
         bool modifierHasBeenRemoved = false;

         for (var i = listOfModifiers.Count - 1; i >= 0; i--)
         {
            if (ReferenceEquals(source, listOfModifiers[i].Source))
            {
               listOfModifiers.RemoveAt(i);
               modifierHasBeenRemoved = true;
            }
         }

         return modifierHasBeenRemoved;
      }
   }

   private float CalculateModifiedValue(int digitAccuracy)
   {
      digitAccuracy = Math.Clamp(digitAccuracy, 0, MAXIMUM_ROUND_DIGITS);

      float finalValue = baseValue;

      for (int i = 0; i < _modifiersOperations.Count; i++)
         finalValue += _modifiersOperations.Values[i].CalculateModifiersValue(baseValue, finalValue);

      IsDirty = false;

      return (float)Math.Round(finalValue, digitAccuracy);
   }

   private void OnValueChanged() => ValueChanged?.Invoke();
   private void OnModifiersChanged() => ModifiersChanged?.Invoke();
}

In the constructor, we use the ModifierOperationsCollection.GetModifierOperations to get the lists of all our modifier types and add them to a Sorted List, that is sorted by the value of the ModifierType.

After that, the switch statement in the AddModifier is substituted with _modifiersOperations[modifier.Type].AddModifier(modifier); that through polymorphism calls the appropriate IModifierOperation instance and is added to that instance’s list.

The same is happening in the TryRemoveModifier method.

Example of Usage

There are some more changes, mostly for error checking and to improve encapsulation, like adding the ModifierOperations implementations as private nested classes to the ModifierOperationsCollection class, but because I don’t want to drag this post with details, I will provide the link at the end of the post to the github repo, so that you can check the whole project yourselves.

I believe it is better for the rest of the post to show, why all those changes were needed.

Let’s suppose that we now want to add a new Modifier Type. We can do that, without touching the existing code, the code can even be compiled into a DLL and any new behavior can still be added. For this example, let’s suppose that our new type of modifier will be called Base absolute reduction. We want this modifier to suppress any existing modifier and make the stat that it is added to, reduced by a percentage of its starting base value. We also want only the biggest reduction of these modifiers to be applied to the stat, the others can be ignored.

For example, if we had a base strength stat with a starting value of 100, that had been modified with different modifiers to 200, after the application of the base absolute reduction type with a value of 0.2 (20%) the result should be 80. If we add a base absolute reduction after that with a value of 0.1 (10%) the result would still be 80 as only the biggest of these modifiers apply.

Even if we add a flat modifier of 20 after that, the value will continue to be 80. The moment we remove this base absolute reduction modifier though, all the suppressed modifiers will be applied normally, in our example the value of the strength stat after the removal will be 90 (the base absolute reduction of 10%) and after the removal of the 10% base absolute reduction, the strength value will become 220 (200 plus the 20 flat modifier we added while the base absolute reductions were applied)

First, we create the ModifierOperationsBaseAbsoluteReduction class that inherits from the ModifierOperationsBase class, and we implement the CalculateModifiersValue method that is appropriate for our new type:

public class ModifierOperationsBaseAbsoluteReduction : ModifierOperationsBase
{
   public ModifierOperationsBaseAbsoluteReduction(int capacity) : base(capacity) { }
   public ModifierOperationsBaseAbsoluteReduction() { }

   public override float CalculateModifiersValue(float baseValue, float currentValue)
   {
      var biggestModifier = 0f;

      for (var i = 0; i < Modifiers.Count; i++)
         biggestModifier = Mathf.Max(biggestModifier, Modifiers[i]);

      var modifierValue = biggestModifier == 0f ? 0f : baseValue * (1 - biggestModifier) - currentValue;

      return modifierValue;
   }
}

Here we find the biggest of the base absolute reduction modifiers, we multiply it with the base stat value, and we return this minus the current stat value. This effectively makes the calculation: currentValue = baseValue * (1 - biggestModifier) - currentValue

Then, we want this modifier to be calculated after any other modifiers have been calculated, so that it can suppress them. We do that by calling:

var  BaseAbsoluteReduction = Stat.NewModifierType(400, () => new ModifierOperationsBaseAbsoluteReduction());

400 is the order of calculation, which is higher than any of the existing modifiers.

Now, we can create these types of modifiers in different parts of our game, for example in a cursed item or as a spell effect:

Modifier strengthCurse = new Modifier(0.2f, BaseAbsoluteReduction);

and add them whenever we want, for example whenever the cursed item is equipped, or a spell has been cast, to the appropriate stat:

strength = new Stat(100);
strength.AddModifier(strengthCurse);

Overloads of the Stat class

The Stat class, has two optional parameters. It can be called like this:

strength = new Stat(100, 2); 

To provide the digit accuracy desired for the float calculations, or it can be called like this:

strength = new Stat(100, 2, 10);

Here, the third parameter, is the initial size of the maximum number of modifiers of each type that is expected this stat to have at any one point. This parameter helps with avoiding the garbage collector. As each type of modifier has a List with the applied modifiers for the stat, the third parameter effectively initializes those lists with a default capacity.

The initial default capacity of each list is 4. If at any point in time, a list resize is required, a warning will be showed. This won’t affect the functionality of the Stat system in any way, it is just a convenient way to avoid the garbage collector during the addition of new modifiers to any of our stats in the game.

Getting the Modifiers of Each Stat

The modifiers that each Stat has, at any point in time can be seen by calling the GetModifiers() and GetModifiers(ModifierType modifierType) methods. Both of these methods return a IReadOnlyList<Modifier> with the current modifiers applied to the Stat.

Conclusion

This is it about the Stat System. The github repo with the code, also has a simple character class that performs calculations with different modifiers, prints the results in the console and has comments on how these results are calculated.

You can check the GitHub repository with the final code here: Stat System

Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.


Follow me: