Vampire Survivors Part 23

Creating a Rogue-like (like Vampire Survivors) in Unity — Part 23: Buff / Debuff System

This article is a part of the series:
Creating a Rogue-like Shoot 'Em Up (like Vampire Survivors) in Unity

This article will be free until the accompanying video is released.

To complete our new weapon system for this series, we will need to have a proper buff / debuff system, because there are weapons in Vampire Survivors that apply buffs and debuffs to enemies. Hence, we’re going to be working on this in Part 23, so we can create cool stuff like this:

Clock Lancet effect
This is so cool.
  1. What is a buff?
    1. Examples of buffs (and debuffs)
    2. Conceptualising the buff system
  2. New BuffData scriptable object
    1. The BuffData script
    2. Properties in BuffData
    3. The BuffData.Type enumeration
    4. Buff variations
    5. The BuffData.Stats nested class
  3. Implementing a buff tracking system
    1. Creating the parent EntityStats class
    2. The nested Buff and BuffInfo classes
    3. Defining abstract methods in EntityStats
    4. Updating the PlayerStats script
    5. Updating the EnemyStats script
  4. Integrating Buffs into our weapons
    1. Modifying Weapon.Stats to accept BuffData
    2. Modifying Projectile to apply buffs
    3. Modifying Aura to apply buffs
    4. Modifying LightningRingWeapon to apply buffs
  5. Applying buff visual effects
    1. Applying particle effects to the buff
    2. Applying the Tint on your buff
    3. Updating the damage flash to use the new Tint system
    4. Changing animation speed
  6. Creating new buffs to test out the system
    1. Freeze buff
  7. Miscellaneous (but important) stuff
    1. Multiplicative buffs
    2. Disabling attack if Damage or Might is 0
    3. Allowing enemies to apply buffs
  8. Conclusion

1. What is a buff?

A buff is a temporary boost or handicap (i.e. debuff) that is applied to a unit in a game. They are most apparent in RTS or MOBA-styled games, although you can find them in many games that involve characters fighting one another.

a. Examples of buffs (and debuffs)

Buffs commonly modify the stats of an in-game character that is affected by them.

Bloodlust buff - DOTA 2
This character from DOTA 2 buffs himself to increase his attack speed stat.

Though their effects may not always be restricted to stat modification. Buffs can also remove the ability to perform certain actions.

Clock Lancet effect
Enemies hit by the bolt are frozen, and become unable to move or attack. This is one of the weapons that the buff system will allow us to create.

Or add effects that are not related to a character’s stats:

Nduja Fritta Tanto
Picking up the Nduja Fritta Tanto in Vampire Survivors will give you a buff that spews flames in the direction you are moving in.

In gaming parlance, when a buff provides an advantage to the character they are affected, they are called buffs; however, when they provide a disadvantage, they are usually called debuffs. For the purposes of this article (and the buff system that we are coding), debuffs are also considered buffs.

This is because a debuff does the same thing as a buff, except that they provide modifications in the opposite direction of buffs. Therefore, they fit into the buff system the same way that a buff does.

b. Conceptualising the buff system

The buff system that we will be creating in this article will be built very similarly to how Warcraft III’s was built. Almost every game that has a buff system will follow a similar framework, but I am citing Warcraft III as an example because they have a character UI in-game that shows you all the buffs and debuffs that a given character is currently affected by, which is really helpful for understanding what a buff and debuff system essentially is:

Warcraft III buffs / debuffs
If you mouse over the icon, you will also see a brief description of the buff, as well as how long it will last on your character.

As we build the buff system through the course of this article, I want you to keep this imagery above in mind, as it will help in understanding why certain things are designed the way that they are.


Article continues after the advertisement:


2. New BuffData scriptable object

The first thing that we are going to build is the BuffData scriptable object. Just like our weapons, we want to have a way for us to easily create and manage new buffs in our project.

a. The BuffData script

As such, we will need to create a scriptable object that we can use to record and store all the buffs and debuffs that we have in our game.

In my project, I store the BuffData script in the Assets/Scripts/Buff System folder.

BuffData.cs

using UnityEngine;

/// <summary>
/// BuffData is a class that can be used to create a basic buff on any EntityStats
/// object. This basic buff will either heal or damage the owner, and expires after
/// a certain duration.
/// </summary>
[CreateAssetMenu(fileName = "Buff Data", menuName = "2D Top-down Rogue-like/Buff Data")]
public class BuffData : ScriptableObject
{
    public new string name = "New Buff";
    public Sprite icon;

    [System.Flags]
    public enum Type : byte { buff = 1, debuff = 2, freeze = 4, strong = 8 }
    public Type type;

    public enum StackType : byte { refreshDurationOnly, stacksFully, doesNotStack }
    public enum ModifierType : byte { additive, multiplicative }

    [System.Serializable]
    public class Stats
    {
        public string name;

        [Header("Visuals")]
        [Tooltip("Effect that is attached to the GameObject with the buff.")]
        public ParticleSystem effect;
        [Tooltip("The tint colour of units affected by this buff.")]
        public Color tint = new Color(0,0,0,0);
        [Tooltip("Whether this buff slows down or speeds up the animation of the affected GameObject.")]
        public float animationSpeed = 1f;

        [Header("Stats")]
        public float duration;
        public float damagePerSecond, healPerSecond;

        [Tooltip("Controls how frequently the damage / heal per second applies.")]
        public float tickInterval = 0.25f;

        public StackType stackType;
        public ModifierType modifierType;

        public Stats()
        {
            duration = 10f; 
            damagePerSecond = 1f;
            healPerSecond = 1f;
            tickInterval = 0.25f;
        }

        public CharacterData.Stats playerModifier;
        public EnemyStats.Stats enemyModifier;
    }

    public Stats[] variations = new Stats[1] {
        new Stats {name = "Level 1"}
    };

    public float GetTickDamage(int variant = 0)
    {
        Stats s = Get(variant);
        return s.damagePerSecond * s.tickInterval;
    }

    public float GetTickHeal(int variant = 0)
    {
        Stats s = Get(variant);
        return s.healPerSecond * s.tickInterval;
    }

    public Stats Get(int variant = -1)
    {
        return variations[Mathf.Max(0, variant)];
    }
}

This will allow us to create new Buff Data scriptable objects in our Project window.

Buff Data scriptable objects
The BuffData scriptable object contains information about how the buff affects your stats.

b. Properties in BuffData

The BuffData scriptable object contains 4 variables within:

PropertyDescription
NameThe name of the buff.
IconA sprite image that can be used to represent the buff (like in the Warcraft III example above).
TypeWhether the buff belongs to any of the categories in the dropdown.
VariationsAll the different versions of the buff.
Buff Data close up
All the variables in BuffData.

Of the 4 variables above, only 2 of them — Type and Variations — are going to be used by our system. Name and Icon are there for us to label and track the buff internally, but it is not currently used in-game. In future, if we create a UI system that displays all buffs, these properties will come in handy.

c. The BuffData.Type enumeration

The Type variable allows us to categorise our buff, which is necessary because we have Freeze and Debuff resistances in our enemy’s stats:

Buff Data Type enumerations
In the code above, we have only included 4 different categories of buff types, but you can add more if you wish.

Note that you can tag your buff with multiple types if you wish — you are not restricted to selecting only one Type for your buff.

Buff Data Types multiple
The buff has been labelled “Strong”, “Freeze” and “Buff”.

You can also add more Types for your buffs by adding more types in the Type enum in the BuffData script, but make sure to assign a number that is a power of 2 to additional types (see the yellow highlight below for an example).

[System.Flags]
public enum Type : byte { buff = 1, debuff = 2, freeze = 4, strong = 8, burn = 16, poison = 32 }

By assigning different types to your buffs, you will later be able to apply effects to only a specific subset of them. For example, later on, we are going to use the freeze and debuff types to implement our Freeze and Debuff resistance stats from the last part.

Notice that the Type enum has a byte after its declaration — this is a small optimisation made to the enumeration type to save it space. By default, all enumerations take up 4 bytes of space, as they are stored as an integer. Since our enumeration above only has 4 options, the byte at the back instructs the enumeration to be saved as a single byte instead.

If you have more than 8 options in your enumeration, you will need to remove the : byte at the end of the enumeration’s declaration.

Check out the short below for a more detailed explanation of why this works the way it does.

d. Buff variations

The Variations array contains the bulk of data for our buff. It is a nested class that stores all the important data for our buff, such as:

  • The effect that will play on the character affect by the buff.
  • Whether a character should be tinted a different colour when affected by the buff.
  • Which stats should be affected by the buff, and how they are affected.

Variations has been made an array to allow us to create many different variations of a single buff. The variations are necessary because different weapons and pickups will be able to apply different versions of the same buff — for example, I may have a weapon that applies a Freeze debuff for 2 seconds, and another that applies the debuff for an indefinite amount of time.

By having buff variations, we skip the need for us to create a different Buff Data file for each version of the buff that exists in the game.

Variations in BuffData
The variations here are for different levels of the Clock Lancet freeze, as higher levels of the weapon apply a longer duration of the Freeze buff.

e. The BuffData.Stats nested class

The variations variable in our BuffData class — if you look at the provided code — is created using the BuffData.Stats type:

public Stats[] variations = new Stats[1] {
    new Stats {name = "Level 1"}
};

BuffData.Stats is a nested class within BuffData that is used to store stat information for each variation of the buff. It contains all of the data that you usually associate with buffs:

PropertyDescription
NameThe name of your variation. ’nuff said.
Visuals
EffectA Particle System prefab that gets parented to and plays on the character that has the buff.
TintWhether the character should receive a modification to their colour when it has the buff. E.g. when a character is under the Freeze buff, it becomes coloured blue.
Animation SpeedWhether the affected character’s animation speed changes. E.g. a unit under the Freeze buff has an animation speed of 0 as it cannot move.
Stats
DurationHow long the buff lasts for. Note that if this value is set to 0 or below, this buff will last forever until it is manually removed, or the character dies.
Damage Per SecondHow much damage the character receives per second when having this buff. Set to 0 if the buff does not deal damage.
Heal Per SecondHow much health the character restores per second when having this buff. Set to 0 if the buff does not heal.
Tick IntervalHow often the damage / heal per second is applied. For example, if the Tick Interval is 0.25 and the Damage Per Second is 12; the affected character will receive 3 damage in every 0.25-second interval, for a total of 12 damage per second.
Stack TypeThis is an enumeration that controls what happens if you try to apply a buff to a character that already has it. There are 3 possible options:
  • refreshDurationOnly: Only restarts the duration of the buff.
  • stacksFully: Applies the effect of the reapplied buff separately, i.e. if a buff does 20 damage per second and it stacks fully, 2 applications of the buff will do 40 damage per second.
  • doesNotStack: The buff cannot be reapplied on a unit that already has it.
Modifier TypeHow does the buff affect the stats? If it affects the stat additively, it gives a flat value boost; whereas if it stacks multiplicatively, it increases the stat by the percentage specified.
Player ModifierBoth of these variables contain the respective objects that are used to quantify a player’s stats (i.e. CharacterData.Stats) and an enemy’s stats (i.e. EnemyStats.Stats). They record the effect of the buff on a player’s stats, as well as an enemy’s stats.
Enemy Modifier

Further down in the article, we will be documenting how to implement each of these features. As a part of that, we will also be explaining some of these features in more detail.


Article continues after the advertisement:


3. Implementing a buff tracking system

Now that we have the framework for storing buff data, we will need to have a script that we can use to manage our buffs on every enemy and player character.

Mention that buffs use the Duration stat.

a. Creating the parent EntityStats class

Since we want our buffs to be applicable to both players and enemies, we create a class that is meant to become a superclass to both the PlayerStats and EnemyStats script. This way, we will be able to apply buffs to both PlayerStats and EnemyStats objects.

EntityStats.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Stats is a class that is inherited by both PlayerStats and EnemyStats.
/// It is here to provide a way for Buffs to be applied to both PlayerStats
/// and EnemyStats.
/// </summary>
public abstract class EntityStats : MonoBehaviour
{
    protected float health;

    [System.Serializable]
    public class Buff
    {
        public BuffData data;
        public float remainingDuration, nextTick;
        public int variant;

        public Buff(BuffData d, int variant = 0, float durationMultiplier = 1f)
        {
            data = d;
            BuffData.Stats buffStats = d.Get(variant);
            remainingDuration = buffStats.duration * durationMultiplier;
            nextTick = buffStats.tickInterval;
            this.variant = variant;
        }

        public BuffData.Stats GetData()
        {
            return data.Get(variant);
        }
    }

    protected List<Buff> activeBuffs = new List<Buff>();

    [System.Serializable]
    public class BuffInfo
    {
        public BuffData data;
        public int variant;
        [Range(0f, 1f)] public float probability = 1f;
    }

    // Gets a certain buff from the active buffs list.
    // If <variant> is not specified, it only checks whether the buff is there.
    // Otherwise, we will only get the buff if it is the correct <data> and <variant> values.
    public virtual Buff GetBuff(BuffData data, int variant = -1)
    {
        foreach(Buff b in activeBuffs)
        {
            if (b.data == data)
            {
                // If a variant of the buff is specified, we must make
                // sure our buff is the same variant before returning it.
                if (variant >= 0)
                {
                    if (b.variant == variant) return b;
                }
                else
                {
                    return b;
                }
            }
        }
        return null;
    }

    // If applying a buff via BuffInfo, we will also check the probability first.
    public virtual bool ApplyBuff(BuffInfo info, float durationMultiplier = 1f)
    {
        if(Random.value <= info.probability)
            return ApplyBuff(info.data, info.variant, durationMultiplier);
        return false;
    }

