[New] Unlocking Abilities With Predicates

It’s been a few months since the conceived post of Unlocking Abilities. But now I’ve refactored my system and it’s time to build a more robust skill tree system for your RPG.

Recently I’ve been working on various Item related things and in doing so; meant I got more time to study and get accustom to our IPredicateEvaluator – so now it’s made me want to re-visit the skill system I had in place and introduce Evaluators along with an overhaul to the former system too.

This required quite a significant overhaul of what was written from the aforementioned post. But in the end, it’s well worth it!


  1. Open up IPredicateEvaluator – Add HasSkill to your EPredicate!
using System.Collections.Generic;

namespace Game.Utils
{
    public enum EPredicate
    {
        Select,
        HasQuest,
        CompletedObjective, 
        CompletedQuest, 
        HasLevel,
        MinimumTrait, 
        HasItem,
        HasItems, 
        HasItemEquipped,
        HasSkill
    }

    public interface IPredicateEvaluator
    {
        bool ? Evaluate(EPredicate predicate, List<string> parameters);
    }
}

After adding the new enum condition to EPredicate then open up your PredicatePropertyDrawer script and we’re going to take the code that MinimumTrait uses and add apply that to HasSkill.

Why do we want to copy the MinimumTrait code? Because for HasSkill we can utilize the functionality of checking whether another skill is unlocked by checking if they have a certain level reached. This means that the old SkillConfig became redundant.

Besides using the Evaluators meant we are using the awesome PropertyDrawer Brian created which infinitely creates a flexible and visual addition to our conditions for skills during design time.

  • Inside OnGui underneath MinimumTrait’s code bracket add the following:
            if (selectedPredicate == EPredicate.HasSkill)
            {
                position.y += propHeight;
                DrawSkill(position, parameterZero);
                position.y += propHeight;
                DrawIntSlider(position, "Minimum", parameterOne, 1, 100);
            }

Then create the new DrawSkill method like this:

        void DrawSkill(Rect position, SerializedProperty element)
        {
            if (!Enum.TryParse(element.stringValue, out EStat skill))
            {
                skill = EStat.HeadShot;
            }
            EditorGUI.BeginProperty(position, new GUIContent("Skill"), element);
            EStat newSkill = (EStat)EditorGUI.EnumPopup(position, "Skill:", skill);
            if (newSkill != skill)
            {
                element.stringValue = $"{newSkill}";
            }
            EditorGUI.EndProperty();
        }

Lastly you need to add the following inside the method GetPropertyHeight underneath MinimumTrait:

case EPredicate.HasSkill:

Ending of PredicatePropertyDrawer.cs


  1. Create an AbilityStore class and apply it to your Player’s GameObject.

After loading up this new store class we need to create a private class and title it SkillBonus don’t forget to use the [Serializable] attribute above this class.

        [Serializable]
        class SkillBonus
        {
            public EStat skillStat = EStat.Strength;
            public EStat stat = EStat.Damage;
            public float perLevel = 1f;
        }

This will apply bonuses to whatever EStat you wish later. (Good for passive skills)

Then we’re going to create a few dictionaries to store information about our EStats and their assigned points. As well as some cached references for other uses at the top of our class.

        [SerializeField] List<SkillBonus> bonusConfigs = new List<SkillBonus>();

        BaseStats baseStats = null;

        AbilityRowUI[] abilityRowUI = null;

        Dictionary<EStat, int> assignedPoints = new Dictionary<EStat, int>();
        Dictionary<EStat, int> stagedPoints = new Dictionary<EStat, int>();

        Dictionary<EStat, Dictionary<EStat, int>> modifiers = new Dictionary<EStat, Dictionary<EStat, int>>();

To be brief the dictionaries function exactly the same from our TraitStore. AbilityRowUI’s are the same prinicple as the TraitRowUI’s from the course. We’ll create that class in a moment. Modifiers is what we can use to return bonuses depending on how many points you wish to set inside the AbilityStore for passive skills.

Because the class is quite extensive and a lot of it works from the courses code but just switched for EStat checking; I’ll briefly go over the entire script.

using Game.UI.Abilities;
using Game.Attributes;
using Game.Inventories;
using Game.Saving;
using Game.Utils;
using System;
using System.Collections.Generic;
using UnityEngine;
using Newtonsoft.Json.Linq;

namespace Game.Abilities
{

    public class AbilityStore : MonoBehaviour, IJsonSaveable, IPredicateEvaluator
    {
        [Serializable]
        class SkillBonus
        {
            public EStat skillStat = EStat.Strength;
            public EStat stat = EStat.Damage;
            public float perLevel = 1f;
        }

        [SerializeField] List<SkillBonus> bonusConfigs = new List<SkillBonus>();

        BaseStats baseStats = null;

        AbilityRowUI[] abilityRowUI = null;

        Dictionary<EStat, int> assignedPoints = new Dictionary<EStat, int>();
        Dictionary<EStat, int> stagedPoints = new Dictionary<EStat, int>();

        Dictionary<EStat, Dictionary<EStat, int>> modifiers = new Dictionary<EStat, Dictionary<EStat, int>>();

        List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>();

        // PUBLIC

        public List<IPredicateEvaluator> GetEvaluators()
        {
            return evaluators;
        }

        public int GetProposedPoints(EStat stat)
        {
            return GetPoints(stat) + GetStagedPoints(stat);
        }

        public int GetPoints(EStat stat)
        {
            return assignedPoints.ContainsKey(stat) ? assignedPoints[stat] : 0;
        }

        public int GetSkillStat(EStat stat)
        {
            if (!baseStats) baseStats = GetComponent<BaseStats>();
            return (assignedPoints.ContainsKey(stat) ? assignedPoints[stat] : 0) + baseStats.GetStat(stat);
        }

        public int GetStagedPoints(EStat stat)
        {
            return stagedPoints.ContainsKey(stat) ? stagedPoints[stat] : 0;
        }

        public void AssignPoints(EStat stat, int amount)
        {
            if (!CanAssignPoints(stat, amount)) return;

            stagedPoints[stat] = GetStagedPoints(stat) + amount;
        }

