Flexible Ability Upgrade System

Hi, I’ve been banging my head on this one for a while now.
I am trying to create a Mass Effect 3 style ability progression system, where an ability has 5 upgrades, or 6 upgrades if we include the ability itself.

I would like to be able to script an ability, and assign an upgrade script to each upgrade slot in the editor. I have been trying to program it in such a way that the abilities do not need to explicitly know what upgrade they have, and the upgrades do not need to know what ability they are attached to.

Now, I’m not sure that its possible without using reflection… and honestly, I’m fine if each ability has only individualized upgrades even if it means that I’ll need a “GrenadeDamageUp” and a “FlamethrowerDamageUp”. I’d prefer to generalize where I can, but I think I have fried my brain and would like to hear other people’s ideas on how to create a flexible “drag’n’drop” upgrade system for abilities.

Below is my code attempt. I will, of course, refactor things and put them in separate files when I get a working baseline. Also I did not use interfaces because I cannot [SerializeReference] them. If necessary I can refactor the base classes to implement an interface later.

public class AbilityBase: MonoBehaviour
{
    public virtual void Use(AbilityUpgradeProgressData upgradeData)
    {

    }

}

[Serializable]
public class AbilityUpgradeProgressData
{
    public bool AbilityUnlocked;
    public bool Upgrade1;
    public bool Upgrade2a;
    public bool Upgrade2b;
}
public class TestAbility01 : AbilityBase
{
    [SerializeField]
    private float Damage;
    [SerializeField]
    private float Range;
    [SerializeField]
    private float Cooldown;

    [SerializeReference]
    private AbilityUpgrade Upgrade1;
    [SerializeReference]
    private AbilityUpgrade Upgrade2a;
    [SerializeReference]
    private AbilityUpgrade Upgrade2b;

    public override void Use(AbilityUpgradeProgressData upgradeData)
    {
        if (!upgradeData.AbilityUnlocked)
            return;

        object realDamage = Damage;
        object realRange = Range;
        object realCooldown = Cooldown;


        var upgradeReferenceValues = new Dictionary<string, AbilityApplyUpgradeFieldData>();
        upgradeReferenceValues.Add("Damage", new AbilityApplyUpgradeFieldData(Damage,ref realDamage));
        upgradeReferenceValues.Add("Range", new AbilityApplyUpgradeFieldData(Range,ref realRange));
        upgradeReferenceValues.Add("Cooldown", new AbilityApplyUpgradeFieldData(Cooldown,ref realCooldown));

        if (upgradeData.Upgrade1)
            Upgrade1.ApplyUpgrade(this, upgradeReferenceValues);

        if (upgradeData.Upgrade2a)
            Upgrade2a.ApplyUpgrade(this, upgradeReferenceValues);

        if (upgradeData.Upgrade2b)
            Upgrade2b.ApplyUpgrade(this, upgradeReferenceValues);

        Debug.Log($"Ability cast with {realDamage} damage, {realRange} range, and {realCooldown} cooldown.");
    }
}

public class AbilityApplyUpgradeFieldData
{
    public object BaseValue;
    public object ReferenceValue;

    public AbilityApplyUpgradeFieldData(object baseValue, ref object referenceValue)
    {
        BaseValue = baseValue;
        ReferenceValue = referenceValue;
    }
}

[Serializable]
public class AbilityUpgrade//<T> where T : AbilityBase
{
    public virtual void ApplyUpgrade(AbilityBase ability, Dictionary<string, AbilityApplyUpgradeFieldData> args)
    {

    }
}
[Serializable]
public class UpgradeStat : AbilityUpgrade
{
    public string statName;
    public float StatIncreasePercen = 1f;

    public override void ApplyUpgrade(AbilityBase ability, Dictionary<string, AbilityApplyUpgradeFieldData> args)
    {
        if (args.ContainsKey(statName))
        {
            args[statName].ReferenceValue = (float)args[statName].BaseValue * StatIncreasePercen;


        }
    }
}
public class UseAbilityOnStart : MonoBehaviour
{
    [SerializeField]
    private AbilityBase ability;
    [SerializeField]
    private AbilityUpgradeProgressData upgrades;

