Level/Rarity/Random Stats Upgrades

So I was playing Dislyte this evening on my phone, and was busy upgrading “Relics” (the game’s equivalent of equipment), and I decided to throw together a similar upgrading system for our RPG Course. This particular system has also been used in Summoner’s War and RAID: Shadow Legends.

I’ll start out by describing the concept behind the upgradable equipment.

Stats

Stats can be Primary Stats or Secondary stats, and can be absolute modifiers or percentage modifiers.

Primary Stat

Every piece of equipment has a Primary stat modifier. The types of possible primary stat modifiers is determined by the location of the gear. For example: a Chest Plate might have a primary stat of Defense. Shoes might have a primary stat of Speed or Dodge. Gloves might have a primary stat of accuracy. Primary stats increase with each item level. The initial value of the primary stat is set by the Rarity.

Secondary Stat

Each piece of equipment can have up to four secondary stats. The number of secondary stats is determined by a combination of level and quality. The higher the rarity, the more secondary stats the item starts with. As you gain stats by levelling, either a new (random) stat is added or if all four stats are enabled, then one of the random stats is boosted. Secondary stats initial value is set by Rarity, and only increases if the stat is boosted (by earning a secondary stat after the 4th stat is added).

Quality

Items have a quality, usually represented by the number of stars. There is a direct relationship between the number of stars and the number of secondary stats that the item begins with. For this tutorial, I'll be calling the Quality ranks
Poor (no stars) -- No starting secondary stats
Fair -- One secondary stat
Well Crafted -- Two Secondary stats
High Quality -- Three Secondary stats
Perfect -- Four Secondary stats

Level

Items used in the system start at Level 1, and can be upgraded to a max level. This level varies from game to game. Every level increase increases the value of the primary stat. The amount of the increase is determined by the Rarity. Every MaxLevel / 4 levels, a Secondary Stat is added or increased. Item levels are increased by spending materials, coins, or both. Some systems use coins and a material, other systems use just coins, but each attempt has a possibility of failure. The higher the level, the higher the chance of failure on each successive attempt. Dislyte uses this method, and caps the number of failures before victory is automatic.

Rarity

Items have a rarity which determines the initial value of the Primary Stat and Secondary stats, as well as the amount of increase when those stats are boosted. For our scheme, we'll use the following rarities:
Common -- A Very low stat value, it's also the value multiplied by the other rarities
Uncommon -- Twice as high a stat as Common
Rare -- Three times as high as Common
Mythic -- Four times as high as Common
Legendary -- Five times as high as Common

So all of these factors work together to determine the Stat Modifiers for a given item. We’re going to start by setting up some Enums and getting the initial framework for the Items.

ItemQuality.cs
namespace RPG.Inventories
{
    public enum ItemQuality
    {
        Poor=0,
        Fair=1,
        WellCrafted=2,
        HighQuality=3,
        Perfect=4
    }
}

You’ll note that I specified the integer value of each element in the series. By default, the Enum starts at 0 and increases by 1, but I’m being explicit here just for absolute clarity. We’ll be able to determine how many secondary stats the item starts with by simply casting the enum to an int.

ItemRarity.cs
namespace RPG.Inventories
{
    public enum ItemRarity
    {
        Common=1,
        Uncommon=2,
        Rare=3,
        Mythic=4,
        Legendary=5
    }
}

Note that in this case, I started at 1 instead of zero. This gives us a simple multiplier for the starting level for a stat and the increase in levels.

Next, we need a container struct for each modifier that will be on the item. This struct will hold all the information needed to determine the value of the modifier.

StatModifier.cs
using RPG.Stats;
using UnityEngine;

namespace RPG.Inventories
{
    [System.Serializable]
    public struct StatModifier
    {
        public Stat Stat;
        public float InitialValue;
        public float IncreasePerLevel;
        public bool IsPercentage;
        [HideInInspector] public int Level;
        [HideInInspector] public ItemRarity Rarity;
        

