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.

Discussion Using ScriptableObjects for stats with different, but also shared stats.

Discussion in 'Scripting' started by Buddy24, Sep 8, 2023.

  1. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    I am trying to use scriptable objects as a way to easily edit stats of units, for balancing purposes but also to have them all in one place, easily accessible. For that I have created the following system:

    Interfaces that hold one stat (potentially could hold more stats, if they were a set couple, like min and max range). These don't have to be integers, its just the first 3 I thought of.

    public interface IMaxHealthStat {
    public int MaxHealth { get; }
    }

    public interface IAttackDamageStat {
    public int AttackDamage { get; }
    }

    public interface IMovementSpeedStat {
    public int MovementSpeed { get; }
    }


    Which force a ScriptableObject to provide that stat:

    public class CustomStatsOne : ScriptableObject, IMaxHealthStat, IAttackDamageStat, IMovementSpeedStat {
    [SerializeField]
    int maxHealth;
    public int MaxHealth => maxHealth;

    [SerializeField]
    int attackDamage;
    public int AttackDamage => attackDamage;

    [SerializeField]
    int movementSpeed;
    public int MovementSpeed => movementSpeed;
    }

    public class CustomStatsTwo : ScriptableObject, IMaxHealthStat, IAttackDamageStat {
    [SerializeField]
    int maxHealth;
    public int MaxHealth => maxHealth;

    [SerializeField]
    int attackDamage;
    public int AttackDamage => attackDamage;
    }


    Which is then held in a Stats component, which components like HealthSystem can access independently and cast the stats to the interface they need.

    I would have liked to just use the Properties instead of adding the fields, but those can't be Serialized fields. However this approach is quite tedious in regards to creating new Units, so I can't help but wonder if there's a better approach to creating these flexible component based ScriptableObjects for holding stats. Since stats will be mixed between Units, I can't just use classic inheritance, and opted for the interfaces.
     
  2. CodeRonnie

    CodeRonnie

    Joined:
    Oct 2, 2015
    Posts:
    362
    I don't understand what you mean by that. Could you elaborate on why your scriptable objects for stats cannot use standard inheritance to suit your needs?

    It seems like you could end up with an object that implements a lot of interfaces that way. In any case you're going to have to make separate concrete classes for each type of stats object. Why not have the common stats that are shared by all or a major subset of units in a base class? Are there really so many differences in which stats a unit might contain? If so, you're going to have to then code every single permutation of different combinations of stats into separate class implementations with a long list of implemented interfaces. It seems like you're certainly not going to save yourself any work on the programming side. For example, Unit X implements an entirely different permutation of stats, now you have to go and code a brand new scriptable object that has the exact list of stats that Unit X uses, with all of those interfaces with the implemented property and serialized field.

    If you really want to avoid inheritance entirely you could serialize a single type of collection of stats and their values on each unit. However, that opens up a lot of new issues. You need a good way to serialize a variable collection of properties with particular IDs and values of various types. A property value type could be a float, or an int, or a bool. Abstracting this all the way out, you're basically writing your own configuration property serialization library. What happens if a type does not even implement a property that your code expects? What are the default values? This kind of solution starts to abstract away from even the benefits of using a language with strong typing. That's kind of why I ask, are you sure you can't just design your classes to have common stats grouped by class? Surely the logic of your game at some point requires all enemy units to work a certain way, and all player units to work a certain way. If the logic is expecting certain properties, then why not just have a class that contains all of the expected properties? You could split your scriptable objects up by common stats, player only stats, enemy only stats, etc.
     
  3. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    I did consider doing it your way, but then I run into the standard issues of inheritance and that's why I try to go for composition instead. Lets say the Health, Attack and movement speed stat are shared by every unit. I have towers, that don't have movement speed. Or I add a tank type that doesnt attack. or I add a unit that can't be killed, because its timed or because its a permanent damage source like a trap maybe.

    I do plan on grouping stuff and using inheritance over this system. I can have a base unit stat ScriptableObject, that has all those, I can use for basic units, and I can inherit from that and add stuff. But I do like to keep it in building blocks below that, so in cases like I mentioned above I can stay flexible.

    I could also do this:

    public interface IBaseUnitStats : IMaxHealthStat, IAttackDamageStat, IMovementSpeedStat { }

    public class CustomStatsThree : ScriptableObject, IBaseUnitStats {
    [SerializeField]
    int maxHealth;
    public int MaxHealth => maxHealth;

    [SerializeField]
    int attackDamage;
    public int AttackDamage => attackDamage;

    [SerializeField]
    int movementSpeed;
    public int MovementSpeed => movementSpeed;
    }

    But that doesnt save me any work, so I probably wont.

    I guess what im hoping for from this post is an idea how to save myself the weird property/field workaround. I am pretty happy with the idea of those interfaces.
     
  4. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    I did just now realize, that by putting the Interfaces on the scriptable object I won’t be able to cast the stat component into them for other components to access them.

    This means I can’t use the RequireComponent tag for the health system to guarantee that a maxHealth stat will be available in the stat component. I confused the actual stat component and scriptable object as one. Not sure this is still the best solution then, but I don’t really see another that’s as modular as this.
     
  5. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    You mean you run into the standard problem of not understanding how to use inheritance, gotcha!

    In many of my projects, I not only inherit everything, I have everything inherited from one class. And I have no issues at all with separating towers from troops, or all 30 different types of troops from each other, while only ever one function declared for each particular in the main parent class.

    But true, it is all on how you setup the classes, as I think using virtual voids do "force you" regardless in sub-classes. So I don't use any abstraction, I simply have any and all functions or variables within the appropriate type of parent class, and only the child class of that, calls the particular methods it needs. So to be fair, it's all on how you use it. :)

    But if you feel more comfortable with using interfaces, or even some other way, then do what makes you comfortable. As there are many ways to do the same thing. But I just had to correct that common misconception that inheritance is flawed in that regard, as I have no issues with it. :cool:
     
  6. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,248
    This feels like a case where'd you'd actually want to compose stats in a collection, pretty much in the same manner that we compose components in a game object.

    Something like this:
    Code (CSharp):
    1. public interface IStat { }
    2.  
    3. [System.Serializable]
    4. public sealed class MaxHealthStat : IStat
    5. {
    6.     [SerializeField]
    7.     private int _maxHealth;
    8.  
    9.     public int MaxHealth => _maxHealth;
    10. }
    11.  
    12. [System.Serializable]
    13. public sealed class AttackDamageStat : IStat
    14. {
    15.     [SerializeField]
    16.     private int _attackDamage;
    17.  
    18.     public int AttackDamage => _attackDamage;
    19. }
    20.  
    21. [System.Serializable]
    22. public sealed class StatsContainer
    23. {
    24.     [SerializeReference]
    25.     private List<IStat> _stats = new();
    26.  
    27.     public T GetStat<T>() when T : IStat
    28.     {
    29.         int count = _stats.Count;
    30.         for (int i = 0; i < count; i++)
    31.         {
    32.             var stat = _stats[i];
    33.             if (stat is T cast)
    34.             {
    35.                 return cast;
    36.             }
    37.         }
    38.      
    39.         return null;
    40.     }
    41.  
    42.     public bool TryGetStat(out T stat) when T : IStat
    43.     {
    44.         stat = this.GetStat<T>();
    45.         return stat != null;
    46.     }
    47. }
    48.  
    49. [CreateAssetMenu]
    50. public sealed class StatContainerAsset : ScriptableObject
    51. {
    52.     [SerializeField]
    53.     private StatsContainer _statcontainer = new();
    54.  
    55.     public StatsContainer StatContainer => _statcontainer;
    56. }
    SerializeReference could easily be replaced with each stat being a scriptable object, it's just my preference to use.

    Upside of this approach is it's flexible and scalable. Downside is its performance likely doesn't scale well. Though if you're dealing with those kinds of numbers when you likely want to be looking at a more DOTS approach.

    @wideeyenow_unity Can you please stop continually bringing up your use of inheritance? It's really not good advice and is honestly a misuse of it.
     
    Last edited: Sep 9, 2023
    CodeRonnie likes this.
  7. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    If there was any problem with using it, I wouldn't be mentioning it. There are many code structures, and design principals, and no two are the same. So I'm sorry you don't like my structure, or see it's ease of use, but truly it's a matter(and difference) of opinion.
     
  8. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    I do think inheritance has its place, but for units specifically, I definitely see issues with it. How do you avoid implementing things several times or leaving stuff unused that came with the inheritance? If you could explain it with the no attacking tank, no hp immortal unit and the not moving tower example from my initial post, id be very grateful. the way I see it you start with hp. Inherit from that and add dmg. but if you create the immortal attacking one you'd now have to start from 0 (or disregard the hp which is bad practice). you'd end up implementing attack twice. Unless you do it all with interfaces, in which case that's the same approach im taking here.
     
  9. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    That is why I went for the interface idea, but I see now that I missed a few things. However I do want a flexible approach like that.

    I tried to get your approach to work, but I don't get any SerializedField in the Inspector, do you know why? I wanted to see what it would look like. The only two reasons why im using scriptable objects in the first place is because id have all stats for a unit in one handy file and I could edit them at runtime for balancing purposes. Im considering just creating Stat components with the same interface structure I have now, which at least would have all stats in one place and allow for (non persistent as far as I can tell) changes during runtime for testing. And allow me to have the Health component do [RequireComponent(typeof(IHealthStat))].

    Its either that or go with what I have now and throw an error when a stat is missing, unless there is another good option I don't know about yet.
     
  10. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    The same way you don't have any issue with, not using everything that gets inherited from, the MonoBehaviour class. The next subclass inline only uses what it's defined to do, if it calls it. So the misconception of Inheritance can only use abstraction, is false.

    I only see multiple implementations being used with interfaces, as a "ITakeDamage" is purely a set way to call "DecrementHealth()". So, you'll wind up seeing more an more implementations of "ITakeBossDamage" or "ITakeBuffDamage".

    But to be fair, the class in question should decipher all these things, with checks of "canTakeDamage"(a bool of invincibility) or a value of "armorNegation"(an equation of any lost health).

    So in my view, interfaces or inheritance are basically just ways to communicate data transfer from one class to another. And to be even more fair, both of these methods can be misused, and easily misunderstood. So I'm not trying to push any agenda, just clearly giving the opinion that they both can do the same thing. :)
     
  11. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,248
    Well note in the example I used SerializeReference, not SerializeField. The former supports polymorphism, the latter does not.

    Note that Unity doesn't have much in the way of out of the box support with SerializeReference, so you'll either need to write a custom editor, or use tools like Odin Inspector that do bring it up to supported levels.

    Hence why rather than a collection of an interface type, I suggested that you could have a collection of a base-class scriptable object type. The main downside is you can end up with way too many scriptable objects in your project.

    As a final method - the one I like the least - is that you could compose all this on game objects with regular behaviour components, expressing interfaces. That would be the most "Unity way" to do things that would require the least amount of boiler plate.
     
    SisusCo likes this.
  12. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    Sorry, yes, I did use that.

    I see, then I don't think this is the way to go for me since I mostly wanted ScriptableObjects for their simplicity to use with the standard inspector. But thanks for suggesting it.

    Could you explain to me how this would look? Is this similar to what I suggested in my previous response to you?creating a Stat component that extends interfaces which other components like a HealthSystem use to access their relevant stats? Since I end up with a Stat game object on every unit anyways since I don't want to link the SO manually to all other components, I don't think this would be worse. its just slightly less clean than a SO in my opinion.

    I did think ScriptableObjects would be the best way to do configs or stats or whatever you wanna call them, but I feel like that's only the case if you have a lot of different things with the same kinds of variables. like a card game with manacost, name, hp, damage, description, sprite etc. if there is deviation I don't see a great way to do it sadly.
     
  13. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,248
    Well like I said you can just change my approach from an interface to a base class.

    It's honestly a very small change:
    Code (CSharp):
    1. public abstract class CharacterStatBase : ScriptableObject { }
    2.  
    3. [CreateAssetMenu(menuName = "Stats/Max Health Stat")]
    4. public sealed class MaxHealthStat : CharacterStatBase
    5. {
    6.     [SerializeField]
    7.     private int _maxHealth;
    8.  
    9.     public int MaxHealth => _maxHealth;
    10. }
    11.  
    12. [CreateAssetMenu(menuName = "Stats/Attack Damage Stat")]
    13. public sealed class AttackDamageStat : CharacterStatBase
    14. {
    15.     [SerializeField]
    16.     private int _attackDamage;
    17.  
    18.     public int AttackDamage => _attackDamage;
    19. }
    20.  
    21. [System.Serializable]
    22. public sealed class StatsContainer
    23. {
    24.     [SerializeField]
    25.     private List<CharacterStatBase> _stats = new();
    26.  
    27.     public T GetStat<T>() when T : CharacterStatBase
    28.     {
    29.         int count = _stats.Count;
    30.         for (int i = 0; i < count; i++)
    31.         {
    32.             var stat = _stats[i];
    33.             if (stat is T cast)
    34.             {
    35.                 return cast;
    36.             }
    37.         }
    38.    
    39.         return null;
    40.     }
    41.  
    42.     public bool TryGetStat(out T stat) when T : CharacterStatBase
    43.     {
    44.         stat = this.GetStat<T>();
    45.         return stat != null;
    46.     }
    47. }
    48.  
    49. [CreateAssetMenu]
    50. public sealed class StatContainerAsset : ScriptableObject
    51. {
    52.     [SerializeField]
    53.     private StatsContainer _statcontainer = new();
    54.  
    55.     public StatsContainer StatContainer => _statcontainer;
    56. }
    But like I said you'll end up with tons of scriptable objects, which is why my preference is on the SerializeReference approach, where you can be a lot more compact with plain C# classes.

    It would look similar to my approach above, just that you piece meal each stat with components rather than scriptable objects. Again, it's my least preferred approach (well mass inheritance would be my least favourite approach but I don't consider it even an option).

    I also realise I forgot to decorate the plain C# classes with
    [System.Serializable]
    in my previous examples, which is why nothing might have been showing.
     
  14. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    Thanks ill try around with these, if anyone else has any other idea im happy to hear them as well :D
     
  15. SisusCo

    SisusCo

    Joined:
    Jan 29, 2019
    Posts:
    1,164
    You can serialize the backing field of a property though.
    Code (CSharp):
    1. [field: SerializeField]
    2. public int MaxHealth { get; private set; }
     
  16. Peter77

    Peter77

    QA Jesus

    Joined:
    Jun 12, 2013
    Posts:
    6,477
    Just a quick note to mention the ScriptableObjectContainer package, as it can help overcome the issue with "end up with tons of assets".

    The ScriptableObjectContainer package allows you to add ScriptableObjects to another ScriptableObject, known as the ScriptableObjectContainer, so that everything is consolidated into a single asset.

    https://github.com/pschraut/UnityScriptableObjectContainer

    upload_2023-9-9_17-9-49.png
     
    SisusCo likes this.
  17. Buddy24

    Buddy24

    Joined:
    Oct 2, 2021
    Posts:
    8
    Thank you! By coincidence I just found out about this before reading this. It makes my approach a lot cleaner already, I still have to figure out how to solve the problem where I confused my stat component and scriptable object as one now, but this is a step in the right direction.
     
    SisusCo likes this.
  18. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    6,248
    This kind of feels like SerializeReference, but using scriptable objects sub-objects instead of plain C# classes. The only advantage I could really see is if you needed to reference the sub-assets directly.