    private float cooldown;


    void Start()
    {
        //ability.Use(upgrades);
    }

    private void Update()
    {
        if (cooldown <= 0)
        {
            cooldown = 2;
            ability.Use(upgrades);
        }
        else
        {
            cooldown -= Time.deltaTime;
        }
    }

}

I have since tried a new approach. I feel this is a good start, but needs a bit of refactoring to be less unwieldy.
Any input is appreciated, as I am somewhat fumbling to find a flexible, scalable, and designer friendly approach.

public class AbilityBase : MonoBehaviour, IAbility
{
    [SerializeField]
    private AbilityUpgradeProgressData upgradeData;
    public AbilityUpgradeProgressData UpgradeData { get => upgradeData; set => upgradeData = value; }

    public bool AbilitySupressed { get; set; }

    [SerializeField]
    protected AbilityUpgrade upgrade1;
    [SerializeField]
    protected AbilityUpgrade upgrade2;
    [SerializeField]
    protected AbilityUpgrade upgrade3a;
    [SerializeField]
    protected AbilityUpgrade upgrade3b;
    [SerializeField]
    protected AbilityUpgrade upgrade4a;
    [SerializeField]
    protected AbilityUpgrade upgrade4b;
    [SerializeField]
    protected AbilityUpgrade upgrade5a;
    [SerializeField]
    protected AbilityUpgrade upgrade5b;

    public virtual bool TryUse()
    {
        if (!ValidateCanUseAbility())
            return false;

        Use();

        return true;
    }

    protected virtual void Use()
    {
        ApplyUpgrades();
    }

    protected virtual bool ValidateCanUseAbility()
    {
        if (upgradeData == null)
            return false;

        if (!upgradeData.AbilityUnlocked)
            return false;

        if (AbilitySupressed)
            return false;

        //Add checks for cooldown or charges here, per specific ability requirments

        return true;
    }

    protected virtual void ApplyUpgrades()
    {
        if (upgradeData.Upgrade1)
            upgrade1.Invoke();

        if (upgradeData.Upgrade2)
            upgrade2.Invoke();

        if (upgradeData.Upgrade3a)
            upgrade3a.Invoke();

        if (upgradeData.Upgrade3b)
            upgrade3b.Invoke();

        if (upgradeData.Upgrade4a)
            upgrade4a.Invoke();

        if (upgradeData.Upgrade4b)
            upgrade4b.Invoke();

        if (upgradeData.Upgrade5a)
            upgrade5a.Invoke();

        if (upgradeData.Upgrade5b)
            upgrade5b.Invoke();
    }
}

[Serializable]
public class AbilityUpgradeProgressData
{
    public bool AbilityUnlocked;
    public bool Upgrade1;
    public bool Upgrade2;
    public bool Upgrade3a;
    public bool Upgrade3b;
    public bool Upgrade4a;
    public bool Upgrade4b;
    public bool Upgrade5a;
    public bool Upgrade5b;

    //Used for UI interfacing
    public float GetUIUpgradeLevel()
    {
        if (!AbilityUnlocked)
            return -1;

        float returnVal = 0;

        if (Upgrade1)
            returnVal = 1;
        if (Upgrade2)
            returnVal = 2;
        if (Upgrade3a || Upgrade3b)
            returnVal = 3;
        if (Upgrade4a || Upgrade4b)
            returnVal = 4;
        if (Upgrade5a || Upgrade5b)
            returnVal = 5;

        return returnVal;
    }
}

public interface IAbility
{
    AbilityUpgradeProgressData UpgradeData { get; set; }
    bool TryUse();
}

[Serializable]
public class AbilityUpgrade
{
    public string UpgradeName;
    public string UpgradeDescription;
    //add extra data like icon, or make the data into a scriptable object for property injection if necessary

    public event Action UpgradeApplied = delegate { };