        public bool CanAssignPoints(EStat stat, int points)
        {
            if (GetStagedPoints(stat) + points < 0) return false;
            if (GetUnallocatedPoints() < points) return false;
            return true;
        }

        public bool SkillCheck(ref Skill skill)
        {
            if (skill.NoCondition || skill.Unlocked) return true;
            return skill.Condition.Check(GetEvaluators());
        }

        public void ResetPoints()
        {
            assignedPoints.Clear();
            stagedPoints.Clear();
        }

        public int GetUnallocatedPoints()
        {
            return GetAssignablePoints() - GetTotalProposePoints();
        }

        public int GetTotalProposePoints()
        {
            int total = 0;

            foreach (int points in assignedPoints.Values)
            {
                total += points;
            }

            foreach (int points in stagedPoints.Values)
            {
                total += points;
            }

            return total;

        }

        public void Commit()
        {
            foreach (EStat skill in stagedPoints.Keys)
            {
                assignedPoints[skill] = GetProposedPoints(skill);

                for (int i = 0; i < abilityRowUI.Length; i++)
                {
                    abilityRowUI[i].Evaluate();

                    if (abilityRowUI[i].GetAbilityRowSkill().SkillStat == skill)
                    {
                        abilityRowUI[i].UpdateSkill();
                    }
                }
            }
            stagedPoints.Clear();
        }

        public int GetAssignablePoints()
        {
            return baseStats.GetStat(EStat.TotalAbilityPoints);
        }

        // PRIVATE
        void Awake()
        {
            abilityRowUI = AbilityRowUICollection();
            baseStats = GetComponent<BaseStats>();

            foreach (SkillBonus bonus in bonusConfigs)
            {
                if (!modifiers.ContainsKey(bonus.stat))
                {
                    modifiers[bonus.stat] = new Dictionary<EStat, int>();
                }

                modifiers[bonus.stat][bonus.skillStat] = Mathf.FloorToInt(bonus.perLevel);
            }

            foreach (IPredicateEvaluator evaluator in GetComponents<IPredicateEvaluator>())
            {
                if (!evaluators.Contains(evaluator)) evaluators.Add(evaluator);
            }
        }

        static AbilityRowUI[] AbilityRowUICollection()
        {
            return FindObjectsOfType<AbilityRowUI>();
        }

        public bool? Evaluate(EPredicate predicate, List<string> parameters)
        {
            if (predicate == EPredicate.HasSkill)
            {
                if (Enum.TryParse<EStat>(parameters[0], out EStat stat))
                {
                    Debug.Log($"Para[0]:{parameters[0]}/Pts:{GetSkillStat(stat)} | Para[1] Points:{parameters[1]}\nIsPara[0] >= Para[1]  ? {GetSkillStat(stat) >= Int32.Parse(parameters[1])}");
                    return GetSkillStat(stat) >= Int32.Parse(parameters[1]);
                }
            }
            return null;
        }

        public JToken CaptureAsJToken()
        {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;
            foreach (KeyValuePair<EStat, int> pair in assignedPoints)
            {
                stateDict[pair.Key.ToString()] = JToken.FromObject(pair.Value);
            }
            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            if (state is JObject stateObject)
            {
                assignedPoints.Clear();
                IDictionary<string, JToken> stateDict = stateObject;
                foreach (KeyValuePair<string, JToken> pair in stateDict)
                {
                    if (Enum.TryParse(pair.Key, true, out EStat traitStat))
                    {
                        assignedPoints[traitStat] = pair.Value.ToObject<int>();
                    }
                }
            }
        }
    }
}

Notable Mentions

public void Commit() method does a bit of extra logic than the courses code is shipped with. Here I’ve made it so that every time we commit points we send out a message for each AbilityRowUI script to listen to regarding 'Checking Evaluators' on the skills. If the Check returns true then inside our AbilityRowUI the ‘plusButton’ returns interactable with a few exceptions.

static AbilityRowUI[] AbilityRowUICollection() method will find each AbilityRowUI and then store it with the premise that the AbilityRowUI count never changing once initialized. (For purposes to do iteration over each skill performing Checks.)

public bool SkillCheck(ref Skill skill) this Method will be called each time a Commit button is pressed, looping through each AbilityRowUI and Checking whether those skills can be unlocked. It also gets called OnEnable() inside AbilityRowUI’s script.

List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>(); in order to reduce performance waste. – Inside the Awake() method I grab a cache of each IPredicateEvaluator component so I can iterate over it without finding each one every time I need to call ‘Evaluate’ from AbilityRowUI.cs

End of AbilityStore.cs


  1. Create AbilityRowUI class
using Game.Abilities;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Game.UI.Inventories;
using Game.Inventories;
using Game.Attributes;

namespace Game.UI.Abilities
{
    public class AbilityRowUI : MonoBehaviour, IItemHolder
    {
        // Configurable Parameters
        [SerializeField] Skill activeSkill;
        [SerializeField] TMP_Text skillName;
        [SerializeField] TMP_Text skillValue;
        [SerializeField] Button minusButton;
        [SerializeField] Button plusButton;
        [SerializeField] Button unlockButton;
        [SerializeField] Button skillImage;

        // Cached References
        BaseStats baseStats = null;
        AbilityStore abilityStore = null;

        // PUBLIC

        public int CurrentSkillLevel()
        {
            return Mathf.Min(GetTruePoints(), (baseStats.GetStat(activeSkill.SkillStat) + activeSkill.SkillCap));
        }

        public void Allocate(int points)
        {
            abilityStore.AssignPoints(activeSkill.SkillStat, points);
        }

        public InventoryItem GetItem()
        {
            return activeSkill;
        }

        public Skill GetAbilityRowSkill()
        {
            return activeSkill;
        }

        public void UpdateSkill()
        {
            activeSkill.SkillLevel = CurrentSkillLevel();
            activeSkill.Unlocked = true ? activeSkill.SkillLevel > 0 : activeSkill.SkillLevel <= 0;
            skillImage.interactable = activeSkill.Unlocked;
        }

