Stat modifier for ScriptableObject weapons/abilites

I want to be able to switch between multiple abilities, so I use ScriptableObject assets to contain them, and they’re called from a PlayerAbilityScript attached to my Player GameObject.
All abilities have a manaCost and coolDown, but only OffensiveAbility:Ability have damage and range.

I’ve got it working fine, but now I want to create enhancements that the player picks up that can alter properties (damage, manaCost, castingTime, coolDown, etc) of all or just some specific Abilities. But I can’t figure out a way to code it so that it works with the ScriptableObjects.

This is my code, very simplified:

public abstract class Ability : ScriptableObject
{
    public string abilityName;
    public float manaCost = 0f;
    public float cooldownTime = 10f;

    public abstract void TryToUseAbility(PlayerAbilityController controller, PlayerAbilityController.AbilityData abilityData);
}

// ------------------------------

public abstract class OffensiveAbility : Ability
{
    public float damage = 10f;
    public float range = 10f;

    public override void TryToUseAbility(PlayerAbilityController controller, PlayerAbilityController.AbilityData abilityData) {  }
}

// ------------------------------

[CreateAssetMenu(fileName = "New ProjectileAbility", menuName = "Abilities/New Projectile Ability", order = 1)]
public class ProjectileAbility : OffensiveAbility
{
    public float travelSpeed = 50f;
    public GameObject prefab;

    // Called from PlayerAbilityController when player presses a key
    public override void TryToUseAbility(PlayerAbilityController controller, PlayerAbilityController.AbilityData abilityData)
    {
        if(abilityData.coolDownRemaining <= 0)
        {
            CreateProjectile(controller, abilityData);
            abilityData.coolDownRemaining = cooldownTime;
        }
       
    }

    void CreateProjectile(PlayerAbilityController controller, PlayerAbilityController.AbilityData abilityData)
    {
        // Code to instantiate and initialize prefab to travel in the correct direction, deal damage according to parameter in OffensiveAbility, etc
    }
}

// ------------------------------

// The class that modifies stats. No idea how to write this class.
public class Enhancement : ScriptableObject
{
}

// ------------------------------

public class PlayerAbilityController : MonoBehaviour
{
    [SerializeField] PlayerController playerController;
    [SerializeField] Ability[] abilities = new Ability[2];
    [SerializeField] AbilityData[] abilityDatas = new AbilityData[2];
    [SerializeField] List<Enhancement> enhancements;

    void Start()
    {
        for (int i = 0; i < abilities.Length; i++)
            if (abilities[i] != null)
                abilityDatas[i] = new AbilityData(abilities[i]);
    }

    void Update()
    {
        foreach (var abData in abilityDatas)
            if (abData.coolDownRemaining > 0)
                abData.coolDownRemaining -= Time.deltaTime;
    }

    // Called by event when player presses left mouse button
    public void Ability1Input(InputAction.CallbackContext context)
    {
        OnAbilityKeyDown(abilityDatas[0]);
    }

    // Called by event when player presses right mouse button
    public void Ability2Input(InputAction.CallbackContext context)
    {
        OnAbilityKeyDown(abilityDatas[1]);
    }

    void OnAbilityKeyDown(AbilityData abilityData)
    {
        abilityData.ability.TryToUseAbility(this, abilityData);
    }

// ------------------------------

    // Contains non-static data belonging to the ability.
    [Serializable]
    public class AbilityData
    {
        public Ability ability;
        public float coolDownRemaining = 0;

        public AbilityData(Ability ability)
        {
            this.ability = ability;
        }
    }
}

If it is a singleplayer game with no official modding support planned, you can make each enhanceable property its own ScriptableObject.

So instead of making the mana cost of an ability a serialized float value inside your SO instance that represents your ability, you would make it a reference to a ScriptableObject instance that contains the float value, representing the ability property. The reason for doing this is being able to reference this SO instance from everywhere, including the enhancements that alter this particular ability property of that particular ability.

One problem with this is that it is not very maintainable, because you end up with so many SO instances. Remember, for each ability that represents your SO instance, you will have to create a new SO instance for every single ability property if each ability property is unique to each ability.

Nonetheless, this is a very fast solution for singleplayer games where you can easily “connect the dots” of your enhancements and ability properties, since you can “softcode” what each ability enhancement does to each ability property. Writing some editor extensions, you can also largely automate creating those SO instances, as well as update existing ability enhancements when adding new abilities.

There’s no modding support, and the game is single player, so that would indeed work. It solves both my issue of easily defining which stat(s) the Enhancement would affect, and storing the sum of all Enhancements per stat so that I can just call on a float.
But as you said, it sounds like it would get very messy.

My initial idea was to store all possible stats for every Ability in the AbilityData class, and update the sum of every stat in the class every time a new Enhancement is added/removed. The Ability class has a reference to AbilityData, so it would use the value from there. Since you can only add/remove enhancements between levels, it wouldn’t affect performance. But it too gets very messy.

  • AbilityData would have to contain every possible stat. So even if it is used for a Fireball (ProjectileAbility), it would have an empty parameter for healingPower for example.
  • There is no good way to to identify which stat the enhancement is affecting, so there would be a lot of IFs…