    // Adds a buff to an entity.
    public virtual bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        Buff b;
        BuffData.Stats s = data.Get(variant);

        switch(s.stackType)
        {
            // If the buff stacks fully, then we can have multiple copies of the buff.
            case BuffData.StackType.stacksFully:
                activeBuffs.Add(new Buff(data, variant, durationMultiplier));
                RecalculateStats();
                return true;

            // If it only refreshes the duration, we will find the buff that is
            // already there and reset the remaining duration (if the buff is not there yet).
            // Otherwise, we just add the buff.
            case BuffData.StackType.refreshDurationOnly:
                b = GetBuff(data, variant);
                if(b != null)
                {
                    b.remainingDuration = s.duration * durationMultiplier;
                } 
                else
                {
                    activeBuffs.Add(new Buff(data, variant, durationMultiplier));
                    RecalculateStats();
                }
                return true;

            // In cases where buffs do not stack, if the buff already exists, we ignore it.
            case BuffData.StackType.doesNotStack:
                b = GetBuff(data, variant);
                if (b != null)
                {
                    activeBuffs.Add(new Buff(data, variant, durationMultiplier));
                    RecalculateStats();
                    return true;
                }
                return false;
        }
        
        return false;
    }

    // Removes all copies of a certain buff.
    public virtual bool RemoveBuff(BuffData data, int variant = -1)
    {
        // Loop through all the buffs, and find buffs that we need to remove.
        List<Buff> toRemove = new List<Buff>();
        foreach (Buff b in activeBuffs)
        {
            if (b.data == data)
            {

                if (variant >= 0)
                {
                    if (b.variant == variant) toRemove.Add(b);
                }
                else
                {
                    toRemove.Add(b);
                }
            }
        }

        // We need to remove the buffs outside of the loop, otherwise this
        // will cause performance issues with the foreach loop above.
        if(toRemove.Count > 0)
        {
            activeBuffs.RemoveAll(item => toRemove.Contains(item));
            RecalculateStats();
            return true;
        }
        return false;
    }

    // Generic take damage function for dealing damage.
    public abstract void TakeDamage(float dmg);

    // Generic restore health function.
    public abstract void RestoreHealth(float amount);

    // Generic kill function.
    public abstract void Kill();

    // Forces the entity to recalculate its stats.
    public abstract void RecalculateStats();

    protected virtual void Update()
    {
        // Counts down each buff and removes them after their remaining
        // duration falls below 0.
        List<Buff> expired = new List<Buff>();
        foreach(Buff b in activeBuffs)
        {
            BuffData.Stats s = b.data.Get(b.variant);

            // Tick down on the damage / heal timer.
            b.nextTick -= Time.deltaTime;
            if (b.nextTick < 0)
            {
                float tickDmg = b.data.GetTickDamage(b.variant);
                if(tickDmg > 0) TakeDamage(tickDmg);
                float tickHeal = b.data.GetTickHeal(b.variant);
                if(tickHeal > 0) RestoreHealth(tickHeal);
                b.nextTick = s.tickInterval;
            }

            // If the buff has a duration of 0 or less, it will stay forever.
            // Don't reduce the remaining duration.
            if (s.duration <= 0) continue;

            // Also tick down on the remaining buff duration.
            b.remainingDuration -= Time.deltaTime;
            if (b.remainingDuration < 0) expired.Add(b);
        }

        // We remove the buffs outside the foreach loop, as it will affect the
        // iteration if we remove items from the list while a loop is still running.
        activeBuffs.RemoveAll(item => expired.Contains(item));
        RecalculateStats();
    }

}

b. What does the EntityStats superclass do?

The most important thing that the EntityStats class does is introduce a new activeBuffs list, which stores all the buffs that are currently applying to the entity. The Update() function in EntityStats also counts down each buff, and automatically removes the buff when it expires.

Note that in the Update() function, the buff system is specifically coded to not remove any buffs that have a Duration set to a value of 0 or less.

The class also provides 3 different functions that can be used to apply, remove, or get information about a buff on a character. These functions are:

  • ApplyBuff()
  • RemoveBuff()
  • GetBuff()

In the sections below, we will be exploring how these functions are used as we implement more of this buff system.

Notice that in the ApplyBuff() function, there is a switch-case statement that checks for what the stack type of the buff is. This function applies the buff’s effect differently depending on the Stack Type that we have set for the buff, and if you want to add your own stack types, you will also need to modify the ApplyBuff() function accordingly.

c. The nested Buff and BuffInfo classes

If you read the EntityStats script, you will also find that there are 2 classes nested within:

  1. EntityStats.Buff: Used to represent a buff that is currently applied to a character. It tracks which variation of the buff is being applied, as well as the remaining duration of the buff. Every buff that is created is added to the activeBuffs list on EntityStats.
  2. EntityStats.BuffInfo: This is a data structure that is used by agents that need to apply buffs. We will be elaborating more on this later, but BuffInfo is built to store the following information:
    • What kind of buff is being applied
    • What variation of the buff is being applied
    • What is the probability of the buff applying (e.g. some weapons may only freeze 30% of the time)

d. Defining abstract methods in EntityStats

Because our buffs are capable of dealing damage (and by extension, killing a character), restoring health, and changing the stats of the character, we have also defined these abstract methods in the class:

// Generic take damage function for dealing damage.
public abstract void TakeDamage(float dmg);

// Generic restore health function.
public abstract void RestoreHealth(float amount);

// Generic kill function.
public abstract void Kill();

// Forces the entity to recalculate its stats.
public abstract void RecalculateStats();

The first 2 functions are there to allow the EntityStats class to be able to receive damage and restore health, because the Update() function needs to be able to deal damage:

if (b.nextTick < 0)
{
    float tickDmg = b.data.GetTickDamage(b.variant);
    if(tickDmg > 0) TakeDamage(tickDmg);
    float tickHeal = b.data.GetTickHeal(b.variant);
    if(tickHeal > 0) RestoreHealth(tickHeal);
    b.nextTick = s.tickInterval;
}

The abstract RecalculateStats() function allows it to force a stat recalculation, because in both the ApplyBuff() and RemoveBuff() methods, we make a call to RecalculateStats() to trigger a stat recalculation after a buff is added or removed. This is important because buffs are capable of changing the stats of the character it is applied to.

An abstract function does not need to be implemented (i.e. we don’t need to write code for it), but it mandates that any subclasses will need to implement it. Hence, we will need to be…

e. Updating the PlayerStats script

Now, we want to get PlayerStats to inherit from EntityStats, which means we will need to make a few modifications to it:

PlayerStats.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerStats : MonoBehaviourEntityStats
{

    CharacterData characterData;
    public CharacterData.Stats baseStats;
    [SerializeField] CharacterData.Stats actualStats;

    public CharacterData.Stats Stats
    {
        get { return actualStats; }
        set
        {
            actualStats = value;
        }
    }
    public CharacterData.Stats Actual
    {
        get { return actualStats; }
    }

    float health;
    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

        // If we try and set the current health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed

            if (health != value)
            {
                health = value;
                UpdateHealthBar();
            }
        }
    }
    #endregion

    [Header("Visuals")]
    public ParticleSystem damageEffect; // If damage is dealt.
    public ParticleSystem blockedEffect; // If armor completely blocks damage.

    //Experience and level of the player
    [Header("Experience/Level")]
    public int experience = 0;
    public int level = 1;
    public int experienceCap;

    //Class for defining a level range and the corresponding experience cap increase for that range
    [System.Serializable]
    public class LevelRange
    {
        public int startLevel;
        public int endLevel;
        public int experienceCapIncrease;
    }

    //I-Frames
    [Header("I-Frames")]
    public float invincibilityDuration;
    float invincibilityTimer;
    bool isInvincible;

    public List<LevelRange> levelRanges;

    PlayerInventory inventory;
    PlayerCollector collector;
    public int weaponIndex;
    public int passiveItemIndex;

    [Header("UI")]
    public Image healthBar;
    public Image expBar;
    public TMP_Text levelText;

    void Awake()
    {
        characterData = CharacterSelector.GetData();
        if (CharacterSelector.instance)
            CharacterSelector.instance.DestroySingleton();

        inventory = GetComponent<PlayerInventory>();
        collector = GetComponentInChildren<PlayerCollector>();

        //Assign the variables
        baseStats = actualStats = characterData.stats;
        collector.SetRadius(actualStats.magnet);
        health = actualStats.maxHealth;
    }

    void Start()
    {
        //Spawn the starting weapon
        inventory.Add(characterData.StartingWeapon);

        //Initialize the experience cap as the first experience cap increase
        experienceCap = levelRanges[0].experienceCapIncrease;

        GameManager.instance.AssignChosenCharacterUI(characterData);

        UpdateHealthBar();
        UpdateExpBar();
        UpdateLevelText();
    }

    protected override void Update()
    {
        base.Update();
        if (invincibilityTimer > 0)
        {
            invincibilityTimer -= Time.deltaTime;
        }
        //If the invincibility timer has reached 0, set the invincibility flag to false
        else if (isInvincible)
        {
            isInvincible = false;
        }

        Recover();
    }

    public override void RecalculateStats()
    {
        actualStats = baseStats;
        foreach (PlayerInventory.Slot s in inventory.passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p)
            {
                actualStats += p.GetBoosts();
            }
        }

        // We have to account for the buffs from EntityStats as well.
        foreach(Buff b in activeBuffs)
        {
            actualStats += b.GetData().playerModifier;
        }

        // Update the PlayerCollector's radius.
        collector.SetRadius(actualStats.magnet);
    }

    public void IncreaseExperience(int amount)
    {
        experience += amount;

        LevelUpChecker();
        UpdateExpBar();
    }

    void LevelUpChecker()
    {
        if (experience >= experienceCap)
        {
            //Level up the player and reduce their experience by the experience cap
            level++;
            experience -= experienceCap;

            //Find the experience cap increase for the current level range
            int experienceCapIncrease = 0;
            foreach (LevelRange range in levelRanges)
            {
                if (level >= range.startLevel && level <= range.endLevel)
                {
                    experienceCapIncrease = range.experienceCapIncrease;
                    break;
                }
            }
            experienceCap += experienceCapIncrease;

            UpdateLevelText();

            GameManager.instance.StartLevelUp();

            // If the experience still exceeds the experience cap, level up again.
            if (experience >= experienceCap) LevelUpChecker();
        }
    }

    void UpdateExpBar()
    {
        // Update exp bar fill amount
        expBar.fillAmount = (float)experience / experienceCap;
    }

    void UpdateLevelText()
    {
        // Update level text
        levelText.text = "LV " + level.ToString();
    }

    public override void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            // Take armor into account before dealing the damage.
            dmg -= actualStats.armor;

            if (dmg > 0)
            {
                // Deal the damage.
                CurrentHealth -= dmg;

                // If there is a damage effect assigned, play it.
                if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);

                if (CurrentHealth <= 0)
                {
                    Kill();
                }
            }
            else
            {
                // If there is a blocked effect assigned, play it.
                if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
            }

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;
        }
    }

    void UpdateHealthBar()
    {
        //Update the health bar
        healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
    }

    public override void Kill()
    {
        if (!GameManager.instance.isGameOver)
        {
            GameManager.instance.AssignLevelReachedUI(level);

            GameManager.instance.GameOver();
        }
    }

    public override void RestoreHealth(float amount)
    {
        // Only heal the player if their current health is less than their maximum health
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += amount;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }

    void Recover()
    {
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += Stats.recovery * Time.deltaTime;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }
}

Besides changing PlayerStats to subclass EntityStats, the changes are mainly just adding an override marker to the functions that are now defined in EntityStats.

In Update(), we also add a base.Update() line to the function because we want the Update() function from EntityStats to run alongside the one from PlayerStats. This is because the Update() function from EntityStats is responsible for tracking the buffs, so if you do not call it, buffs on the player will never expire.

f. Updating the EnemyStats script