        public void Evaluate()
        {
            plusButton.interactable = abilityStore.SkillCheck(ref activeSkill);
        }

        // PRIVATE

        void Awake()
        {
            abilityStore = GameObject.FindGameObjectWithTag("Player").GetComponent<AbilityStore>();
            baseStats = abilityStore.GetComponent<BaseStats>();

            minusButton.onClick.AddListener(() => Allocate(-1));
            plusButton.onClick.AddListener(() => Allocate(+1));
        }

        void OnEnable()
        {
            Evaluate();
        }

        void Update()
        {
            UpdateUI();
        }

        void UpdateUI()
        {
            skillValue.text = $"{baseStats.GetStat(activeSkill.SkillStat) + abilityStore.GetProposedPoints(activeSkill.SkillStat)}";
            skillName.text = $"{activeSkill.SkillStat}";

            minusButton.interactable = abilityStore.CanAssignPoints(activeSkill.SkillStat, -1);

            unlockButton.gameObject.SetActive(abilityStore.GetStagedPoints(activeSkill.SkillStat) > 0);
            skillImage.interactable = activeSkill.Unlocked;

            if (baseStats.GetLevel() < activeSkill.GetLevel()) return;

            if (CheckCondition() && baseStats.GetLevel() >= activeSkill.GetLevel())
            {
                plusButton.interactable = abilityStore.CanAssignPoints(activeSkill.SkillStat, +1)
                && abilityStore.GetProposedPoints(activeSkill.SkillStat) < activeSkill.SkillCap;
            }
        }

        bool CheckCondition()
        {
            if (!plusButton.interactable && activeSkill.NoCondition) return true;
            else return plusButton.interactable;
        }

        int GetTruePoints()
        {
            return baseStats.GetStat(activeSkill.SkillStat) + abilityStore.GetProposedPoints(activeSkill.SkillStat);
        }
    }
}

AbilityRowUI’s will keep track of each point invested.
If you have two points into Skill 0 then void UpdateSkill() will tell that skill to be unlocked; set it’s level equal to the GetStat(stat) + points invested. Remember that we’re using Stats not Traits/Skills.

void UpdateUI() method will notify that Row what to display based on circumstances.
It will check if your Skill’s GetLevel() is greater or lesser than your player’s level. Another circumstance is whether our Evaluate Check returns true or false and if we CanAssignPoints to said skill.

            if (CheckCondition() && baseStats.GetLevel() >= activeSkill.GetLevel())
            {
                plusButton.interactable = abilityStore.CanAssignPoints(activeSkill.SkillStat, +1)
                && abilityStore.GetProposedPoints(activeSkill.SkillStat) < activeSkill.SkillCap;
            }

So basically this first Checks whether the Skill can proceed to become unlocked based on the circumstances of the Evaluate process we’re doing from SkillCheck(). If it returns false we back out. If it returns true than we also check to ensure our player can unlock based on the level difference.

Then another check happens to see whether this plusButton should even be activated. Has this row been maxed out? Can we assign points? That will dictate whether you can invest points to unlock new skills.

End of AbilityRowUI.cs


  1. Creating AbilityUI class

This class used to be called the SkillTree but now it’s been reduced to something similar to the TraitUI class and it’s primary function is serialize and deserialize our skill data. It also is where whenever you create an Ability/Skill to place it inside the 'SkillsList'

using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using Game.Abilities;
using Game.Saving;
using Newtonsoft.Json.Linq;
using Game.Inventories;

namespace Game.UI.Abilities
{
    public class AbilityUI : MonoBehaviour, IJsonSaveable
    {
        // Configurable Parameters
        [SerializeField] List<Skill> skillsList = new List<Skill>();
        [SerializeField] TMP_Text unassignedPoints;
        [SerializeField] List<Button> commitButton = new List<Button>();

        // State
        Dictionary<int, Skill> skillsDictionary = new Dictionary<int, Skill>();
        Skill inspectedSkill = null;

        // Cached References
        AbilityStore abilityStore = null;
        ActionStore actionStore = null;

        /// <summary>
        /// Unity Event. Plug this up on the `Image` OnClick() so it can be slotted into an Actionbar.
        /// </summary>
        /// <param name="id_skill"></param>
        public void AddOrRemoveSkillFromSlot(int id_skill)
        {
            if (skillsDictionary.TryGetValue(id_skill, out inspectedSkill))
            {
                if (inspectedSkill.Unlocked)
                {
                    for (int i = actionStore.GetCurrentSlot(); i < actionStore.GetMaxSlots(); i++)
                    {
                        if (actionStore.GetAction(i) == null && !inspectedSkill.SkillInUse)
                        {
                            inspectedSkill.SkillInUse = true;
                            actionStore.AddAction(inspectedSkill, i, 1);
                            break;
                        }

                        else if (actionStore.GetAction(i) == inspectedSkill && inspectedSkill.SkillInUse)
                        {
                            inspectedSkill.SkillInUse = false;
                            actionStore.RemoveItems(i, 1);
                            break;
                        }
                    }
                }
            }
        }

        void Awake()
        {
            abilityStore = GameObject.FindGameObjectWithTag("Player").GetComponent<AbilityStore>();
            actionStore = abilityStore.GetComponent<ActionStore>();

            BuildSkillList();

            foreach (Button button in commitButton)
            {
                button.onClick.AddListener(abilityStore.Commit);
            }
        }

        void BuildSkillList()
        {
            foreach (Skill skill in skillsList)
            {
                if (skillsDictionary.ContainsValue(skill)) continue;
                skillsDictionary.Add(skill.SkillID, skill);
            }
        }

        void Update()
        {
            unassignedPoints.text = $"{abilityStore.GetUnallocatedPoints()}";
        }