Something like this:

// The class that modifies stats. No idea how to write this class.
public class Enhancement : ScriptableObject
{
public Ability affectsAbility; // if null it affects all abilities
public float coolDownMultiplier;
public float damageMultiplier;
public float healingPowerMultiplier;
}

public class AbilityData
{
    public Ability ability;
    public float coolDownRemaining = 0;

    public float totalDamage;
    public float totalCoolDownTime;
    public float totalHealingPower;
    public AbilityData(Ability ability, List<Enhancement> enhancements)
    {
        this.ability = ability;

        foreach(var enhancement in enhancements)
        {
             if(enhancement.affectsAbility == null || enhancement.affectsAbility = ability)
            {
                if(enhancement.damageMultiplier > 0)
                     // Recalculate totalDamage

                if(enhancement.coolDownMultiplier> 0)
                     // Recalculate totalCoolDownTime

                if(enhancement.healingPowerMultiplier> 0)
                     // Recalculate totalHealingPower;

                // etc
            }
        }
    }


}

I have the very same problem in my game, having (ability) properties, needing to alter them using enhancements and so on. I tried out different solutions in the past (because my game isn’t singleplayer and does support modding) and so far I have not found a picture perfect solution.

Yes, this is one of the key problems. There are multiple ways to solve this, but all of them come with their own disadvantage. Here are some ideas that I considered for my own project:

Solution 1: Storing all (enhanceable) properties of each ability in their own objects and reference them.
This is the solution that I have already posted. What you do here is explicitely referencing objects with one another, as this allows you to link together your logic. Consider this short example:

AbilityProperty

public class AbilityProperty : ScriptableObject
{
   [SerializeField]
   private float baseValue;

   [SerializeField]
   private List<Enhancement> activeEnhancements;

   public float Value
   {
       get => // calculate the value based on baseValue and the activeEnhancements;
   }

   public void Add(Enhancement enhancement)
   {
       activeEnhancements.Add(enhancement);
   }

   public bool Remove(Enhancement enhancement)
   {
       return activeEnhancements.Remove(enhancement);
   }
}

Enhancement

public class Enhancement: ScriptableObject
{
    [SerializeField]
    private float value;

    [SerializeField]
    private List<AbilityProperty> targets;

    public void Activate()
    {
        foreach(var target in targets)
        {
            target.Add(this);
        }
    }

    public void Deactivate()
    {
        foreach(var target in targets)
        {
            target.Remove(this);
        }
    }
}

Each ability property has a list of active enhancements affecting it. Each enhancement knows which ability properties it affects and possibly how, depending on how you program it, since maybe you want to alter different ability properties in different ways with the same enhancement. You could use a UnityEvent to automatically activate the right enhancement from an item when it is picked up, for instance.

This solution allows you to do all the reference work in the editor, however, comes with the disadvantage of having to make all ability properties into an object type that you can reference in the editor. Theoretically, you can probably tinker around with UnityEvent to avoid this and use a custom class instead of a ScriptableObject.

Solution 2: Use Command Pattern to switch between different dispatchers.

Another solution is to use SerializeReference (with a custom editor extension like SerializeReferenceButton ) to create instances of dispatchers that switch between different ability properties and resolve the targeting for you:

Dispatcher

[Serializable]
public abstract class AbilityPropertyDispatcher
{
    [SerializeField]
    protected Ability ability;

    public abstract AbilityProperty GetProperty();
}

[Serializable]
public class DamagePropertyDispatcher : AbilityPropertyDispatcher
{
    public AbilityProperty GetProperty()
    {
        return ability.Damage;
    }
}

This solution requires you to write an implementation of the dispatcher for each ability property, then allows you to use this everywhere to choose which property you want to select. With this solution, one more or less fakes delegates from a certain point of view, since Unity cannot serialize any kind of delegate that would you allow to return something, the property that one needs.

Personally, I prefer solution one and that’s what I did in my project, but with GameObjects instead of ScriptableObjects, as well as introducing something like PropertyType which allows me to choose a property type (damage, cooldown, …) similar to a C# enumeration, which I then get based on the instance of an entity that I provide:
6299632--697186--property-example2.png
6299632--697189--property-example3.png

As well as offering a provider for each entity that keeps track of all properties that an entity has. An ability could be an entity, for instance.

6299632--697198--property-example4.png

Thanks for the extensive write-up! =)
I really didn’t expect this to be an issue, but it really is trickier to do well than I expected.
I think I’m also limited by using non-instantiated SOs, might have to change that approach.

That’s exactly my experience. One of the key problems appears to be to tell an enhancement which property it is supposed to change and how, being able to reference said property. That’s why I was so distinct about singleplayer yes/no and modding yes/no, because answering these two questions has a huge impact on possible designs, mostly because in singleplayer games with no modding, you do not have to worry about instance-based properties, because maybe only the player and their abilities has properties, which you can create using SO instances, referencing those from everywhere. If you have to differ based on instances, knowing which property, but not of which character instance, things get messy very quickly.