Creating Unique Items in Unity Using Scriptable Objects

I’ve been working on a project recently and have been using Scriptable Objects to create a unique and dynamic item system. However, I’ve run into a bit of a snag when it comes to managing item durability, specifically with instances of the same item in the inventory. I’m hoping some of you might have insights into solving this issue.

Here’s a bit about what I’ve done so far: I’ve implemented a loot system where items are instantiated based on drop rates. This part seems to be working fine; I loop over the number of items in the drop and instantiate the respective item objects. These items are then stored in the inventory.

The problem I’m facing arises when I try to handle the durability of these items, particularly when there are multiple instances of the same item in the inventory. For instance, let’s say I have 5 Hunting Bows in the inventory. If I equip one of them and use it, the durability should reduce for that specific equipped bow, not affect the durability of all the other bows in the inventory.

I’ve noticed that the way I’ve set up my system seems to be causing this issue. When I decrease the durability for the equipped bow, it’s affecting all the other instances of that item as well. Clearly, I need a way to make each item instance unique so that their properties, like durability, are independent of each other.

I’m wondering if any of you have encountered a similar situation or have insights into how to handle this effectively. Perhaps there’s a way to modify my approach to ensure that each item instance maintains its own properties and state. I’m also open to considering different techniques or design patterns that might work better for this scenario.

If you’ve successfully dealt with a situation like this or have any suggestions on how to make items unique while maintaining shared characteristics. The code below was my last try to a solution

 for (int i = 0; i < drop.number; i++)
{
    if (drop.item is WeaponConfig && i > 0)
    {
        LogInfo("Inside drop.item is WeaponConfig condition");
        
        var droppedItem = drop.item as WeaponConfig;
        if (droppedItem == null)
        {
            LogError("droppedItem is null. Skipping.");
            return;
        }

        LogInfo("Creating new item instance...");
        var item = ScriptableObject.CreateInstance<WeaponConfig>();
        var repairRequirements = ScriptableObject.CreateInstance<RepairRequirements>();

        LogInfo("Setting item properties...");
        item.ItemID = System.Guid.NewGuid().ToString();
        item.name = droppedItem.GetDisplayName();
        item.RepairRequirements = repairRequirements;
        item.Category = droppedItem.Category;
        item.ConsumableCategory = droppedItem.ConsumableCategory;
        item.Icon = droppedItem.Icon;
        item.Price = droppedItem.Price;
        item.Definition = droppedItem.Definition;
        item.Category = droppedItem.Category;

        // Set the repair requirements properties
        if (droppedItem.RepairRequirements == null)
        {
            LogError("droppedItem.RepairRequirements is null. Skipping.");
            return;
        }

        LogInfo("Setting repair requirements properties...");
        item.RepairRequirements.ItemRepairRequirements = droppedItem.RepairRequirements.ItemRepairRequirements;
        item.RepairRequirements.IsItemDamaged = droppedItem.RepairRequirements.IsItemDamaged;
        item.RepairRequirements.ItemDurability = droppedItem.RepairRequirements.ItemDurability;
        item.RepairRequirements.RepairAmount = droppedItem.RepairRequirements.RepairAmount;

        LogInfo("Adding item to inventory...");
        inventory.AddToFirstEmptySlot(item, 1);
    }
    else
    {
        LogInfo("Inside else condition");
        
        inventory.AddToFirstEmptySlot(drop.item, 1);
    }

}                                 

@Brian_Trotter

This may shock you, but Instantiating copies is MUCH simpler than this:

var item = Instantiate(droppedItem);

I’ll need to know more about your Durability setup before I can figure out why your inventory seems to be filled with originals… Quick question: Is this something that’s happening after a RestoreState()?

Let’s see your WeaponConfig.cs, and we’ll figure this one out.

WeaponConfig.cs