Of course, we also have to update EnemyStats to inherit from EntityStats as well. Along the way, we will fix a few issues with it:

  • We allow some of our stats to have negative values, so that they can also serve as modifiers in a debuff.
  • We modify the TakeDamage() function to only trigger damage flashes when there is more than 0 damage. Otherwise, every time an enemy receives a buff, it will flash as though it is receiving damage once every few moments.
  • We add a new TakeDamage() function that does not trigger a knockback. This is necessary for our buff system, because otherwise buffs that deal damage over time will constantly be creating knockback on the enemies.
  • We add a + operator function to both the Resistances and Stats nested classes, so that we are able to easily add resistances and stats from the buffs later on.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : MonoBehaviourEntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(0-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        [Min(0)] public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    float currentHealth;

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    Color originalColor;
    SpriteRenderer sr;
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    void Start()
    {
        RecalculateStats();

        // Calculate the health and check for level boosts.
        currentHhealth = actualStats.maxHealth;

        sr = GetComponent<SpriteRenderer>();
        originalColor = sr.color;

        movement = GetComponent<EnemyMovement>();
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // We have to account for the buffs from EntityStats as well.
        foreach (Buff b in activeBuffs)
        {
            actualStats += b.GetData().enemyModifier;
        }

        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;

        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }

        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        currentHealth -= dmg;
        StartCoroutine(DamageFlash());

        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }

        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        sr.color = damageColor;
        yield return new WaitForSeconds(damageFlashDuration);
        sr.color = originalColor;
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sr.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sr.color = new Color(sr.color.r, sr.color.g, sr.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

Besides the above, like PlayerStats, we are also adding an override marker to the functions that are now defined in EntityStats.

Also, because the RestoreHealth() function is defined in EntityStats, we will also need to define a RestoreHealth() function for the EnemyStats class.


Article continues after the advertisement:


4. Integrating Buffs into our weapons

Now that our buff system is all set up, it is time for us to modify our existing scripts so that they become capable of applying buffs.

a. Modifying Weapon.Stats to accept BuffData

The first thing we will do is allow weapons to apply buffs. We create a new appliedBuffs variable on the Weapon.Stats struct that uses the EntityStats.BuffInfo data structure, and create an ApplyBuff() function for the Weapon class:

Weapon.cs

using UnityEngine;

/// <summary>
/// Component to be attached to all Weapon prefabs. The Weapon prefab works together with the WeaponData
/// ScriptableObjects to manage and run the behaviours of all weapons in the game.
/// </summary>
public abstract class Weapon : Item
{
    [System.Serializable]
    public class Stats : LevelData
    {
 
        [Header("Visuals")]
        public Projectile projectilePrefab; // If attached, a projectile will spawn every time the weapon cools down.
        public Aura auraPrefab; // If attached, an aura will spawn when weapon is equipped.
        public ParticleSystem hitEffect, procEffect;
        public Rect spawnVariance;

        [Header("Values")]
        public float lifespan; // If 0, it will last forever.
        public float damage, damageVariance, area, speed, cooldown, projectileInterval, knockback;
        public int number, piercing, maxInstances;

        public EntityStats.BuffInfo[] appliedBuffs;

        // Allows us to use the + operator to add 2 Stats together.
        // Very important later when we want to increase our weapon stats.
        public static Stats operator +(Stats s1, Stats s2)
        {
            Stats result = new Stats();
            result.name = s2.name ?? s1.name;
            result.description = s2.description ?? s1.description;
            result.projectilePrefab = s2.projectilePrefab ?? s1.projectilePrefab;
            result.auraPrefab = s2.auraPrefab ?? s1.auraPrefab;
            result.hitEffect = s2.hitEffect == null ? s1.hitEffect : s2.hitEffect;
            result.procEffect = s2.procEffect == null ? s1.procEffect : s2.procEffect;
            result.spawnVariance = s2.spawnVariance;
            result.lifespan = s1.lifespan + s2.lifespan;
            result.damage = s1.damage + s2.damage;
            result.damageVariance = s1.damageVariance + s2.damageVariance;
            result.area = s1.area + s2.area;
            result.speed = s1.speed + s2.speed;
            result.cooldown = s1.cooldown + s2.cooldown;
            result.number = s1.number + s2.number;
            result.piercing = s1.piercing + s2.piercing;
            result.projectileInterval = s1.projectileInterval + s2.projectileInterval;
            result.knockback = s1.knockback + s2.knockback;
            result.appliedBuffs = s2.appliedBuffs == null || s2.appliedBuffs.Length <= 0 ? s1.appliedBuffs : s2.appliedBuffs;
            return result;
        }

        // Get damage dealt.
        public float GetDamage()
        {
            return damage + Random.Range(0, damageVariance);
        }
    }

    protected Stats currentStats;

    protected float currentCooldown;

    protected PlayerMovement movement; // Reference to the player's movement.

    // For dynamically created weapons, call initialise to set everything up.
    public virtual void Initialise(WeaponData data)
    {
        base.Initialise(data);
        this.data = data;
        currentStats = data.baseStats;
        movement = GetComponentInParent<PlayerMovement>();
        ActivateCooldown();
    }

    protected virtual void Update()
    {
        currentCooldown -= Time.deltaTime;
        if (currentCooldown <= 0f) //Once the cooldown becomes 0, attack
        {
            Attack(currentStats.number + owner.Stats.amount);
        }
    }

    // Levels up the weapon by 1, and calculates the corresponding stats.
    public override bool DoLevelUp()
    {
        base.DoLevelUp();

        // Prevent level up if we are already at max level.
        if (!CanLevelUp())
        {
            Debug.LogWarning(string.Format("Cannot level up {0} to Level {1}, max level of {2} already reached.", name, currentLevel, data.maxLevel));
            return false;
        }

        // Otherwise, add stats of the next level to our weapon.
        currentStats += (Stats)data.GetLevelData(++currentLevel);
        return true;
    }

    // Lets us check whether this weapon can attack at this current moment.
    public virtual bool CanAttack()
    {
        return currentCooldown <= 0;
    }

    // Performs an attack with the weapon.
    // Returns true if the attack was successful.
    // This doesn't do anything. We have to override this at the child class to add a behaviour.
    protected virtual bool Attack(int attackCount = 1)
    {
        if (CanAttack())
        {
            ActivateCooldown();
            return true;
        }
        return false;
    }

    // Gets the amount of damage that the weapon is supposed to deal.
    // Factoring in the weapon's stats (including damage variance),
    // as well as the character's Might stat.
    public virtual float GetDamage()
    {
        return currentStats.GetDamage() * owner.Stats.might;
    }

    // Get the area, including modifications from the player's stats.
    public virtual float GetArea()
    {
        return currentStats.area * owner.Stats.area;
    }

    // For retrieving the weapon's stats.
    public virtual Stats GetStats() { return currentStats; }

    // Refreshes the cooldown of the weapon.
    // If <strict> is true, refreshes only when currentCooldown < 0.
    public virtual bool ActivateCooldown(bool strict = false)
    {
        // When <strict> is enabled and the cooldown is not yet finished,
        // do not refresh the cooldown.
        if(strict && currentCooldown > 0) return false;

        // Calculate what the cooldown is going to be, factoring in the cooldown
        // reduction stat in the player character.
        float actualCooldown = currentStats.cooldown * Owner.Stats.cooldown;

        // Limit the maximum cooldown to the actual cooldown, so we cannot increase
        // the cooldown above the cooldown stat if we accidentally call this function
        // multiple times.
        currentCooldown = Mathf.Min(actualCooldown, currentCooldown + actualCooldown);
        return true;
    }

    // Makes the weapon apply its buff to a targeted EntityStats object.
    public void ApplyBuffs(EntityStats e)
    {
        // Apply all assigned buffs to the target.
        foreach (EntityStats.BuffInfo b in GetStats().appliedBuffs)
            e.ApplyBuff(b, owner.Actual.duration);
    }
}

This will create the following interface on all of our WeaponData scriptable objects:

WeaponData applied buffs
You are now able to assign a buff to a weapon.

The Applied Buffs array uses EntityStats.BuffInfo. Recall earlier in the article when we said we would be using this data structure?

b. Modifying Projectile to apply buffs

The change above only made our WeaponData capable of being assigned buffs. To make our weapons apply these buffs, we will need to also modify the GameObjects that help them do damage.

Projectile.cs

using UnityEngine;

/// <summary>
/// Component that you attach to all projectile prefabs. All spawned projectiles will fly in the direction
/// they are facing and deal damage when they hit an object.
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class Projectile : WeaponEffect
{

    public enum DamageSource { projectile, owner };
    public DamageSource damageSource = DamageSource.projectile;
    public bool hasAutoAim = false;
    public Vector3 rotationSpeed = new Vector3(0, 0, 0);

    protected Rigidbody2D rb;
    protected int piercing;

    // Start is called before the first frame update
    protected virtual void Start()
    {
        rb = GetComponent<Rigidbody2D>();
        Weapon.Stats stats = weapon.GetStats();
        if (rb.bodyType == RigidbodyType2D.Dynamic)
        {
            rb.angularVelocity = rotationSpeed.z;
            rb.velocity = transform.right * stats.speed * weapon.Owner.Stats.speed;
        }

        // Prevent the area from being 0, as it hides the projectile.
        float area = weapon.GetArea();
        if(area <= 0) area = 1;
        transform.localScale = new Vector3(
            area * Mathf.Sign(transform.localScale.x),
            area * Mathf.Sign(transform.localScale.y), 1
        );

        // Set how much piercing this object has.
        piercing = stats.piercing;

        // Destroy the projectile after its lifespan expires.
        if (stats.lifespan > 0) Destroy(gameObject, stats.lifespan);

        // If the projectile is auto-aiming, automatically find a suitable enemy.
        if (hasAutoAim) AcquireAutoAimFacing();
    }

    // If the projectile is homing, it will automatically find a suitable target
    // to move towards.
    public virtual void AcquireAutoAimFacing()
    {
        float aimAngle; // We need to determine where to aim.

        // Find all enemies on the screen.
        EnemyStats[] targets = FindObjectsOfType<EnemyStats>();

        // Select a random enemy (if there is at least 1).
        // Otherwise, pick a random angle.
        if (targets.Length > 0)
        {
            EnemyStats selectedTarget = targets[Random.Range(0, targets.Length)];
            Vector2 difference = selectedTarget.transform.position - transform.position;
            aimAngle = Mathf.Atan2(difference.y, difference.x) * Mathf.Rad2Deg;
        }
        else
        {
            aimAngle = Random.Range(0f, 360f);
        }

        // Point the projectile towards where we are aiming at.
        transform.rotation = Quaternion.Euler(0, 0, aimAngle);
    }

    // Update is called once per frame
    protected virtual void FixedUpdate()
    {
        // Only drive movement ourselves if this is a kinematic.
        if (rb.bodyType == RigidbodyType2D.Kinematic)
        {
            Weapon.Stats stats = weapon.GetStats();
            transform.position += transform.right * stats.speed * weapon.Owner.Stats.speed * Time.fixedDeltaTime;
            rb.MovePosition(transform.position);
            transform.Rotate(rotationSpeed * Time.fixedDeltaTime);
        }
    }

    protected virtual void OnTriggerEnter2D(Collider2D other)
    {
        EnemyStats es = other.GetComponent<EnemyStats>();
        BreakableProps p = other.GetComponent<BreakableProps>();

        // Only collide with enemies or breakable stuff.
        if (es)
        {
            // If there is an owner, and the damage source is set to owner,
            // we will calculate knockback using the owner instead of the projectile.
            Vector3 source = damageSource == DamageSource.owner && owner ? owner.transform.position : transform.position;

            // Deals damage and destroys the projectile.
            es.TakeDamage(GetDamage(), source);

            // Get the weapon's stats.
            Weapon.Stats stats = weapon.GetStats();
            
            weapon.ApplyBuffs(es); // Apply all assigned buffs to the target.

            // Reduce the piercing value, and destroy the projectile if it runs of out of piercing.
            piercing--;
            if (stats.hitEffect)
            {
                Destroy(Instantiate(stats.hitEffect, transform.position, Quaternion.identity), 5f);
            }
        }
        else if (p)
        {
            p.TakeDamage(GetDamage());
            piercing--;

            Weapon.Stats stats = weapon.GetStats();
            if (stats.hitEffect)
            {
                Destroy(Instantiate(stats.hitEffect, transform.position, Quaternion.identity), 5f);
            }
        }

        // Destroy this object if it has run out of health from hitting other stuff.
        if (piercing <= 0) Destroy(gameObject);
    }
}

The changes above are pretty straightforward — we simply call Weapon.ApplyBuffs() from the projectile and apply it to the entity that we are damaging. Other than that, the rest of the changes are simply adding comments to the code.

c. Modifying Aura to apply buffs

Some of our weapons don’t use projectiles to deal damage. They use an aura instead. Hence, we will have to modify the Aura script to apply buffs as well.

Aura.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// An aura is a damage-over-time effect that applies to a specific area in timed intervals.
/// It is used to give the functionality of Garlic, and it can also be used to spawn holy
/// water effects as well.
/// </summary>
public class Aura : WeaponEffect
{

    Dictionary<EnemyStats, float> affectedTargets = new Dictionary<EnemyStats, float>();
    List<EnemyStats> targetsToUnaffect = new List<EnemyStats>();

    // Update is called once per frame
    void Update()
    {
        Dictionary<EnemyStats, float> affectedTargsCopy = new Dictionary<EnemyStats, float>(affectedTargets);

        // Loop through every target affected by the aura, and reduce the cooldown
        // of the aura for it. If the cooldown reaches 0, deal damage to it.
        foreach (KeyValuePair<EnemyStats, float> pair in affectedTargsCopy)
        {
            affectedTargets[pair.Key] -= Time.deltaTime;
            if (pair.Value <= 0)
            {
                if (targetsToUnaffect.Contains(pair.Key))
                {
                    // If the target is marked for removal, remove it.
                    affectedTargets.Remove(pair.Key);
                    targetsToUnaffect.Remove(pair.Key);
                }
                else
                {
                    // Reset the cooldown and deal damage.
                    Weapon.Stats stats = weapon.GetStats();
                    affectedTargets[pair.Key] = stats.cooldown * Owner.Stats.cooldown;
                    pair.Key.TakeDamage(GetDamage(), transform.position, stats.knockback);

                    weapon.ApplyBuffs(pair.Key); // Apply all assigned buffs to the target.

                    // Play the hit effect if it is assigned.
                    if (stats.hitEffect)
                    {
                        Destroy(Instantiate(stats.hitEffect, pair.Key.transform.position, Quaternion.identity), 5f);
                    }
                }
            }
        }
    }

    void OnTriggerEnter2D(Collider2D other)
    {
        if (other.TryGetComponent(out EnemyStats es))
        {
            // If the target is not yet affected by this aura, add it
            // to our list of affected targets.
            if (!affectedTargets.ContainsKey(es))
            {
                // Always starts with an interval of 0, so that it will get
                // damaged in the next Update() tick.
                affectedTargets.Add(es, 0);
            }
            else
            {
                if (targetsToUnaffect.Contains(es))
                {
                    targetsToUnaffect.Remove(es);
                }
            }
        }
    }

    void OnTriggerExit2D(Collider2D other)
    {
        if (other.TryGetComponent(out EnemyStats es))
        {
            // Do not directly remove the target upon leaving,
            // because we still have to track their cooldowns.
            if (affectedTargets.ContainsKey(es))
            {
                targetsToUnaffect.Add(es);
            }
        }
    }
}

It uses the same logic as the modification to the Projectile script.

d. Modifying LightningRingWeapon to apply buffs

If you want your Lightning Ring to apply buffs and debuffs, you will also need to modify how the weapon is coded, because it is the only weapon that does not use an Aura or Projectile GameObject to apply its damage. It deals damage directly using its DamageArea() function.

LightningRingWeapon.cs

using System.Collections.Generic;
using UnityEngine;

// Damage does not scale with Might stat currently.
public class LightningRingWeapon : ProjectileWeapon
{

    List<EnemyStats> allSelectedEnemies = new List<EnemyStats>();

    protected override bool Attack(int attackCount = 1)
    {
        // If no projectile prefab is assigned, leave a warning message.
        if (!currentStats.hitEffect)
        {
            Debug.LogWarning(string.Format("Hit effect prefab has not been set for {0}", name));
            ActivateCooldown(true);
            return false;
        }

        // If there is no projectile assigned, set the weapon on cooldown.
        if (!CanAttack()) return false;

        // If the cooldown is less than 0, this is the first firing of the weapon.
        // Refresh the array of selected enemies.
        if (currentCooldown <= 0)
        {
            allSelectedEnemies = new List<EnemyStats>(FindObjectsOfType<EnemyStats>());
            ActivateCooldown();
            currentAttackCount = attackCount;
        }

        // Find an enemy in the map to strike with lightning.
        EnemyStats target = PickEnemy();
        if (target)
        {
            DamageArea(target.transform.position, GetArea(), GetDamage());

            Instantiate(currentStats.hitEffect, target.transform.position, Quaternion.identity);
        }

        // If there is a proc effect, play it on the player.
        if (currentStats.procEffect)
        {
            Destroy(Instantiate(currentStats.procEffect, owner.transform), 5f);
        }

        // If we have more than 1 attack count.
        if (attackCount > 0)
        {
            currentAttackCount = attackCount - 1;
            currentAttackInterval = currentStats.projectileInterval;
        }

        return true;
    }

    // Randomly picks an enemy on screen.
    EnemyStats PickEnemy()
    {
        EnemyStats target = null;
        while(!target && allSelectedEnemies.Count > 0)
        {
            int idx = Random.Range(0, allSelectedEnemies.Count);
            target = allSelectedEnemies[idx];

            // If the target is already dead, remove it and skip it. 
            if(!target)
            {
                allSelectedEnemies.RemoveAt(idx);
                continue;
            }

            // Check if the enemy is on screen.
            // If the enemy is missing a renderer, it cannot be struck, as we cannot
            // check whether it is on the screen or not.
            Renderer r = target.GetComponent<Renderer>();
            if (!r || !r.isVisible)
            {
                allSelectedEnemies.Remove(target);
                target = null;
                continue;
            }
        }

        allSelectedEnemies.Remove(target);
        return target;
    }

    // Deals damage in an area.
    void DamageArea(Vector2 position, float radius, float damage)
    {
        Collider2D[] targets = Physics2D.OverlapCircleAll(position, radius);
        foreach (Collider2D t in targets)
        {
            EnemyStats es = t.GetComponent<EnemyStats>();
            if (es)
            {
                es.TakeDamage(damage, transform.position);
                ApplyBuffs(es);
            }
        }
    }
}

Again, the idea is the same here — we just call ApplyBuffs() on the enemy that are being damaged.


Article continues after the advertisement:


5. Applying buff visual effects

Now, our buffing mechanism should already be working for our weapons. Unfortunately, the visual cues that we assign to our BuffData aren’t coded to show up yet — that’s the next thing that we will need to work on.

Buff Data visuals
We will need to get these settings to apply to the entities we are applying the buff to.

a. Applying particle effects to the buff

The first thing we will do is apply any assigned particle system to the entity that is affected by the buff. Unlike the other particle effects on our project, this one needs to be parented to the GameObject. Hence, instead of specifying the location of the effect like we usually do:

Instantiate(myEffectPrefab, owner.transform.position, owner.transform.rotation);

We will need to specify the Transform component to parent it to:

Instantiate(myEffectPrefab, owner.transform);

To do so, we will need to have a reference to the owner of the buff from within the Buff class. Hence, we make a few changes to the constructor, and modify the calls to new Buff() from within the ApplyBuff() function.

Of course, we’ll also need to add an extra line into both RemoveBuff() and Update(), to remove the particle effect when the buff is either removed or expires.

foreach(Buff b in toRemove)
{
    if (b.effect) Destroy(b.effect.gameObject);
    activeBuffs.Remove(b);
}

Here are the full set of changes to EntityStats:

EntityStats.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Stats is a class that is inherited by both PlayerStats and EnemyStats.
/// It is here to provide a way for Buffs to be applied to both PlayerStats
/// and EnemyStats.
/// </summary>
public abstract class EntityStats : MonoBehaviour
{
    protected float health;

    [System.Serializable]
    public class Buff
    {
        public BuffData data;
        public float remainingDuration, nextTick;
        public int variant;

        public ParticleSystem effect; // Particle system associated with this buff.

        public Buff(BuffData d, EntityStats owner, int variant = 0, float durationMultiplier = 1f)
        {
            data = d;
            BuffData.Stats buffStats = d.Get(variant);
            remainingDuration = buffStats.duration * durationMultiplier;
            nextTick = buffStats.tickInterval;
            this.variant = variant;

            // Save the effect so that when the debuff finishes, we can remove it.
            if (buffStats.effect) effect = Instantiate(buffStats.effect, owner.transform);
        }

        public BuffData.Stats GetData()
        {
            return data.Get(variant);
        }
    }

    protected List<Buff> activeBuffs = new List<Buff>();

    // Gets a certain buff from the active buffs list.
    // If <variant> is not specified, it only checks whether the buff is there.
    // Otherwise, we will only get the buff if it is the correct <data> and <variant> values.
    public virtual Buff GetBuff(BuffData data, int variant = -1)
    {
        foreach(Buff b in activeBuffs)
        {
            if (b.data == data)
            {
                // If a variant of the buff is specified, we must make
                // sure our buff is the same variant before returning it.
                if (variant >= 0)
                {
                    if (b.variant == variant) return b;
                }
                else
                {
                    return b;
                }
            }
        }
        return null;
    }

    // Adds a buff to an entity.
    public virtual bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        Buff b;
        BuffData.Stats s = data.Get(variant);

        switch(s.stackType)
        {
            // If the buff stacks fully, then we can have multiple copies of the buff.
            case BuffData.StackType.stacksFully:
                activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                RecalculateStats();
                return true;

            // If it only refreshes the duration, we will find the buff that is
            // already there and reset the remaining duration (if the buff is not there yet).
            // Otherwise, we just add the buff.
            case BuffData.StackType.refreshDurationOnly:
                b = GetBuff(data, variant);
                if(b != null)
                {
                    b.remainingDuration = s.duration * durationMultiplier;
                } 
                else
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                }
                return true;

            // In cases where buffs do not stack, if the buff already exists, we ignore it.
            case BuffData.StackType.doesNotStack:
                b = GetBuff(data, variant);
                if (b != null)
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                    return true;
                }
                return false;
        }
        
        return false;
    }

    // Removes all copies of a certain buff.
    public virtual bool RemoveBuff(BuffData data, int variant = -1)
    {
        // Loop through all the buffs, and find buffs that we need to remove.
        List<Buff> toRemove = new List<Buff>();
        foreach (Buff b in activeBuffs)
        {
            if (b.data == data)
            {

                if (variant >= 0)
                {
                    if (b.variant == variant) toRemove.Add(b);
                }
                else
                {
                    toRemove.Add(b);
                }
            }
        }

        // We need to remove the buffs outside of the loop, otherwise this
        // will cause performance issues with the foreach loop above.
        if(toRemove.Count > 0)
        {
            activeBuffs.RemoveAll(item => toRemove.Contains(item));
            foreach(Buff b in toRemove)
            {
                if (b.effect) Destroy(b.effect.gameObject);
                activeBuffs.Remove(b);
            }
            RecalculateStats();
            return true;
        }
        return false;
    }

    // Generic take damage function for dealing damage.
    public abstract void TakeDamage(float dmg);

    // Generic restore health function.
    public abstract void RestoreHealth(float amount);

    // Generic kill function.
    public abstract void Kill();

    // Forces the entity to recalculate its stats.
    public abstract void RecalculateStats();

    protected virtual void Update()
    {
        // Counts down each buff and removes them after their remaining
        // duration falls below 0.
        List<Buff> expired = new List<Buff>();
        foreach(Buff b in activeBuffs)
        {
            // Tick down on the damage / heal timer.
            b.nextTick -= Time.deltaTime;
            if (b.nextTick < 0)
            {
                float tickDmg = b.data.GetTickDamage(b.variant);
                if(tickDmg > 0) TakeDamage(tickDmg);
                float tickHeal = b.data.GetTickHeal(b.variant);
                if(tickHeal > 0) RestoreHealth(tickHeal);
                b.nextTick = b.data.variations[b.variant].tickInterval;
            }

            // Also tick down on the remaining buff duration.
            b.remainingDuration -= Time.deltaTime;
            if (b.remainingDuration < 0) expired.Add(b);
        }

        // We remove the buffs outside the foreach loop, as it will affect the
        // iteration if we remove items from the list while a loop is still running.
        activeBuffs.RemoveAll(item => expired.Contains(item));
        foreach (Buff b in expired)
        {
            if (b.effect) Destroy(b.effect.gameObject);
            activeBuffs.Remove(b);
        }
        RecalculateStats();
    }

}

