My take on the Progression SO

While the array lookup method of assigning stats is a decent method of managing stats, to me it has two drawbacks… 1) It’s rather tedious to have to do each of these entries for every character, and 2) Generally speaking, your progression is going to follow some sort of predictable pattern anyways.

My take was a little different. I still wanted something easily customized by a later designer (likely to be me) but I didn’t want to spend a lot of time calculating all those levels… so I created another class…

 [System.Serializable]
    class ProgressionStatFormula
    {
        [Range(1,1000)]
        [SerializeField] float startingValue=100;
        [Range(0,1)]
        [SerializeField] float percentageAdded = 0.0f;
        [Range(0,1000)]
        [SerializeField] float absoluteAdded = 10;
        

        public float Calculate(int level)
        {
            if (level <= 1) return startingValue;
            float c = Calculate(level - 1);
            return c + (c * percentageAdded) + absoluteAdded;
        }
    }

The idea here is that you’ll start with the level one value. Then you calculate a new value for each level you wish to access. There are two modification paths which can be used separately or together (I considered making one modifier with a toggle for formula). At level 1, the formula will aways return the starting value. At subsequent levels, it will perform a recursive calculation to arrive at the new value by getting the value of the previous level, adding that value to itself times the multipler and adding the absolute addition. This leaves you tweaking only a very few parameters to get a virtually unlimited progression of stat values. If you want things to ramp up exponentially by level, you can use the multiplier (You’d be surprised how quickly a character can go from 100 to 1,000,000 using a percentageAdded value of 1!), if you want a slow consistent progression use the absolute added.

It wouldn’t be that hard to modify this into a hybrid system where you could type in an array of values but let the system fall back to the formula if the array is incomplete or missing.

13 Likes

I haven’t encountered recursion in any of the courses I’ve taken so far.

If you don’t mind elaborating what happens when Calculate() calls float c = Calculate(level - 1); ?

Wow, this is an oldie of mine from before I got hired on.

It might be best to point you to what I consider to be a pretty good explanation on recursion.

1 Like

That helped a lot thank you! Also thank you for always being so speedy and answering my questions. This course has been the first time I’m taking advantage of the discussions with my own questions and it’s helping a lot!

Thanks for this statformula class idea, this is much easier than manually entering. Will there be any pitfalls with this to look out for in future parts of the course if we got with your implementation?

Also, am I right in thinking that if we want to implement a curved power progression that tops off for a player (logarithmically such that a level 60 is much stronger than a level 20, but level 99 is not much stronger than a level 90) that we could modify the percentage added formula to use ln? Mainly trying to test my understanding that this would be the location to change.

Thanks for your help with Q&A.

Not sure how I missed this Q.
Actually for a rolloff type of approach, this specific formula wouldn’t work well, as it is fairly linear. I have since switched to using AnimationCurves instead of this formula, which allows you to set values at specific points on the curve. With these, it’s easier to create a curve that ramps up quickly and slows gains over time, but at the same time making an experience required that increases slowly in the beginning and ramps up heavily over time. With this formula, the ramp rate will be constant over the lifetime of the formula.

Can you elaborate on how you would implement the animation curve into the above script please? I am interested in using the same approach as I would want experience to be handled slightly differently in its change over time as compared to say, player health. Thanks as always!

[System.Serializable]
    class ProgressionStatFormula
    {
         [SerializeField] AnimationCurve curve = new AnimationCurve();
        

        public float Calculate(int level)
        {
            return curve.Evaluate(level);
        }
    }

You then need to set up each curve to run from 0 to the max level. The good news is that if you exceed the max level, then the curve will continue extrapolating the values based on the logic of the curve.

Thanks brian! didn’t realize it was so simple. Haha!

This is a great post. Thanks @Brian_Trotter!

As an addition, I’ve used similar methods in the past with ScriptableObjects. It’s a little tedious in the beginning, but allows for quick fine-tuning, etc.

I’d have a base ScriptableObject

public abstract class FormulaBase : ScriptableObject
{
     public abstract float Calculate(int level);
}

With which I can now create a few possible ‘formulae’

[CreateAssetMenu(menuName = "Formula/Linear")]
public class LinearFormula : FormulaBase
{
    [Range(1,1000)]
    [SerializeField] float startingValue=100;
    [Range(0,1)]
    [SerializeField] float percentageAdded = 0.0f;
    [Range(0,1000)]
    [SerializeField] float absoluteAdded = 10;
    
    public override float Calculate(int level)
    {
        if (level <= 1) return startingValue;
        float c = Calculate(level - 1);
        return c + (c * percentageAdded) + absoluteAdded;
    }
}

[CreateAssetMenu(menuName = "Formula/Curve")]
public class CurveFormula : FormulaBase
{
    [SerializeField] AnimationCurve curve = new AnimationCurve();
    