[CreateAssetMenu(fileName = "Weapon", menuName = "Inventory/Item/Weapons/Make New Weapon", order = 0)]
    public class WeaponConfig : EquipableItem 
    {
        [SerializeField] AttackDefinition definition;
        [SerializeField] EnergyConductivityRating weaponConductivityRating;
        [SerializeField] List<AmmoType> allowedAmmoTypes = new List<AmmoType>();
        [SerializeField][Range(1f,30f)] int weaponLevel = 1;
        [SerializeField] Weapon equippedPrefab = null;
        [SerializeField] Weapon unEquippedPrefab = null;
        [SerializeField] AnimatorOverrideController animatorOverride = null;
        [SerializeField] bool isDestroyedOnUnequip = false;
        [SerializeField] bool isRightHanded = true;
        [SerializeField] bool isAxe = false;
        [SerializeField] bool isBow = false;
        [SerializeField] bool isCrossBow = false;
        [SerializeField] bool isARCrossBow = false;
        [SerializeField] bool isSpear = false;

        const string weaponName = "Weapon";
        
        public EnergyConductivityRating WeaponConductivityRating { get => weaponConductivityRating; private set => weaponConductivityRating = value; }
        public List<AmmoType> AllowedAmmoTypes { get => allowedAmmoTypes; set => allowedAmmoTypes = value; }
        public AttackDefinition Definition { get => definition; set => definition = value; }
        public Weapon EquippedPrefab { get => equippedPrefab; set => equippedPrefab = value; }
        public Weapon UnEquippedPrefab { get => unEquippedPrefab; set => unEquippedPrefab = value; }
        public bool IsDestroyedOnUnequip { get => isDestroyedOnUnequip; set => isDestroyedOnUnequip = value; }
        public bool IsRightHanded { get => isRightHanded; set => isRightHanded = value; }
        public bool IsAxe { get => isAxe; set => isAxe = value; }
        public bool IsBow { get => isBow; set => isBow = value; }
        public bool IsCrossBow { get => isCrossBow; set => isCrossBow = value; }
        public bool IsARCrossBow { get => isARCrossBow; set => isARCrossBow = value; }
        public bool IsSpear { get => isSpear; set => isSpear = value; }

        public Weapon Spawn(Transform rightHand, Transform leftHand, Transform AxeSpawn, Transform spearSpawn,Transform bowSpawn,Transform crossbowSpawn,Transform arcrossbowSpawn, Animator animator)
        {
            DestroyOldWeapon(rightHand, leftHand);

            Transform unequipTransform = GetUnEquippedTransform(AxeSpawn, spearSpawn,bowSpawn,crossbowSpawn,arcrossbowSpawn);
        
            if (unequipTransform != null)
            {
                LogInfo($"Unequip transform: {unequipTransform}");
                DestroyOldWeapon(unequipTransform);
            }
            
            Weapon spawnedWeapon = null;
            if (GetEquippedPrefab() != null)
            {
                Transform handTransform = GetHandTransform(rightHand, leftHand);
                spawnedWeapon = Instantiate(GetEquippedPrefab(), handTransform);
                spawnedWeapon.gameObject.name = weaponName;
                LogInfo($"Spawned weapon: {spawnedWeapon.gameObject.name} at position: {spawnedWeapon.transform.position}");
            }

            AnimatorOverrideController overrideController = animator.runtimeAnimatorController as AnimatorOverrideController;
            if (animatorOverride != null)
            {
                animator.runtimeAnimatorController = animatorOverride;
                LogInfo($"Animator override set to: {animatorOverride.name}");
            }
            else if (overrideController != null)
            {
                animator.runtimeAnimatorController = overrideController.runtimeAnimatorController;
                LogInfo($"Animator override set to: {overrideController.runtimeAnimatorController.name}");
            }

            return spawnedWeapon;
        }

        public Weapon Spawn(Transform rightHand, Transform leftHand, Animator animator)
        {
            DestroyOldWeapon(rightHand, leftHand);

            Weapon spawnedWeapon = null;
            if (GetEquippedPrefab() != null)
            {
                Transform handTransform = GetHandTransform(rightHand, leftHand);
                spawnedWeapon = Instantiate(GetEquippedPrefab(), handTransform);
                spawnedWeapon.gameObject.name = weaponName;
                LogInfo($"Spawned weapon: {spawnedWeapon.gameObject.name} at position: {spawnedWeapon.transform.position}");
            }

            AnimatorOverrideController overrideController = animator.runtimeAnimatorController as AnimatorOverrideController;
            if (animatorOverride != null)
            {
                animator.runtimeAnimatorController = animatorOverride;
                LogInfo($"Animator override set to: {animatorOverride.name}");
            }
            else if (overrideController != null)
            {
                animator.runtimeAnimatorController = overrideController.runtimeAnimatorController;
                LogInfo($"Animator override set to: {overrideController.runtimeAnimatorController.name}");
            }

            return spawnedWeapon;
        }
        public Weapon UnEquipWeapon(Transform rightHand, Transform leftHand, Transform axeSpawn, Transform spearSpawn,Transform bowSpawn,Transform crossbowSpawn,Transform arcrossbowSpawn)
        {
            LogInfo("Starting to unequip weapon.");
            DestroyOldWeapon(rightHand, leftHand);
            
            Weapon weapon = null;
            Transform handTransform = GetUnEquippedTransform(axeSpawn, spearSpawn,bowSpawn,crossbowSpawn,arcrossbowSpawn);
            LogInfo($"Hand transform: {handTransform}");

            if (unEquippedPrefab != null && handTransform != null)
            {
                DestroyOldWeapon(handTransform);
                if(!isDestroyedOnUnequip)
                {
                    weapon = Instantiate(unEquippedPrefab, handTransform);
                    weapon.gameObject.name = weaponName;
                    LogInfo($"UnEquipped weapon instantiated: {weapon.gameObject.name}");
                }
               
            }

            LogInfo("Finished unequipping weapon.");
            return weapon;
        }

        private void DestroyOldWeapon(Transform hand)
        {
            Transform oldWeapon = hand.Find(weaponName);
            if (oldWeapon == null) {
                LogInfo("No old weapon found on " + hand.name);
                return;
            }

            oldWeapon.name = "DESTROYING";
            Destroy(oldWeapon.gameObject);
            LogInfo("Old weapon destroyed from " + hand.name);
        }

        private void DestroyOldWeapon(Transform rightHand, Transform leftHand)
        {
            Transform oldWeapon = rightHand.Find(weaponName);
            if (oldWeapon == null)
            {
                oldWeapon = leftHand.Find(weaponName);
            }

            if (oldWeapon == null) 
            {
                LogWarning("No old weapon found on either hand");
                return;
            }

            oldWeapon.name = "DESTROYING";
            Destroy(oldWeapon.gameObject);
            LogInfo("Old weapon destroyed from " + oldWeapon.parent.name);
        }

        /// <summary>
        /// Returns the transform for the unequipped weapon based on the current weapon type.
        /// </summary>
        /// <param name="axeSpawn">Transform of the axe spawn location.</param>
        /// <param name="spearSpawn">Transform of the spear spawn location.</param>
        /// <param name="bowSpawn">Transform of the bow spawn location.</param>
        /// <param name="crossbowSpawn">Transform of the crossbow spawn location.</param>
        /// <param name="arcrossbowSpawn">Transform of the arcrossbow spawn location.</param>
        /// <returns>The transform of the unequipped weapon's spawn location.</returns>
        private Transform GetUnEquippedTransform(Transform axeSpawn, Transform spearSpawn, Transform bowSpawn, Transform crossbowSpawn, Transform arcrossbowSpawn)
        {
            Transform unequipLocation;

            if (isAxe)
            {
                unequipLocation = axeSpawn;
            }
            else if (isBow)
            {
                unequipLocation = bowSpawn;
            }
            else if (isCrossBow)
            {
                unequipLocation = crossbowSpawn;
            }
            else if (isARCrossBow)
            {
                unequipLocation = arcrossbowSpawn;
            }
            else if(isSpear)
            {
                unequipLocation = spearSpawn;
            }
            else
            {
               unequipLocation = null;
            }

            LogInfo($"GetUnEquippedTransform: Unequipped location is {unequipLocation}");

            return unequipLocation;
        }

        private Transform GetHandTransform(Transform rightHand, Transform leftHand)
        {
            try 
            {
                Transform handTransform = isRightHanded ? rightHand : leftHand;
                LogInfo("Successfully obtained hand transform.");
                return handTransform;
            }
            catch (Exception e)
            {
                LogError($"Failed to obtain hand transform: {e}");
                return null;
            }
        }

        public ProjectileConfig GetProjectileConfig()
        {
            return  definition.GetProjectileConfig();
        }

        public List<DamageCharacteristics> GetCharacteristics()
        {
            return GetDefinition().GetCharacteristics();
        }

        public IEnumerable<DamageCharacteristics> DamageCharacteristics
        {
            get
            {
                if (GetDefinition().GetCharacteristics().Count == 0)
                {
                    yield return new DamageCharacteristics();
                }
                else
                {
                    foreach (var Characteristic in GetDefinition().GetCharacteristics())
                    {
                        yield return Characteristic;
                    }
                }
            }
        }

       

        public List<AmmoType> GetAllowedAmmoTypes()
        {
            return allowedAmmoTypes;
        }

        public Weapon GetEquippedPrefab()
        {
            return equippedPrefab;
        }

        public Transform GetProjectileLaunchPoint()
        {
           return GetEquippedPrefab().GetComponent<ProjectileLaunchPoint>().GetProjectileFirePoint();
        }

        public int GetWeaponLevel()
        {
            return weaponLevel;
        }

        public AttackDefinition GetDefinition()
        {
            return definition;
        }
    }
}