        public JToken CaptureAsJToken()
        {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;

            for (int i = 0; i < skillsList.Count; i++)
            {
                JObject skillState = new JObject();
                IDictionary<string, JToken> skillStateDict = skillState;
                skillState["skillLevel"] = JToken.FromObject(skillsDictionary[i].SkillLevel);
                skillState["skillUnlocked"] = JToken.FromObject(skillsDictionary[i].Unlocked);
                skillState["skillInUse"] = JToken.FromObject(skillsDictionary[i].SkillInUse);
                stateDict[i.ToString()] = skillState;
            }

            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            if (state is JObject stateObject)
            {
                IDictionary<string, JToken> stateDict = stateObject;

                for (int i = 0; i < skillsList.Count; i++)
                {
                    if (stateDict.ContainsKey(i.ToString()) && stateDict[i.ToString()] is JObject skillState)
                    {
                        IDictionary<string, JToken> skillStateDict = skillState;
                        skillsDictionary[i].SkillLevel = skillStateDict["skillLevel"].ToObject<int>();
                        skillsDictionary[i].Unlocked = skillStateDict["skillUnlocked"].ToObject<bool>();
                        skillsDictionary[i].SkillInUse = skillStateDict["skillInUse"].ToObject<bool>();
                    }
                }
            }
        }
    }
}

On your AbilityRow / AbilityRowUI.cs you will have an image that becomes ‘active’ after a skill is unlocked. void AddOrRemoveSkillFromSlot() method is my way of placing the skill automatically into ability slots. In my RPG they cannot be placed inside the inventory but if you didn’t make any changes to Inventory then this will occur with your Skills. I can update later on how I make it so that Skills only remain inside Actionbars.

To make this work on your skillImagebutton put a OnClick listener for this by navigating to your AbilityUI gameObject and then applying the AddOrRemoveSkillFromSlot() plus the unique identifier index for that skill for that row.

End of AbilityUI.cs


  1. Create Skill script
using Game.Attributes;
using Game.Utils;
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Game.Abilities
{
    [System.Serializable]
    [CreateAssetMenu(fileName = "New Skill", menuName = "Scriptable Objects/Skill/Create New Skill")]
    public class Skill : Ability
    {
        [Serializable]
        public class SkillModifier
        {
            public EffectStrategy effect;
            public List<int> levels = new List<int>();
        }

        [Header("Skill Configurations")]
        #region Configurable Parameters
        [SerializeField] Condition skillCondition;
        [SerializeField] EStat skillStat = EStat.HeadShot;
        [SerializeField] int skillID;
        [Range(1, 100)] [SerializeField] int requiredPointsToUnlock;
        [Range(0, 100)][SerializeField] int skillLevel;
        [Range(1, 100)] [SerializeField] int skillCapLevel;
        [SerializeField] bool skillUnlocked;
        [SerializeField] bool noCondition = false;
        [SerializeField] bool skillInUse;
        [SerializeField] List<SkillModifier> skillUpgrades = new List<SkillModifier>();
        #endregion

        #region Properties
        public Condition Condition { get { return skillCondition; }set { skillCondition = value; } }
        public EStat SkillStat { get { return skillStat; } set { skillStat = value; } }
        public int SkillID { get { return skillID; } set { skillID = value; } }
        public int RequiredPointsToUnlock { get { return requiredPointsToUnlock; } set { requiredPointsToUnlock = value; } }
        public int SkillLevel { get { return skillLevel; } set { skillLevel = value; } }
        public int SkillCap { get { return skillCapLevel; } set { skillCapLevel = value; } }
        public bool Unlocked { get { return skillUnlocked; } set { skillUnlocked = value; } }
        public bool NoCondition { get { return noCondition; } set { noCondition = value; } }
        public bool SkillInUse { get { return skillInUse; } set { skillInUse = value; } }
        public List<SkillModifier> SkillUpgrades { get { return skillUpgrades; } set { skillUpgrades = value; } }
        #endregion
    }
}

Skills have a SkillModifier class that allows you to tune the different values based on its level. I show an example of this in another post later. But essentially it gives progression to your healing or damage effects over the course of the skills life. This means you need to account for ‘extra’ levels outside that wouldn’t normally be obtainable due to the fact Stats can ‘gain’ you additional levels through gear.

Skills utilize a unique identifier similar to the string the courses use as ‘GetItemID()’. Each one needs to be unique from each other else they’ll throw an error when building the skill list out upon Awake().

Setup Process


Inside your Stats enum add your skills to the list. Inside Progression add these new stats with any levels you like. They don’t require any actual data but if you wish to have skills go over the cap this is a nice bonus for items that can roll stats that boost your skills.

Create a new section inside your Canvas and title it ‘AbilityUI’ – the AbilityUI object needs to have the AbilityUI script attached to it – follow through and use the same setup process as you did with the Traits.

Each row must have two textual components a image (button) / commit button as children.

Each AbilityRow have their own commit (Unlock) button. Each row requires a Skill to be attached and then some Text components. The image component is what will be placed inside your Actionbars.

You can also add a tooltip spawner on said Image gameObject so you can hover to see what the skill does.


Creating Skills


Once you’ve compiled the Skill script. Navigate to your new CreateAssetMenu and create your first Skill. Once completed the important things to note is listed below.

  1. Set up a item level for the skill. (This should be implemented via InventoryItem). Any item that you wish to have prerequisites like needing a certain player level before attempting to unlock will require GetLevel() to work.
  2. If you wish to use The IPredicateEvaluator you can populate the 'SkillCondition' to your hearts content. EPredicate.HasSkill is specifically used to determine when a dependency of a skill has been satisfied. If you have Skill A unlocked and invested with 3 pts, but Skill B has a condition requiring Skill A to have 5 pts invested then it will not allow you to attempt to unlock Skill B until that conditions satisfied.
  3. Skills don’t require a pickup. They’re not meant to be droppable!
  4. Skill Modifiers are basically the levels in which the damage or heal is taken in account from. If you intend to have multiple ‘out of bound’ levels say, from gear that grants bonus levels, you must account SkillModifier levels for that - else this could error out when you try implementing it into your system.

Here’s how I calculate the SkillModifiers.

using System;
using UnityEngine;

namespace Game.Abilities
{
    public abstract class EffectStrategy : ScriptableObject
    {
        public abstract void StartEffect(AbilityData data, Action finished);
        