    public override float Calculate(int level)
    {
        return curve.Evaluate(level);
    }
}

When I need something like this on a component, I just create a field and drag a new formula instance onto it

public class WeaponBase : MonoBehaviour
{
    [SerializeField] int weaponLevel = 1;
    [SerializeField] FormulaBase weaponBaseDamageFormula = default;

    public float GetWeaponBaseDamage()
    {
        if (weaponBaseDamageFormula is null)
        {
            return 0f;
        }
        return weaponBaseDamageFormula.Calculate(weaponLevel);
    }
}

I can now switch the formula quickly without too much fuss.

Note There are some caveats with using ScriptableObjects that one should be aware of, but if you keep that in mind they are extremely useful.

That’s an excellent approach!

wow that is super cool! Thanks for sharing this approach!!

So I waste alot of time making custom editors… so i just made this one for the progression script. Its the second time ive made one. This time its purely an inspector editor instead of a window editor. I have a window editor i made too. This is what this one looks like. What do you all think?

2 Likes

I also make a window editor for the progression, but with the help of the Odin inspector :

I linked you post in Another another solution for GetStat() Since I am using a version of this in my actual game. Thanks for the Idea and the Calculation Base Code that I pretty much just copied and pasted straight from here, some minor tweaks to fit my needs.

I am only up to Saving Experience Points Video. This may change between now and the end of the course and then I might switch it over to using Scriptable Objects for each type needed and have them referenced in the Fighter class, I haven’t deiced yet. This is what I have Implemented so far:

Progression.cs

using RPGEngine.Core;
using UnityEngine;

namespace RPGEngine.Stats
{
    [CreateAssetMenu(fileName = "Progression", menuName = "RPG Project/Progression", order = 0)]
    public class Progression : ScriptableObject
    {
        [System.Serializable]
        public struct ProgressionFormula
        {
            [Range(1,1000)]
            [SerializeField] private float startingValue;
            [Range(0,1)]
            [SerializeField] private float percentageAdded;
            [Range(0,1000)]
            [SerializeField] private float absoluteAdded;

            [SerializeField] private bool useCurve;
            [SerializeField, ShowIfBool("useCurve")] private  AnimationCurve curve;

            public float Calculate(int level)
            {
                if (level <= 1) return startingValue;
                var c = Calculate(level - 1);
                var value = c + c * percentageAdded + absoluteAdded;
                if (!useCurve) return value;
                return value + c * curve.Evaluate(1f / level);
            }
        }

        [System.Serializable]
        private struct ProgressionStat
        {
            [SerializeField] private Stat stat;
            [SerializeField] private ProgressionFormula progression;
            public Stat Stat => stat;
            public ProgressionFormula Progression => progression;
        }
        
        [System.Serializable]
        private struct ProgressionCharacterClass
        {
            [SerializeField] private CharacterClass characterClass;
            [SerializeField] private ProgressionStat[] stats;

            public CharacterClass CharacterClass => characterClass;
            public ProgressionFormula this[Stat stat] => FindStat(stat);

            private ProgressionFormula FindStat(Stat stat)
            {
                foreach (ProgressionStat s in stats)
                {
                    if (s.Stat == stat) return s.Progression;
                }

                throw new System.ArgumentOutOfRangeException(
                    nameof(stat), $"Progression Character Class does not contain a Stat of {stat}");
            }
        }
        
        [SerializeField] private ProgressionCharacterClass[] characterClasses;
        public ProgressionFormula this[CharacterClass cc, Stat stat] => FindProgressionCharacterClassWithStat(cc, stat);

        private ProgressionFormula FindProgressionCharacterClassWithStat(CharacterClass cc, Stat stat)
        {
            var ccFound = false;
            var error = "";
            foreach (ProgressionCharacterClass pcc in characterClasses)
            {
                if (pcc.CharacterClass != cc) continue;
                try
                {
                    return pcc[stat];
                }
                catch (System.ArgumentOutOfRangeException e)
                {
                    error =
                        $"{(ccFound ? $"Multiple progressions for {cc} found:\n{error}\n" : "")}No {stat} found in {cc}\n{e}";
                }
                    
                ccFound = true;
            }
            
            if (!ccFound)
                throw new System.ArgumentOutOfRangeException(
                    nameof(characterClasses), $"No Progression {cc} found");

            throw new System.ArgumentOutOfRangeException(nameof(characterClasses), error);
        }
    }
}

BaseStats.cs

using UnityEngine;

namespace RPGEngine.Stats
{
    public class BaseStats : MonoBehaviour
    {
        [SerializeField, Range(1, 99)] private int startingLevel = 1;
        [SerializeField] private CharacterClass characterClass;
        [SerializeField] private Progression progression;

