Search Unity

My newbie version of observer pattern... nicely layered lasagna or bowl of spaghetti?

Discussion in 'Scripting' started by djweaver, Sep 19, 2020.

?

Lasagna or Spaghetti?

  1. Lasagna

    100.0%
  2. Spaghetti

    0 vote(s)
    0.0%
  1. djweaver

    djweaver

    Joined:
    Jul 4, 2020
    Posts:
    105
    I was toying around with creating an event system for my tic tac toe game when I had this idea to implement a variation of the observer pattern where virtually every major component inherits from a class that acts as both observer and provider, transmitting messages over channels kind of like a walkie talkie or cb radio (former truck driver here, hehe, HATED channel 19 chatter btw). Fast forward by about a dozen marlboros and I have created this system where you have a class Entity that inherits from MonoBehaviour, which represents each "entity" or object system within the game. Thats a pretty broad paintbrush, but essentially there are three tiers of "entities" that communicate at different levels (ie. on different channels). The tiers are as follows:

    The System tier which serves as a root for the entire communication structure. This consists of a Manager entity (think of it like a game manager) that observes the systemChannel. The systemChannel is primarily for registering/unregistering entities, scene control, and data injection (not yet implemented in my example, but think of loading/saving to json for data continuity and injecting it upon request when requested from other entities)

    The next tier is the Controller tier. A controller entity represents a control point for vital abstract systems... in my game which I will hopefully be releasing by the end of this decade, there are (currently) three major groupings that each observe their own channels... World, Game, and Interface. Okay, I was kidding, its not tic tac toe... anyways, moving on: These entity structures exist in their own namespaces and their Controller.cs script inherits from Entity, making them capable of tuning in to messages (I call them Requests). For example, the Interface.Controller sends and receives requests pertaining to UI components... it is a parent object in-scene, over every interface object. Because I have three controllers, I have three corresponding channels... worldChannel, gameChannel, and interfaceChannel, but eventually there will be more. Imagine a playerChannel, enemiesChannel, npcChannel, etc. Each Entity (or more properly at this tier, Controller which extends Entity) can subscribe to every event across their respective channel with one simple Listen(string channel) function.

    The bottom tier is your Object/Component tier. These Entities are components of each of the above systems. They do not get their own channels. Instead they communicate on the channel of their Controller, shooting requests and responses much like HTTP traffic. In fact, a lot of these concepts are gleaned from my javascript and RESTful api shenanigans, including Express whereby you have routing elegantly handled and easily introduced to middlewares that can manipulate requests and responses which brings me to the event system itself:

    You might be asking yourself, why do all this? Why not just use the event system as-is? Well, I don't know. Maybe you can tell me if this is overengineered trash or not (I am too newbie to know the difference). But I do know this... there is very little coupling in this system. You can introduce anything at any time, and that thing can send a message to any number of objects over a specific channel... all with one function: Events.transmit(Request request). So long as it inherits from Entity, no references are needed whatsoever.

    The Request class is basically a data class that represents instructions about what you are transmitting... much like an http request. When you communicate via Events.transmit(Request request) you instantiate a request object and it gets pushed via a single action/event to a Router script. This router is a single class instantiated on a game object in the scene, and it parses the data and routes it to the appropriate channel. Each Request contains data such as Guid of the sender (which is created upon instantiating the Entity and registered with the System tier Manager), target channel, instructions for basic CRUD functions, data, etc.

    Upon instantiation of every Entity, a Guid is generated and a System tier request is made to add that Entity to a dictionary handled by the Manager. IF you want your Entity to listen to any channel using the Listen(string channel) function, the Entity instantiates an EventHandler class, which sets a delegate to whatever channel you Listen() to. This delegate is your Inbox, and when your Inbox is triggered, you get the Request object of the sender. The eventual goal or idea behind this system of routing, is to allow easy introduction of modules that can manipulate requests at the System level (ie. when it hits the router)... again, much like middlewares in http routing.

    Anyways, I was hoping to get some feedback on how overengineered awesome this is. Heres the code... (each component is 50 lines or less):

    Entity.cs
    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4.  
    5. public class Entity : MonoBehaviour
    6. {
    7.     private EventHandler eventHandler;
    8.     protected Action<Request> Inbox;
    9.     protected List<string> Subscriptions;
    10.     public Guid id { get; }
    11.     public string entityName = null;
    12.  
    13.     public Entity()
    14.     {
    15.         id = Guid.NewGuid();
    16.         Subscriptions = new List<string>();
    17.     }
    18.  
    19.     protected void Register(string name)
    20.     {
    21.         Request regMsg = new Request(id, "system", "update", 0f, name);
    22.         Events.transmit(regMsg);
    23.     }
    24.  
    25.     protected void Listen(string channel)
    26.     {
    27.         if (Subscriptions.Contains(channel)) return;
    28.         Subscriptions.Add(channel);
    29.         eventHandler = new EventHandler(channel);
    30.         eventHandler.inbox += Receive;
    31.     }
    32.  
    33.     protected void UnListen(string channel)
    34.     {
    35.         if (!Subscriptions.Contains(channel)) return;
    36.         Subscriptions.Remove(channel);
    37.         eventHandler.UnsetChannel(channel);
    38.     }
    39.  
    40.     protected void UnListenAll()
    41.     {
    42.         string[] channels = { "world", "game", "system", "interface" };
    43.         foreach (string channel in Subscriptions) eventHandler.UnsetChannel(channel);
    44.         foreach (string channel in channels) Subscriptions.Remove(channel);
    45.         eventHandler.inbox -= Inbox;
    46.     }
    47.  
    48.     private void Receive(Request request) => Inbox?.Invoke(request);
    49. }
    50.  

    EventHandler.cs
    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3.  
    4. public class EventHandler
    5. {
    6.     private string _channel;
    7.     private Dictionary<string, Action> eventSubscriptions;
    8.     private Dictionary<string, Action> eventUnsubscriptions;
    9.     public Action<Request> inbox;
    10.  
    11.     public EventHandler(string channel)
    12.     {
    13.         _channel = channel;
    14.         SetChannel(channel);
    15.     }
    16.  
    17.     void Receive(Request request)
    18.     {
    19.         inbox?.Invoke(request);
    20.     }
    21.  
    22.     public void SetChannel(string channel)
    23.     {
    24.         eventSubscriptions = new Dictionary<string, Action>()
    25.         {
    26.             {"system", () => Events.systemChannel += Receive},
    27.             {"world", () => Events.worldChannel += Receive},
    28.             {"game", () => Events.gameChannel += Receive},
    29.             {"interface", () => Events.interfaceChannel += Receive}
    30.         };
    31.         UnsetChannel(_channel);
    32.         _channel = channel;
    33.         eventSubscriptions[channel].Invoke();
    34.     }
    35.  
    36.     public void UnsetChannel(string channel)
    37.     {
    38.         eventUnsubscriptions = new Dictionary<string, Action>()
    39.         {
    40.             {"system", () => Events.systemChannel -= Receive},
    41.             {"world", () => Events.worldChannel -= Receive},
    42.             {"game", () => Events.gameChannel -= Receive},
    43.             {"interface", () => Events.interfaceChannel -= Receive}
    44.         };
    45.         eventUnsubscriptions[channel].Invoke();
    46.     }
    47. }
    48.  

    Router.cs
    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4.  
    5. public class Router : MonoBehaviour
    6. {
    7.     private Dictionary<string, Action> Channels;
    8.     void OnEnable() => Events.transmit += Route;
    9.     void OnDisable() => Events.transmit -= Route;
    10.     void Route(Request request)
    11.     {
    12.         Channels = new Dictionary<string, Action>(){
    13.                 {"system", () => Events.systemChannel?.Invoke(request)},
    14.                 {"world", () => Events.worldChannel?.Invoke(request)},
    15.                 {"game", () => Events.gameChannel?.Invoke(request)},
    16.                 {"interface", () => Events.interfaceChannel?.Invoke(request)}
    17.         };
    18.         Channels[request.channel].Invoke();
    19.     }
    20. }

    Manager.cs
    Code (CSharp):
    1. using System;
    2. using System.Collections.Generic;
    3.  
    4. public class Manager : Entity
    5. {
    6.     public static Dictionary<Guid, string> Entities;
    7.     void Awake()
    8.     {
    9.         Entities = new Dictionary<Guid, string>();
    10.         Entities.Add(id, "Manager");
    11.     }
    12.     void OnEnable()
    13.     {
    14.         Listen("system");
    15.         Inbox += Received;
    16.     }
    17.     void OnDisable() => UnListenAll();
    18.     void Received(Request request)
    19.     {
    20.         if (request.action == "update") RegisterEntity(request.id, request.data);
    21.     }
    22.  
    23.     void RegisterEntity(Guid id, string name)
    24.     {
    25.         Entities.Add(id, name);
    26.     }
    27. }

    Controller.cs
    Code (CSharp):
    1. namespace World
    2. {
    3.     public class Controller : Entity
    4.     {
    5.         void Start() => Register("World Controller");
    6.         void OnEnable()
    7.         {
    8.             Listen("world");
    9.             Inbox += Received;
    10.         }
    11.         void OnDisable() => UnListenAll();
    12.         void Received(Request request) => print("REQ from: ID" + request.id);
    13.     }
    14. }

    Events.cs
    Code (CSharp):
    1. using System;
    2.  
    3. public static class Events
    4. {
    5.     public static Action<Request> transmit;
    6.     public static Action<Request> worldChannel;
    7.     public static Action<Request> gameChannel;
    8.     public static Action<Request> interfaceChannel;
    9.     public static Action<Request> systemChannel;
    10.     // public static Action pause;
    11.     // public static Action dawn;
    12.     // public static Action noon;
    13.     // public static Action dusk;
    14.     // public static Action midnight;
    15. }

     
  2. Kurt-Dekker

    Kurt-Dekker

    Joined:
    Mar 16, 2013
    Posts:
    38,745
    This is generally not a good heuristic for selecting a particular engineering approach, unless you're simply interested in learning more about the observer pattern. In that case, sure, give it a whirl, that's how we learn!

    Ideally you would have an actual quantifiable problem you're trying to solve, one that you can clearly articulate, and then research approaches to solving the problem, and try one or two of them to see if it actually works.

    That's actually engineering.

    As for evaluating the code, not one single soul here could possibly help in this regard as they can only look at it, which isn't useful. It will require you to use it, perhaps only for a little while, perhaps for much longer, to establish a feel for whether it is an improvement over what you had before.

    As always, use source control (git works awesomely with Unity3D) so you can instantly revert when you realize your solution makes something worse in a way that you cannot live with.
     
    djweaver likes this.
  3. djweaver

    djweaver

    Joined:
    Jul 4, 2020
    Posts:
    105
    @Kurt-Dekker yeah, you are right lol, I've actually realized how pointless this system is in the context of the game I'm making but man, I've learned so much from toying around with this. Coming from javascript to C# has blown my mind because there are so many things built into C# that allow you to do crazy, (albeit convoluted and overengineered) stuff like this:

    Code (CSharp):
    1. public class Router : MonoBehaviour
    2. {
    3.     private Dictionary<Guid, Dictionary<string, Delegate>> Channels =
    4.         new Dictionary<Guid, Dictionary<string, Delegate>>();
    5.     public static Action<Guid, List<string>, object> registration;
    6.     public int delegateCount = 0;
    7.     public int channelCount = 0;
    8.     void OnEnable() => registration += Register;
    9.     void OnDisable() => registration -= Register;
    10.     void Register(Guid id, List<string> channels, object obj)
    11.     {
    12.         print("ADDING DELEGATES");
    13.         Dictionary<string, Delegate> actionDict = new Dictionary<string, Delegate>();
    14.         foreach (string action in channels)
    15.         {
    16.             var current = Delegate.CreateDelegate(typeof(Action<Request>), obj, action);
    17.             actionDict.Add(action, current);
    18.             delegateCount++;
    19.         }
    20.         Channels.Add(id, actionDict);
    21.         channelCount++;
    22.         Request msg = new Request("LOL");
    23.         Channels[id]["Message"]?.DynamicInvoke(msg);
    24.     }
    25. }
    This is something I designed for my router module that allows you to create a list of actions/events from a monobehaviour at runtime. It assigns each grouping of events to the particular Guid of the instance calling it, which I refer to as the "Channel", and passes a dictionary of the delegates back to that instance. As is, it uses the string list it gets passed to automagically assign these delegates to overrides of abstract functions in the base class of the instance here:

    Code (CSharp):
    1. public abstract class Entity : MonoBehaviour
    2. {
    3.     public Guid id = Guid.NewGuid();
    4.     public Channel channel;
    5.     protected abstract void Message(Request request);
    6.     protected abstract void Read(Request request);
    7.     protected List<string> actions = new List<string>()
    8.     {
    9.         "Message",
    10.         "Read",
    11.     };
    12.     protected void Register()
    13.     {
    14.         channel = new Channel(id, actions, (object)this);
    15.     }
    16. }
    In this particular example, the base class generates two predefined actions from a list, Message and Read, however if you wanted to, you could provide general purpose abstract method declarations for any number of custom actions that could be generated at runtime by the derived class. Here is an example of the derived class, which can be instanced:

    Code (CSharp):
    1. public class Manager : Entity
    2. {
    3.     void Start() => Register();
    4.  
    5.     protected override void Message(Request req)
    6.     {
    7.         print(req.body);
    8.     }
    9.     protected override void Read(Request req)
    10.     {
    11.         print(req.body);
    12.     }
    13.     void OnDisable() { }
    14. }
    And here is the actual "Channel" class that is used to generate a unique instance of the actions for each particular Manager class instance, so that channels don't conflict:

    Code (CSharp):
    1. public class Channel
    2. {
    3.     Guid id;
    4.     object obj;
    5.     List<string> actions;
    6.     public Channel(Guid id, List<string> actions, object obj)
    7.     {
    8.  
    9.         Router.registration?.Invoke(id, actions, obj);
    10.     }
    11. }
    12.  

    Its hard to imagine a use-case for this, but I love exploring this kind of stuff. Maybe you could use this to do something like bring up a console window or even a chat window and generate your own unique actionable channels at runtime. On the surface all it needs is a string or list of strings.
     
    Last edited: Sep 21, 2020
    Kurt-Dekker likes this.