b. Applying the Tint on your buff

The next thing we will want to do is apply the colour assigned to the Tint attribute to our buffed units. Again, we will have to modify EntityStats to do so.

Because it is possible for multiple buffs to be applied to a character at the same time, and because we have already implemented a damage flash whenever enemies get hurt in Part 14, we will need to have a system that is able to handle multiple tints being applied to a single character.

How the Damage Floating Text looks like.
Enemies temporarily flash a different colour when hit.

Hence, we create 2 new functions in EntityStats to manage our colours:

  • ApplyTint(): A function you call to add a tint to an existing character.
  • RemoveTint(): A function you call to remove a tint from an existing character.

All of these colours are stored inside a protected List called appliedTints, which tracks all the colours that are currently being applied to a character. We also have a protected function UpdateColor(), which sums all of these tints together and updates the sprite that the colours are being applied to.

Finally, we apply this tinting system to our buff system by calling ApplyTint() in our code whenever a buff is applied, and RemoveTint() whenever a buff is removed.

When setting the tint on your buff, do remember to set the Alpha (Transparency) of your colour to a value above 0. Otherwise, the colour of your tint will not show up at all. The higher the Alpha, the stronger the colour will be.

BuffData setting the right Tint
Remember to set the Alpha after setting your colour!

The full set of changes are highlighted below:

EntityStats.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Stats is a class that is inherited by both PlayerStats and EnemyStats.
/// It is here to provide a way for Buffs to be applied to both PlayerStats
/// and EnemyStats.
/// </summary>
public abstract class EntityStats : MonoBehaviour
{
    protected float health;

    // Tinting system.
    protected SpriteRenderer sprite;
    protected Color originalColor;
    protected List<Color> appliedTints = new List<Color>();
    public const float TINT_FACTOR = 4f;

    [System.Serializable]
    public class Buff
    {
        public BuffData data;
        public float remainingDuration, nextTick;
        public int variant;

        public ParticleSystem effect; // Particle system associated with this buff.
        public Color tint; // Tint color associated with this buff.

        public Buff(BuffData d, EntityStats owner, int variant = 0, float durationMultiplier = 1f)
        {
            data = d;
            BuffData.Stats buffStats = d.Get(variant);
            remainingDuration = buffStats.duration * durationMultiplier;
            nextTick = buffStats.tickInterval;
            this.variant = variant;

            // Save the effect so that when the debuff finishes, we can remove it.
            if (buffStats.effect) effect = Instantiate(buffStats.effect, owner.transform);
            if (buffStats.tint.a > 0)
            {
                tint = buffStats.tint;
                owner.ApplyTint(buffStats.tint);
            }
        }

        public BuffData.Stats GetData()
        {
            return data.Get(variant);
        }
    }

    protected List<Buff> activeBuffs = new List<Buff>();

    [System.Serializable]
    public class BuffInfo
    {
        public BuffData data;
        public int variant;
        [Range(0f, 1f)] public float probability = 1f;
    }

    protected virtual void Start()
    {
        sprite = GetComponent<SpriteRenderer>();
        originalColor = sprite.color;
    }

    public virtual void ApplyTint(Color c)
    {
        appliedTints.Add(c);
        UpdateColor();
    }

    public virtual void RemoveTint(Color c)
    {
        appliedTints.Remove(c);
        UpdateColor();
    }

    protected virtual void UpdateColor()
    {
        // Computes the target color.
        Color targetColor = originalColor;
        float totalWeight = 1f;
        foreach(Color c in appliedTints)
        {
            targetColor = new Color(
                targetColor.r + c.r * c.a * TINT_FACTOR, 
                targetColor.g + c.g * c.a * TINT_FACTOR, 
                targetColor.b + c.b * c.a * TINT_FACTOR, 
                targetColor.a
            );
            totalWeight += c.a * TINT_FACTOR;
        }
        targetColor = new Color(
            targetColor.r / totalWeight,
            targetColor.g / totalWeight,
            targetColor.b / totalWeight,
            targetColor.a
        );

        // Set all of our sprites' colour to the computed target color.
        sprite.color = targetColor;
    }

