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!
- Open up
IPredicateEvaluator
– AddHasSkill
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
- 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
- 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
- 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 skillImage
button 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
- 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.
- 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.
- 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. - Skills don’t require a pickup. They’re not meant to be droppable!
- 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.