Search Unity

  1. Welcome to the Unity Forums! Please take the time to read our Code of Conduct to familiarize yourself with the forum rules and how to post constructively.
  2. We have updated the language to the Editor Terms based on feedback from our employees and community. Learn more.
    Dismiss Notice
  3. Join us on November 16th, 2023, between 1 pm and 9 pm CET for Ask the Experts Online on Discord and on Unity Discussions.
    Dismiss Notice
  4. Dismiss Notice

About class and scriptableObject structures for items

Discussion in 'Scripting' started by On-Stand-By, Sep 6, 2021.

  1. On-Stand-By

    On-Stand-By

    Joined:
    Sep 30, 2017
    Posts:
    14
    Hello !

    I'm working on a "simple" management game and I stumble on a problem when reorganizing the item structure of my scriptableObjects which looks like this :

    As you can see, Meal appears two times in the hierarchy because it's food, but also a craftable.

    Food is a type of item that will rot after a certain time
    Craftable is an item that was made from other items and gives a recipe.

    But a Meal is both of those items ! But every craftable aren't meals, and every food aren't craftable.
    I spent a few hours looking for solutions to this problem as I don't want to have duplicate code in my classes or unused properties in my scriptableObjects, but didn't found a satisfying answer.

    Maybe I don't even know what I'm looking for or I'm too tired to see the obvious solution and a good night of sleep would work better.

    But still I wanted to ask if it looks like I'm going in the right direction or if the structure should be remade.

    Thank you in advance ! :)

    (Sorry if I'm in the wrong section, but it looks like a inheritance misunderstanding to me ^^')
     
    Last edited: Sep 6, 2021
  2. StarManta

    StarManta

    Joined:
    Oct 23, 2006
    Posts:
    8,742
    You probably want to use interfaces. An interface is basically the outline of a class, defining the ins and outs, but no data or actual functionality. Importantly for this, you can inherit as many interfaces as you please.

    So "Craftable" should probably be "ICraftable" with the relevant functions defined there:
    Code (csharp):
    1. public interface ICraftable {
    2. public Item[] GetPossibleCraftingResults(); // or whatever
    3. }
    and then implement the interface
    Code (csharp):
    1. public class Meal : Food, ICraftable {
    2. public Item[] GetPossibleCraftingResults() {
    3. ...
    4. }
    5. }
     
  3. On-Stand-By

    On-Stand-By

    Joined:
    Sep 30, 2017
    Posts:
    14
    I thought about interfaces but I put it aside (too quickly) because it would have required to move the recipe array directly to Item class, which isn't a big deal with a second thought at it.

    Thank you very much for your quick reply !

    As I just said : I'm too tired to see the obvious solution and a good night of sleep would work better.
     
  4. _geo__

    _geo__

    Joined:
    Feb 26, 2014
    Posts:
    1,115
    As StarManta suggest you can use Interfaces.

    But maybe an abstracted look will help: In C# you can only inherit from one base class (I assume your graph means inheritance). Therefore you can only build a tree like structure which grows in one direction and has no cross overs. Thus, unless you change your mechanism of "composition" to something else (Interface inheritance, composition without inheritance, ...), you will always run into this problem. So by ditching interfaces you actually guaranteed yourself a headache (unless your data actually is a none crossing tree, which turns out it is not).

    You have two choices. Accept you can't have cross-over data/logic (very inflexible) or use another method of composition. I would avoid inheritance for as long as possible. It's just not flexible enough. I hope this helped, it's very late for me too, read ya tomorrow.
     
  5. On-Stand-By

    On-Stand-By

    Joined:
    Sep 30, 2017
    Posts:
    14
    I use scriptableObjects to store any base data about items. The item class is the representation of those items, it has a ref to a scriptableObject but also have some properties specific to the instance of this class, like its quality grade or decay state for the Food subclass.
    This solution isn't very flexible but simple to manage, an item is only a single and simple class instance combined to a scriptableObject.

    I don't think there will be more complex cases than a meal that are food and craftable and interfaces seems a good solution for this problem. (Unless I decide to create some edible weapons, which is an interesting idea)

    I made a class diagram (without methods) of the Item structure in my game, if it makes more understandable than my explanation.

    I prefix all scriptableObjects with s_ to differentiate them from classes but I wonder if I should use S instead.
     
    Last edited: Sep 7, 2021
  6. _geo__

    _geo__

    Joined:
    Feb 26, 2014
    Posts:
    1,115
    I have made a little example concerning what I meant with preferring composition over inheritance.

    You can just import it into any project (it's all in one "_ItemExperiments" folder, start with the "Main" object in the scene). In the example your ICraftable, Food, Meal, Armor, Weapon would all be just equal Traits in a list which you can combine freely. Maybe it will give you some ideas how you could handle your issue.
     

    Attached Files:

  7. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    5,917
    I recently completely converted how my items work in my game by going for a component structure over a mix of inheritance/interfaces.

    This issue I was having was that for every different type of item (even slightly different items), it was a new class that had to be wired together with lots of repeated stuff that couldn't really be avoided. It was getting cumbersome and tiring to work with before long.

    Instead just having a list of components that I could freely add to a list, and look for with my own 'TryGetItemComponent' methods, has turned out to be absurdly more flexible and easier to work with.

    Of course I'm only able to do this because of Plugins like OdinInspector being able to expose polymorphic lists using SerializeReference, but I think any sort of item/inventory system is going to need some editor work behind it, and this is a method that's no doubt worth looking at.
     
  8. On-Stand-By

    On-Stand-By

    Joined:
    Sep 30, 2017
    Posts:
    14
    It took me some time to understand how the whole system worked (plus some time seeing friend and painting miniatures), I'm sorry for replying this late.

    I feel like your solution is a bit too complicated although it's very flexible. Having 4 class per trait and 2 scriptableObjects per item is a bit too much for me. Maybe I'm just not used enough to interface to keep everything in mind. I'll try to convert my current system to avoid inheritance and focus on interfaces and I'll let you know something good came out of it or if I ended hitting my head against the wall endlessly :3

    Edit : By the way thank you very much ! I don't know why I though you couldn't make a list of interfaces, which makes thing waaay more easier ! :D

    Yup Unity doesn't like when thing gets too customized
     
    Last edited: Sep 24, 2021
  9. _geo__

    _geo__

    Joined:
    Feb 26, 2014
    Posts:
    1,115
    Yes, I admit at first it looks a little confusing (it is due to SOs an the whole "template vs runtime data" concept I added). I think I should have explained it instead of just dumping the code on you. You do buy the flexibility with more setup work (i.e. setting up the components before combining them). But the more components you have the less setup work you need to do and the fun part (composition) will take over.

    Most of the "Data" scripts are there to marry default Unity SOs with Interfaces and separate your template data from your object data. If you define and load your data in code (from a json maybe) then you could reduce it a lot.

    So, rather late, here is some documentation on the example (plus an updated version as .zip).

    Let's get a sneak peek at the result of all of this (the API you would end up with by using this system):

    Main:
    Code (CSharp):
    1.  
    2. public class Main : MonoBehaviour
    3. {
    4.     /// <summary>
    5.     /// Reference to ScriptableObject with all item templates.
    6.     /// </summary>
    7.     public ItemTemplateList ItemTemplates;
    8.  
    9.     public void Start()
    10.     {
    11.         var itemFactory = new ItemFactory(ItemTemplates);
    12.  
    13.         // index 0 is the cheese
    14.         // You can add your own means of selection instead but I wanted to keep it simple.
    15.         var cheese = itemFactory.CreateItem(0);
    16.         var bananas = itemFactory.CreateItem(1);
    17.  
    18.         // is the cheese consumeable?
    19.         if (cheese.HasTrait<IConsumeable>())
    20.         {
    21.             var cheeseConsumeable = cheese.GetTrait<IConsumeable>();
    22.             Debug.Log("Cheese amount: " + cheeseConsumeable.AmountLeft());
    23.             Debug.Log("Ate " + cheeseConsumeable.Consume(1) + " cheese.");
    24.             Debug.Log("Cheese left: " + cheeseConsumeable.AmountLeft());
    25.         }
    26.  
    27.         if (bananas.HasTrait<IConsumeable>())
    28.         {
    29.             var bananasConsumeable = bananas.GetTrait<IConsumeable>();
    30.             Debug.Log("Bananas amount: " + bananasConsumeable.AmountLeft());
    31.             Debug.Log("Ate " + bananasConsumeable.Consume(2) + " bananas.");
    32.             Debug.Log("Bananas left: " + bananasConsumeable.AmountLeft());
    33.         }
    34.     }
    35. }
    Main uses an ItemTemplatesList (a list of ScriptableObjects) and an ItemFactory (instantiates items by handing them a list of trait data).

    Before we get into the meat of the code here is an overview of how the logic and the data is structured:
    TraitsData.png
    NOTICE: TraitTemplates can be reused in many items. That's the composition we are after.

    TraitsLogic.png
    NOTICE: TraitConsumeableData exists on both sides, the ScriptableObject templates and (as a copy) in runtime Items.

    Item Factory:
    It needs item template data to work (surprise). Luckily our Main object is nice enough to hand it over. You can use dependency injection etc. for this but let's keep it simple here.
    Code (CSharp):
    1. public class ItemFactory
    2. {
    3.     /// <summary>
    4.     /// Reference to SO item template data.
    5.     /// </summary>
    6.     protected ItemTemplateList itemTemplates;
    7.  
    8.     public ItemFactory(ItemTemplateList itemTemplates)
    9.     {
    10.         this.itemTemplates = itemTemplates;
    11.     }
    12.  
    13.     /// <summary>
    14.     /// Creates an item by taking the ItemTemplate at the given index from itemTemplates list.
    15.     /// </summary>
    16.     /// <param name="index"></param>
    17.     /// <returns></returns>
    18.     public ITraitList CreateItem(int index)
    19.     {
    20.         return new Item(itemTemplates.Templates[index]);
    21.     }
    22. }
    Item:
    An item is nothing more but a list of runtime objects (list of ITrait). Each Trait is LOGIC plus DATA and both only exist at runtime (the DATA is copied from the ScriptableObject template by TraitListTemplate.InstantiateTraits()).
    Code (CSharp):
    1. public class Item : ITraitList
    2. {
    3.     public List<ITrait> Traits;
    4.  
    5.     public Item(TraitListTemplate traitListTemplate)
    6.     {
    7.         Traits = TraitListTemplate.InstantiateTraits(traitListTemplate);
    8.     }
    9.  
    10.     /// <summary>
    11.     /// Does the list of traits contain this trait?
    12.     /// </summary>
    13.     /// <typeparam name="T"></typeparam>
    14.     /// <returns></returns>
    15.     public bool HasTrait<T>() where T : class
    16.     {
    17.         return GetTrait<T>() != null;
    18.     }
    19.  
    20.     /// <summary>
    21.     /// Get the trait.<br />
    22.     /// Returns NULL if not found.
    23.     /// </summary>
    24.     /// <typeparam name="T"></typeparam>
    25.     /// <returns>The trait or NULL if not found.</returns>
    26.     public T GetTrait<T>() where T : class
    27.     {
    28.         foreach (var trait in Traits)
    29.         {
    30.             if (trait is T)
    31.             {
    32.                 return trait as T;
    33.             }
    34.         }
    35.         return null;
    36.     }
    37. }
    Let's look at a concrete Trait implementation.

    IConsumeable:
    Here is our interface for the consumeable Trait (the item can be consumed one or more times):
    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public interface IConsumeable
    4. {
    5.  
    6.     /// <summary>
    7.     /// Consumes it. May only consume N parts of many.<br>
    8.     /// Returns how much has been consumed.
    9.     /// </summary>
    10.     /// <param name="amount">How much to consume.</param>
    11.     /// <returns>Returns how much has been consumed. Can return 0.</returns>
    12.     int Consume(int amount);
    13.  
    14.     /// <summary>
    15.     /// Returns how often this can be consumed.
    16.     /// </summary>
    17.     /// <returns></returns>
    18.     int AmountLeft();
    19. }
    And here is the consumeable implementation. It is split into three parts. One data and one logic object for runtime use and one ScriptableObject as data template. This is the tedious setup part.

    TraitConsumeableData (notice this is not a ScriptableObject but it is serializable):
    Code (CSharp):
    1. [System.Serializable]
    2. public class TraitConsumeableData : ITraitData
    3. {
    4.     public int Amount = 10;
    5.     public int ReductionPerCosumption = 1;
    6.  
    7.     public TraitConsumeableData(int amount, int reductionPerCosumption)
    8.     {
    9.         Amount = amount;
    10.         ReductionPerCosumption = reductionPerCosumption;
    11.     }
    12.  
    13.     public T GetCopy<T>() where T : class, ITraitData
    14.     {
    15.         return new TraitConsumeableData(Amount, ReductionPerCosumption) as T;
    16.     }
    17. }
    18.  
    ITraitData is just there to have a common root and enforce a Copy method on all TraitDataTemplates. We don't want to accidentally modify our template data in our running game (important in the editor). Therefore we use this to make a copy and work with that.
    Code (CSharp):
    1.  
    2. public interface ITraitData
    3. {
    4.     T GetCopy<T>() where T : class, ITraitData;
    5. }
    6.  
    Consumeable (Logic):
    This whole Consumeable is just one of potentially many Traits to implement. This is the fun part where we implement our logic.
    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public class Consumeable : ITrait, IConsumeable
    4. {
    5.     public TraitConsumeableData Data;
    6.  
    7.     public Consumeable(TraitConsumeableData data)
    8.     {
    9.         this.Data = data;
    10.     }
    11.  
    12.     /// <summary>
    13.     /// <inheritdoc/>
    14.     /// </summary>
    15.     public int Consume(int amount)
    16.     {
    17.         int leftBefore = Data.Amount;
    18.         Data.Amount = Mathf.Max(0, Data.Amount - amount);
    19.         return leftBefore - Data.Amount;
    20.     }
    21.  
    22.     /// <summary>
    23.     /// <inheritdoc/>
    24.     /// </summary>
    25.     public int AmountLeft()
    26.     {
    27.         return Data.Amount;
    28.     }
    29.  
    30.     public ITraitData GetData()
    31.     {
    32.         return Data;
    33.     }
    34. }
    ITrait is again just a common root with one method for all traits. The method exists to get the data which can then be copied into the actual item.
    Code (CSharp):
    1.  
    2. public interface ITrait
    3. {
    4.     ITraitData GetData();
    5. }
    6.  
    We already know IConsumeable from above.

    TraitConsumeableTemplate:
    This is a ScriptableObject which wraps some data. We use it to store a TraitConsumeableData as template for our runtime Traits.
    Code (CSharp):
    1. [CreateAssetMenu(fileName = "Trait Consumeable", menuName = "ScriptableObjects/ItemTraits/Consumeable", order = 2)]
    2. public class TraitConsumeableTemplate : TraitTemplate
    3. {
    4.     [SerializeField]
    5.     protected TraitConsumeableData data;
    6.  
    7.     public override ITraitData GetData()
    8.     {
    9.         return data;
    10.     }
    11. }
    TraitListTemplate:
    Another noteworthy class is TraitListTemplate (also a ScriptableObject).
    The SOs part is just a list but the interesting thing are the utility methods. This is where we match the SOs with runtime types (data and logic).

    At some point we have to associate which runtime class matches the data. This is done "manually" in combineLogicWithData() which I consider very ugly as you would have to update this with every new Type of Trait you add. But it is simple enough to work in this example.

    Code (CSharp):
    1. [CreateAssetMenu(fileName = "ItemTraitList", menuName = "ScriptableObjects/Item", order = 1)]
    2. public class TraitListTemplate : ScriptableObject
    3. {
    4.     public List<TraitTemplate> Traits;
    5.  
    6.     /// <summary>
    7.     /// Instantiates the logic for reach trait template. Each instance gets a separate copy of the tempate data.
    8.     /// </summary>
    9.     /// <param name="traitListTemplate"></param>
    10.     /// <returns></returns>
    11.     public static List<ITrait> InstantiateTraits(TraitListTemplate traitListTemplate)
    12.     {
    13.         var traits = new List<ITrait>();
    14.         foreach (var traitTemplate in traitListTemplate.Traits)
    15.         {
    16.             traits.Add(combineLogicWithData(traitTemplate));
    17.         }
    18.         return traits;
    19.     }
    20.  
    21.     /// <summary>
    22.     /// Matches the logic to the template data and returns an instance.
    23.     /// </summary>
    24.     /// <param name="traitTemplate"></param>
    25.     /// <returns></returns>
    26.     protected static ITrait combineLogicWithData(ITrait traitTemplate)
    27.     {
    28.         // Now this is ugly but we only do it at instantiation
    29.         var templateData = traitTemplate.GetData();
    30.  
    31.         // Consumeable
    32.         if (templateData is TraitConsumeableData)
    33.         {
    34.             return new Consumeable(templateData.GetCopy<TraitConsumeableData>());
    35.         }
    36.  
    37.         // .. add for every new trait
    38.  
    39.         return null;
    40.     }
    41. }
    I hope this gives more insight into how the code was intended to work. I also renamed the Trait SOs as the names in the first zipped example are definitely confusing.
     

    Attached Files:

    Last edited: Sep 25, 2021
    ledshok likes this.