        public StatModifier(Stat stat, float initialValue, float increasePerLevel, bool isPercentage, int level, ItemRarity rarity)
        {
            Stat = stat;
            InitialValue = initialValue;
            IncreasePerLevel = increasePerLevel;
            IsPercentage = isPercentage;
            Level = level;
            Rarity = rarity;
        }
        
        public float Evaluate()
        {
            float actualInitialValue =  (int)Rarity * InitialValue;
            float actualIncreasePerLevel = (int)Rarity * IncreasePerLevel;
            return actualInitialValue + actualIncreasePerLevel * Level;
        }
    }
}

This struct covers all the needed details for one stat. It’s Evaluate method gets the initial value of the stat, based on it’s rarity, and then adds stats per level to get a final value. We’ll be using this in the next class, our new Item, the UpgradableEquipmentItem

Summary
using System.Collections.Generic;
using GameDevTV.Inventories;
using RPG.Stats;
using UnityEngine;


namespace RPG.Inventories
{
    [CreateAssetMenu(fileName = "UpgradeableEquipment", menuName = "Inventory/UpgradableEquipment", order = 0)]
    public class UpgradableEquipmentItem : EquipableItem, IModifierProvider
    {
        [SerializeField] private StatModifier[] possiblePrimaryStats;
        [SerializeField] private StatModifier[] possibleSecondaryStats;


        private ItemRarity rarity;
        private ItemQuality quality;
        private int itemLevel;

        public ItemRarity Rarity() => rarity;
        public ItemQuality Quality() => quality;
        public int ItemLevel() => itemLevel;
        
        private List<StatModifier> actualStatModifiers = new List<StatModifier>();

        /// <summary>
        /// Determine rarity and quality based on the level.  Create stats based on setup values.
        /// </summary>
        /// <param name="level"></param>
        public void SetupNewItem(int level)
        {
            if (possibleSecondaryStats.Length < 4)
            {
                Debug.Log($"{GetDisplayName()} needs more possible secondary items to function properly!");
            }

            if (possiblePrimaryStats.Length == 0)
            {
                Debug.Log($"{GetDisplayName()} needs at least one possible Primary Stat to function");
            }
            rarity = (ItemRarity)GenerateCharacteristic(level, 1, 5);
            quality = (ItemQuality)GenerateCharacteristic(level, 0, 4);
            StatModifier primaryModifier = possiblePrimaryStats[Random.Range(0, possiblePrimaryStats.Length)];
            actualStatModifiers.Clear();
            actualStatModifiers.Add(primaryModifier);
            
            for (int i = 0; i < (int)quality; i++)
            {
                bool isValid = true;
                do
                {
                    StatModifier potentialModifier = possibleSecondaryStats[Random.Range(0, possibleSecondaryStats.Length)];
                    foreach (StatModifier actualStatModifier in actualStatModifiers)
                    {
                        if (potentialModifier.Stat == actualStatModifier.Stat &&
                            potentialModifier.IsPercentage == actualStatModifier.IsPercentage)
                        {
                            isValid = false;
                            break;
                        }
                    }
                } while (!isValid);
            }
        }

        private static int GenerateCharacteristic(int level, int startingValue, int maxValue=5)
        {
            int workingValue = startingValue;
            int testLevel = level * 4;
            // here, we're giving the item a chance to grow past Common. 
            // Each successful roll increases the Rarity, but the test for the next rarity level drops 
            while (workingValue < maxValue && Random.Range(0, 100) < testLevel)
            {
                workingValue++;
                testLevel = level / workingValue;
            }

            return workingValue;
        }

        public IEnumerable<float> GetAdditiveModifiers(Stat stat)
        {
            foreach (var actualStatModifier in actualStatModifiers)
            {
                if (stat == actualStatModifier.Stat && !actualStatModifier.IsPercentage)
                    yield return actualStatModifier.Evaluate();
            }
        }

        public IEnumerable<float> GetPercentageModifiers(Stat stat)
        {
            foreach (var actualStatModifier in actualStatModifiers)
            {
                if (stat == actualStatModifier.Stat && actualStatModifier.IsPercentage)
                    yield return actualStatModifier.Evaluate();
            }
        }
    }
}

