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

Question Best way to refactor this Interaction system to obtain a fully dynamic architecture.

Discussion in 'Scripting' started by Mobaster, Sep 3, 2023.

  1. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Hello,
    I am currently developing an Interaction system that aims to allow my different MonoBehaviour to interact with each other without hardcoded dependencies.

    My objective is to allow this to work between MonoBehaviour attached to the same GameObject and also between MonoBehaviour of different GameObjects.

    Keep in mind that following code has been cut to avoid to the bone to avoid unnecessary bloatiness.
    Also very important, Interactions are ScriptableObjects.

    To implement the previously explained architecture I created 2 classe, Propagator and ExternalPropagator, that have a very basic functionality.
    Propagator, that propagate Interactions inside the GameObject it is attached to, looks like this:

    Code (CSharp):
    1. public class Propagator : MonoBehaviour
    2. {
    3.     [SerializeField] public UnityEvent<Interaction> InteractionPropagation;
    4.  
    5.     public void Subscribe(IEntity subscriber)
    6.     {
    7.         this.InteractionPropagation.AddListener(subscriber.Interact);
    8.     }
    9. }
    On the other hand ExternalPropagator tries to propagate Interactions on an another GameObject picked arbitrarily and based on arbitraty conditions. Currently I only implemented a superclass and concrete class, with the concrete class containg all the code necessary to explain how the architecture works:

    Code (CSharp):
    1. public class ElementalTerrainPropagator : ExternalPropagator
    2. {
    3.     [SerializeField] Interaction m_onTriggerEnter;
    4.    
    5.     private void OnTriggerEnter2D(Collider2D other)
    6.     {
    7.         if (other.gameObject.TryGetComponent(out Propagator propagator))
    8.         {
    9.             propagator.InteractionPropagation.Invoke(this.m_onTriggerEnter);
    10.         }
    11.     }
    12. }
    The IEntity interface that you can see inside Propagator contains only this code:
    Code (CSharp):
    1. public interface IEntity
    2. {
    3.     public void Interact(Interaction interaction);
    4. }
    IEntity is implemented by Entity, a superclass that all my custom MonoBehaviours shares. The code relative to the Interaction system inside of Entity is the following:

    Code (CSharp):
    1. public abstract class Entity: MonoBehaviour, IEntity
    2. {
    3.     // Called at OnEnable
    4.     protected virtual void PropagationSubscription()
    5.     {
    6.         if(this.TryGetComponent<Propagator>(out Propagator propagator))
    7.             propagator.Subscribe(this);
    8.     }
    9.     public abstract void Interact(Interaction interaction);
    10. }
    What I am trying to implement now is a Responder. A class which job is contain a data structure able to map a Type of Interaction to his corresponding delegate (ex a Dictionary) or to implement an ad hoc overloading on a method like HandleInteraction.

    My main problem is that Responder to properly compute the Interaction needs access to the various subclasses attributes and methods, that gets lost since the instances get upcasted to the Interactions superclass.
    To make myself more clear, I have a Damage class that inherits from Combat that inherits from Interaction, aka
    Code (csharp):
    1. Damage : Combat : Interaction : ScriptableObject
    .
    When for example a Projectile propagates Damage on another GameObject, for example a mob or the player itself, various Responders (of different MonoBehaviours) could receive and handle the Interaction. For example by displaying something on UI or affect the Vitality of the target.

    I have looked into implementing a system using Generics and/or Reflection, but this are topics I am still not confident in.

    How would you implement such a system in a way that adding a new type of Interaction does not force me to expand something like a method overload, switch statement or implement a new UnityEvent bus on the Propagator specific to that new kind of Interaction?
     
  2. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    I'm not sure why you're focused on GameObject itself, as handling the class(component) you make for that gameObject would just be your one-stop-shop. As any calculations for gameObject loads a bunch of nonsense each time it's called, and you'd still have to use a GetComponent() anyway, which sounds like an awful lot of beating-around-the-bush.

    One prime example is collision checking, as using(for example)
    if (hitCollider == class.cachedCollider)
    is way faster than
    if (hitCollider.gameObject == myList.checkableGameObjects)
    . But you'll learn all of this when you start benchmarking.

    If you mean how to have just one method within the class of the "interactable" to just interact case by case? You would still need a couple "if" checks regardless, even if you pass the parameter within said function
    public void InteractWithMeBy(someVariable var)
    . But by just having "Interact()" would be a ton of if/switch statements. The only way I can think to cut that down, would be to have better checks on whatever technically starts said interaction, that way it only calls particular functions within said interacted class(for example)
    public void ChangeHealth(float newHealth)
    ,
    public void SetLightBulbActive(bool isOn)
    , etc...

    But like all code, there's many ways to do the same thing. So it boils down to either, what makes more sense to you, involves less typing, or what is more performant. And everyone has their own ways of doing things, lol, as you'll probably see when more post on this. :)
     
    Last edited: Sep 3, 2023
  3. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    37,210
    Using Interfaces in Unity3D:

    https://forum.unity.com/threads/how...rereceiver-error-message.920801/#post-6028457

    https://forum.unity.com/threads/manager-classes.965609/#post-6286820

    Check Youtube for other tutorials about interfaces and working in Unity3D. It's a pretty powerful combination.

    Oooh, I wouldn't do that... definitely interfaces above that sorta multi-level inherited brittleness.
     
  4. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    I am having an hard time understanding what you are trying to say here. I think you are suggesting to cache various components of the GameObject at Awake/OnEnable but I am not super sure.
    Just for info, I am relatively new to gamedev but not to programming so I still haven't started the optimization aspect of the job. I am for the moment avoiding optimization to slow down the learning process.


    Same here, I am having a little bit of an hard time trying to follow you reasoning, could you please elaborate more? Thanks.
     
  5. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Wait slow down, you can actually launch a GetComponent search (I am guessing is a BFS) based on INTERFACES? Damn this change everything.
    Does this meant I am for example attach an IPurchasable interface on elements in let's say a shop system, then I could have my mouse launch Purchase interactions at those UI elements after raycasting for collision?
    As said in a different answer I am new to gamdev, but the sequence I meant is:
    1. From Input System get mouse's World Space position
    2. Raycast from that position to collide with an UI element (ex a representation of an health potion)
    3. OnCollision verifiy that the collider has MonoBehaviours implementing the IPurchasable interface (could be more than one) using collider.TryGetComponents<IPurchasable>.
    4. Invoke the Purchase(Interaction.Purchase offer) method of the MonoBehaviour attached to the health potion, that after some computation, if purchase is successful, will add an actual health potion to player inventory.
    Is this what you are suggesting?

    Why not multi-level inherited interfaces then? Having a single level of specialization kinda defeat the point of polymosphism.
     
  6. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    37,210
    This was exactly my inner brain when I discovered this. It's amazing.

    In my gamedev experience inheritance leads to brittleness, and when it's deeper than a single level, the brittleness is almost without exception. The same people who hate upon global variables as "introducing too much coupling" will construct the craziest multi-level all-across-their-game object inheritance structure which ends up making their game completely and totally unrefactorable without touching everything up and down the hierarchy tree when they realize their original assumptions were slightly off. Your mileage may vary.
     
    CodeRonnie, Bunny83 and Sluggy like this.
  7. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    The main reason I came out with the Propagator is because I thought that GetComponent<> only worked on concrete classes inheriting MonoBehaviour. And since I wanted to avoid hardcoded dependencies (ex GetComponent<Vitality> in the Damage Interaction script) that's what I came up with.

    But does my numered list of operation move in the right direction?
     
  8. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    37,210
    I'm not gonna comment on software that doesn't exist.

    If it appears to suit your data transformation needs, then implement it and see if it really does.

    "The purpose of all programs, and all parts of those programs, is to transform data from one form to another." - Mike Acton
     
    Mobaster likes this.
  9. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    Components are just scripts, like Transform, GameObject, Collider, Renderer, etc... are all a class somewhere, and calling the gameObject script for off-hand logic is very brutal(performance or not).

    So if you make a class for that gameObject, within it you can cache(save reference) to any and every component associated with it. example:
    Code (CSharp):
    1. public class Player : MonoBehaviour
    2. {
    3.     public Transform trans; // which is recommended
    4.     public Renderer rend;
    5.     public Collider coll;
    6.  
    7.     public Awake()
    8.     {
    9.         trans = GetComponent<Transform>(); //or: trans = transform;
    10.         rend = GetComponent<Renderer>();
    11.         coll = GetComponent<Collider>();
    12.         otherClass.allPlayers.Add(this);
    13.     }
    14. }
    Then in another class make any lists you have be the class, not the gameObject:
    public static List<Player> allPlayers = new List<Player>();


    So in any other code, you simply call that other class, and handle anything without getting it's components again:
    Code (CSharp):
    1. void HandleCollisionViaList(Collider col)
    2. {
    3.     if (col == otherClass.allPlayers[0].coll)
    4.     {
    5.         // handle player "anything" by using allPlayers[0]
    6.         // if the player is index of 0
    7.         otherClass.allPlayers[0].health -= 10;
    8.         otherClass.allPlayers[0].rend.enabled = false;
    9.         otherClass.allPlayers[0].trans.position = new Vector3(100, 100, 100);
    10.     }
    11. }
    12. // or
    13. void HandleCollisionViaGetOnce(Collider col)
    14. {
    15.     Player player = col.GetComponent<Player>();
    16.     if (player == null) { print("no player found by collider"); return; }
    17.     player.health -= 10;
    18.     player.rend.enabled = false;
    19.     player.trans.position = new Vector3(100, 100, 100);
    20. }
    But true, a lot of tutorials show using lists of gameObjects, which in my view is a very bad way to start someone off learning. ;)
     
    Last edited: Sep 3, 2023
  10. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    853
    For the question about your 'propagator' - The term you are looking for is Message Dispatcher. It's a very common pattern in programming for exactly this sort of thing. If you want an example of what I have used in literally every C# project I've ever written since like 2008, here it is: https://github.com/Slugronaut/Toolbox-MessageDispatch. There is one rather glaring weakness to this method though. It requires building some kind of dependency on the dispatcher is basically all of the code that uses it. Even though my example is built around interfaces, that is still an interface who's contract must be met. In most cases I usually just use a singleton pattern even though I kinda hate them. More recently I've built a simple service locator-like system though it still mostly relies on a singleton to do that work so.... yeah.
     
  11. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    That is exactly the pattern I used to create the Propagator, simply instead of using C# delegates I used Unity native type UnityEvent.
    Regarding the dependency, unfortunately somewhere a somewhat dependency must be written but I would rather centralize it in a single class (Singleton or not) so refactoring is not an herculean task.
     
  12. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Ok this I got, basically at startup caching a reference to the other components I need.
    Fair enough, this should avoid the need to start addition graph search.

    I get what you are doing here but I don't see where I would need to do this.
    Since basically every Player class is subscribing itself to that list an iteration or an hashcode would be required to access the correct object on collision.
    I see that in the first code you are comparing directly only with element 0 of the AllPlayer List, but this is unrealistic
    I am getting that this otherClass containing the List of reference would exist outside of the scene, probably as a Singleton.
    The problem that I see with this approach is that every instantiated object of type Player (or whatever Type) would add themselves to this global List at startup, forcing anyone needing a specific instance to iterate the entire List (or use an hashcode) to find the required instance.
     
  13. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    Everywhere... as cached references are the most performant way of doing things..

    Yes, every player that is in the game would have their own index, so simply calling the correct index gives you anything you need from that player, instantly..

    This statement makes no sense.. it's an example, there would only be one player in it, so it's index would be that of 0.. If you have more players, then you'd need to iterate, so "0" would be "i" in that case.

    No, it would exist in the scene, and more than likely be a manager class, or some sort of master class that handles every frame logic. With the list being static, and only one instance of that class, you can call that list from anywhere in your code, easily..

    It would only be a list of players(if there are more than one), and only players themselves would add themselves to this. So you have a quick easy list to iterate through, to get any player you want. And no need for hashcode's as it's already lighting quick, speed wise..

    As far as anything else, they would have their own lists(hammers, bullets, explosions, etc...), which is a beautiful way to make your own object pooling simply. So now, performance x2...

    But either way, if you enjoy calling multiple processes instead of my way of just calling a single process, that's what you like to do. Who am I to judge? Like I said before, rule #1 is understanding your own code, so always do what makes you comfortable doing! :)
     
  14. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    and just FYI, iterating a list of classes is super quick, so if you have 10,000 players in your scene? ok, maybe expect 0.101ms as a hiccup, true..
     
  15. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    Except, for the mentioned collision check of:
    Code (CSharp):
    1. void HandleCollisionViaGetOnce(Collider col)
    2. {
    3.     Player player = col.GetComponent<Player>();
    4.     if (player == null) { print("no player found by collider"); return; }
    5.     player.health -= 10;
    6.     player.rend.enabled = false;
    7.     player.trans.position = new Vector3(100, 100, 100);
    8. }
    Nothing beats a single GetComponent() call from a collider. I just used the example above this one to show it's use. A single collider check and a single get class is the most performant way to do that. Didn't want my words twisted, lol.

    And also TryGetComponent() is actually faster and better than just get, then check if null. So I'd look into that as well:
    https://docs.unity3d.com/ScriptReference/Component.TryGetComponent.html
     
    Last edited: Sep 4, 2023
  16. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Well yeah that's a Singleton.

    Iterating a List has linear time complexity, meaning that if let's say an explosion collides with 7 different types of elements of the scene, with the architecture you are suggesting a an iteration on istances of those 7 times is required in the worst case scenario.
    Let's say that the explosion collides with a 3 damageable scene objects, 3 different kind of enemies, and a wall, you would need to iterate all the Lists containing the references to those particular types.
    I personally doubt that this is more efficient that launch a GetComponent search on only the involved objects.
     
  17. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Iterating a list is O(n), the complexity is well known.
     
  18. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    Caching is for sure a great trick that I was not using enough, to thank you for remanding. I will use it for sure.

    Yep I am alredy using this, thanks.
     
  19. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    Then you also know it depends on what it is you're iterating. A quick reference to class pointer is blazing fast, and as mentioned the only thing that beats it is GetComponent() as that uses internal pointers. And bottom on the list is interfaces.

    But I iterate through 10,000 classes(map tiles 100x100), and even with all that logic applied to what case does what, still doesn't give any noticeable frame lag at 60fps. So your mention of just iterating through a few players, is nothing to worry about because even benchmarking nanoseconds would be hard to judge the difference.

    The only time it gets heavy, for me especially, is when I do a for loop within a for loop, that gets a little hairy. will admit..

    But using my own Object Pooling with iterating through those static lists of classes, isn't any worry, even in a bullet hell. So why you're worried is beyond me.

    And what do you have against singletons? it's just a cached class reference, which stated cached beats everything. As if you did a pure
    public static Player playerMe;
    is way faster than GetComponent.
     
  20. Mobaster

    Mobaster

    Joined:
    Mar 17, 2023
    Posts:
    10
    The nightmarish n^2.
    I understand better what you are saying now, but since I cannot find anywhere online the actual implementation of GetComponet is hard for me to give out proper feedback. I will have to sit down and implement a benchmark project to tests all this stuff, but later one when I have a better grasp on Unity.

    Nothing in particular, I try to avoid them tho because of previous past experiences in which Singletons made refactoring very very hard. Since they are a "single point of failure" they can be problematic to refactor and/or become God Classes since they are very convenient to use.

    What do you mean by that?
     
  21. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    I can't find my benchmarks, but when testing long ago different ways of script to script communication, interfaces were absolutely dreadful with performance. It was something along the lines of calling a communicator to call a communicator to call what you actually want.

    However this was awhile back, they may have been improved since, as Unity changes practically every year. But like your supposed issues with singletons, it probably was something that set a bad taste in my mouth, and remembered to steer clear ever since, lol.. So it could possibly just be user error, same as finding anything wrong with singletons. The mind is a terrible thing! :oops:

    If you mean how GetComponent() works, you can go through the definitions in your script editor. But it uses C++ implementation to get just that particular pointer. But in a singular reference there's no need to call to the C++ side of things, so by an extremely small fraction singleton wins.

    But like you mention, if it comes to iterating 10+ instances, then GetComponent wins hands down. Especially with the fore-mentioned collision checking, as that would technically do n^2 in logic. Totally agree. :)

    However I could argue that checking against an array(colliders) per instance, even technically doing n^2, is still the slightest issue to worry about.

    But if you're anything like me, you want to get as many things in the game as possible, say a medieval strategy game, you want all the swordsman and archers Unity can handle to make your battles or castle sieges as epic as they can be. So by all means, aim for each micro-optimization you can! :cool:

    On that note ^: caching Transform, instead of calling
    transform.whatever;
    , since
    transform
    actually uses a GetComponent() call, is way more performant if moving a lot of objects. Also, on that note, having just one Update() move each of those cached transforms, is way faster than each class using it's own Update() to move itself. But this is all maximum performance stuff, not sure if you even need it. But thought I'd mention.
     
  22. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    853
    I think you might be approaching your system from the wrong direction. You seem to be basing your entire system around MonoBehaviours and propagating messages within a GameObject hierarchy. It should be a single centralized thing that has no concern of such stuff. It should only know two things 1) What types of messages are valid (in my example i use IMessage interface for this) and 2) What listeners are available for it.

    For example. In my system I can create a new message like so:
    Code (CSharp):
    1.  
    2. public class PlayerDiedMsg : IMessage {}
    And absolutely anyone that is interested in this particular message can register to listen like so:
    Code (CSharp):
    1.  
    2. GlobalMessageDispatcher.Instance.AddListener<PlayerDiedMsg>(HandlePlayerDied);
    3.  
    4. void HandlePlayerDied(PlayerDiedMsg msg)
    5. {
    6.     //react to player death here
    7. }
    To post the message all I need to do is:
    Code (CSharp):
    1.  
    2. GlobalMessageDispatcher.Instance.PostMessage(new PlayerDiedMsg());
    There is zero need to know about GameObject hierarchies, who is sending what or whom is listening for what. It's all completely decoupled. The only things that matter to the dispatcher system are the list of registered listeners and the messages themselves.

    If for some reason you need to know which player died or you need a reference to them then it is a simple matter to adjust the message itself like so:
    Code (CSharp):
    1.  
    2. public class PlayerDiedMsg : IMessage
    3. {
    4.     readonly PlayerRef Player;
    5.     public PlayerDiedMsg(PlayerRef player)
    6.     {
    7.         Player = player;
    8.     }
    9. }
    10.  
    Where 'PlayerRef' could be a reference to say, a monobehaviour attached to the player gameobject or something. Either way only the sender and receivers of the message care about this. The dispatcher only cares about the message itself and registered listeners.
     
    Last edited: Sep 4, 2023
    wideeyenow_unity likes this.
  23. wideeyenow_unity

    wideeyenow_unity

    Joined:
    Oct 7, 2020
    Posts:
    728
    If using my structure, it would simply be a list iteration of all that need to hear that "message", and it would just call that function(of particular calling) within those classes(that need to hear it).

    So potato/potahto, lol, it's all on how you have it structured, as there are many ways to the same thing. :cool:
     
    Sluggy likes this.
  24. Sluggy

    Sluggy

    Joined:
    Nov 27, 2012
    Posts:
    853
    I get where OP is coming from. In truth I use a combination of the two. For well-known GameObject hierarchies that are project specific I usually just keep a master list of important things in a caching behaviour like what you have. For more generic systems that need to work in a variety of projects or for cross-entity communication (like, say, the UI needs to know that the player died in my above example, so it can fade out the screen) then I always go with message dispatching since it means that I can write code that doesn't care where the source of a message came from or if anyone will react to any given message (which makes it easier to migrate code to new projects as well as mock things for testing).
     
    wideeyenow_unity likes this.
  25. SisusCo

    SisusCo

    Joined:
    Jan 29, 2019
    Posts:
    1,155
    If I understand correctly what you are after, you can achieve this by:
    1. Specifying the type of the delegate's parameter as a generic type of the interaction base class / interface.
    2. Creating an abstract base class for your responders with two generic type arguments: first being the interaction type, and the second one the parameter type.
    Code (CSharp):
    1. public interface IInteraction<TEventData>
    2. {
    3.     event UnityAction<TEventData> Performed;
    4. }
    5.  
    6. public abstract class Responder<TInteraction, TEventData> : MonoBehaviour
    7.     where TInteraction : IInteraction<TEventData>
    8. {
    9.     [SerializeField] private TInteraction interaction;
    10.  
    11.     protected abstract void OnInteractionPerformed(TEventData data);
    12.  
    13.     private void OnEnable() => interaction.Performed += OnInteractionPerformed;
    14.     private void OnDisable() => interaction.Performed -= OnInteractionPerformed;
    15. }
    Example implementation:
    Code (CSharp):
    1. public sealed class FloatingDamageIndicator : Responder<Damage, int>
    2. {
    3.     [SerializeField] private TMP_Pro text;
    4.     [SerializeField] private Animation floatingAnimation;
    5.  
    6.     protected override void OnInteractionPerformed(int damageAmount)
    7.     {
    8.         text.text = damageAmount.ToString();
    9.         floatingAnimation.Play();
    10.     }
    11. }
     
    Last edited: Sep 5, 2023