Search Unity

  1. Unity 6 Preview is now available. To find out what's new, have a look at our Unity 6 Preview blog post.
    Dismiss Notice
  2. Unity is excited to announce that we will be collaborating with TheXPlace for a summer game jam from June 13 - June 19. Learn more.
    Dismiss Notice
  3. Dismiss Notice

Question Programming best practice: Where to define non-MonoBehaviour classes?

Discussion in 'Scripting' started by hntb_adam, May 22, 2024.

  1. hntb_adam

    hntb_adam

    Joined:
    Jun 28, 2023
    Posts:
    21
    Hey all, I'm working on a project that will involve serializing data to save and load changes to the world. For example, a player can leave a note, and I would serialize the position of the note as well as its text content for it to be loaded by other players in the same position. I'm used to working with MonoBehaviours, where you'd define the Note class, give it fields and methods to store and render the note, and put the Note component on a GameObject.

    However, in order to serialize the data, you need to create a custom class that doesn't inherit from MonoBehaviour, and I'm unsure where to define it. Initially my thought was to include it in the same file as the MonoBehaviour code used to render the note, but I feel like it's redundant to have that code included with every note that gets placed. My other thought was to create a manager script that contains definitions for all custom classes that need to be serialized, but my worry is it could get unwieldy.

    I'm wondering if there's any programming pattern/best practice for defining custom classes that don't derive from MonoBehaviour, especially when being used for serialization.

    Thanks!
     
  2. karliss_coldwild

    karliss_coldwild

    Joined:
    Oct 1, 2020
    Posts:
    613
    This sentence doesn't make sense. Either you described it poorly or you have a misunderstanding of how C# works.

    Can you give an example of code which you think is redundant?

    Location of class definition doesn't matter for efficiency, where you define variables with the type of said class does. Location of class definition (the type not instances) needs to be chosen based on readability and ease of code navigation.
     
  3. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,594
    It's not abnormal. I often have "token" structs that allow me to tokenize the state of my script for saving.

    You can even use this token as the fields of your script:
    Code (csharp):
    1. public class Note : MonoBehaviour
    2. {
    3.  
    4.     public NoteToken info;
    5.  
    6.     #region Special Types
    7.  
    8.     [System.Serializable]
    9.     public struct NoteToken
    10.     {
    11.         public string text;
    12.         //other properties
    13.     }
    14.  
    15.     #endregion
    16.  
    17. }
    The convenience of it being a struct is that it's copy by value, not by ref. If you want to use a class create a 'Clone' method on it (maybe even implement the ICloneable interface). This way if you want to copy states between scripts you aren't just ref'n the same one.
     
    spiney199 likes this.
  4. hntb_adam

    hntb_adam

    Joined:
    Jun 28, 2023
    Posts:
    21
    For example, let's say I create a script called Marker.cs that looks like:

    Code (CSharp):
    1. public class Marker:MonoBehaviour
    2. {
    3.     public enum MarkerType
    4.     {
    5.         textNote,perspective
    6.     }
    7.     public string message;
    8.     public MarkerType markerType;
    9. }
    I would then use other scripts to reference this info to do things like displaying the text in a text box, changing the color based on marker type, etc.

    However, since you can't serialize a MonoBehaviour class, I also create a duplicate of the class for serialization purposes, like so:

    Code (CSharp):
    1. [Serializable]
    2. public class MarkerData
    3. {
    4.     [SerializeField]
    5.     public Vector3 pos;
    6.     [SerializeField]
    7.     public string msg;
    8.  
    9.     [SerializeField]
    10.     public int type;
    11. }
    I then have a serialization script that can convert a Marker component into MarkerData for serialization, and read a JSON of MarkerData and parse it into Marker components.

    To me it feels ugly to include the MarkerData class as part of the Marker.cs script, since Marker.cs will exist on every marker in the scene, and I really just want the data defined in one place. But maybe I'm just overthinking it, or misunderstanding something?
     
  5. hntb_adam

    hntb_adam

    Joined:
    Jun 28, 2023
    Posts:
    21
    That's really interesting. And custom structs can be serialized into JSON?
     
  6. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,594
    Yes.

    I think you're overthinking it.

    Your token and your object are technically different things. The object stored in serialized data isn't your object, it's a token of your object, and having a data contract to represent that serialized state isn't uncommon.

    And if you're going to have a token for some class... why not put it with that class?

    Like I said using the token as the data container for your class is doable in some situations. But in others it might not be. For example I might have an "Inventory" class which when serialized is just an array of every inventory item. But when I deserialize it I might shove it into dictionaries and have some other auxiliary data that facilitates the implementation of my 'Inventory' class as well as optimizations. The serialized data is a flat array, but the actual class is a complex set of data. In this scenario my token (an array) is going to be very different from the data of the class.
     
    Bunny83 likes this.
  7. spiney199

    spiney199

    Joined:
    Feb 11, 2021
    Posts:
    8,390
    The only C# scripts that need to be arranged in a particular way are MonoBehaviour and ScriptableObject classes, where there can only be one per script file, and the type and file names need to match.

    For everything else, it doesn't matter. They're just fancy text files. You can nest classes, write multiple classes per file, etc.

    So really just define it where it makes sense. Can be its own script file, can be nested if it's only really used by one other type.
     
    Bunny83 likes this.
  8. Bunny83

    Bunny83

    Joined:
    Oct 18, 2010
    Posts:
    4,189
    This makes no sense as this is not how code works. C# is a compiled language, not an interpreted scripting language. It absolutely doesn't matter where a certain type is defined. C# files are just source files and they aren't "attached" to gameobjects. Unity specifically links the TextAsset which is the actual script file with the class with the same name. So the script asset can be used to attach the class with the same name to a gameobject. It absolutely doesn't matter what else is in that file.

    All your classes are compiled into a single assembly (DLL file). So "script files" do not even exist at runtime, only the compiled classes and all are in the same assembly (unless you split them up with assembly definition files).

    You may want to download a C# / .NET reflector like ILSpy which can decompile and view any .NET assembly. You can use it on your "Assembly-CSharp.dll". You find your compiled assemblies in the ScriptAssemblies subfolder inside the Library folder. Maybe that helps to get a better view how C# code works :)
     
    spiney199 likes this.
  9. lordofduct

    lordofduct

    Joined:
    Oct 3, 2011
    Posts:
    8,594
    Came back to give an example of what I was talking about with inventory.

    Here is a very simple inventory for a small game I slapped together in a week:
    Code (csharp):
    1. using UnityEngine;
    2. using System.Collections.Generic;
    3. using System.Linq;
    4.  
    5. using Cysharp.Threading.Tasks;
    6.  
    7. using com.spacepuppy;
    8. using com.spacepuppy.Project;
    9. using com.spacepuppy.Utils;
    10.  
    11. using com.infinijump.gameplay;
    12. using com.infinijump.io;
    13.  
    14. namespace com.infinijump
    15. {
    16.  
    17.     public sealed class UserInventory : ServiceComponent<UserInventory>
    18.     {
    19.  
    20.         #region Fields
    21.  
    22.         [Header("Item Info")]
    23.         [SerializeField]
    24.         private SerializableInterfaceRef<IGuidAssetSet> _allCollectibles = new();
    25.         [SerializeField, SerializableGuid.Config(ObjectRefField = true)]
    26.         private SerializableGuid _starId;
    27.         [SerializeField, SerializableGuid.Config(ObjectRefField = true)]
    28.         private SerializableGuid _diamondId;
    29.  
    30.         [Header("Other Stuff")]
    31.         [SerializeField]
    32.         private float _minimumSaveFrequency = 5f;
    33.         [SerializeField]
    34.         private int _starToDiamondConversionRate = 10;
    35.  
    36.         [System.NonSerialized]
    37.         private Dictionary<System.Guid, int> _items = new();
    38.  
    39.         [System.NonSerialized]
    40.         private double _lastSaveTime = double.NegativeInfinity;
    41.         [System.NonSerialized]
    42.         private bool _saveQueued;
    43.         [System.NonSerialized]
    44.         private bool _saving;
    45.  
    46.         #endregion
    47.  
    48.         #region CONSTRUCTOR
    49.  
    50.         protected override void OnValidAwake()
    51.         {
    52.             _ = this.LoadInventory();
    53.         }
    54.  
    55.         #endregion
    56.  
    57.         #region Properties
    58.  
    59.         public IGuidAssetSet AllCollectibles
    60.         {
    61.             get => _allCollectibles.Value;
    62.             set => _allCollectibles.Value = value;
    63.         }
    64.  
    65.         public System.Guid StarId
    66.         {
    67.             get => _starId;
    68.             set => _starId = value;
    69.         }
    70.  
    71.         public System.Guid DiamondId
    72.         {
    73.             get => _diamondId;
    74.             set => _diamondId = value;
    75.         }
    76.  
    77.         public int StarCount => _items.TryGetValue(_starId, out int count) ? count : 0;
    78.  
    79.         public int DiamondCount => _items.TryGetValue(_diamondId, out int count) ? count : 0;
    80.  
    81.         #endregion
    82.  
    83.         #region Methods
    84.  
    85.         async UniTask LoadInventory()
    86.         {
    87.             if (_saving) return;
    88.  
    89.             var json = await PersistentStorage.ReadAsync("inventory").AsUniTask();
    90.             var token = string.IsNullOrEmpty(json) ? new InventoryToken() : JsonUtility.FromJson<InventoryToken>(json);
    91.  
    92.             _saveQueued = false;
    93.             _lastSaveTime = Time.unscaledTimeAsDouble;
    94.             _items.Clear();
    95.             if (token.Items?.Length > 0 && this.AllCollectibles != null)
    96.             {
    97.                 var collectibles = this.AllCollectibles;
    98.                 foreach (var item in token.Items)
    99.                 {
    100.                     if (System.Guid.TryParse(item.Id, out System.Guid id))
    101.                     {
    102.                         var collectible = collectibles.LoadAsset<CollectibleInfo>(id);
    103.                         if (collectible && collectible.InventoryUse.CanStore())
    104.                         {
    105.                             _items[collectible.Id] = item.Count;
    106.                         }
    107.                     }
    108.                 }
    109.             }
    110.  
    111.             Messaging.Broadcast<IInventoryReloadedGlobalHandler, UserInventory>(this, (o, a) => o.OnInventoryReloaded(a));
    112.         }
    113.  
    114.         async UniTask SaveInventory()
    115.         {
    116.             if (_saving) return;
    117.  
    118.             try
    119.             {
    120.                 _saveQueued = false;
    121.                 _lastSaveTime = Time.unscaledTimeAsDouble;
    122.                 _saving = true;
    123.  
    124.                 Messaging.Broadcast<IInventorySaveGlobalHandler, UserInventory>(this, (o, a) => o.OnBeforeSave(a));
    125.  
    126.                 var token = new InventoryToken()
    127.                 {
    128.                     Items = _items.Select(o => new ItemToken()
    129.                     {
    130.                         Id = o.Key.ToString(),
    131.                         Count = o.Value,
    132.                     }).ToArray(),
    133.                 };
    134.  
    135.                 var json = JsonUtility.ToJson(token, false);
    136.                 await PersistentStorage.WriteAsync("inventory", json);
    137.                 Messaging.Broadcast<IInventorySaveGlobalHandler, System.ValueTuple<UserInventory, bool>>((this, false), (o, a) => o.OnSaveComplete(a.Item1, a.Item2));
    138.             }
    139.             catch(System.Exception ex)
    140.             {
    141.                 Debug.LogException(ex);
    142.                 Debug.LogWarning("FAILED SAVE GAME!");
    143.                 Messaging.Broadcast<IInventorySaveGlobalHandler, System.ValueTuple<UserInventory, bool>>((this, true), (o, a) => o.OnSaveComplete(a.Item1, a.Item2));
    144.             }
    145.             finally
    146.             {
    147.                 _lastSaveTime = Time.unscaledTimeAsDouble;
    148.                 _saving = false;
    149.             }
    150.         }
    151.  
    152.         private void Update()
    153.         {
    154.             if (_saveQueued && !_saving && (Time.unscaledTimeAsDouble - _lastSaveTime) > _minimumSaveFrequency)
    155.             {
    156.                 _ = this.SaveInventory();
    157.             }
    158.         }
    159.  
    160.         /// <summary>
    161.         /// Adds an item to the inventory and queues up a save.
    162.         /// </summary>
    163.         /// <param name="item">Item to add</param>
    164.         /// <returns>Total count of that item.</returns>
    165.         public int AddItem(CollectibleInfo item, int count = 1) => item ? AddItem(item.Id, count, false) : -1;
    166.  
    167.         /// <summary>
    168.         /// Adds an item to the inventory and queues up a save.
    169.         /// </summary>
    170.         /// <param name="itemid">Item to add</param>
    171.         /// <returns>Total count of that item.</returns>
    172.         public int AddItem(System.Guid itemid, int count = 1) => AddItem(itemid, count, false);
    173.  
    174.         private int AddItem(System.Guid itemid, int count, bool suppressChangedMessage)
    175.         {
    176.             if (!(this.AllCollectibles?.Contains(itemid) ?? false)) return -1;
    177.  
    178.             if (_items.TryGetValue(itemid, out int total))
    179.             {
    180.                 count = Mathf.Max(total + count, 0);
    181.             }
    182.  
    183.             if (itemid == _starId && count >= _starToDiamondConversionRate) //if it was a star, try to convert to diamonds
    184.             {
    185.                 int diamondCount = count / _starToDiamondConversionRate;
    186.                 count -= diamondCount * _starToDiamondConversionRate;
    187.                 this.AddItem(_diamondId, diamondCount, true);
    188.             }
    189.  
    190.             _items[itemid] = count;
    191.             _saveQueued = true;
    192.             if (!suppressChangedMessage) Messaging.Broadcast<IInventoryChangedGlobalHandler, UserInventory>(this, (o, a) => o.OnInventoryChanged(a));
    193.             return count;
    194.         }
    195.  
    196.         public int GetCount(CollectibleInfo item) => item != null && _items.TryGetValue(item.Id, out int count) ? count : 0;
    197.  
    198.         public bool ConsumeItem(CollectibleInfo item)
    199.         {
    200.             if (!item) return false;
    201.  
    202.             if (_items.TryGetValue(item.Id, out int total) && total > 0)
    203.             {
    204.                 total--;
    205.                 _items[item.Id] = total;
    206.                 _saveQueued = true;
    207.                 Messaging.Broadcast<IInventoryChangedGlobalHandler, UserInventory>(this, (o, a) => o.OnInventoryChanged(a));
    208.                 return true;
    209.             }
    210.             else
    211.             {
    212.                 return false;
    213.             }
    214.         }
    215.  
    216.         public void QueueSave()
    217.         {
    218.             _saveQueued = true;
    219.         }
    220.  
    221.         #endregion
    222.  
    223.         #region Messages
    224.  
    225.         public interface IInventoryReloadedGlobalHandler
    226.         {
    227.             void OnInventoryReloaded(UserInventory inventory);
    228.         }
    229.  
    230.         public interface IInventorySaveGlobalHandler
    231.         {
    232.             void OnBeforeSave(UserInventory inv);
    233.             void OnSaveComplete(UserInventory inv, bool failed);
    234.         }
    235.  
    236.         public interface IInventoryChangedGlobalHandler
    237.         {
    238.             void OnInventoryChanged(UserInventory inventory);
    239.         }
    240.  
    241.         #endregion
    242.  
    243.         #region Special Types
    244.  
    245.         [System.Serializable]
    246.         class InventoryToken
    247.         {
    248.             public ItemToken[] Items;
    249.         }
    250.  
    251.         [System.Serializable]
    252.         struct ItemToken
    253.         {
    254.             public string Id;
    255.             public int Count;
    256.         }
    257.  
    258.         #endregion
    259.  
    260.     }
    261.  
    262. }
    Note how my token is very different from the actual data since I'm storing guids and not actual items, and the data in the inventory is a dict and not an array like in the token.
     
    Bunny83 likes this.
  10. karliss_coldwild

    karliss_coldwild

    Joined:
    Oct 1, 2020
    Posts:
    613
    That's not how it works in many ways.

    1. First of all once the code is compiled concept of *.cs doesn't exist anymore (beside debug info). Unity has a weird requirement of having the MonoBehavior classes in .cs files with the same name, but that's probably something with how they implemented editor workflow.
    2. When the game is running it's not a copy Marker.cs or even Marker class that get's attached to instance of marker, it's an instance of Marker class.
    3. All the code, class definitions, functions will have single copy of them in memory. Having multiple markers doesn't mean that there are multiple copies of Marker class code, or anything else in Marker.cs. Only the instances of Marker class (that is collection of it's nonstatic field values) need a unique copy for each marker.

    Any additional non MonoBehavior classes you put in Marker.cs source file has no impact on runtime behavior or Marker class. The division between .cs files is only for the purpose of code readability and searchability while writing and editing it. (It has also slight effect of compilation process, but you don't have to worry about that.) You can have 1 class in single .cs file, you can have 10 (non mono behavior) classes in single .cs file, or you can even have 1 class split across 10 .cs files. Compiled code should be identical. Only change in behavior is if you make nested classes instead multiple top level classes, it slightly influences the full name and visibility rules. C# isn't like java where nested class instances are associated with specific instance of parent class.


    Code (CSharp):
    1. //MarkerData.cs
    2. class MarkerData {
    3.    // some fields
    4. }
    5. //Marker.cs
    6. class Marker : MonoBehavior {
    7.    MarkerData MakeSerializableData() {
    8.      
    9.    }
    10. }

    Code (CSharp):
    1. //Marker.cs
    2. class MarkerData {
    3.    // some fields
    4. }
    5. class Marker : MonoBehavior {
    6.    MarkerData MakeSerializableData() {
    7.      
    8.    }
    9. }

    Code (CSharp):
    1. //Marker.cs
    2. class Marker : MonoBehavior {
    3.    class MarkerData {
    4.         // some fields
    5.    }
    6.    MarkerData MakeSerializableData() {
    7.    
    8.    }
    9. }

    Code (CSharp):
    1. //SerializableData.cs
    2. class MarkerData {
    3.         // some fields
    4. }
    5. class FooData {
    6. }
    7. class BarData {
    8. }
    9. //Marker.cs
    10. class Marker : MonoBehavior {
    11.    MarkerData MakeSerializableData() {
    12.    
    13.    }
    14. }
    With exception of third case the full name of MarkerData class being Marker.MarkerData all these 4 versions are the same. It doesn't matter where non mono behavior classes are defined. Which version you choose is very subjective and will somewhat depend on code size in each class, different programmers will draw the line for splitting or grouping things into multiple files at different place.


    A much more important question is where you store instances of MarkerData, how you create MarkerData from Marker, and how you synchronized the data between the two.
     
    Bunny83 likes this.
  11. hntb_adam

    hntb_adam

    Joined:
    Jun 28, 2023
    Posts:
    21
    Thanks for clarifying :)

    I think I'm doing essentially what you're suggesting -- having the "token" class (thanks for the term) and then the MonoBehaviour class, and the MonoBehaviour class has methods to take the token data and represent it visually in the scene.

    That actually makes a lot of sense. I mostly learned programming in Unity, so never paid much attention to the underlying framework. Now that I'm starting to work with more complex topics like data serialization, it's good to have an understanding of the fundamentals.

    This was a great explanation as well.