    public void Invoke()
    {
        UpgradeApplied.Invoke();
    }
}
public class TestAbility01 : AbilityBase
{
    [SerializeField]
    private float damage;
    [SerializeField]
    private float range;
    [SerializeField]
    private float cooldown;

    private float remainingCooldown;

    private float upgradeDamagePercent;
    private float upgradeRangePercent;
    private float upgradeCooldownPercent;

    private string upgradeString;

    private void Start()
    {
        upgrade1.UpgradeApplied += () => { upgradeDamagePercent += .25f; };

        upgrade2.UpgradeApplied += () => { upgradeRangePercent += .2f; };

        upgrade3a.UpgradeApplied += () => { upgradeCooldownPercent += .25f; };
        upgrade3b.UpgradeApplied += () => { upgradeDamagePercent += .25f; };

        upgrade4a.UpgradeApplied += () => { upgradeCooldownPercent += .25f; };
        upgrade4b.UpgradeApplied += () => { upgradeRangePercent += .4f; };

        upgrade5a.UpgradeApplied += () => { upgradeDamagePercent += .2f; };
        upgrade5b.UpgradeApplied += () => { upgradeString += "With Fire Effect"; };
    }

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

        float realDamage = damage * upgradeDamagePercent;
        float realRange = range * upgradeRangePercent;
        float realCooldown = cooldown * upgradeCooldownPercent;

        Debug.Log($"Ability cast with {realDamage} Damage, {realRange} Range, and {realCooldown} Cooldown, {upgradeString}");

        upgradeDamagePercent = 0;
        upgradeRangePercent = 0;
        upgradeCooldownPercent = 0;
        upgradeString = "";
    }

    protected override bool ValidateCanUseAbility()
    {
        if (!base.ValidateCanUseAbility())
            return false;

        //Not Implimented yet
        if (remainingCooldown > 0)
            return false;

        return true;
    }

}

I think a good approach will need two stages.

One, you define the potential upgrades in the ability itself. This should be a collection, not a bunch of fields. The ability itself should be immutable at run time, and probably be defined as a scriptable object.

Then, for runtime use, you have a wrapper ability class (probably a plain class) that holds onto one of your ability objects. It itself has a collection of active upgrades, and it responsible for handling adding upgrades (ie: mutating itself, while leaving the scriptable object as is), or filtering queries to see if any of its upgrades change the result.

This is a situation where SerializeReference does good work, but you’ll also need custom inspector work to make the workflow possible.

That’s the thing with flexible systems, there’s a lot of abstraction and boilerplate required, alongside editor work to make the workflow usable.

Thanks for the input!
I have Odin inspector, so a lot of the editor work can be done through that.
I’ll need to try and apply the pattern you suggested and get back to you.
I may ask for some prototype code if I get confused.

Okay, I implemented the pattern, and I have good results and bad results. The good results is that it works! And seems fairly scalable and able to have isolated parts!

The bad results is that its a bit of a mess… and I’m not sure if I severely overcomplicated something, or if this is just the way it is. Here is the code for reference.

Base classes

public class AbilityBase : ScriptableObject
{
    [SerializeReference]
    protected List<AbilityUpgradeBase> possibleUpgrades = new List<AbilityUpgradeBase>();
    [SerializeField]
    protected AbilityWrapperBase wrappedType;



    public AbilityWrapperBase GetAbilityWrapper(AbilityUpgradeProgressData upgradeData, GameObject origin)
    {
        SetWrappedType();
        AbilityWrapperBase wrappedAbility = (AbilityWrapperBase)Activator.CreateInstance(wrappedType.GetType(), new object[3] { this, upgradeData, origin });
        return wrappedAbility;
    }

    //Override this in child classes to define base functionality
    public virtual void Use(AbilityWrapperBase wrapper)
    {

    }

    //Override this in child classes to assign the correct wrapper
    protected virtual void SetWrappedType()
    {
        wrappedType = AbilityWrapperBase.InstanciateEmpty();
    }