        public int SetUpEffectLevel(Skill skill, int effectValue)
        {
            if (skill == null) return effectValue;

            Skill.SkillModifier[] modifiers = skill.SkillUpgrades.ToArray();

            if (skill.SkillUpgrades.Count == 0) return effectValue;

            if (skill.SkillUpgrades.Count <= 1) return effectValue = skill.SkillUpgrades[0].levels[skill.SkillLevel - 1];

            int totalAmount = 0;

            foreach (Skill.SkillModifier progression in modifiers)
            {
                totalAmount += progression.levels[skill.SkillLevel - 1];
            }

            return totalAmount;
        }
    }
}

Last note:

If you plan to have a skill that doesn’t need to Evaluate for Predicates set ‘NoCondition’ as true. Else leave it set as false so that the skill can be checked until conditions are true. This means that NoCondition will not have that extra check, only the checks would be if you CanAssignPoints to said skill or if you meet the level requirement.

1 Like

General Additional Information


So because we’re using ‘Stats’ for our skills I wanted to go out on a limp and share why that is. As I aforementioned that in my RPG Traits no longer exist outside of Stats… well this is also true for Skills. I don’t use the enum Traits since they now belong to my Stats enum and the reason behind this is simply due to the fact the courses system was a bit limited in what we can do with the traits.

I couldn’t easily apply traits to items. Not without major refactoring or we could simply undo the traits and make them behave more like stats. I learned this from another post on this community not long ago through Brian and implemented those changes.

Now items can have stats that directly affect the traits which in return affect whatever stats they’re
affecting. Well, the case is the same with Skills. Skills utilize the same concept as Traits which mean they’re also apart of the Stats enum.

Here’s my current EStat list

namespace Game.Attributes
{
    /// <summary>
    /// Stats are comprised of attributes, skills, traits.
    /// </summary>
    public enum EStat
    {
        Health,
        Mana,
        Strength,
        Dexterity,
        Constitution,
        Wisdom,
        Intellect,
        Charisma,
        ManaRegenRate,
        ExperienceReward,
        ExperienceRequiredToLevelUp,
        Damage,
        SpellDamage,
        TotalTraitPoints,
        BuyingDiscountPercentage,
        Defence,
        TotalAbilityPoints,
        HeadShot,
        PoisonShot,
        BloodShot
    }
}

So before making any drastic changes and implementing this system there’s two things to consider.

  1. Go ahead and implement the changes from Trait → Stat which means there’s a bit of refactoring in store for your Trait system and also some changes to your BaseStats system.
  2. Rather than refactoring and changing your system you can simply continue using the Traits system which means then you’d need to create a new enum titled ESkill for your skills. Put your skills inside this and then simply swap out anywhere that references ‘Stat’ for the new enum.

Some methods could break I haven’t done a thorough check. This process would also mean you’d no longer need references to your BaseStats. Everything should work, just without the added bonuses as the refactored path. Because SkillBonus relies on BaseStats it may yield no results back.

The classes that will be affected would be: AbilityUI, AbilityRowUI, AbilityStore.


Autoequip Information


Create a new interface script and call it IActionStore.cs

namespace Game.Inventories
{
    public interface IActionStore
    {
        int AddItemToActionSlots(InventoryItem item, int number);
    }
}

Inside ActionStore.cs add the new interface and then inside your new member do the following:

        public int AddItemToActionSlots(InventoryItem item, int number)
        {
            if (item.GetSlotPlacement() == EItemSlotPlacement.AbilitiesSlot)
            {
                int currentIndex = 0;
                int maxIndex = 6;
                return EquippingActionItem(item, currentIndex, maxIndex, number);
            }

            else if (item.GetSlotPlacement() == EItemSlotPlacement.ConsumablesSlot)
            {
                int currentIndex = 6;
                int maxIndex = 9;
                return EquippingActionItem(item, currentIndex, maxIndex, number);
            }
            return 0;
        }

Inside my project I have two ‘actionbars’ that’re separate. 0-6 will only ever contain Skills. 7-9 will only ever contain consumables or ability scrolls. If you don’t wish to have that then simply remove the else if portion of the code.

Inside Inventory HasSpaceFor method I added the following:

        public bool HasSpaceFor(InventoryItem item)
        {
            if (item is Skill) return false; 
            return FindSlot(item) >= 0;
        }

This way Skills cannot be placed inside your Inventory. I then created a new enum called EItemSlotPlacement with the intent of keeping track of what ‘hotbars’ items could contain.

This means you can have an area only Potions can be stored – or Skills.

Populate the enum and make one that’s for AbilitiesSlot.

Inside InventoryItem.cs add a new serialized field with this enum. Create yourself a getter and setter for this field and then let’s head back over to Inventory.cs

Inside Inventory AddToFirstEmptySlot() do the following:


            actionStore = GetComponent<ActionStore>();

            if (item.GetSlotPlacement() == EItemSlotPlacement.AbilitiesSlot)
            {
                foreach (IActionStore store in GetComponents<IActionStore>())
                {
                    number -= store.AddItemToActionSlots(item, number);
                }

                if (number <= 0) return true;
            }

Inside your ActionSlotUI.cs you will need to add the same field EItemSlotPlacement and then assign that field to be Skills on each individual prefab ActionSlot.

        [SerializeField] EItemSlotPlacement placementSlot = EItemSlotPlacement.None;

        public void AddItems(InventoryItem item, int number)
        {
            if (item.GetSlotPlacement() != placementSlot)
            {
                inventory.AddToFirstEmptySlot(item, number);
                return;
            }

            store.AddAction(item, index, number);
        }

There you go, you now have the same functionality as I have in my project meaning when you equip skills the only way to ‘remove’ them is with the AddOrRemoveSkillFromSlot() callback on your skillImage.


AbilityRowUI & AbilityUI


Row Buttons:

Your unlock button needs to have an OnClick() function setup. This is your ‘commit’ button but it also needs to notify the skillImage button to switch on since the ability is now unlocked. Set up a boolean to turn the image on when pressed. By default I have all commit buttons set to false on interactable. This way they only become interactable if they’re evaluated.