    // Gets a certain buff from the active buffs list.
    // If <variant> is not specified, it only checks whether the buff is there.
    // Otherwise, we will only get the buff if it is the correct <data> and <variant> values.
    public virtual Buff GetBuff(BuffData data, int variant = -1)
    {
        foreach(Buff b in activeBuffs)
        {
            if (b.data == data)
            {
                // If a variant of the buff is specified, we must make
                // sure our buff is the same variant before returning it.
                if (variant >= 0)
                {
                    if (b.variant == variant) return b;
                }
                else
                {
                    return b;
                }
            }
        }
        return null;
    }

    // If applying a buff via BuffInfo, we will also check the probability first.
    public virtual bool ApplyBuff(BuffInfo info, float durationMultiplier = 1f)
    {
        if(Random.value <= info.probability)
            return ApplyBuff(info.data, info.variant, durationMultiplier);
        return false;
    }

    // Adds a buff to an entity.
    public virtual bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        Buff b;
        BuffData.Stats s = data.Get(variant);

        switch(s.stackType)
        {
            // If the buff stacks fully, then we can have multiple copies of the buff.
            case BuffData.StackType.stacksFully:
                activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                RecalculateStats();
                return true;

            // If it only refreshes the duration, we will find the buff that is
            // already there and reset the remaining duration (if the buff is not there yet).
            // Otherwise, we just add the buff.
            case BuffData.StackType.refreshDurationOnly:
                b = GetBuff(data, variant);
                if(b != null)
                {
                    b.remainingDuration = s.duration * durationMultiplier;
                } 
                else
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                }
                return true;

            // In cases where buffs do not stack, if the buff already exists, we ignore it.
            case BuffData.StackType.doesNotStack:
                b = GetBuff(data, variant);
                if (b != null)
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                    return true;
                }
                return false;
        }
        
        return false;
    }

    // Removes all copies of a certain buff.
    public virtual bool RemoveBuff(BuffData data, int variant = -1)
    {
        // Loop through all the buffs, and find buffs that we need to remove.
        List<Buff> toRemove = new List<Buff>();
        foreach (Buff b in activeBuffs)
        {
            if (b.data == data)
            {

                if (variant >= 0)
                {
                    if (b.variant == variant) toRemove.Add(b);
                }
                else
                {
                    toRemove.Add(b);
                }
            }
        }

        // We need to remove the buffs outside of the loop, otherwise this
        // will cause performance issues with the foreach loop above.
        if(toRemove.Count > 0)
        {
            foreach(Buff b in toRemove)
            {
                if (b.effect) Destroy(b.effect.gameObject);
                if (b.tint.a > 0) RemoveTint(b.tint);
                activeBuffs.Remove(b);
            }
            RecalculateStats();
            return true;
        }
        return false;
    }

    // Generic take damage function for dealing damage.
    public abstract void TakeDamage(float dmg);

    // Generic restore health function.
    public abstract void RestoreHealth(float amount);

    // Generic kill function.
    public abstract void Kill();

    // Forces the entity to recalculate its stats.
    public abstract void RecalculateStats();

    protected virtual void Update()
    {
        // Counts down each buff and removes them after their remaining
        // duration falls below 0.
        List<Buff> expired = new List<Buff>();
        foreach(Buff b in activeBuffs)
        {
            BuffData.Stats s = b.data.Get(b.variant);

            // Tick down on the damage / heal timer.
            b.nextTick -= Time.deltaTime;
            if (b.nextTick < 0)
            {
                float tickDmg = b.data.GetTickDamage(b.variant);
                if(tickDmg > 0) TakeDamage(tickDmg);
                float tickHeal = b.data.GetTickHeal(b.variant);
                if(tickHeal > 0) RestoreHealth(tickHeal);
                b.nextTick = s.tickInterval;
            }

            // If the buff has a duration of 0 or less, it will stay forever.
            // Don't reduce the remaining duration.
            if (s.duration <= 0) continue;

            // Also tick down on the remaining buff duration.
            b.remainingDuration -= Time.deltaTime;
            if (b.remainingDuration < 0) expired.Add(b);
        }

        // We remove the buffs outside the foreach loop, as it will affect the
        // iteration if we remove items from the list while a loop is still running.
        foreach (Buff b in expired)
        {
            if (b.effect) Destroy(b.effect.gameObject);
            if (b.tint.a > 0) RemoveTint(b.tint);
            activeBuffs.Remove(b);
        }
        RecalculateStats();
    }
}

Notice that in the EntityStats script, we also define a Start() function. This means that in our subclasses, PlayerStats and EnemyStats, we will need to modify their Start() functions so that they will invoke this parent function as well. Otherwise, the tinting system will not work on them.

PlayerStats.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerStats : EntityStats
{

    CharacterData characterData;
    public CharacterData.Stats baseStats;
    [SerializeField] CharacterData.Stats actualStats;

    public CharacterData.Stats Stats
    {
        get { return actualStats; }
        set
        {
            actualStats = value;
        }
    }
    public CharacterData.Stats Actual
    {
        get { return actualStats; }
    }

    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

        // If we try and set the current health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed

            if (health != value)
            {
                health = value;
                UpdateHealthBar();
            }
        }
    }
    #endregion

    [Header("Visuals")]
    public ParticleSystem damageEffect; // If damage is dealt.
    public ParticleSystem blockedEffect; // If armor completely blocks damage.

    //Experience and level of the player
    [Header("Experience/Level")]
    public int experience = 0;
    public int level = 1;
    public int experienceCap;

    //Class for defining a level range and the corresponding experience cap increase for that range
    [System.Serializable]
    public class LevelRange
    {
        public int startLevel;
        public int endLevel;
        public int experienceCapIncrease;
    }

    //I-Frames
    [Header("I-Frames")]
    public float invincibilityDuration;
    float invincibilityTimer;
    bool isInvincible;

    public List<LevelRange> levelRanges;

    PlayerInventory inventory;
    PlayerCollector collector;

    [Header("UI")]
    public Image healthBar;
    public Image expBar;
    public TMP_Text levelText;

    void Awake()
    {
        characterData = CharacterSelector.GetData();
        if (CharacterSelector.instance)
            CharacterSelector.instance.DestroySingleton();

        inventory = GetComponent<PlayerInventory>();
        collector = GetComponentInChildren<PlayerCollector>();

        //Assign the variables
        baseStats = actualStats = characterData.stats;
        collector.SetRadius(actualStats.magnet);
        health = actualStats.maxHealth;
    }

    protected override void Start()
    {
        base.Start();
        //Spawn the starting weapon
        inventory.Add(characterData.StartingWeapon);

        //Initialize the experience cap as the first experience cap increase
        experienceCap = levelRanges[0].experienceCapIncrease;

        GameManager.instance.AssignChosenCharacterUI(characterData);

        UpdateHealthBar();
        UpdateExpBar();
        UpdateLevelText();
    }

    protected override void Update()
    {
        base.Update();
        if (invincibilityTimer > 0)
        {
            invincibilityTimer -= Time.deltaTime;
        }
        //If the invincibility timer has reached 0, set the invincibility flag to false
        else if (isInvincible)
        {
            isInvincible = false;
        }

        Recover();
    }

    public override void RecalculateStats()
    {
        actualStats = baseStats;
        foreach (PlayerInventory.Slot s in inventory.passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p)
            {
                actualStats += p.GetBoosts();
            }
        }

        // We have to account for the buffs from EntityStats as well.
        foreach(Buff b in activeBuffs)
        {
            actualStats += b.GetData().playerModifier;
        }

        // Update the PlayerCollector's radius.
        collector.SetRadius(actualStats.magnet);
    }

    public void IncreaseExperience(int amount)
    {
        experience += amount;

        LevelUpChecker();
        UpdateExpBar();
    }

    void LevelUpChecker()
    {
        if (experience >= experienceCap)
        {
            //Level up the player and reduce their experience by the experience cap
            level++;
            experience -= experienceCap;

            //Find the experience cap increase for the current level range
            int experienceCapIncrease = 0;
            foreach (LevelRange range in levelRanges)
            {
                if (level >= range.startLevel && level <= range.endLevel)
                {
                    experienceCapIncrease = range.experienceCapIncrease;
                    break;
                }
            }
            experienceCap += experienceCapIncrease;

            UpdateLevelText();

            GameManager.instance.StartLevelUp();

            // If the experience still exceeds the experience cap, level up again.
            if (experience >= experienceCap) LevelUpChecker();
        }
    }

    void UpdateExpBar()
    {
        // Update exp bar fill amount
        expBar.fillAmount = (float)experience / experienceCap;
    }

    void UpdateLevelText()
    {
        // Update level text
        levelText.text = "LV " + level.ToString();
    }

    public override void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            // Take armor into account before dealing the damage.
            dmg -= actualStats.armor;

            if (dmg > 0)
            {
                // Deal the damage.
                CurrentHealth -= dmg;

                // If there is a damage effect assigned, play it.
                if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);

                if (CurrentHealth <= 0)
                {
                    Kill();
                }
            }
            else
            {
                // If there is a blocked effect assigned, play it.
                if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
            }

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;
        }
    }

    void UpdateHealthBar()
    {
        //Update the health bar
        healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
    }

    public override void Kill()
    {
        if (!GameManager.instance.isGameOver)
        {
            GameManager.instance.AssignLevelReachedUI(level);

            GameManager.instance.GameOver();
        }
    }

    public override void RestoreHealth(float amount)
    {
        // Only heal the player if their current health is less than their maximum health
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += amount;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }

    void Recover()
    {
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += Stats.recovery * Time.deltaTime;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }
}

We will have to update the Start() function in the EnemyStats script as well, together with…

c. Updating the damage flash to use the new Tint system

For the EnemyStats, recall that in Part 14, we also created damage feedback for it, which also tints the sprite whenever it is hit.

How the Damage Floating Text looks like.
Enemies we hit will flash, as the damage feedback code in EnemyStats changes their colour temporarily.