    public List<AbilityUpgradeBase> GetActiveUpgrades(AbilityUpgradeProgressData upgradeData)
    {
        List<AbilityUpgradeBase> returnVal = new List<AbilityUpgradeBase>();

        List<bool> hasUpgrades = upgradeData.ToList();
        for (int i = 0; i < hasUpgrades.Count; i++)
        {
            if (hasUpgrades[i] && possibleUpgrades.Count > i)
                returnVal.Add(possibleUpgrades[i]);
        }


        return returnVal;
    }
}

public class AbilityWrapperBase
{
    public bool Origin { get; protected set; }

    protected AbilityBase abilityBase;
    protected List<AbilityUpgradeBase> activeUpgrades;

    public event Action<AbilityWrapperBase> OnUse = delegate { };


    public AbilityWrapperBase(AbilityBase abilityBase, AbilityUpgradeProgressData upgradeData, GameObject origin)
    {
        if (abilityBase == null)
            return;


        this.abilityBase = abilityBase;
        activeUpgrades = abilityBase.GetActiveUpgrades(upgradeData);
        Origin = origin;
        OnUse += abilityBase.Use;
    }

    public static AbilityWrapperBase InstanciateEmpty()
    {
        return new AbilityWrapperBase(null, null, null);
    }
    public static T InstanciateEmpty<T>() where T : AbilityWrapperBase
    {
        return Activator.CreateInstance(typeof(T), new object[3] { null, null, null }) as T;
    }

    public virtual void Use()
    {
        foreach (var upgrade in activeUpgrades)
        {
            upgrade.Use(this);
        }

        OnUse.Invoke(this);
    }

}

[Serializable]
public class AbilityUpgradeBase
{

    public virtual void Use(AbilityWrapperBase wrapperAbility)
    {

    }
}

Implimentation

[CreateAssetMenu(menuName = "AbilitySystem/ Create New Test Ability", fileName = "New Test Ability")]
public class TestAbility : AbilityBase
{
    [SerializeField]
    private float damage;
    [SerializeField]
    private float range;
    [SerializeField]
    private float cooldown;

    protected override void SetWrappedType()
    {
        wrappedType = AbilityWrapperBase.InstanciateEmpty<TestAbilityWrapper>();
    }

    public override void Use(AbilityWrapperBase wrapperAbility)
    {
        TestAbilityWrapper wrapper = wrapperAbility as TestAbilityWrapper;

        float realDamage = damage * (1 + wrapper.damagePercentModified);
        float realRange = range * (1 + wrapper.rangePercentModified);
        float realCooldown = cooldown * (1 + wrapper.cooldownPercentModified);

        Debug.Log($"Ability cast with {realDamage} Damage, {realRange} Range, and {realCooldown} second Cooldown");
    }
}

public class TestAbilityWrapper : AbilityWrapperBase
{

    public float damagePercentModified;
    public float rangePercentModified;
    public float cooldownPercentModified;

    public TestAbilityWrapper(AbilityBase abilityBase, AbilityUpgradeProgressData upgradeData, GameObject origin) : base(abilityBase, upgradeData, origin)
    {
    }
}

public class TestAbilityUpgradeDamage : AbilityUpgradeBase
{
    public float damagePercentUp;
    public override void Use(AbilityWrapperBase wrapperAbility)
    {
        TestAbilityWrapper wrapper = wrapperAbility as TestAbilityWrapper;
        wrapper.damagePercentModified += damagePercentUp;
    }
}

public class TestAbilityUpgradeRange : AbilityUpgradeBase
{
    public float rangePercentUp;
    public override void Use(AbilityWrapperBase wrapperAbility)
    {
        TestAbilityWrapper wrapper = wrapperAbility as TestAbilityWrapper;
        wrapper.rangePercentModified += rangePercentUp;
    }
}

public class TestAbilityUpgradeCooldown : AbilityUpgradeBase
{
    public float cooldownPercentUp;
    public override void Use(AbilityWrapperBase wrapperAbility)
    {
        TestAbilityWrapper wrapper = wrapperAbility as TestAbilityWrapper;
        wrapper.cooldownPercentModified += cooldownPercentUp;
    }
}

