Search Unity

Decoupled(or not!) variables via generic-type ScriptableObjects. Code I'm proud of that you can use!

Discussion in 'Scripting' started by Bropoc, May 14, 2019.

  1. Bropoc

    Bropoc

    Joined:
    Sep 30, 2014
    Posts:
    8
    After watching this talk from Unite Austin 2017 about making ScriptableObjects the core of your game architecture, and after finding this pleasant idea presented by TheVastBurnie where ScriptableObjects are editable in the inspector context of the objects that reference them, I combined the two ideas together. Here I've implemented them with generics because code recycling is a good idea, even if the Unity editor doesn't like them.

    In his talk, Ryan Hipple refers to this pattern as "FloatVariable" and the class that contains them as "FloatReference," but I prefer the terms "ScriptableValue" and "ScriptableValueReference." But what are they? They're basically just a ScriptableObject that holds a single variable. This is useful for many things. You can hold a character's health points in one, and then this one ScriptableObject can be referenced in your animation system, your game state system, your UI, enemy behavior, audio system, and so on, all without any of them even knowing that the character exists. All they care about is this health point value which is disconnected from everything else. This makes testing and debugging simpler since you can create contexts in which only the variable and only the system you're working on are present.

    For the following examples, I'm going to be implementing these as a float, but any type that Unity can serialize should work.

    To get an idea of what all of this following nonsense will look like when implemented, here's a screenshot.


    Now, here's a generic.

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public class ScriptableValueGeneric<T> : ScriptableObject, ISerializationCallbackReceiver
    4. {
    5.     /*In builds, values held in ScriptableObjects will
    6.     revert to their initial value when the program is restarted.
    7.     This is not the case in editor or play mode. Therefore
    8.     to keep things consistent, we elaborate on things here a bit.
    9.     The end result is that when play mode begins, the values
    10.     changed in game will be restored.*/
    11.  
    12.     [SerializeField]
    13.     public T initialValue; //This is the value your designers should care about.
    14.  
    15.     [System.NonSerialized]
    16.     public T value; //This is the value your game should care about at runtime.
    17.  
    18.     public void OnAfterDeserialize()
    19.     {
    20.         value = initialValue;
    21.     }
    22.  
    23.     public void OnBeforeSerialize(){}
    24. }
    Unity "can't" work with generics, so we'll have to manually implement this. It's easy, though, E.G.:

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. [CreateAssetMenu(fileName = "svFloat", menuName = "Scriptable Value/Float", order = 1)]
    4. [System.Serializable]
    5. public class SVFloat : ScriptableValueGeneric<float>{}
    And that's enough to create a ScriptableObject derived from a generic whose values can be referenced in-editor.
    But there are times when we don't necessarily need a variable that can be accessed by everything. Sometimes we just need to tweak a number that is only used by one system. Local variables are perfectly acceptable in this case.
    But, rather than telling our designers when this is or is not appropriate, we can give them options. We can create a class that allows a choice between a local value and a ScriptableObject:

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. public class SVRefGeneric<T, SV> where SV : ScriptableValueGeneric<T>
    4. {
    5.     public enum VariableScopeChoice {localValue, scriptableValue}
    6.     public VariableScopeChoice scopeChoice;
    7.  
    8.     #pragma warning disable 0649
    9.     /*"by design" this warning is thrown even though we have an
    10.     attribute that makes it untrue. The
    11.     intention appears to be to make it in-line with compiler
    12.     errors outside Unity, even though we're obviously using
    13.     Unity. At least we have a way to "fix" it ourselves.
    14.     Sorry, end of rant.
    15.     Anyway, the intent of having these variables be private
    16.     is that we want the designer to choose one of two types
    17.     of variables; one that is a scriptable object for decoupling
    18.     purposes and another choice that is locally-held in case
    19.     that value is never used elsewhere. Since it couldn't be
    20.     both, it makes sense to have them both be accessed by the
    21.     same getter depending on which is selected.*/
    22.     [SerializeField]private T localValue;
    23.     #pragma warning restore 0649
    24.  
    25.     [SerializeField]private SV scriptableValue = null;
    26.  
    27.     public T value
    28.     {
    29.         get
    30.         {
    31.             switch(scopeChoice)
    32.             {
    33.                 default: //VariableScopeChoice.localValue
    34.                     return localValue;
    35.                 case VariableScopeChoice.scriptableValue:
    36.                     return scriptableValue.value;
    37.             }
    38.         }
    39.     }
    40.  
    41. }
    Again, since this is a generic we need to wrap it up into a form that Unity is comfortable with:

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. [System.Serializable]
    4. public class SVFloatRef : SVRefGeneric<float, SVFloat>{}
    5.  
    Now we want a tidy drawer to put a bow on things. This is where we get just a little fancy. Here, the designer can select via the enum drop-down list whether the variable is local or our ScriptedVariable, and the inspector will only show the appropriate field for what they've selected. As a bonus, it will also present that value in a fold-out and allow for in-line editing without having to switch to the inspector for the ScriptableObject.

    But first, we need a little bit of housekeeping or the editor will throw warnings at us:

    Code (CSharp):
    1. using UnityEngine;
    2. using UnityEditor;
    3. [CanEditMultipleObjects]
    4. [CustomEditor(typeof(MonoBehaviour), true)]
    5. public class MonoBehaviourEditor : Editor
    6. {
    7. }
    Now, on to this. Don't forget that editor extensions have to be put into a folder in your assets named "Editor"!

    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3.  
    4. [CustomPropertyDrawer(typeof(SVRefGeneric<,>))]
    5. public class SVRefDrawerGeneric<T, SV> : PropertyDrawer where SV : ScriptableValueGeneric<T>
    6. {
    7.     private Editor editor = null;
    8.  
    9.     public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    10.     {
    11.         EditorGUI.BeginProperty(position, label, property);
    12.         EditorGUI.LabelField(position, label);
    13.      
    14.         Rect scopeRect = new Rect(position.x + 55, position.y, 95, position.height);
    15.         Rect valueRect = new Rect(position.x + 155, position.y, position.width-165, position.height);
    16.      
    17.         EditorGUI.PropertyField(scopeRect, property.FindPropertyRelative("scopeChoice"), GUIContent.none);
    18.         switch(property.FindPropertyRelative("scopeChoice").enumValueIndex)
    19.         {
    20.             case (int)SVRefGeneric<T,SV>.VariableScopeChoice.localValue:
    21.                 EditorGUI.PropertyField(valueRect, property.FindPropertyRelative("localValue"), GUIContent.none);
    22.                 break;
    23.             case (int)SVRefGeneric<T,SV>.VariableScopeChoice.scriptableValue:
    24.                 EditorGUI.PropertyField(valueRect, property.FindPropertyRelative("scriptableValue"), GUIContent.none, true);
    25.                 if(property.FindPropertyRelative("scriptableValue").objectReferenceValue != null)
    26.                 {
    27.                     property.isExpanded = EditorGUI.Foldout(position, property.isExpanded,GUIContent.none);
    28.                     if(property.isExpanded)
    29.                     {
    30.                         EditorGUI.indentLevel++;
    31.                      
    32.                         if(!editor)
    33.                             Editor.CreateCachedEditor(property.FindPropertyRelative("scriptableValue").objectReferenceValue, null, ref editor);
    34.                         editor.OnInspectorGUI();
    35.                      
    36.                         EditorGUI.indentLevel--;
    37.                     }
    38.                 }
    39.                 break;
    40.         }
    41.      
    42.         EditorGUI.EndProperty();
    43.     }
    44. }
    45.  
    And once again, Unity hates generics so we have to take this last step(remember, put this in your "Editor" folder!):

    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3.  
    4. [CustomPropertyDrawer(typeof(SVFloatRef))]
    5. public class SVFloatDrawer : SVRefDrawerGeneric<float, SVFloat>{}
    Okay. Now with all that gruntwork out of the way, actually using these is incredibly simple.
    All you have to do is create each a new class of the inheritors of our Generic, with some modifications...

    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. [CreateAssetMenu(fileName = "svVector3", menuName = "Scriptable Variables/Vector3", order = 1)]
    4. [System.Serializable]
    5. public class SVVector3 : ScriptableValueGeneric<Vector3>{}
    Code (CSharp):
    1. using UnityEngine;
    2.  
    3. [System.Serializable]
    4. public class SVVector3Ref : SVRefGeneric<Vector3, SVVector3>{}
    5.  
    Code (CSharp):
    1. using UnityEditor;
    2. using UnityEngine;
    3.  
    4. [CustomPropertyDrawer(typeof(SVVector3Ref))]
    5. public class SVVector3Drawer : SVRefDrawerGeneric<Vector3, SVVector3>{}
    Create a ScriptableObject(Assets menu, right-click -> Create -> ScriptableValues -> Vector3) and in whatever object you code, just call the SVRef type:

    Code (CSharp):
    1.  
    2. public SVVector3Ref ourSVVec3Ref;
    3.  
    and that's all there is to it.

    If we want to(for example) print the value, all we need to do is:
    Code (CSharp):
    1. print(svVec3Ref.value);
    All done.

    We get our variable that is choosably either local or a ScriptableObject, we get our decoupled variables, and we get a tidy drawer to edit either.



    Once the basic generics are implemented, it's easy to add more types to this pattern. I hope it's useful to someone!
     
  2. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    5,378
    Very cool! I have a similar setup with my open source Datasacks package, basically scriptable object editor-aware variables, and nowadays I don't do anything in Unity without them.

    I am going to check out your stuff and see what I can learn. I intentionally chose mine to be VERY weakly typed, and that is a design decision that has worked out well so far, but I am always working on my Datasacks package to make it mo' bettah.

    This is an overview of my setup:

    20180724_datasacks_overview.png

    Datasacks is presently hosted at these locations:

    https://bitbucket.org/kurtdekker/datasacks

    https://github.com/kurtdekker/datasacks

    https://gitlab.com/kurtdekker/datasacks
     
  3. Shack_Man

    Shack_Man

    Joined:
    Jun 7, 2017
    Posts:
    226
    Thanks a lot, that was a great read. I didn't think of making scriptable objects generic yet...
    Btw, do you use a scriptable object pool? E.g. in my current I have creatures that spawn and despawn, and it would be useful if some of their values were scriptable objects (I'm currently only using one to hold the data to define what creature it is), so whenever one gets created, it will create it's own SO, and then of course a class that keeps track of them so I can recycle the SO.
     
unityunity