UnlockButton

skillImage button needs to have an OnClick() function setup. This is how you will ‘add or remove’ to your abilities slot.

skillImageButton

Lastly here is the set up of my AbilityRowUI / AbilityUI

Image of AbilityUI gameobject

image of AbilityRowUI gameobject

Nicely done!

1 Like

Thanks mate! I remember I was stumped doing the Evaluate code a few months ago but now I’ve tackled it the same way I done with the Traits. Feels very satisfying and am quite happy with this revamped skill system!

Made some slight changes to the AbilityRowUI script due to performance concerns. Also introduced an Action.

Here’s the full script of both scripts now. Below I’ll go into the changes for the Methods affected.

AbilityStore -- Click to view the full script.
    public class AbilityStore : MonoBehaviour, IModifierProvider, IJsonSaveable, IPredicateEvaluator
    {
        [Serializable]
        class SkillBonus
        {
            public EStat skillStat = EStat.Strength;
            public EStat stat = EStat.Damage;
            public float perLevel = 1f;
        }

        // Configurable Parameters
        [SerializeField] int totalActionbarSlots = 9;
        [SerializeField] List<SkillBonus> bonusConfigs = new List<SkillBonus>();

        // State
        Dictionary<EStat, int> assignedPoints = new Dictionary<EStat, int>();
        Dictionary<EStat, int> stagedPoints = new Dictionary<EStat, int>();
        Dictionary<EStat, Dictionary<EStat, float>> modifiers = new Dictionary<EStat, Dictionary<EStat, float>>();
        List<IPredicateEvaluator> evaluators = new List<IPredicateEvaluator>();

        // Cached References
        ActionStore actionStore = null;
        BaseStats baseStats = null;

        // Action
        /// <summary>
        /// Broadcasts when an AbilityRowUI has been updated
        /// </summary>
        public event Action onEvaluateChanged;

        // PUBLIC

        public List<IPredicateEvaluator> GetEvaluators()
        {
            return evaluators;
        }

        public int GetProposedPoints(EStat stat)
        {
            return GetPoints(stat) + GetStagedPoints(stat);
        }

        public int GetPoints(EStat stat)
        {
            return assignedPoints.ContainsKey(stat) ? assignedPoints[stat] : 0;
        }

        public int GetSkillStat(EStat stat)
        {
            if (!baseStats) baseStats = GetComponent<BaseStats>();
            return (assignedPoints.ContainsKey(stat) ? assignedPoints[stat] : 0) + baseStats.GetStat(stat);
        }

        public int GetStagedPoints(EStat stat)
        {
            return stagedPoints.ContainsKey(stat) ? stagedPoints[stat] : 0;
        }

        public int GetTotalProposePoints()
        {
            int total = 0;

            foreach (int points in assignedPoints.Values)
            {
                total += points;
            }

            foreach (int points in stagedPoints.Values)
            {
                total += points;
            }

            return total;
        }

        public int GetAssignablePoints()
        {
            return baseStats.GetStat(EStat.TotalAbilityPoints);
        }

        public bool CanAssignPoints(EStat stat, int points)
        {
            if (GetStagedPoints(stat) + points < 0) return false;
            if (GetUnallocatedPoints() < points) return false;
            return true;
        }

        /// <summary>
        /// If the player's level is less than that of the skill return false.
        /// Otherwise, if the skill has no condition or is already unlocked return true.
        /// Else do a skill check to determine any remaining prerequisites from dependencies.
        /// </summary>
        /// <param name="skill">the skill we're evaluating to determine if it's already unlocked or has conditions remaining.</param>
        /// <returns></returns>
        public bool SkillCheck(ref Skill skill)
        {
            if (baseStats.GetLevel() < skill.GetLevel()) return false;
            if (skill.NoCondition || skill.Unlocked) return true;
            return skill.Condition.Check(GetEvaluators());
        }

        public int GetUnallocatedPoints()
        {
            return GetAssignablePoints() - GetTotalProposePoints();
        }

        public void AssignPoints(EStat stat, int amount)
        {
            if (!CanAssignPoints(stat, amount)) return;

            stagedPoints[stat] = GetStagedPoints(stat) + amount;
        }

        public void ResetPoints()
        {
            assignedPoints.Clear();
            stagedPoints.Clear();
            OnEvaluate();
        }

        public void Commit()
        {
            foreach (EStat skill in stagedPoints.Keys)
            {
                assignedPoints[skill] = GetProposedPoints(skill);
            }

            stagedPoints.Clear();
            OnEvaluate();
        }

        /// <summary>
        /// Broadcasts and updates any changes made for Skills inside AbilityRowUI
        /// </summary>
        public void OnEvaluate() => onEvaluateChanged();
        

        // PRIVATE

        void Awake()
        {
            actionStore = GetComponent<ActionStore>();
            baseStats = GetComponent<BaseStats>();

            SetSkillBonus();
            GetIPredicateModifiers();
        }


        void GetIPredicateModifiers()
        {
            string evaluatorString = "";
            int amountFound = 0;

            foreach (IPredicateEvaluator evaluator in GetComponents<IPredicateEvaluator>())
            {
                if (!evaluators.Contains(evaluator)) evaluators.Add(evaluator);
                evaluatorString += $"\n{evaluator}";
                amountFound++;
            }

            if (evaluatorString != "") Debug.Log($"List of IPredicateEvaluators Found: {amountFound}! {evaluatorString}");
        }

        /// <summary>
        /// Accumulative passive boosts. `Modifiers` use floats and then are casted as integers.
        /// </summary>
        void SetSkillBonus()
        {
            foreach (SkillBonus bonus in bonusConfigs)
            {
                if (!modifiers.ContainsKey(bonus.stat))
                {
                    modifiers[bonus.stat] = new Dictionary<EStat, float>();
                }

                modifiers[bonus.stat][bonus.skillStat] = bonus.perLevel;
            }
        }

        void Update()
        {
            UseActions();
        }

        void UseActions()
        {
            for (int i = 0; i < totalActionbarSlots; i++)
            {
                if (Input.GetKeyDown(KeyCode.Alpha1 + i))
                {
                    actionStore.Use(i, gameObject);
                }
            }
        }

        // Interface Members

        public bool? Evaluate(EPredicate predicate, List<string> parameters)
        {
            if (predicate == EPredicate.HasSkill)
            {
                if (Enum.TryParse<EStat>(parameters[0], out EStat stat))
                {
                    Debug.Log($"Para[0]:{parameters[0]}/Pts:{GetSkillStat(stat)} | Para[1] Points:{parameters[1]}\nIsPara[0] >= Para[1]  ? {GetSkillStat(stat) >= Int32.Parse(parameters[1])}");
                    return GetSkillStat(stat) >= Int32.Parse(parameters[1]);
                }
            }
            return null;
        }

        public IEnumerable<int> GetAdditiveModifiers(EStat stat)
        {
            if (modifiers.ContainsKey(stat))
            {
                foreach (KeyValuePair<EStat, float> pair in modifiers[stat])
                {
                    yield return (int)(GetSkillStat(pair.Key) * pair.Value);
                }
            }
        }

        public IEnumerable<int> GetPercentageModifiers(EStat stat)
        {
            yield break;
        }

        public JToken CaptureAsJToken()
        {
            JObject state = new JObject();
            IDictionary<string, JToken> stateDict = state;
            foreach (KeyValuePair<EStat, int> pair in assignedPoints)
            {
                stateDict[pair.Key.ToString()] = JToken.FromObject(pair.Value);
            }
            return state;
        }

        public void RestoreFromJToken(JToken state)
        {
            if (state is JObject stateObject)
            {
                assignedPoints.Clear();
                IDictionary<string, JToken> stateDict = stateObject;
                foreach (KeyValuePair<string, JToken> pair in stateDict)
                {
                    if (Enum.TryParse(pair.Key, true, out EStat traitStat))
                    {
                        assignedPoints[traitStat] = pair.Value.ToObject<int>();
                    }
                }
            }
        }
    }