Not exactly, Mr. Brian. This occurs right when the game is launched. The loot chest populates its inventory upon awake, and then the player receives items from the loot chest.

RepairRequirements.cs
[CreateAssetMenu(fileName = "RepairRequirements", menuName = "Inventory/Item Repair/RepairRequirements", order = 0)]
public class RepairRequirements : ScriptableObject 
{
    [SerializeField] List<ResourceCost> itemRepairRequirements = new();
    [SerializeField] float repairAmount = 0;
    [SerializeField] float itemDurability = 100f;
    [SerializeField][Range(0f,400f)] float itemRepairCost = 200f;
    [SerializeField] bool isItemDamaged = false;
    
    public List<ResourceCost> ItemRepairRequirements { get => itemRepairRequirements; set => itemRepairRequirements = value; }
    public float RepairAmount { get => repairAmount; set => repairAmount = value; }
    public float ItemDurability { get => itemDurability; set => itemDurability = value; }
    public bool IsItemDamaged { get => isItemDamaged; set => isItemDamaged = value; }
}

I can’t find the RepairRequirements field within the WeaponConfig.cs… is it part of EquipableItem?

When an item is damaged, are you damaging an instance variable of the EquipableItem or debiting the durability directly in the RepairRequirements?

RepairRequirements is part of the InventoryItem class and i am reducing the durability using the specific items repairRequirement for example