In the next post, we’ll go over creating the random item at runtime, as well as loading and saving the item.

3 Likes

Instantiating an Item

Because a ScriptableObject is essentially a shared object between every script that has a reference to it, you can't just call SetupNewItem() and expect things to work properly. What we need to do at runtime is Instantiate a copy of the item when it's created or when it's restored by RestoreState.

First, we need to look at the places that items are created.

RandomDropper

RandomDropper is the source of most of the items in the game. When you kill an enemy, there is a chance to drop one or more InventoryItem. RandomDropper will need to determine if the item is an UpgradableEquipmentItem, and if it is it will need to instantiate a copy of the item. Of course, it's also used by the Player to drop items from Inventory to the ground, which means we also need to know if the item is an instance or an original item. Additionally, RandomDropper will need to save information about the item so that the item is restored properly.

PickupSpawner

PickupSpawner spawns an item in it's Serialized field. It will need to determine if this is an UpgradableEquipmentItem and if it is, Instantiate a copy of the item and set it up. You don't really need to Save/Restore the cloned item, as you can just recreate it if it hasn't already been picked up, and nobody really knows what the item is until it's picked up, at which point it will be properly saved.

Inventory

New items aren't created in Inventory, but we need to store the state of the item along with it's itemID when we Save and Load the game. If it's an UpgradableItem, it should be instantiated and it's values restored.

Equipment

The same concepts as Inventory apply.

Shops

Shops are a bit more complicated. The simplest approach here is to instantiate the item at runtime, and not worry about saving/loading between sessions. This would mean that if you saved the game and came back to the vendor at a later time, the item might have different stats. I may tackle saving the actual item data so once it's instantiated it's the same between sessions, but we'll see.

Instantiating a copy of a ScriptableObject is actually quite easy if you have the specific object already at hand. Here’s a quick demo to demonstrate

using UnityEngine;
public class ScriptableObjectCloner :MonoBehaviour
{
     [SerializeField] ScriptableObject objectToClone;
     ScriptableObject clonedObject;