        public float GetStatValue(Stat stat)
        {
            try
            {
                return progression[characterClass, stat].Calculate(startingLevel);
            }
            catch (System.Exception e)
            {
                Debug.LogWarning(e);
                return 0;
            }
        }
    }
}

In Health.cs

private void Awake()
{
    _actionScheduler = GetComponent<ActionScheduler>();

    _animator = GetComponentInChildren<Animator>();
    _hasAnimator = _animator != null;

    if (_hasAnimator)
    {
        _dieHash = Animator.StringToHash(DeathTrigger);
    }

    value = max = GetComponent<BaseStats>().GetStatValue(Stat.Health);
}

private void AwardExp(GameObject instigator)
{
    if (!instigator.CompareTag("Player")) return;
            
    float expAmount = 0;
    BaseStats stats = GetComponent<BaseStats>();
    if (stats) expAmount = stats.GetStatValue(Stat.ExperienceReward);
            
    Experience exp = instigator.GetComponent<Experience>();
    if (exp) exp.GainExperience(expAmount);
            
    Debug.Log($"<color=red>{instigator.name}</color> <color=darkblue>receives <color=red>{expAmount}</color> EXP</color>");
}
1 Like

Updated this to use More performant lookup dictionaries.

using System.Collections.Generic;
using System.Linq;
using RPGEngine.Core;
using UnityEngine;

namespace RPGEngine.Stats
{
    [CreateAssetMenu(fileName = "Progression", menuName = "RPG Project/Progression", order = 0)]
    public class Progression : ScriptableObject
    {
        [System.Serializable]
        public struct ProgressionFormula
        {
            [Range(1,1000)]
            [SerializeField] private float startingValue;
            [Range(0,1)]
            [SerializeField] private float percentageAdded;
            [Range(0,1000)]
            [SerializeField] private float absoluteAdded;

            [SerializeField] private bool useCurve;
            [SerializeField, ShowIfBool("useCurve")] private  AnimationCurve curve;

            public float Calculate(int level)
            {
                if (level <= 1) return startingValue;
                var c = Calculate(level - 1);
                var value = c + c * percentageAdded + absoluteAdded;
                if (!useCurve) return value;
                return value + c * curve.Evaluate(1f / level);
            }
        }

        [System.Serializable]
        private struct ProgressionStat
        {
            [SerializeField] private Stat stat;
            [SerializeField] private ProgressionFormula progression;
            public Stat Stat => stat;
            public ProgressionFormula Progression => progression;
        }
        
        [System.Serializable]
        private struct ProgressionCharacterClass
        {
            [SerializeField] private CharacterClass characterClass;
            [SerializeField] private ProgressionStat[] stats;
            
            private Dictionary<Stat, ProgressionFormula> _lookupTable;

            public CharacterClass CharacterClass => characterClass;
            public ProgressionFormula this[Stat stat] => FindStat(stat);

            private ProgressionFormula FindStat(Stat stat)
            {
                BuildLookupTable();

                if (!_lookupTable.ContainsKey(stat))
                    throw new System.ArgumentOutOfRangeException(nameof(stat),
                        $"Progression does not contain an entry for {stat} in the class {characterClass}");

                return _lookupTable[stat];
            }

            private void BuildLookupTable()
            {
                if (_lookupTable != null) return;
                _lookupTable = stats.ToDictionary(pStat => pStat.Stat, pStat => pStat.Progression);
            }
        }
        
        [SerializeField] private ProgressionCharacterClass[] characterClasses;

        private Dictionary<CharacterClass, ProgressionCharacterClass> _lookupTable;
        public ProgressionFormula this[CharacterClass cc, Stat stat] => FindProgressionCharacterClassWithStat(cc, stat);

        private ProgressionFormula FindProgressionCharacterClassWithStat(CharacterClass cc, Stat stat)
        {
            try
            {
                BuildLookupTable();
                return _lookupTable[cc][stat];
            }
            catch (System.ArgumentOutOfRangeException)
            {
                throw new System.ArgumentOutOfRangeException(nameof(characterClasses),
                    $"{name} does not contain an entry for {stat} in the class {cc}");
            }
            catch (System.Exception e)
            {
                if(!_lookupTable.ContainsKey(cc))
                {
                    throw new System.ArgumentOutOfRangeException(nameof(characterClasses),
                        $"{name} does not contain an entry for characterClass {cc}");
                }

                throw new System.Exception($"{name}: Something went Horribly wrong!\n{e}");
            }
        }

        private void BuildLookupTable()
        {
            if (_lookupTable != null) return;

            _lookupTable = characterClasses.ToDictionary(pcc => pcc.CharacterClass,
                pcc => pcc);
        }
    }
}

Privacy & Terms