public class TestAbilityUpgradeWithFire : AbilityUpgradeBase
{
    public override void Use(AbilityWrapperBase wrapperAbility)
    {
        TestAbilityWrapper wrapper = wrapperAbility as TestAbilityWrapper;
        wrapper.OnUse += ApplyFire;
    }

    private void ApplyFire(AbilityWrapperBase wrapperAbility)
    {
        Debug.Log("With Fire");
    }
}

Ability casting

public class UseAbilityOnStart : MonoBehaviour
{
    [SerializeField]
    private AbilityBase ability;
    [SerializeField]
    private AbilityUpgradeProgressData upgrades;

    private float cooldown;

    private AbilityWrapperBase wrappedAbility;

    void Start()
    {
        wrappedAbility = ability.GetAbilityWrapper(upgrades,gameObject);
       //wrappedAbility.Use();
    }

    private void Update()
    {
        if (cooldown <= 0)
        {
            cooldown = 2;
            wrappedAbility.Use();
        }
        else
        {
            cooldown -= Time.deltaTime;
        }
    }

    private void OnValidate()
    {
        wrappedAbility = ability.GetAbilityWrapper(upgrades, gameObject);
    }

}

I apologize for the code dump… however it should compile (once using statements are added) for anyone that copy-pastes the code.
Is there a cleaner way to implement this?

EDIT: oops you’ll need this to compile too

[Serializable]
public class AbilityUpgradeProgressData
{
    public bool AbilityUnlocked;
    public bool Upgrade1;
    public bool Upgrade2;
    public bool Upgrade3a;
    public bool Upgrade3b;
    public bool Upgrade4a;
    public bool Upgrade4b;
    public bool Upgrade5a;
    public bool Upgrade5b;

    public List<bool> ToList()
    {
        List<bool> returnVal = new List<bool>();

        returnVal.Add(Upgrade1);
        returnVal.Add(Upgrade2);
        returnVal.Add(Upgrade3a);
        returnVal.Add(Upgrade3b);
        returnVal.Add(Upgrade4a);
        returnVal.Add(Upgrade4b);
        returnVal.Add(Upgrade5a);
        returnVal.Add(Upgrade5b);

        return returnVal;
    }
}

Don’t apologize, your code is great.
If you’re not showing a progress bar on the abillity you could use a coroutine to re enable your skill after a cooldown, using one of the wait returns.

I would use separate components for each skill,
Ex: One component for Fire Lv1, one component for Fire Lv2
Then place the right components on you character.

  • have you see this?

I have seen that, and it is a great framework for abilities without branching upgrade paths. I could create separate components for each level of an ability, but I would then need
Grenade0 (no choice)
Grenade1 (no choice)
Grenade2 (no choice)
Grenade3a (with choice)
Grenade3b (with choice)
Grenade4a3a
Grenade4a3b
Grenade4b3a
Grenade4b3b
Grenade 5a4a3a
so on and so forth.

Hence I am looking for a solution where I can attach upgrades to a base ability.
As for a cooldown or something… yeah I think a coroutine would be good, though I think a better solution would be to have an OnUpdate() method in the wrapper to process state (so more than just cooldown can be handled internally).
However I’m not a big fan of using a monobehavior to call abilitywrapper.OnUpdate() every frame… I’d rather find a way to make the wrappers monobehaviors themselves.

So… yeah! I might be refactoring today, because you are right about needing to show cooldowns. :slight_smile:

For the cooldowns you could make a coroutine with a for loop inside and use a waitForSecondsRealtime, that way you could update the UI every one second or every 0.1f second only when cooldown is active.

You can extend the knowledge on that videos for your own ability system, you can make another system to manage which abilities you have equipped and which ones you can equip, then you update it on you player, It would be good to have a script on your player that keeps track of the skills components. You can send it a new array of skills via an Action for example.

That way your code would be very reusable, things won’t fall apart in case something is missing.