AbilityRowUI -- Click to view the full script.
    public class AbilityRowUI : MonoBehaviour, IItemHolder
    {
        // Configurable Parameters
        [SerializeField] Skill activeSkill;
        [SerializeField] TMP_Text skillName;
        [SerializeField] TMP_Text skillValue;
        [SerializeField] Button minusButton;
        [SerializeField] Button plusButton;
        [SerializeField] Button unlockButton;
        [SerializeField] Button skillImage;

        // Cached References
        BaseStats baseStats = null;
        AbilityStore abilityStore = null;

        // PUBLIC

        /// <summary>
        /// Allot a -1 or +1 point if CanAssignPoints returns true.
        /// </summary>
        /// <param name="points"></param>
        public void Allocate(int points)
        {
            abilityStore.AssignPoints(activeSkill.SkillStat, points);
        }

        /// <summary>
        /// Set the current skill level based on the skill stat points
        /// </summary>
        /// <returns>The lowest number from Mathf.Min(GetTruePoints(), BaseStats.GetStat() + SkillCap)</returns>
        public int SetLevel()
        {
            return Mathf.Min(GetTruePoints(), (baseStats.GetStat(activeSkill.SkillStat) + activeSkill.SkillCap));
        }

        /// <summary>
        /// GetItem() is used to display the itemToolTipSpawner with the correct `InventoryItem`.
        /// </summary>
        /// <returns></returns>
        public InventoryItem GetItem()
        {
            return activeSkill;
        }

        // PRIVATE

        void Awake()
        {
            abilityStore = GameObject.FindGameObjectWithTag("Player").GetComponent<AbilityStore>();
            baseStats = abilityStore.GetComponent<BaseStats>();

            minusButton.onClick.AddListener(() => Allocate(-1));
            plusButton.onClick.AddListener(() => Allocate(+1));
            abilityStore.onEvaluateChanged += UpdateSkill;
            abilityStore.onEvaluateChanged += Evaluate;
            baseStats.onLevelUp += Evaluate;
        }

        void OnEnable()
        {
            StartCoroutine(OnEnableDelay());
        }

        void OnDestroy()
        {
            abilityStore.onEvaluateChanged -= UpdateSkill;
            abilityStore.onEvaluateChanged -= Evaluate;
            baseStats.onLevelUp -= Evaluate;
        }

        void Update()
        {
            UpdateUI();

            if (CanBeInteractable())
            {
                plusButton.interactable = abilityStore.CanAssignPoints(activeSkill.SkillStat, +1)
                && abilityStore.GetProposedPoints(activeSkill.SkillStat) < activeSkill.SkillCap;
            }
        }

        /// <summary>
        /// Updates each row's points and skill status.
        /// </summary>
        void UpdateUI()
        {
            skillValue.text = $"{baseStats.GetStat(activeSkill.SkillStat) + abilityStore.GetProposedPoints(activeSkill.SkillStat)}";
            skillName.text = $"{activeSkill.SkillStat}";

            minusButton.interactable = abilityStore.CanAssignPoints(activeSkill.SkillStat, -1);
            unlockButton.gameObject.SetActive(abilityStore.GetStagedPoints(activeSkill.SkillStat) > 0);
            skillImage.interactable = activeSkill.Unlocked;
        }

        /// <summary>
        /// Whether our plusButton can be enabled.
        /// </summary>
        /// <returns></returns>
        bool CanBeInteractable()
        {
            if (!plusButton.interactable && activeSkill.NoCondition) return true;
            else return plusButton.interactable;
        }

        /// <summary>
        /// 
        /// </summary>
        /// <returns>The collective points this row has from BaseStats.GetStat() + abilityStore.GetPoints()</returns>
        int GetTruePoints()
        {
            return baseStats.GetStat(activeSkill.SkillStat) + abilityStore.GetPoints(activeSkill.SkillStat);
        }

        /// <summary>
        /// Yield WaitUntil BaseStats is set. Creates a loop while OnEnable() is active.
        /// </summary>
        /// <returns></returns>
        IEnumerator OnEnableDelay()
        {
            yield return new WaitWhile(() => baseStats == null);
            Evaluate();
            yield return new WaitForSeconds(3f);
            StartCoroutine(OnEnableDelay());
        }


        /// <summary>
        /// Subscriber for BaseStats / AbilityStore to return whether our plusButton should become enabled.
        /// </summary>
        void Evaluate()
        {
            plusButton.interactable = abilityStore.SkillCheck(ref activeSkill);
        }

        /// <summary>
        /// Subscriber for AbilityStore to Update our skill with the appropriate information for it's level, unlocked status etc.
        /// </summary>
        void UpdateSkill()
        {
            activeSkill.SkillLevel = SetLevel();
            activeSkill.Unlocked = true ? activeSkill.SkillLevel > 0 : activeSkill.SkillLevel <= 0;
            skillImage.interactable = activeSkill.Unlocked;
        }

    }