float currentItemDurability = item.RepairRequirements.ItemDurability;
currentItemDurability -= reductionAmount;
currentItemDurability = Mathf.Clamp(currentItemDurability, 0.0f, itemMaxDurability);

item.RepairRequirements.ItemDurability = currentItemDurability;

LogInfo($"Reduced Item Durability to: {currentItemDurability}");

I see what’s going on. Because it’s a ScriptableObject, and you’re using a reference to that ScriptableObject, the same rules apply as to the InventoryItem. A change to one is a change to all. You’ll also need to instantiate the RepairRequirements…
Something like

[SerializeField] RepairRequirements repairRequirementsTemplate;
RepairRequirements repairRequirements;

/// Call this when you drop the item, as in item.SetupRepairRequirements(); (one line does it all, not four)
public void SetupRepairRequirements()
{
    if(repairRequirementsTemplate==null) return;
    repairRequirements = Instantiate(repairRequirementsTemplate);
}

So you are saying that , I must include that SetupRepairRequirements() in the inventory item.cs and when I instantiate the item in the loot class I must also call the method on the item that is instantiated?

Yes, that’s correct.

Mr Brian, I will give it a try and will inform you on the result once compilation and testing is complete. And on the side note, have I improved in programming from 2 years ago that is if you can remember me.

Mr Brian, it worked. If you don’t mind would explain why you thought, it would work? Nested Instantiation

It has to do with reference types verses value types.

A value type is something like an int, a string, a float, or a struct. These represent the actual value, and when they are passed around, the value is passed, not the reference. Here’s a hypothetical method to demonstrate what I mean…

int MyInt=1;

void Update()
{
    Debug.Log($"Update calling Increment({MyInt}");
    Increment(MyInt);
    Debug.Log($"After calling Increment MyInt = {MyInt}");
}
void Increment(int intToIncrement)
{
    intToIncrement++;
    Debug.Log($"Incremented to {intToIncrement}");
}

Because we are passing an integer, the actual value is passed to Increment, not a reference to the value. Our result will be a spam of the following Logs:

Update calling Increment(1)
Incremented to 2
After calling Increment Myint = 1

This is because MyInt is never touched by increment, only the intToIncrement is touch, but it is a copy of the original, so changes don’t propagate back to the original.

Reference values are things like ScriptableObjects, MonoBehaviours, Lists, Dictionaries, and basically any class (not structs, those are value types).

So let’s modify our previous example, but we’re going to pass a ScriptableObject which has a public int as an exposed value.

MyScriptableObject so;

void Start()
{
    so.MyInt = 1;
}

void Update()
{
    Debug.Log($"Before calling Increment, so.MyInt = {so.MyInt}");
    Increment(so); //this time we're passing the ScriptableObject
    Debug.Log($"After calling Increment, so.MyInt = {so.MyInt}");
}

void Increment(MyScriptableObject other)
{
   other.MyInt++;
   Debug.Log($"Increment other.MyInt is now {other.MyInt}");
}

This time, since we’re passing a reference value, what we’re actually passing to Increment is a reference to the very same ScriptableObject. MyInt is not copied becasue we’re just following the pointer to MyInt. In this case, MyInt is the same object in both Update and Increment.
Your Console will now spam you with MyInt rapidly gaining frame over frame.

This why I was fairly certain that this was your problem with the RepairRequirements. Without instantiation, every copy of the WeaponConfig was sharing the exact same reference object, meaning that every time one decremented the ItemDurability, it was decremented by all. By instantiating the RepairRequirements when we instantiate the WeaponConfig, we ensure that it’s working on it’s own private copy of the requirements.

1 Like

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms