Saving/Loading Dynamic Scriptable Objects

Hello,

I am attempting to save/load inventory data that contains items as scriptable objects. I followed a video that works on a basic level, but with static SO items where the stats on a “Bronzesword of Strength” are predetermined and manually input into an Array in the editor. I can generate these, Save and load these. Here is where I have deviated and am trying to adjust the system to my needs.

I have implemented a D2/PoE style of item randomization that gives items stats based on the random Prefixes/Suffixes that are assigned at instantiation. This means that there are many, many more combinations of stats/items and no need to manually populate these in the inspector. But, I cannot seem to modify the existing Save/Load to work with these new SO items. Code below:

Existing Save/Load for Inventory:

private void SaveInventory(SaveData data)
    {
        List<SlotScript> slots = InventoryScript.instance.GetAllItems();
        foreach (SlotScript slot in slots)
        {
            data.MyInventoryData.MyItems.Add(new ItemData(slot.MyItem.MyTitle, slot.MyItems.Count, slot.MyIndex, slot.MyBag.MyBagIndex));
        }
    }

    private void LoadInventory(SaveData data)
    {
        foreach(ItemData itemData in data.MyInventoryData.MyItems)
        {
            Item item = Instantiate(Array.Find(items, x => x.MyTitle == itemData.MyTitle));
            //Item itemObj = Instantiate(itemList.Find(x => x.MyTitle == itemData.MyTitle)); //<---Attempt at fix
            for (int i = 0; i < itemData.MyStackCount; i++)
            {
                InventoryScript.instance.PlaceInSpecific(item, itemData.MySlotIndex, itemData.MyBagIndex);
                //InventoryScript.instance.PlaceInSpecific(itemObj, itemData.MySlotIndex, itemData.MyBagIndex); //<---Attempt at fix
            }
        }
    }

The collection called items is the Array that works for the old system. I have tried a multitude of things but the one that seems to be the easiest lift and make sense (in my brain) is changing the array of items to a List and then Add/Remove from the list as they get collected/dropped or Saved/Loaded.

I don’t know how to add these SO items to the List (if it is possible, of course).

Here is my InventoryData class:

[Serializable]
public class InventoryData
{
    public List<BagData> MyBags { get; set; }
    public List<ItemData> MyItems { get; set; }
    public InventoryData()
    {
        MyBags = new List<BagData>();
        MyItems = new List<ItemData>();
    }
}

So, am i really close but just not doing something properly? Or am i going down a complicated path and need to rework my entire save/load system or even getting my items away from SO?

I would be happy to share more code or explain further if necessary. Appreciate any and all time spent trying to help me.

When I did items that had dynamic and sometimes mutable values, I found I needed to make a plain C# wrapper class for my items which held all the values that were added on top of the scriptable object item (name prefixes, etc), or could change (such as durability, etc).

This wrapper would be used in most places where items were used, instead of direct references to the item (the wrapper has a reference to the item nonetheless).

Then this extra data would be written out alongside the item’s unique ID. Then said extra data can be read from and applied to the runtime wrapper upon load.

This is all I can suggest as you haven’t told us how you’re generating these randomised items, nor have you told us how you’re serialising and de-serialising your save data.

Naturally if you’re doing something like Instantiating new scriptable objects, you will have to ‘regenerate’ them upon load. Hence my wrapper suggestion, as I’d personally prefer not to instantiate scriptable objects.

Thank you for the info.

I am saving via binary formatter:

public void SaveItems(SavedGame savedGame)
    {
        Debug.Log("Saving items at end of level");
        try
        {
            BinaryFormatter bf = new BinaryFormatter();

            FileStream file = File.Open(Application.persistentDataPath + "/" + savedGame.gameObject.name + ".dat", FileMode.Create);

            SaveData data = new SaveData();

            SaveEquipment(data);
            SaveBags(data);
            SaveInventory(data);

            bf.Serialize(file, data);
            file.Close();
            ShowSavedFiles(savedGame);
        }
        catch (System.Exception)
        {
            throw;
        }
    }

This is how i generate random equipment:

public Equipment CreateEquipment(int iLvl)
    {
        Equipment newEquipment;

        SelectPropertiesRandomly(iLvl);
        float rand = Random.Range(0, 1f);
        if (rand > 0.9)
        {
            newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
               property2, property3, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Ascended, SelectItemTier(iLvl));
        }
        else if (rand > .8)
        {
            newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
                property2, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Epic, SelectItemTier(iLvl));
        }
        else if (rand > .65)
        {
            newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1, SelectRandomBaseItem(iLvl)
                , Enums.ItemRarity.Rare, SelectItemTier(iLvl));
        }
        else if (rand > .5)
        {
            newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), SelectRandomBaseItem(iLvl)
                , Enums.ItemRarity.Magic, SelectItemTier(iLvl));
        }
        else if (rand > .35)
        {
            newEquipment = new Equipment(iLvl, SelectRandomSuffix(iLvl), SelectRandomBaseItem(iLvl), Enums.ItemRarity.Uncommon, SelectItemTier(iLvl));
        }
        else { newEquipment = new Equipment(iLvl, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Common, SelectItemTier(iLvl)); }

        newEquipment.itemLevel = iLvl;
        newEquipment.MyIcon = newEquipment.Sprite;
        newEquipment.MyPrice = (int)CalculateItemValue(newEquipment);
        return newEquipment;
    }

This is then called via a LootTable script:

public int itemLevel;
    [SerializeField] GenerateEquipment equipGen;
    public List<ItemDrop> MyDroppedItems { get; set; }

    private bool haveRolledLoot = false;

    public List<ItemDrop> GetLoot()
    {
            MyDroppedItems = new List<ItemDrop>();
            SelectRandomLootFromTable();
        LootWindow.instance.SetupLootWindow(MyDroppedItems);
        return MyDroppedItems;
    }

    private void SelectRandomLootFromTable()
    {
        MyDroppedItems.Add(new ItemDrop(equipGen.CreateEquipment(itemLevel), this)); //Adds an object of the type Equipment casted as a type of ItemDrop to the list of MyDroppedItems
        //haveRolledLoot = true;
    }

The Equipment type inherits from the Item type which is a scriptable object.

Hopefully this context helps a bit.

Can you explain this wrapper that holds the item’s values and how it would be used in place of the items? Most of this is new to me and I’m trying to wrap (ha) my head around doing this. Seriously, thank you for the info previously provided.

Well you’re doing a few things wrong then.

A: Don’t use Binary formatter. It’s unsafe, terrible, and old, and you can’t even debug the values you serialise out. Use JSON or other reputable serialisers instead, such as the Odin Serialiser.

B: You should not be making Scriptable Objects with constructors. You should be using ScriptableObject.CreateInstance<T>().

And it’s hard to tell if you are or not, but you also cannot serialise out scriptable objects or reference to them. You will always have to replace UnityEngine.Object references and data with plain C# data that can be serialised out. No getting around this.

And what I mean by wrappers can be pretty simple:

public class Item : ScriptableObject
{
    [SerializeField]
    private string _itemName;
   
    public string ItemName => _itemName;
}

[System.Serializable]
public class ItemWrapper
{
    [SerializeField]
    private Item _item;
   
    [SerializeField]
    private string _itemNamePrefix;
   
    [SerializeField]
    private string _itemNameSuffix;
   
    public string ItemName
    {
        get
        {
            string name string.Empty;
           
            if (!string.IsNullOrEmpty(_itemNamePrefix))
            {
                name += $"{_itemNamePrefix} ";
            }
           
            name += _item.ItemName;
           
            if (!string.IsNullOrEmpty(_itemNameSuffix))
            {
                name += $" {_itemNameSuffix}";
            }
           
            return name;
        }
    }
   
    public ItemWrapper(Item item, string prefix = null, string suffix = null)
    {
        _item = item;
        _itemNamePrefix = prefix;
        _itemNameSuffix = suffix;
    }
}

And in most cases you use your wrapper class instead of direct references to your item assets.

This is just a very basic, hard-coded example to how to do custom names without having to mutate an existing scriptable object asset.

My own implementations were a lot more sophisticated, using a mix of immutable modifiers (so references to other scriptable objects that defined modifiers for an item, and mutable values for stats like durability and similar, stored in a component based fashion (using SerializeReference).

Okay, I’m gonna try and chew on this a bit and redo my save/load away from BinaryFormatter. Last question (for a while), if I shouldn’t use constructors with ScriptableObjects, how can I use the CreateInstance you mentioned to replace something like this:

//Magic Rarity
    public Equipment(int iLvl, MyAffix prefix, MyAffix suffix, BaseItem baseItem, Enums.ItemRarity rarity, MyItemTier tier)
    {
        this.itemLevel = iLvl;
        this.prefix = prefix;
        this.suffix = suffix;
        this.baseItem = baseItem;
        this.itemIcon = baseItem.icon;
        this.itemSlot = baseItem.slot;
        this.itemTier = tier;
        this.MyItemRarity = rarity;
        this.prefix.attributes[0].value = (int)Random.Range(prefix.attributes[0].minValue, prefix.attributes[0].maxValue);
        this.suffix.attributes[0].value = (int)Random.Range(suffix.attributes[0].minValue, suffix.attributes[0].maxValue);
        hasPrefix = true; hasSuffix = true;
        this.MyTitle = Name;
    }

Just change it to a regular method.

1 Like

Okay, after some gnawing on the issue and trying to use your feedback, am i making any progress? I’m trying to understand wrappers better and have a much better idea of scriptable objects, but i am struggling to put the two together like you suggested.

I just copied the Wrapper example code you put and changed the name. Here is the constructor in the wrapper:

public EquipmentWrapper(Equipment equipment, string prefix = null, string suffix = null)
    {
        _equipment = equipment;
        _equipNamePrefix = prefix;
        _equipNameSuffix = suffix;
    }

I removed the constructors from my SO class and this is where i am getting confused.

Here is a basic function in the SO to set the values instead of using a constructor:

public void SetEquipmentValues(int iLvl, MyAffix prefix, MyAffix suffix, MyItemProperty property1, MyItemProperty property2,
        MyItemProperty property3, BaseItem baseItem, Enums.ItemRarity rarity, MyItemTier tier)
    {
        this.itemLevel = iLvl;
        this.prefix = prefix;
        this.suffix = suffix;
        this.property1 = property1;
        this.property2 = property2;
        this.property3 = property3;
        this.baseItem = baseItem;
        this.itemIcon = baseItem.icon;
        this.itemSlot = baseItem.slot;
        this.itemTier = tier;
        this.MyItemRarity = rarity;

        this.prefix.attributes[0].value = (int)Random.Range(prefix.attributes[0].minValue, prefix.attributes[0].maxValue);
        this.suffix.attributes[0].value = (int)Random.Range(suffix.attributes[0].minValue, suffix.attributes[0].maxValue);
        this.property1.attributes[0].value = (int)Random.Range(property1.attributes[0].minValue, property1.attributes[0].maxValue);
        this.property2.attributes[0].value = (int)Random.Range(property2.attributes[0].minValue, property2.attributes[0].maxValue);
        this.property3.attributes[0].value = (int)Random.Range(property3.attributes[0].minValue, property3.attributes[0].maxValue);
        hasPrefix = true; hasSuffix = true; hasProperty1 = true; hasProperty2 = true; hasProperty3 = true;
        //this.MyTitle = Name; <---The wrapper class currently handles the name, i guess?
    }

And where i create the equipment typically, I have tried this:

public Equipment CreateEquipment(int iLvl)
    {
        Equipment newEquipment;
        EquipmentWrapper ew;

        SelectPropertiesRandomly(iLvl);
        float rand = Random.Range(0, 1f);
        if (rand > 0.9)
        {
            //New way
            Equipment Instance = ScriptableObject.CreateInstance<Equipment>();
            Instance.SetEquipmentValues(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
               property2, property3, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Ascended, SelectItemTier(iLvl));

            //Other new way?
            ew = new EquipmentWrapper(Instance, "My Prefix", "My Suffix");
            Instance.MyTitle = ew.EquipmentName;
           

            //Old way
            newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
               property2, property3, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Ascended, SelectItemTier(iLvl));
        }

(the rest of that function is from above, none of it changed while making these changes)

So i think my issue is how does the wrapper class take on the values of the SO values (like from the function)? Do i do that in the CreateEquipment function (which is in a separate class called GenerateEquipment)? To summarize, I have the Equipment SO class, the EquipmentWrapper class (identical to your sample), and then the GenerateEquipment class which choses what tier, what prefix, the base item, etc.

Because of the major changes i have not been able to compile and am just trying to get the proper thought process down before i try to make all the eventual changes and compile this puppy. Am I any closer or am i further lost in the forest?

I am tired, frustrated, and posting this before nite nite time, just looking for a direction. Again, any assistance is appreciated. At least im learning lol (maybe?)

Note that ‘wrapper’ is a general C# term that can mean a few things.

In this instance, the point of the wrapper is to act as an interface/intermediary (<- this is the important part) between the immutable asset, and anything else on the outside. The wrapper is the mutable part. Any queries should be done through the wrapper, and it could/should have logic that ‘checks’ for any modifications stored inside itself, before returning a value that may or may not be different from the original value.

My example was just a very basic example in how to do one small part of the overall idea, not to be taken ‘as is’. How you engineer this is entirely up to the specifics of your project.

For example, in one of my projects - as mentioned - the wrappers contained lists of components. Any queries of the wrapper objects would generally walk through these components to check if any modified the result of the query.

Such as something like this for names:

[System.Serializable]
public class ItemWrapper
{
    [SerializeReference]
    private Item _item;
  
    [SerializeReference]  
    private List<ItemModificationComponentBase> _itemModifications = new();
  
    public string GetItemName()
    {
        string name = _item.Name;
      
        foreach(var modifier in _itemModifications)
        {
            if (modifier is INameModifier nameModifier)
            {
                name = nameModifier.ModifyName(name);
            }
        }
      
        return name;
    }
}

Again, just an incomplete example to help give you ideas. This kind of stuff requires a lot of engineering.

2 Likes

Its been a while. Work travel, house projects, life in general. Finally able to work on this again.

I believe i got the Wrapper class working. I used a very similar format that you shared:

public EquipmentWrapper(Equipment equipment, int iLvl, MyAffix prefix = null, MyAffix suffix = null, MyItemProperty prop1 = null, MyItemProperty prop2 = null,
        MyItemProperty prop3 = null, BaseItem baseItem = null, Enums.ItemRarity rarity = Enums.ItemRarity.Uncommon, MyItemTier tier = null, Sprite icon = null, string name = null)
    {
        _equipment = equipment;
        _itemLevel = iLvl;
        _prefix = prefix;
        _suffix = suffix;
        _property1 = prop1;
        _property2 = prop2;
        _property3 = prop3;
        _baseItem = baseItem;
        _rarity = rarity;
        _itemTier = tier;
        _itemIcon = icon;
        _itemName = name;
    }

Then where i previously instantiated the Equipment ScriptableObjects, I replaced with creating an Instance of that scriptable object as you recommended. I then use a function to assign the Instance’s equipment values to what is generated at item creation like before. Finally, i create a new EquipmentWrapper class and set its values to that of those generated from the previous portion of the function:

public Equipment CreateEquipment(int iLvl)
    {
        //Equipment newEquipment;
        EquipmentWrapper ew;
        Equipment Instance = ScriptableObject.CreateInstance<Equipment>();
        Enums.ItemRarity rarity = Enums.ItemRarity.Common;
        SelectPropertiesRandomly(iLvl);
        float rand = Random.Range(0, 1f);
        if (rand > 0.9)
        {
            //New way
            rarity = Enums.ItemRarity.Ascended;
            Instance.SetAscendedEquipmentValues(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
               property2, property3, SelectRandomBaseItem(iLvl), rarity, SelectItemTier(iLvl));
            //Old way
            //    newEquipment = new Equipment(iLvl, SelectRandomPrefix(iLvl), SelectRandomSuffix(iLvl), property1,
            //       property2, property3, SelectRandomBaseItem(iLvl), Enums.ItemRarity.Ascended, SelectItemTier(iLvl));
        }
        else if (rand > .8)
        {
               ......
        }

ew = new EquipmentWrapper(Instance, iLvl, Instance.prefix, Instance.suffix, Instance.property1,
               Instance.property2, Instance.property3, Instance.baseItem, rarity, Instance.itemTier, Instance.baseItem.icon);

        Instance.MyIcon = Instance.baseItem.icon;
        return Instance;
    }

With these changes, I am back to being able to create the items when they are supposed to drop/generate. So i THINK i got that part right.

Now, in a similar vein of the original post, how do i go about saving these? I have skimmed the Sirenix’s videos and i cannot find where to use the Save/Load. I did find the basic functionality in the documents (here), but its so simple I am not sure how to apply it to items with values. Do i save the EquipmentWrapper class itself or do i have to save the values within it? Again, i am very new to saving/loading and am struggling on how to get this part working.

Again, here is how i saved/loaded the inventory before:

private void SaveInventory(SaveData data)
    {
        List<SlotScript> slots = InventoryScript.instance.GetAllItems();
        foreach (SlotScript slot in slots)
        {
            data.MyInventoryData.MyItems.Add(new ItemData(slot.MyItem.MyTitle, slot.MyItems.Count, slot.MyIndex, slot.MyBag.MyBagIndex));
        }
    }

    private void LoadInventory(SaveData data)
    {
        foreach(ItemData itemData in data.MyInventoryData.MyItems)
        {
            Item item = Instantiate(Array.Find(items, x => x.MyTitle == itemData.MyTitle));
            //Item itemObj = Instantiate(itemList.Find(x => x.MyTitle == itemData.MyTitle)); //<---Attempt at fix
            for (int i = 0; i < itemData.MyStackCount; i++)
            {
                InventoryScript.instance.PlaceInSpecific(item, itemData.MySlotIndex, itemData.MyBagIndex);
                //InventoryScript.instance.PlaceInSpecific(itemObj, itemData.MySlotIndex, itemData.MyBagIndex); //<---Attempt at fix
            }
        }
    }

Here is my suuuper simple implementation for testing with Odin:

public void OdinTestSave(string filePath, SaveData data)
    {
        data.MyPlayerData.MyPlayerX = PlayerMoveModel.instance.transform.GetChild(0).position.x;
        //data.MyPlayerData.MyPlayerY = PlayerMoveModel.instance.transform.GetChild(0).position.y;
        byte[] bytes = SerializationUtility.SerializeValue(data.MyPlayerData.MyPlayerX, DataFormat.Binary);
        File.WriteAllBytes(filePath, bytes);
    }

    public void OdinTestLoad(string filePath, SaveData data)
    {
        if (!File.Exists(filePath)) return;

        byte[] bytes = File.ReadAllBytes(filePath);
        data.MyPlayerData = SerializationUtility.DeserializeValue<PlayerData>(bytes, DataFormat.Binary);
        PlayerMoveModel.instance.transform.GetChild(0).position = new Vector2(data.MyPlayerData.MyPlayerX, 12f);
    }

Obviously, I am testing on position but I am stuck as how to save these WrapperClasses. The byte array is new to me and I dont know if i have to go 1 by 1 saving each value or if i can just save the entire WrapperClass at once.

After i get this working, my thoughts going forward are: when i load, I again CreateInstance of the Equipment scriptable object, then Load the WrapperClass for that item, and do that for each item in the inventory.

Any additional help is amazing, your assistance already has been great.

P.S. I apparently bought the Odin Serializer years ago long before i really needed it so i took your advice and am trying it out.

Glad you’re making progress… I think I showed you this before Corny, but just in case… there’s a good discussion by Xarbrough too:

Load/Save steps:

An excellent discussion of loading/saving in Unity3D by Xarbrough:

Loading/Saving ScriptableObjects by a proxy identifier such as name:

^ ^ ^ ^ ^ We’re using this all over the place for our save games and it works perfectly; it even picks up the updated ScriptableObject (obviously) that might change shape and value over the development life of the game.

When loading, you can never re-create a MonoBehaviour or ScriptableObject instance directly from JSON. The reason is they are hybrid C# and native engine objects, and when the JSON package calls new to make one, it cannot make the native engine portion of the object.

Instead you must first create the MonoBehaviour using AddComponent() on a GameObject instance, or use ScriptableObject.CreateInstance() to make your SO, then use the appropriate JSON “populate object” call to fill in its public fields.

If you want to use PlayerPrefs to save your game, it’s always better to use a JSON-based wrapper such as this one I forked from a fellow named Brett M Johnson on github:

Do not use the binary formatter/serializer: it is insecure, it cannot be made secure, and it makes debugging very difficult, plus it actually will NOT prevent people from modifying your save data on their computers.

I think you’ve misunderstood me because the whole point of my approach is to not have to make instances of scriptable objects, just plain C# classes. The scriptable objects are just immutable blobs of data (assets in this case), while the wrapper class is the only thing that changes, handling all the dynamic factors of items.

It’s important to remember you cannot serialise Unity objects out, or references to them. Not even Odin gets around that. To that end the wrapper is often not going to be compatible with serialising as it likely has references to Unity objects.

In many cases I’ll make a serialisable surrogate, ergo, a data class that only stores plain data that is able to be serialised out by common serialisers, such as Newtonsoft.JSON or the Odin serialiser. When it comes time to save, you convert your runtime data in saveable data and write it out, and on load, you convert it from saveable data to runtime data.

With inventory systems, this means often writing out only the name or some unique ID of the item, and then using that to look up from a database/lookup system when loading the game.

I think you need to make a new test project and get this idea working in the smallest possible scale. I feel like you’re in over your head because there’s huge gaps in your knowledge that are hard for me to convey in forums that you need to fill in.