     void Start()
     {
           clonedObject = Instantiate(objectToClone);
     {
}

When run, clonedObject will exist only in memory, but will be an exact copy of objectToClone.
Practically speaking, this means that we can simply Instantiate a copy of the UpgradableEquipmentObject and a new object will exist in memory with all the same settings set in the inspector for the original object, but changes we make to this new object won’t be reflected on the Original object.

Now we could test each InventoryItem to see if it is an UpgradableEquipmentItem

if(item is UpgradableEquipmentItem upgradable)
{
    var instance = Instantiate(upgradable);
    instance.SetupNewItem(level);
    return instance;
} else return item;

But we might find ourselves wanting to save information about other inventory items, and it would be silly to have checks for each thing. What I’ve chosen to do instead is to treat any InventoryItem that is not stackable as an item that needs to be instanced.

This means that we need to make a couple of minor tweaks to InventoryItem to set things up.

We need a CaptureState and RestoreState to save and load information about the ScriptableObject and we need a method to consistently setup the item (even if the item has no information to save or need to set up). This makes adding changes later down the road infinitely easier.

Go into InventoryItem.cs and add these three methods:

        public virtual object CaptureState() => null;

        public virtual void RestoreState(object state)
        {
        }

        public virtual void SetupNewItem(int level)
        {
        }

These are placeholder methods. They do absolutely nothing now, but they can be overridden by child classes.

Now go into UpgradableEquipment and change the signature in SetupNewItem to read

public override void SetupNewItem(int level);

Now you can call SetupNewItem on any InventoryItem, but it will only actually do something in our UpgradableItem.

We’re also going to flesh out CaptureState and RestoreState in our Upgradable item:

Additions to UpgradableEquipmentItem
        [System.Serializable]
        struct ModifierSaveData
        {
            public int stat;
            public float initialValue;
            public float increasePerLevel;
            public bool isPercentage;
            public int Level;
        }

        [System.Serializable]
        struct UpgradeSaveData
        {
            public int rarity;
            public int quality;
            public int itemLevel;
            public List<ModifierSaveData> saveData;
        }
        
        public override object CaptureState()
        {
            UpgradeSaveData state = new UpgradeSaveData
                                    {
                                        rarity = (int)rarity,
                                        quality = (int)quality,
                                        itemLevel = itemLevel
                                    };
            List<ModifierSaveData> data = new List<ModifierSaveData>();
            foreach (StatModifier actualStatModifier in actualStatModifiers)
            {
                ModifierSaveData item = new ModifierSaveData
                                        {
                                            stat = (int)actualStatModifier.Stat,
                                            initialValue = actualStatModifier.InitialValue,
                                            increasePerLevel = actualStatModifier.IncreasePerLevel,
                                            isPercentage = actualStatModifier.IsPercentage,
                                            Level = actualStatModifier.Level
                                        };
                data.Add(item);
            }
            state.saveData = data;
            return state;
        }

        public override void RestoreState(object state)
        {
            if (state is UpgradeSaveData upgradeSaveData)
            {
                rarity = (ItemRarity)upgradeSaveData.rarity;
                quality = (ItemQuality)upgradeSaveData.quality;
                itemLevel = upgradeSaveData.itemLevel;
                actualStatModifiers.Clear();
                foreach (ModifierSaveData saveData in upgradeSaveData.saveData)
                {
                    StatModifier modifier = new StatModifier();
                    modifier.Stat = (Stat)saveData.stat;
                    modifier.InitialValue = saveData.initialValue;
                    modifier.IncreasePerLevel = saveData.increasePerLevel;
                    modifier.IsPercentage = saveData.isPercentage;
                    modifier.Level = saveData.Level;
                    actualStatModifiers.Add(modifier);
                }
            }
        }

Note: It’s late, so I’m going to finish this section with an explanation of what I’ve done here tomorrow.

1 Like

There really should be somewhere these types of ‘extensions’ can go. There’s a wealth of information here that eventually just gets lost to time in the depths of the forum.

I’ve added the brians-tips-and-tricks tag to this post. I forgot to put it there when I made the first post.

Hey Brian!

While I have my own way of randomly assigning stats to items, that I could also “re-roll”, I wanted to give your system a try as well.
While I was looking over the the SetupNewItem method, I did not really understand how this would actually work.
I see that you hid the stat modifier level and rarity so that it couldn’t be manually inputted.
But also, I see that inside the upgradable item SO, we define the rarity and level of the item.
While we do nothing with the itemLevel, I see that we assign rarity and quality some values using the Generate method.
Then, inside the SetupNewItem method, after we assign the rarity and quality, we use the quality for the loop, but the rarity, we do nothing with it.
Then we create a primary modifier, which we randomly get from the ones available in the array and add it to the actual list.
Then in the for loop, we create a new one that is assigned from the secondary stats and in the foreach loop in the actual modifiers that exist, we check to see if the stats match and if they are percentage, and if they match, we stop. This means that if by chance one of the secondary stat is the same as a main stat, it will simply get out of the loop, but I fail to understand what the purpose was, as we only check but we do not assign nor add anything to any list.
Also, when i tested this, I only got items with 1 primary stat which had 0 as the stat value. But then I remembered that the stats itemRarity is basically common always? Because we change this nowhere, and also since it’s a strut you can’t change it by assigning the rarity you obtain, also, we never create a new modifier so we could at least give it the rarity?
Also in the Generate method, that working value can be way off what the actual rarity / quantity enum values are? If you setup an item for let’s say lvl 35, i guess that the working value will mostly always be over the enum values of those rarity and quality.

Maybe i’m too dumb to understand this, or I’m missing something, but from an initial test, it seems to not work as i would’ve expected it to, or maybe I’m not that lucky with the random rolls?

I just realized how the GenerateCharacteristic works now… but there are still issues remaining with the actual adding of the secondary stats, and also getting the stats to actually increase based on the itemLevel.

I went ahead and created another method to actually update the rarity and the item level of the stat, since we are not using the constructor to create a new stat.
After that In setup item i update each stat with the rarity and level we get, and now the item will have the stats based on the rarity and level.
Next the secondary stat issues, while we have only one actualStatModifier List, that is not really optimal for both our primary and secondary stat, so i went ahead and renamed the 1st list to primary and created a new secondary list. With this, I can no go in the do while loop and actually add the secondary stats to the list.
Now it seems to work, but i still need to tweak it a bit as you can get the same stat even 2-3-4 times. But that wouldn’t be that bad if you are looking to stack on that specific stat.
Here is a test image of one of the items, as you can see it was Perfect, lol and had 3 rolls of strength on it! My warrior would love to get this.

image

Oooh, that would be a nice DPS armor, wouldn’t it!
I made a minor misstep in the code, I realize now… When we add a stat, it needs to be removed from the list of possible upgrades…
I’ll work up adjustments for that, as to conform to Summoners’s War/Raid: Shadow Legends/Dislyte, each new secondary stat should be unique until you reach 4 secondary stats, then each upgrade should simply boost one of the four secondary stats.

I went ahead and made a separate method that returns a stat. It basically adds the rolled stat to a list, and if the stat already exists, it searches for a new one.
One thing i don’t understand though is what is the purpose of the Do While loop there? It looks like it’s checking each of the exiting actual stats against the secondary stat. But what if you will have no secondary stat that is already present in the primary potential ones? Also, checking a list that is also changing at the same time, wouldn’t that possibly throw an error at some point?

If i remove that and just do the for loop for the quantity and then i just get the random rolled stat and add it to the list, it works.
This way you could either use the same single actualStat list or use two different ones like i do, for purposes of being able to differentiate which is the primary and which is the secondary, without having to add an extra bool in the struct.

Here is another thing that i can’t seem to figure out where that is done in the code, since like i mentioned, the secondary stats never get added to the list and also, the rarity and item level is never passed to the stat so in the end those will always be the default, like 1 and 1.

I got side tracked over the last few weeks and haven’t had a chance to do the setup, which will actually be in the RandomDropper class. At the point that the item is dropped, that’s when we assign it’s rarity, quality, level, and primary/secondary stiats.

Yes, I just realized that I wanted to test this and that’s why I was looking for the setup in there. But I got it working and it’s nice.
I could easily tie those item in to my nicely robust crafting system which I will extent to also have an upgrade feature.

Where did you get a crafting system from? I’ve been wanting one for a while now

OK just curious. Maybe I didn’t follow up well, but what’s the point of this system? I mean the drop Library’s percentage dropper can take care of rarity perfectly well, right? (I’m probs gonna implement it anyway :stuck_out_tongue_winking_eye: )

This was actually something I was doing to try to match the item rarity/quality/level system found in games like Summoner’s War, Raid: Shadow Legends, and Dyslite. It’s mostly a starting point for students who want to use such a system.

I actually use a different system for procedural items, where the level is set when the enemy drops the item, and the modifiers are set randomly as well. This means you can create ONE Flaming Sword, and it’s level and modifiers will scale based on the level of the mob that dropped it.

Oh so basically the item statistics build up as the players’ level increases? Interesting. I think I know how I can benefit from such a system :slight_smile:

Yes, but the item itself doesn’t get better as the player increases. If I find a sword dropped by a level one Kobold, by the time I get to level 10, I really want to find a sword dropped by a level 10 Orc, as the level 1 sword will still be a level 1 sword. :slight_smile:

Wait, I thought the additive modifiers make the weapon improve as our levels increase? At least that’s what the code seems to reflect for part 1 (I’ll code part 2 when I’m home)

OK so part of this lecture is this second part here:

if(item is UpgradableEquipmentItem upgradable)
{
    var instance = Instantiate(upgradable);
    instance.SetupNewItem(level);
    return instance;
} else return item;

Where do we place this block of code? A bit unclear on that

This system improves as you spend some sort of resource to level it up. My other system (I need to publish it at some point), the item gets it’s bonuses on creation and those are them.

It would go in RandomDropper, when an item is dropped.

Unfortunately, it appears I never finished this tutorial.

Privacy & Terms