Since we are creating a new tinting system in EntityStats, we will also have to update EnemyStats to use this new tinting system.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : EntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    Color originalColor;
    SpriteRenderer sr;
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    protected override void Start()
    {
        base.Start();

        RecalculateStats();

        // Calculate the health and check for level boosts.
        health = actualStats.maxHealth;

        sr = GetComponent<SpriteRenderer>();
        originalColor = sr.color;

        movement = GetComponent<EnemyMovement>();
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;

        // We have to account for the buffs from EntityStats as well.
        foreach (Buff b in activeBuffs)
        {
            actualStats += b.GetData().enemyModifier;
        }
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;

        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }
            
        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        sr.color = damageColorApplyTint(damageColor);
        yield return new WaitForSeconds(damageFlashDuration);
        sr.color = originalColorRemoveTint(damageColor);
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = srsprite.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            srsprite.color = new Color(srsprite.color.r, srsprite.color.g, srsprite.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

d. Changing animation speed

Finally, some of our buffs also cause enemies to change their animation speed. For instance, the Clock Lancet weapon that we are going to create freezes an enemy’s animation.

Clock Lancet effect
I love this animation.

To allow for this, we will also create 2 new functions in EntityStats:

  • ApplyAnimationMultiplier(): Which multiplies the animation speed of a character by a given value.
  • RemoveAnimationMultiplier(): Which reverts the animation speed back to the original value.

Then, as usual, we use these functions in our existing buff system to apply the animation speed changes to our buffs.

EntityStats.cs

using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Stats is a class that is inherited by both PlayerStats and EnemyStats.
/// It is here to provide a way for Buffs to be applied to both PlayerStats
/// and EnemyStats.
/// </summary>
public abstract class EntityStats : MonoBehaviour
{
    protected float health;

    // Tinting system.
    protected SpriteRenderer sprite;
    protected Animator animator;
    protected Color originalColor;
    protected List<Color> appliedTints = new List<Color>();
    public const float TINT_FACTOR = 4f;

    [System.Serializable]
    public class Buff
    {
        public BuffData data;
        public float remainingDuration, nextTick;
        public int variant;

        public ParticleSystem effect; // Particle system associated with this buff.
        public Color tint; // Tint color associated with this buff.
        public float animationSpeed = 1f;

        public Buff(BuffData d, EntityStats owner, int variant = 0, float durationMultiplier = 1f)
        {
            data = d;
            BuffData.Stats buffStats = d.Get(variant);
            remainingDuration = buffStats.duration * durationMultiplier;
            nextTick = buffStats.tickInterval;
            this.variant = variant;

            // Save the effect so that when the debuff finishes, we can remove it.
            if (buffStats.effect) effect = Instantiate(buffStats.effect, owner.transform);
            if (buffStats.tint.a > 0)
            {
                tint = buffStats.tint;
                owner.ApplyTint(buffStats.tint);
            }

            // Apply animation speed modifications.
            animationSpeed = buffStats.animationSpeed;
            owner.ApplyAnimationMultiplier(animationSpeed);
        }

        public BuffData.Stats GetData()
        {
            return data.Get(variant);
        }
    }

    protected List<Buff> activeBuffs = new List<Buff>();

    [System.Serializable]
    public class BuffInfo
    {
        public BuffData data;
        public int variant;
        [Range(0f, 1f)] public float probability = 1f;
    }

    protected virtual void Start()
    {
        sprite = GetComponent<SpriteRenderer>();
        originalColor = sprite.color;
        animator = GetComponent<Animator>();
    }

    public virtual void ApplyAnimationMultiplier(float factor)
    {
        // Prevent factor from being zero, as it causes problems when removing.
        animator.speed *= Mathf.Approximately(0, factor) ? 0.000001f : factor;
    }

    public virtual void RemoveAnimationMultiplier(float factor)
    {
        animator.speed /= Mathf.Approximately(0, factor) ? 0.000001f : factor;
    }

    public virtual void ApplyTint(Color c)
    {
        appliedTints.Add(c);
        UpdateColor();
    }

    public virtual void RemoveTint(Color c)
    {
        appliedTints.Remove(c);
        UpdateColor();
    }

    protected virtual void UpdateColor()
    {
        // Computes the target color.
        Color targetColor = originalColor;
        float totalWeight = 1f;
        foreach(Color c in appliedTints)
        {
            targetColor = new Color(
                targetColor.r + c.r * c.a * TINT_FACTOR, 
                targetColor.g + c.g * c.a * TINT_FACTOR, 
                targetColor.b + c.b * c.a * TINT_FACTOR, 
                targetColor.a
            );
            totalWeight += c.a * TINT_FACTOR;
        }
        targetColor = new Color(
            targetColor.r / totalWeight,
            targetColor.g / totalWeight,
            targetColor.b / totalWeight,
            targetColor.a
        );

        // Set all of our sprites' colour to the computed target color.
        sprite.color = targetColor;
    }

    // Gets a certain buff from the active buffs list.
    // If <variant> is not specified, it only checks whether the buff is there.
    // Otherwise, we will only get the buff if it is the correct <data> and <variant> values.
    public virtual Buff GetBuff(BuffData data, int variant = -1)
    {
        foreach(Buff b in activeBuffs)
        {
            if (b.data == data)
            {
                // If a variant of the buff is specified, we must make
                // sure our buff is the same variant before returning it.
                if (variant >= 0)
                {
                    if (b.variant == variant) return b;
                }
                else
                {
                    return b;
                }
            }
        }
        return null;
    }

    // If applying a buff via BuffInfo, we will also check the probability first.
    public virtual bool ApplyBuff(BuffInfo info, float durationMultiplier = 1f)
    {
        if(Random.value <= info.probability)
            return ApplyBuff(info.data, info.variant, durationMultiplier);
        return false;
    }

    // Adds a buff to an entity.
    public virtual bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        Buff b;
        BuffData.Stats s = data.Get(variant);

        switch(s.stackType)
        {
            // If the buff stacks fully, then we can have multiple copies of the buff.
            case BuffData.StackType.stacksFully:
                activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                RecalculateStats();
                return true;

            // If it only refreshes the duration, we will find the buff that is
            // already there and reset the remaining duration (if the buff is not there yet).
            // Otherwise, we just add the buff.
            case BuffData.StackType.refreshDurationOnly:
                b = GetBuff(data, variant);
                if(b != null)
                {
                    b.remainingDuration = s.duration * durationMultiplier;
                } 
                else
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                }
                return true;

            // In cases where buffs do not stack, if the buff already exists, we ignore it.
            case BuffData.StackType.doesNotStack:
                b = GetBuff(data, variant);
                if (b != null)
                {
                    activeBuffs.Add(new Buff(data, this, variant, durationMultiplier));
                    RecalculateStats();
                    return true;
                }
                return false;
        }
        
        return false;
    }

    // Removes all copies of a certain buff.
    public virtual bool RemoveBuff(BuffData data, int variant = -1)
    {
        // Loop through all the buffs, and find buffs that we need to remove.
        List<Buff> toRemove = new List<Buff>();
        foreach (Buff b in activeBuffs)
        {
            if (b.data == data)
            {

                if (variant >= 0)
                {
                    if (b.variant == variant) toRemove.Add(b);
                }
                else
                {
                    toRemove.Add(b);
                }
            }
        }

        // We need to remove the buffs outside of the loop, otherwise this
        // will cause performance issues with the foreach loop above.
        if(toRemove.Count > 0)
        {
            //activeBuffs.RemoveAll(item => toRemove.Contains(item));
            foreach(Buff b in toRemove)
            {
                if (b.effect) Destroy(b.effect.gameObject);
                if (b.tint.a > 0) RemoveTint(b.tint);
                RemoveAnimationMultiplier(b.animationSpeed);
                activeBuffs.Remove(b);
            }
            RecalculateStats();
            return true;
        }
        return false;
    }

    // Generic take damage function for dealing damage.
    public abstract void TakeDamage(float dmg);

    // Generic restore health function.
    public abstract void RestoreHealth(float amount);

    // Generic kill function.
    public abstract void Kill();

    // Forces the entity to recalculate its stats.
    public abstract void RecalculateStats();

    protected virtual void Update()
    {
        // Counts down each buff and removes them after their remaining
        // duration falls below 0.
        List<Buff> expired = new List<Buff>();
        foreach(Buff b in activeBuffs)
        {
            BuffData.Stats s = b.data.Get(b.variant);

            // Tick down on the damage / heal timer.
            b.nextTick -= Time.deltaTime;
            if (b.nextTick < 0)
            {
                float tickDmg = b.data.GetTickDamage(b.variant);
                if(tickDmg > 0) TakeDamage(tickDmg);
                float tickHeal = b.data.GetTickHeal(b.variant);
                if(tickHeal > 0) RestoreHealth(tickHeal);
                b.nextTick = s.tickInterval;
            }

            // If the buff has a duration of 0 or less, it will stay forever.
            // Don't reduce the remaining duration.
            if (s.duration <= 0) continue;

            // Also tick down on the remaining buff duration.
            b.remainingDuration -= Time.deltaTime;
            if (b.remainingDuration < 0) expired.Add(b);
        }

        // We remove the buffs outside the foreach loop, as it will affect the
        // iteration if we remove items from the list while a loop is still running.
        //activeBuffs.RemoveAll(item => expired.Contains(item));
        foreach (Buff b in expired)
        {
            if (b.effect) Destroy(b.effect.gameObject);
            if (b.tint.a > 0) RemoveTint(b.tint);
            RemoveAnimationMultiplier(b.animationSpeed);
            activeBuffs.Remove(b);
        }
        RecalculateStats();
    }

}

Article continues after the advertisement:


6. Creating new buffs to test out the system

Now that we have implemented the buff system, as well as the visuals, you can start creating some buffs for your weapons to test it out. Below are the buffs I created:

a. Garlic buff

The Garlic weapon, besides dealing damage to enemies, also increases their susceptibility to knockback by increasing their knockback multiplier stat additively and reduces their Freeze resistance by 10%.

Hence, we can create a buff for the weapon. Here are my values:

Garlic buff data
The weapon applies the same buff at every level, so we only need 1 variation of the buff.

Once the buff is created, you can apply it to the Garlic’s Weapon Data, under the Applied Buffs field. Note that the buff’s Type is set to Debuff. This is important, because otherwise an enemy’s debuff resistance stat will not work against it.

Assigned buff to Garlic
Assign the buff you’ve created to the Garlic’s weapon data.

Here’s how my Garlic debuff looks like. I’ll be creating a separate video to cover how the particle effect is created.

Debuff particle effect with tint
I applied it to the Garlic weapon.

Update 30 August 2024: Here is the video on how to create the Garlic debuff.

b. Freeze buff

The other buff that I created was the freeze buff. I’ve used this graphic a few times in this article now, but I really like the way it looks.

Clock Lancet effect
Enemies hit by the bolt are frozen, and become unable to move or attack. This is one of the weapons that the buff system will allow us to create.

Here are my settings for the Freeze buff:

Freeze buff data
Set the Move Speed and Damage to 0, and remember to make the Modifier Type Multiplicative.

Update 30 August 2024: If you want to create the Clock Lancet weapon, you can check out the forum post for it here. The video for creating the effect is also below:

Alternatively, you can apply the buff to your other weapons, like the Knife or Axe, and enemies should get frozen on hit.

You’ll see that your Freeze debuff will apply the visual effects, but it doesn’t stop them from moving (or hurting you). That’s because we’ll need to implement some more stuff to finish up our buff system.

Variations in BuffData
The different levels of the Freeze buff data.

Article continues after the advertisement:


7. Miscellaneous (but important) stuff

At this point, your buff system should be working great. We’re still not done yet though — there are still a few loose ends to tie up.

a. Multiplicative buffs

Earlier in this article, we made our BuffData have the option of adding their buffs either Additively or Multiplicatively. If you don’t know the difference between them, with a move speed of 5 and a buff value of 0.5:

  • Additively, your move speed after the buff is 5.5, because 5 + 0.5 = 5.5.
  • Multiplicatively, your move speed after the buff is 2.5, because 5 × 0.5 = 2.5.

Multiplicative buffs are important, because certain kinds of buffs will not be possible without them. The Freeze buff, for example, can only be applied multiplicatively because it reduces Move Speed and Damage to 0.

To make multiplicative buffs work, we will need to modify PlayerStats and EnemyStats to accommodate this and modify the RecalculateStats() method:

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : EntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }

        // Allows us to multiply resistances by one another, for multiplicative buffs.
        public static Resistances operator *(Resistances r1, Resistances r2)
        {
            r1.freeze = Mathf.Min(1, r1.freeze * r2.freeze);
            r1.kill = Mathf.Min(1, r1.kill * r2.kill);
            r1.debuff = Mathf.Min(1, r1.debuff * r2.debuff);
            return r1;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }

        // Use the multiply operator to scale stats.
        // Used by the buff / debuff system.
        public static Stats operator *(Stats s1, Stats s2)
        {
            s1.maxHealth *= s2.maxHealth;
            s1.moveSpeed *= s2.moveSpeed;
            s1.damage *= s2.maxHealth;
            s1.knockbackMultiplier *= s2.knockbackMultiplier;
            s1.resistances *= s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    protected override void Start()
    {
        base.Start();

        RecalculateStats();

        // Calculate the health and check for level boosts.
        health = actualStats.maxHealth;
        movement = GetComponent<EnemyMovement>();
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;

        // Create a variable to store all the cumulative multiplier values.
        Stats multiplier = new Stats{
            maxHealth = 1f, moveSpeed = 1f, damage = 1f, knockbackMultiplier = 1, 
            resistances = new Resistances {freeze = 1f, debuff = 1f, kill = 1f}
        };
        // We have to account for the buffs from EntityStats as well.
        foreach (Buff b in activeBuffs)
        {
            actualStats += b.GetData().enemyModifier;
            BuffData.Stats bd = b.GetData();
            switch(bd.modifierType)
            {
                case BuffData.ModifierType.additive:
                    actualStats += bd.enemyModifier;
                    break;
                case BuffData.ModifierType.multiplicative:
                    multiplier *= bd.enemyModifier;
                    break;
            }
        }

        // Apply the multipliers last.
        actualStats *= multiplier;
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;
        
        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }
            
        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        ApplyTint(damageColor);
        yield return new WaitForSeconds(damageFlashDuration);
        RemoveTint(damageColor);
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sprite.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sprite.color = new Color(sprite.color.r, sprite.color.g, sprite.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

To make the modifications to PlayerStats, we first have to add a function to enable 2 sets of CharacterData.Stats to multiply with each other using the * operator, as that data structure is used to store a player character’s stats.

CharacterData.cs

using UnityEngine;

[CreateAssetMenu(fileName = "Character Data", menuName = "2D Top-down Rogue-like/Character Data")]
public class CharacterData : ScriptableObject
{
    [SerializeField]
    Sprite icon;
    public RuntimeAnimatorController animator;
    public Sprite Icon { get => icon; private set => icon = value; }

    [SerializeField]
    new string name;
    public string Name { get => name; private set => name = value; }

    [SerializeField]
    WeaponData startingWeapon;
    public WeaponData StartingWeapon { get => startingWeapon; private set => startingWeapon = value; }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, recovery, armor;
        [Range(-1, 10)] public float moveSpeed, might, area;
        [Range(-1, 5)] public float speed, duration;
        [Range(-1, 10)] public int amount;
        [Range(-1, 1)] public float cooldown;
        [Min(-1)] public float luck, growth, greed, curse;
        public float magnet;
        public int revival;

        public static Stats operator +(Stats s1, Stats s2)
        {
            s1.maxHealth += s2.maxHealth;
            s1.recovery += s2.recovery;
            s1.armor += s2.armor;
            s1.moveSpeed += s2.moveSpeed;
            s1.might += s2.might;
            s1.area += s2.area;
            s1.speed += s2.speed;
            s1.duration += s2.duration;
            s1.amount += s2.amount;
            s1.cooldown += s2.cooldown;
            s1.luck += s2.luck;
            s1.growth += s2.growth;
            s1.greed += s2.greed;
            s1.curse += s2.curse;
            s1.magnet += s2.magnet;
            return s1;
        }
        public static Stats operator *(Stats s1, Stats s2)
        {
            s1.maxHealth *= s2.maxHealth;
            s1.recovery *= s2.recovery;
            s1.armor *= s2.armor;
            s1.moveSpeed *= s2.moveSpeed;
            s1.might *= s2.might;
            s1.area *= s2.area;
            s1.speed *= s2.speed;
            s1.duration *= s2.duration;
            s1.amount *= s2.amount;
            s1.cooldown *= s2.cooldown;
            s1.luck *= s2.luck;
            s1.growth *= s2.growth;
            s1.greed *= s2.greed;
            s1.curse *= s2.curse;
            s1.magnet *= s2.magnet;
            return s1;
        }
    }
    public Stats stats = new Stats
    {
        maxHealth = 100, moveSpeed = 1, might = 1, amount = 0,
        area = 1, speed = 1, duration = 1, cooldown = 1,
        luck = 1, greed = 1, growth = 1, curse = 1
    };
}

Then, we update RecalculateStats() in PlayerStats to handle multiplicative buffs.

PlayerStats.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class PlayerStats : EntityStats
{

    CharacterData characterData;
    public CharacterData.Stats baseStats;
    [SerializeField] CharacterData.Stats actualStats;

    public CharacterData.Stats Stats
    {
        get { return actualStats; }
        set
        {
            actualStats = value;
        }
    }
    public CharacterData.Stats Actual
    {
        get { return actualStats; }
    }

    #region Current Stats Properties
    public float CurrentHealth
    {

        get { return health; }

        // If we try and set the current health, the UI interface
        // on the pause screen will also be updated.
        set
        {
            //Check if the value has changed

            if (health != value)
            {
                health = value;
                UpdateHealthBar();
            }
        }
    }
    #endregion

    [Header("Visuals")]
    public ParticleSystem damageEffect; // If damage is dealt.
    public ParticleSystem blockedEffect; // If armor completely blocks damage.

    //Experience and level of the player
    [Header("Experience/Level")]
    public int experience = 0;
    public int level = 1;
    public int experienceCap;

    //Class for defining a level range and the corresponding experience cap increase for that range
    [System.Serializable]
    public class LevelRange
    {
        public int startLevel;
        public int endLevel;
        public int experienceCapIncrease;
    }

    //I-Frames
    [Header("I-Frames")]
    public float invincibilityDuration;
    float invincibilityTimer;
    bool isInvincible;

    public List<LevelRange> levelRanges;

    PlayerInventory inventory;
    PlayerCollector collector;

    [Header("UI")]
    public Image healthBar;
    public Image expBar;
    public TMP_Text levelText;

    void Awake()
    {
        characterData = CharacterSelector.GetData();
        if (CharacterSelector.instance)
            CharacterSelector.instance.DestroySingleton();

        inventory = GetComponent<PlayerInventory>();
        collector = GetComponentInChildren<PlayerCollector>();

        //Assign the variables
        baseStats = actualStats = characterData.stats;
        collector.SetRadius(actualStats.magnet);
        health = actualStats.maxHealth;
    }

    protected override void Start()
    {
        base.Start();
        //Spawn the starting weapon
        inventory.Add(characterData.StartingWeapon);

        //Initialize the experience cap as the first experience cap increase
        experienceCap = levelRanges[0].experienceCapIncrease;

        GameManager.instance.AssignChosenCharacterUI(characterData);

        UpdateHealthBar();
        UpdateExpBar();
        UpdateLevelText();
    }

    protected override void Update()
    {
        base.Update();
        if (invincibilityTimer > 0)
        {
            invincibilityTimer -= Time.deltaTime;
        }
        //If the invincibility timer has reached 0, set the invincibility flag to false
        else if (isInvincible)
        {
            isInvincible = false;
        }

        Recover();
    }

    public override void RecalculateStats()
    {
        actualStats = baseStats;
        foreach (PlayerInventory.Slot s in inventory.passiveSlots)
        {
            Passive p = s.item as Passive;
            if (p)
            {
                actualStats += p.GetBoosts();
            }
        }

        // Create a variable to store all the cumulative multiplier values.
        CharacterData.Stats multiplier = new CharacterData.Stats
        {
            maxHealth = 1f, recovery = 1f, armor = 1f, moveSpeed = 1f, might = 1f,
            area = 1f, speed = 1f, duration = 1f, amount = 1, cooldown = 1f,
            luck = 1f, growth = 1f, greed = 1f, curse = 1f, magnet = 1f, revival = 1
        };
        // We have to account for the buffs from EntityStats as well.
        foreach (Buff b in activeBuffs)
        {
            actualStats += b.GetData().playerModifier;
            BuffData.Stats bd = b.GetData();
            switch (bd.modifierType)
            {
                case BuffData.ModifierType.additive:
                    actualStats += bd.playerModifier;
                    break;
                case BuffData.ModifierType.multiplicative:
                    multiplier *= bd.playerModifier;
                    break;
            }
        }
        actualStats *= multiplier;

        // Update the PlayerCollector's radius.
        collector.SetRadius(actualStats.magnet);
    }

    public void IncreaseExperience(int amount)
    {
        experience += amount;

        LevelUpChecker();
        UpdateExpBar();
    }

    void LevelUpChecker()
    {
        if (experience >= experienceCap)
        {
            //Level up the player and reduce their experience by the experience cap
            level++;
            experience -= experienceCap;

            //Find the experience cap increase for the current level range
            int experienceCapIncrease = 0;
            foreach (LevelRange range in levelRanges)
            {
                if (level >= range.startLevel && level <= range.endLevel)
                {
                    experienceCapIncrease = range.experienceCapIncrease;
                    break;
                }
            }
            experienceCap += experienceCapIncrease;

            UpdateLevelText();

            GameManager.instance.StartLevelUp();

            // If the experience still exceeds the experience cap, level up again.
            if (experience >= experienceCap) LevelUpChecker();
        }
    }

    void UpdateExpBar()
    {
        // Update exp bar fill amount
        expBar.fillAmount = (float)experience / experienceCap;
    }

    void UpdateLevelText()
    {
        // Update level text
        levelText.text = "LV " + level.ToString();
    }

    public override void TakeDamage(float dmg)
    {
        //If the player is not currently invincible, reduce health and start invincibility
        if (!isInvincible)
        {
            // Take armor into account before dealing the damage.
            dmg -= actualStats.armor;

            if (dmg > 0)
            {
                // Deal the damage.
                CurrentHealth -= dmg;

                // If there is a damage effect assigned, play it.
                if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);

                if (CurrentHealth <= 0)
                {
                    Kill();
                }
            }
            else
            {
                // If there is a blocked effect assigned, play it.
                if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
            }

            invincibilityTimer = invincibilityDuration;
            isInvincible = true;
        }
    }

    void UpdateHealthBar()
    {
        //Update the health bar
        healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
    }

    public override void Kill()
    {
        if (!GameManager.instance.isGameOver)
        {
            GameManager.instance.AssignLevelReachedUI(level);

            GameManager.instance.GameOver();
        }
    }

    public override void RestoreHealth(float amount)
    {
        // Only heal the player if their current health is less than their maximum health
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += amount;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }

    void Recover()
    {
        if (CurrentHealth < actualStats.maxHealth)
        {
            CurrentHealth += Stats.recovery * Time.deltaTime;

            // Make sure the player's health doesn't exceed their maximum health
            if (CurrentHealth > actualStats.maxHealth)
            {
                CurrentHealth = actualStats.maxHealth;
            }
        }
    }
}

…and your Freeze debuff should be stopping enemies from moving now!

b. Disabling attack if Damage or Might is 0

One other thing we’d like to do as well is disable both the enemies and player characters from dealing damage if their damage is set to 0. This makes it so that if we apply a Multiplicative buff that sets the Damage of an enemy to 0 like Freeze does, the enemy will not damage us even if we touch them.