Changes:


Firstly I noticed that I had created a circular dependency between my AbilityStore and the AbilityUI namespace. Each were requiring to know things from one another.

AbilityRowUI < — > AbilityStore

I nipped that in the bud with these changes by using an Action to broadcast anytime I needed the information to update for our rows. Secondly the performance of this way is way more optimized based on what the Profiler was telling me.

I changed the modifiers dictionary within a dictionary to be a <EStat, float> since I didn’t necessarily want the bonuses from my skill stats to only be dealt with whole numbers.

   Dictionary<EStat, Dictionary<EStat, float>> modifiers = new Dictionary<EStat, Dictionary<EStat, float>>();
        // Action
        /// <summary>
        /// Broadcasts when an AbilityRowUI needs to be updated
        /// </summary>
        public event Action onEvaluateChanged;

Created my Action to let the AbilityRow’s know when to do things.

        /// <summary>
        /// If the player's level is less than that of the skill return false.
        /// Otherwise, if the skill has no condition or is already unlocked return true.
        /// Else do a skill check to determine any remaining prerequisites from dependencies.
        /// </summary>
        /// <param name="skill">the skill we're evaluating to determine if it's already unlocked or has conditions remaining.</param>
        /// <returns></returns>
        public bool SkillCheck(ref Skill skill)
        {
            if (baseStats.GetLevel() < skill.GetLevel()) return false;
            if (skill.NoCondition || skill.Unlocked) return true;
            return skill.Condition.Check(GetEvaluators());
        }

Ultimately this is what will be called from the AbilityRowUI’s whenever they need to ‘Evaluate’ a skill check. If the skill isn’t meeting any of these prerequsities then back out. Else it will update when the Action is broadcasted to evaluate said skill.

        public void Commit()
        {
            foreach (EStat skill in stagedPoints.Keys)
            {
                assignedPoints[skill] = GetProposedPoints(skill);
            }

            stagedPoints.Clear();
            OnEvaluate();
        }

Commit (when actually assigning points to a skill stat) will also call the OnEvaluate method which simply invokes our onEvaluateChange action. Handy so that way we can check again after interacting with this button to see if more skills can now be unlocked.

        /// <summary>
        /// Broadcasts and updates any changes made for Skills inside AbilityRowUI
        /// </summary>
        public void OnEvaluate() => onEvaluateChanged();

After that I also forgot to mention in the original post I forgot to create the IModifierProvder members. Here they are – These work so well. I can have a Skill that grants more skill points to other skills passively – Or have a skill that grants a fraction of an attribute which builds up over time depending on how many points are invested. Whatever your heart desires really.

        public IEnumerable<int> GetAdditiveModifiers(EStat stat)
        {
            if (modifiers.ContainsKey(stat))
            {
                foreach (KeyValuePair<EStat, float> pair in modifiers[stat])
                {
                    yield return (int)(GetSkillStat(pair.Key) * pair.Value);
                }
            }
        }

        public IEnumerable<int> GetPercentageModifiers(EStat stat)
        {
            yield break;
        }

End of AbilityStore.cs


AbilityRowUI


Inside Awake() / OnDestroy() I have the subscribers set up as such:

        void Awake()
        {
            abilityStore = GameObject.FindGameObjectWithTag("Player").GetComponent<AbilityStore>();
            baseStats = abilityStore.GetComponent<BaseStats>();

            abilityStore.onEvaluateChanged += UpdateSkill;
            abilityStore.onEvaluateChanged += Evaluate;
            baseStats.onLevelUp += Evaluate;
        }

        void OnDestroy()
        {
            abilityStore.onEvaluateChanged -= UpdateSkill;
            abilityStore.onEvaluateChanged -= Evaluate;
            baseStats.onLevelUp -= Evaluate;
        }

AbilityRowUI’s Evaluate method goes straight to SkillCheck()

Also I wanted the BaseStats.onLevelUp Action to Evaluate the skills check since it is a required skill check in order to become unlockable.

        /// <summary>
        /// Subscriber for BaseStats / AbilityStore to return whether our plusButton should become enabled.
        /// </summary>
        void Evaluate()
        {
            plusButton.interactable = abilityStore.SkillCheck(ref activeSkill);
        }

I have created a delay Update loop since if a skill needs to still be evaluated it simply only needs to occasionally check whilst the AbilityUI is open and active. Before in the original post it was doing this check hundreds of times per frame.

Massively wasteful. It should only need to fire off only if a skill currently isn’t unlocked; requires condition checks when the AbilityRowUI.cs is enabled.

        void OnEnable()
        {
            StartCoroutine(OnEnableDelay());
        }

The Update delay loop inside OnEnable()

        /// <summary>
        /// Yield WaitUntil BaseStats is set. Creates a loop while OnEnable() is active.
        /// </summary>
        /// <returns></returns>
        IEnumerator OnEnableDelay()
        {
            yield return new WaitWhile(() => baseStats == null);
            Evaluate();
            yield return new WaitForSeconds(3f);
            StartCoroutine(OnEnableDelay());
        }

That’s all the changes. Everything works a little more performant and I’m sure there’s a million other things that can be done in order to reduce frame losses. But I figured if anybody out there was adapting this system I needed to come back to report my findings.

Privacy & Terms