To ensure that the Freeze buff works for player characters as well, we will also make weapons be unable to fire if the Might stat of a player character is set to 0.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : EntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }

        // Allows us to multiply resistances by one another.
        public static Resistances operator *(Resistances r1, Resistances r2)
        {
            r1.freeze = Mathf.Min(1, r1.freeze * r2.freeze);
            r1.kill = Mathf.Min(1, r1.kill * r2.kill);
            r1.debuff = Mathf.Min(1, r1.debuff * r2.debuff);
            return r1;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }

        // Use the multiply operator to scale stats.
        // Used by the buff / debuff system.
        public static Stats operator *(Stats s1, Stats s2)
        {
            s1.maxHealth *= s2.maxHealth;
            s1.moveSpeed *= s2.moveSpeed;
            s1.damage *= s2.maxHealth;
            s1.knockbackMultiplier *= s2.knockbackMultiplier;
            s1.resistances *= s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    protected override void Start()
    {
        base.Start();

        RecalculateStats();

        // Calculate the health and check for level boosts.
        health = actualStats.maxHealth;
        movement = GetComponent<EnemyMovement>();
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;

        // Add all the multiplicative buffs to our character first.
        Stats multiplier = new Stats{
            maxHealth = 1f, moveSpeed = 1f, damage = 1f, knockbackMultiplier = 1, 
            resistances = new Resistances {freeze = 1f, debuff = 1f, kill = 1f}
        };
        foreach (Buff b in activeBuffs)
        {
            BuffData.Stats bd = b.GetData();
            switch(bd.modifierType)
            {
                case BuffData.ModifierType.additive:
                    actualStats += bd.enemyModifier;
                    break;
                case BuffData.ModifierType.multiplicative:
                    multiplier *= bd.enemyModifier;
                    break;
            }
        }

        // Finally, apply the multipliers.
        actualStats *= multiplier;
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;
        
        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }
            
        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        ApplyTint(damageColor);
        yield return new WaitForSeconds(damageFlashDuration);
        RemoveTint(damageColor);
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sprite.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sprite.color = new Color(sprite.color.r, sprite.color.g, sprite.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        if (Mathf.Approximately(Actual.damage, 0)) return;

        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

Weapon.cs

using UnityEngine;

/// <summary>
/// Component to be attached to all Weapon prefabs. The Weapon prefab works together with the WeaponData
/// ScriptableObjects to manage and run the behaviours of all weapons in the game.
/// </summary>
public abstract class Weapon : Item
{
    [System.Serializable]
    public class Stats : LevelData
    {
 
        [Header("Visuals")]
        public Projectile projectilePrefab; // If attached, a projectile will spawn every time the weapon cools down.
        public Aura auraPrefab; // If attached, an aura will spawn when weapon is equipped.
        public ParticleSystem hitEffect, procEffect;
        public Rect spawnVariance;

        [Header("Values")]
        public float lifespan; // If 0, it will last forever.
        public float damage, damageVariance, area, speed, cooldown, projectileInterval, knockback;
        public int number, piercing, maxInstances;

        public EntityStats.BuffInfo[] appliedBuffs;

        // Allows us to use the + operator to add 2 Stats together.
        // Very important later when we want to increase our weapon stats.
        public static Stats operator +(Stats s1, Stats s2)
        {
            Stats result = new Stats();
            result.name = s2.name ?? s1.name;
            result.description = s2.description ?? s1.description;
            result.projectilePrefab = s2.projectilePrefab ?? s1.projectilePrefab;
            result.auraPrefab = s2.auraPrefab ?? s1.auraPrefab;
            result.hitEffect = s2.hitEffect == null ? s1.hitEffect : s2.hitEffect;
            result.procEffect = s2.procEffect == null ? s1.procEffect : s2.procEffect;
            result.spawnVariance = s2.spawnVariance;
            result.lifespan = s1.lifespan + s2.lifespan;
            result.damage = s1.damage + s2.damage;
            result.damageVariance = s1.damageVariance + s2.damageVariance;
            result.area = s1.area + s2.area;
            result.speed = s1.speed + s2.speed;
            result.cooldown = s1.cooldown + s2.cooldown;
            result.number = s1.number + s2.number;
            result.piercing = s1.piercing + s2.piercing;
            result.projectileInterval = s1.projectileInterval + s2.projectileInterval;
            result.knockback = s1.knockback + s2.knockback;
            result.appliedBuffs = s2.appliedBuffs == null || s2.appliedBuffs.Length <= 0 ? s1.appliedBuffs : s2.appliedBuffs;
            return result;
        }

        // Get damage dealt.
        public float GetDamage()
        {
            return damage + Random.Range(0, damageVariance);
        }
    }

    protected Stats currentStats;

    protected float currentCooldown;

    protected PlayerMovement movement; // Reference to the player's movement.

    // For dynamically created weapons, call initialise to set everything up.
    public virtual void Initialise(WeaponData data)
    {
        base.Initialise(data);
        this.data = data;
        currentStats = data.baseStats;
        movement = GetComponentInParent<PlayerMovement>();
        ActivateCooldown();
    }

    protected virtual void Update()
    {
        currentCooldown -= Time.deltaTime;
        if (currentCooldown <= 0f) //Once the cooldown becomes 0, attack
        {
            Attack(currentStats.number + owner.Stats.amount);
        }
    }

    // Levels up the weapon by 1, and calculates the corresponding stats.
    public override bool DoLevelUp()
    {
        base.DoLevelUp();

        // Prevent level up if we are already at max level.
        if (!CanLevelUp())
        {
            Debug.LogWarning(string.Format("Cannot level up {0} to Level {1}, max level of {2} already reached.", name, currentLevel, data.maxLevel));
            return false;
        }

        // Otherwise, add stats of the next level to our weapon.
        currentStats += (Stats)data.GetLevelData(++currentLevel);
        return true;
    }

    // Lets us check whether this weapon can attack at this current moment.
    // Disable attack if the player's Might stat is 0.
    public virtual bool CanAttack()
    {
        if (Mathf.Approximately(owner.Stats.might, 0)) return false;
        return currentCooldown <= 0;
    }

    // Performs an attack with the weapon.
    // Returns true if the attack was successful.
    // This doesn't do anything. We have to override this at the child class to add a behaviour.
    protected virtual bool Attack(int attackCount = 1)
    {
        if (CanAttack())
        {
            ActivateCooldown();
            return true;
        }
        return false;
    }

    // Gets the amount of damage that the weapon is supposed to deal.
    // Factoring in the weapon's stats (including damage variance),
    // as well as the character's Might stat.
    public virtual float GetDamage()
    {
        return currentStats.GetDamage() * owner.Stats.might;
    }

    // Get the area, including modifications from the player's stats.
    public virtual float GetArea()
    {
        return currentStats.area * owner.Stats.area;
    }

    // For retrieving the weapon's stats.
    public virtual Stats GetStats() { return currentStats; }

    // Refreshes the cooldown of the weapon.
    // If <strict> is true, refreshes only when currentCooldown < 0.
    public virtual bool ActivateCooldown(bool strict = false)
    {
        // When <strict> is enabled and the cooldown is not yet finished,
        // do not refresh the cooldown.
        if(strict && currentCooldown > 0) return false;

        // Calculate what the cooldown is going to be, factoring in the cooldown
        // reduction stat in the player character.
        float actualCooldown = currentStats.cooldown * Owner.Stats.cooldown;

        // Limit the maximum cooldown to the actual cooldown, so we cannot increase
        // the cooldown above the cooldown stat if we accidentally call this function
        // multiple times.
        currentCooldown = Mathf.Min(actualCooldown, currentCooldown + actualCooldown);
        return true;
    }

    // Makes the weapon apply its buff to a targeted EntityStats object.
    public void ApplyBuffs(EntityStats e)
    {
        // Apply all assigned buffs to the target.
        foreach (EntityStats.BuffInfo b in GetStats().appliedBuffs)
            e.ApplyBuff(b, owner.Actual.duration);
    }
}

c. Implementing enemy resistances

We also need to factor in an enemy’s Freeze and Debuff resistances when applying buffs. To that end, we will have to override the ApplyBuff() function in EntityStats with our own in EnemyStats.

Basically, before applying the buff, we will randomly generate a value between 0 to 1 using Random.value. Then, we will compare the value with our Freeze or Debuff resistance value, and only if the random value is less than the resistance value, the buff will apply.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : EntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }

        // Allows us to multiply resistances by one another.
        public static Resistances operator *(Resistances r1, Resistances r2)
        {
            r1.freeze = Mathf.Min(1, r1.freeze * r2.freeze);
            r1.kill = Mathf.Min(1, r1.kill * r2.kill);
            r1.debuff = Mathf.Min(1, r1.debuff * r2.debuff);
            return r1;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }

        // Use the multiply operator to scale stats.
        // Used by the buff / debuff system.
        public static Stats operator *(Stats s1, Stats s2)
        {
            s1.maxHealth *= s2.maxHealth;
            s1.moveSpeed *= s2.moveSpeed;
            s1.damage *= s2.maxHealth;
            s1.knockbackMultiplier *= s2.knockbackMultiplier;
            s1.resistances *= s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    protected override void Start()
    {
        base.Start();

        RecalculateStats();

        // Calculate the health and check for level boosts.
        health = actualStats.maxHealth;
        movement = GetComponent<EnemyMovement>();
    }

    public override bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        // If the debuff is a freeze, we check for freeze resistance.
        // Roll a number and if it succeeds, we ignore the freeze.
        if ((data.type & BuffData.Type.freeze) > 0)
            if (Random.value <= Actual.resistances.freeze) return false;

        // If the debuff is a debuff, we check for debuff resistance.
        if ((data.type & BuffData.Type.debuff) > 0)
            if (Random.value <= Actual.resistances.debuff) return false;

        return base.ApplyBuff(data, variant, durationMultiplier);
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;

        // Add all the multiplicative buffs to our character first.
        Stats multiplier = new Stats{
            maxHealth = 1f, moveSpeed = 1f, damage = 1f, knockbackMultiplier = 1, 
            resistances = new Resistances {freeze = 1f, debuff = 1f, kill = 1f}
        };
        foreach (Buff b in activeBuffs)
        {
            BuffData.Stats bd = b.GetData();
            switch(bd.modifierType)
            {
                case BuffData.ModifierType.additive:
                    actualStats += bd.enemyModifier;
                    break;
                case BuffData.ModifierType.multiplicative:
                    multiplier *= bd.enemyModifier;
                    break;
            }
        }

        // Finally, apply the multipliers.
        actualStats *= multiplier;
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;
        
        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }
            
        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        ApplyTint(damageColor);
        yield return new WaitForSeconds(damageFlashDuration);
        RemoveTint(damageColor);
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sprite.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sprite.color = new Color(sprite.color.r, sprite.color.g, sprite.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        if (Mathf.Approximately(Actual.damage, 0)) return;

        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

d. Allowing enemies to apply buffs

The other thing that we will be implementing as well, is an Attack Effects field that we add buffs to. This will allow our enemies to apply buffs to the player characters that they damage.

Enemy Stats Attack Effects
Assign a buff to the Attack Effects field, and enemies will apply buffs when they damage a player character.

For those of you who want to have enemies that apply buffs.

EnemyStats.cs

using System.Collections;
using UnityEngine;

[RequireComponent(typeof(SpriteRenderer))]
public class EnemyStats : EntityStats
{

    [System.Serializable]
    public struct Resistances
    {
        [Range(-1f, 1f)] public float freeze, kill, debuff;

        // To allow us to multiply the resistances.
        public static Resistances operator *(Resistances r, float factor)
        {
            r.freeze = Mathf.Min(1, r.freeze * factor);
            r.kill = Mathf.Min(1, r.kill * factor);
            r.debuff = Mathf.Min(1, r.debuff * factor);
            return r;
        }

        public static Resistances operator +(Resistances r, Resistances r2)
        {
            r.freeze += r2.freeze;
            r.kill = r2.kill;
            r.debuff = r2.debuff;
            return r;
        }

        // Allows us to multiply resistances by one another.
        public static Resistances operator *(Resistances r1, Resistances r2)
        {
            r1.freeze = Mathf.Min(1, r1.freeze * r2.freeze);
            r1.kill = Mathf.Min(1, r1.kill * r2.kill);
            r1.debuff = Mathf.Min(1, r1.debuff * r2.debuff);
            return r1;
        }
    }

    [System.Serializable]
    public struct Stats
    {
        public float maxHealth, moveSpeed, damage;
        public float knockbackMultiplier;
        public Resistances resistances;

        [System.Flags]
        public enum Boostable { health = 1, moveSpeed = 2, damage = 4, knockbackMultiplier = 8, resistances = 16 }
        public Boostable curseBoosts, levelBoosts;

        private static Stats Boost(Stats s1, float factor, Boostable boostable)
        {
            if ((boostable & Boostable.health) != 0) s1.maxHealth *= factor;
            if ((boostable & Boostable.moveSpeed) != 0) s1.moveSpeed *= factor;
            if ((boostable & Boostable.damage) != 0) s1.damage *= factor;
            if ((boostable & Boostable.knockbackMultiplier) != 0) s1.knockbackMultiplier /= factor;
            if ((boostable & Boostable.resistances) != 0) s1.resistances *= factor;
            return s1;
        }

        // Use the multiply operator for curse.
        public static Stats operator *(Stats s1, float factor) { return Boost(s1, factor, s1.curseBoosts); }

        // Use the XOR operator for level boosted stats.
        public static Stats operator ^(Stats s1, float factor) { return Boost(s1, factor, s1.levelBoosts); }

        // Use the add operator to add stats to the enemy.
        public static Stats operator +(Stats s1, Stats s2) {
            s1.maxHealth += s2.maxHealth;
            s1.moveSpeed += s2.moveSpeed;
            s1.damage += s2.maxHealth;
            s1.knockbackMultiplier += s2.knockbackMultiplier;
            s1.resistances += s2.resistances;
            return s1;
        }

        // Use the multiply operator to scale stats.
        // Used by the buff / debuff system.
        public static Stats operator *(Stats s1, Stats s2)
        {
            s1.maxHealth *= s2.maxHealth;
            s1.moveSpeed *= s2.moveSpeed;
            s1.damage *= s2.maxHealth;
            s1.knockbackMultiplier *= s2.knockbackMultiplier;
            s1.resistances *= s2.resistances;
            return s1;
        }
    }

    public Stats baseStats = new Stats { 
        maxHealth = 10, moveSpeed = 1, damage = 3, knockbackMultiplier = 1,
        curseBoosts = (Stats.Boostable)(1 | 2), levelBoosts = 0
    };
    Stats actualStats;
    public Stats Actual
    {
        get { return actualStats; }
    }

    public BuffInfo[] attackEffects;

    [Header("Damage Feedback")]
    public Color damageColor = new Color(1, 0, 0, 1); // What the color of the damage flash should be.
    public float damageFlashDuration = 0.2f; // How long the flash should last.
    public float deathFadeTime = 0.6f; // How much time it takes for the enemy to fade.
    EnemyMovement movement;

    public static int count; // Track the number of enemies on the screen.

    void Awake()
    {
        count++;
    }

    protected override void Start()
    {
        base.Start();

        RecalculateStats();

        // Calculate the health and check for level boosts.
        health = actualStats.maxHealth;
        movement = GetComponent<EnemyMovement>();
    }

    public override bool ApplyBuff(BuffData data, int variant = 0, float durationMultiplier = 1f)
    {
        // If the debuff is a freeze, we check for freeze resistance.
        // Roll a number and if it succeeds, we ignore the freeze.
        if ((data.type & BuffData.Type.freeze) > 0)
            if (Random.value <= Actual.resistances.freeze) return false;

        // If the debuff is a debuff, we check for debuff resistance.
        if ((data.type & BuffData.Type.debuff) > 0)
            if (Random.value <= Actual.resistances.debuff) return false;

        return base.ApplyBuff(data, variant, durationMultiplier);
    }

    // Calculates the actual stats of the enemy based on a variety of factors.
    public override void RecalculateStats()
    {
        // Calculate curse boosts.
        float curse = GameManager.GetCumulativeCurse(),
              level = GameManager.GetCumulativeLevels();
        actualStats = (baseStats * curse) ^ level;

        // Add all the multiplicative buffs to our character first.
        Stats multiplier = new Stats{
            maxHealth = 1f, moveSpeed = 1f, damage = 1f, knockbackMultiplier = 1, 
            resistances = new Resistances {freeze = 1f, debuff = 1f, kill = 1f}
        };
        foreach (Buff b in activeBuffs)
        {
            BuffData.Stats bd = b.GetData();
            switch(bd.modifierType)
            {
                case BuffData.ModifierType.additive:
                    actualStats += bd.enemyModifier;
                    break;
                case BuffData.ModifierType.multiplicative:
                    multiplier *= bd.enemyModifier;
                    break;
            }
        }

        // Finally, apply the multipliers.
        actualStats *= multiplier;
    }

    public override void TakeDamage(float dmg)
    {
        health -= dmg;
        
        // If damage is exactly equal to maximum health, we assume it is an insta-kill and 
        // check for the kill resistance to see if we can dodge this damage.
        if (dmg == actualStats.maxHealth)
        {
            // Roll a die to check if we can dodge the damage.
            // Gets a random value between 0 to 1, and if the number is 
            // below the kill resistance, then we avoid getting killed.
            if (Random.value < actualStats.resistances.kill)
            {
                return; // Don't take damage.
            }
        }

        // Create the text popup when enemy takes damage.
        if (dmg > 0)
        {
            StartCoroutine(DamageFlash());
            GameManager.GenerateFloatingText(Mathf.FloorToInt(dmg).ToString(), transform);
        }
            
        // Kills the enemy if the health drops below zero.
        if (health <= 0)
        {
            Kill();
        }
    }

    // This function always needs at least 2 values, the amount of damage dealt <dmg>, as well as where the damage is
    // coming from, which is passed as <sourcePosition>. The <sourcePosition> is necessary because it is used to calculate
    // the direction of the knockback.
    public void TakeDamage(float dmg, Vector2 sourcePosition, float knockbackForce = 5f, float knockbackDuration = 0.2f)
    {
        TakeDamage(dmg);
        
        // Apply knockback if it is not zero.
        if (knockbackForce > 0)
        {
            // Gets the direction of knockback.
            Vector2 dir = (Vector2)transform.position - sourcePosition;
            movement.Knockback(dir.normalized * knockbackForce, knockbackDuration);
        }
    }

    public override void RestoreHealth(float amount)
    {
        if (health < actualStats.maxHealth)
        {
            health += amount;
            if (health > actualStats.maxHealth)
            {
                health = actualStats.maxHealth;
            }
        }
    }

    // This is a Coroutine function that makes the enemy flash when taking damage.
    IEnumerator DamageFlash()
    {
        ApplyTint(damageColor);
        yield return new WaitForSeconds(damageFlashDuration);
        RemoveTint(damageColor);
    }
    public override void Kill()
    {
        // Enable drops if the enemy is killed,
        // since drops are disabled by default.
        DropRateManager drops = GetComponent<DropRateManager>();
        if (drops) drops.active = true;

        StartCoroutine(KillFade());
    }

    // This is a Coroutine function that fades the enemy away slowly.
    IEnumerator KillFade()
    {
        // Waits for a single frame.
        WaitForEndOfFrame w = new WaitForEndOfFrame();
        float t = 0, origAlpha = sprite.color.a;

        // This is a loop that fires every frame.
        while (t < deathFadeTime)
        {
            yield return w;
            t += Time.deltaTime;

            // Set the colour for this frame.
            sprite.color = new Color(sprite.color.r, sprite.color.g, sprite.color.b, (1 - t / deathFadeTime) * origAlpha);
        }

        Destroy(gameObject);
    }

    void OnCollisionStay2D(Collision2D col)
    {
        if (Mathf.Approximately(Actual.damage, 0)) return;

        // Check for whether there is a PlayerStats object we can damage.
        if(col.collider.TryGetComponent(out PlayerStats p))
        {
            p.TakeDamage(Actual.damage);
            foreach(BuffInfo b in attackEffects)
                p.ApplyBuff(b);
        }
    }

    private void OnDestroy()
    {
        count--;
    }
}

8. Conclusion

That’s it for this part! In the next part, we will be improving on the map generation system.

Silver Patrons can download the project files for this part.


Article continues after the advertisement:


Leave a Reply

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

Note: You can use Markdown to format your comments.

For security, use of Google's reCAPTCHA service is required which is subject to the Google Privacy Policy and Terms of Use.

I agree to these terms.

This site uses Akismet to reduce spam. Learn how your comment data is processed.