Unity-Technologies / com.unity.netcode.gameobjects

Netcode for GameObjects is a high-level netcode SDK that provides networking capabilities to GameObject/MonoBehaviour workflows within Unity and sits on top of underlying transport layer.
MIT License
2.14k stars 434 forks source link

Support for Subclass/Inherited/Polymorphic RPC Parameters #2602

Open SoulMuncher opened 1 year ago

SoulMuncher commented 1 year ago

Is your feature request related to a problem? Please describe. I am unable to send subclasses through an RPC without them turning into the highest parent class, unless I explicitly define and checking Enums for each one within the parent class, which is not always feasible, and never ideal. I believe this is a problem with all inherited things, including structs and interfaces, not just classes. See end of NGO Document: https://docs-multiplayer.unity3d.com/netcode/current/advanced-topics/serialization/inetworkserializable/

Describe the solution you'd like The object sent through RPC's should keep its initial type, and should use its own overridden NetworkSerialize function to determine exactly what data needs to be serialized and networked.

In this case, I should be able to send a Cat, Dog, or Bird through this function, and all clients should create the respective new Cat(), new Dog(), or new Bird() with any defined serialization from those specific subclasses, as opposed to serializing the more generic/abstract "Animal"

public void SendAnimalToClients(Animal animal)
{
    Debug.Log($"Server Send: { animal }"); //correctly logs the animal as "Cat", "Dog", "Bird", etc.
    SendAnimalClientRPC(animal);
}

[ClientRpc]
public void SendAnimalClientRPC(Animal animal) //"Cat", "Dog", "Bird" incorrectly gets turned into an "Animal" and uses Animal's generic NetworkSerialize function, so no "Cat", "Dog", "Bird" specific data is serialized.
{
    Debug.Log($"Client Recieve: { animal }"); //logs the animal as an abstract "animal." No subclass-data is retained, no way to determine if the animal is actually "Cat", "Dog", "Bird", etc.
}

Describe alternatives you've considered I believe this is a reasonable implementation, as it reflects the standard programming syntax found in nearly every programming language.

Additional context From my understanding, the recommended solution for inheritance is to create a new manager class that holds an Animal, and check Enums to create a new instance of the subclass during serialization. This is obviously not a convenient nor efficient solution if there are lots of different animals, it does not allow any subclass specific data to be transferred over the RPC, and it does not allow the user to follow good coding practices (no polymorphism)

public enum AnimalTypes
            {
                Cat,
                Dog
            }

public AnimalTypes animalType;
public Animal animal;

public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
            {
                serializer.SerializeValue(ref animalType);
                if (serializer.IsWriter && animal != null)
                {
                    animal.NetworkSerialize(serializer);
                }
                else
                {
                    switch (animalType)
                    {
                        case AnimalTypes.Cat:
                            {
                                animal = new Cat();
                                break;
                            }
                        case AnimalTypes.Dog:
                            {
                                animal = new Dog();
                                break;
                            }
                    }

                    if (animal != null)
                    {
                        animal.NetworkSerialize(serializer);
                    }
                }
            }
NoelStephensUnity commented 1 year ago

Hi @SoulMuncher!

This kind of functionality is not supported "out of the box", but you could do something like this:

using System;
using System.Reflection;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
​
public class BaseClass : INetworkSerializable
{
    static private Dictionary<string, Type> RegisteredTypes = new Dictionary<string, Type>();
    private string SerializedClass;
    private string ClassType;
​
    public static void RegisterAllTypes()
    {
        foreach(var type in Assembly.GetExecutingAssembly().GetTypes())
        {
            if (type.IsSubclassOf(typeof(BaseClass)))
            {
                RegisterType(type);
            }
        }
    }
​
    private static void RegisterType(Type classType)
    {
        if (!RegisteredTypes.ContainsKey(classType.Name))
        {
            RegisteredTypes.Add(classType.Name, classType);
        }
    }
​
    public BaseClass GetInstance()
    {
        if (!RegisteredTypes.ContainsKey(ClassType))
        {
            // If the receiver hasn't created an instance go ahead and register all child derived
            // classes of BaseClass
            RegisterAllTypes();
            // If we still don't find an entry then there is a mismatch in the build where the sender
            // has a new or old class that doesn't exist on the receiver side
            if (!RegisteredTypes.ContainsKey(ClassType))
            {
                Debug.LogError($"Could not find class type ({ClassType})!");
                return null;
            }
​
        }
        return (BaseClass)JsonUtility.FromJson(SerializedClass, RegisteredTypes[ClassType]);
    }
​
    public virtual void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        if (serializer.IsWriter)
        {
            SerializedClass = JsonUtility.ToJson(this);
        }
        serializer.SerializeValue(ref ClassType);
        serializer.SerializeValue(ref SerializedClass);
    }
​
    public virtual void Test()
    {
        throw new System.NotImplementedException();
    }
​
    public BaseClass()
    {
        var classType = GetType();
        RegisterType(GetType());
        ClassType = classType.Name;
    }
}
​
​
[Serializable]
public class FirstChildClass : BaseClass
{
    [SerializeField]
    public int var1;
    [SerializeField]
    public Vector3 var2;
​
    public FirstChildClass(int var1, Vector3 var2)
    {
        this.var1 = var1;
        this.var2 = var2;
    }
    public override void Test()
    {
        Debug.Log($"[FirstChildClass] {var1} | {var2}");
    }
}
​
[Serializable]
public class SecondChildClass : BaseClass
{
    [SerializeField]
    public long var1;
    [SerializeField]
    public Vector3Int var2;
​
    public SecondChildClass(long var1, Vector3Int var2)
    {
        this.var1 = var1;
        this.var2 = var2;
    }
​
    public override void Test()
    {
        Debug.Log($"[SecondChildClass] {var1} | {var2}");
    }
}

And here is a NetworkBehaviour component so you can see it working:

using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
​
public class NetworkBehaviourTest : NetworkBehaviour
{
    [ServerRpc(RequireOwnership = false)]
    private void SendBaseClassDerivedClassServerRpc(BaseClass baseClass, ServerRpcParams serverRpcParams = default)
    {
        // Get the actual instance
        var instance = baseClass.GetInstance();
        // Log the instance type
        Debug.Log(instance);
        // Invoke the virtual method
        instance.Test();
    }
​
    public void Test1()
    {
        Debug.Log("[Test] Sending first child derived class");
        SendBaseClassDerivedClassServerRpc(new FirstChildClass(2, Vector3.one * 2));
    }
    public void Test2()
    {
        Debug.Log("[Test] Sending second child derived class");
        SendBaseClassDerivedClassServerRpc(new SecondChildClass(3, Vector3Int.one * 3));
    }
​
    private void Update()
    {
        if (!IsSpawned)
        {
            return;
        }
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            Test1();
        }
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            Test2();
        }
    }
}

The end result should look like this in the console output: image

Let me know if this provides you with enough information to accomplish your project's design requirements?

SoulMuncher commented 1 year ago

@NoelStephensUnity

Thanks! This definitely looks better, but I'm not sure I know enough about serialization to say if this would work as well as I'd like... This will be a fairly common RPC call in my game, so I am concerned that sending everything to json will produce too much excess data. I would also like it to be able to utilize the NetworkSerialize() functions I've already created so I won't have to define the behavior twice (one for custom json to prevent excess data, and one for usual NGO serialization). I would also like my BaseClass to be abstract, but I could always make a wrapper or similar to solve this.

It could be that I created this problem by setting up the entity system the way I did but, you will probably have better insight on whether or not that's true than I will. Here is the relevant/simplified code from my project to show what I wish to accomplish. In case it matters, I plan on having upwards of 200 entity states (X entities * X states per entity):

public abstract class EntityState<T> : INetworkSerializable where T : Entity
{
    public T context; //dont need to serialize this

    public abstract void EnterState();
    public abstract void UpdateState();
    public abstract void ExitState();

    //TODO - Serialize?
    public virtual void NetworkSerialize<T999>(BufferSerializer<T999> serializer) where T999 : IReaderWriter
    {
        //
    }
}
public class Follow : EntityState<Skeleton>
    {
        public Entity target;
        public int followSpeed;

        public override void EnterState() { }
        public override void ExitState() { }
        public override void UpdateState()
        {
            if (CanAttack())
            {
                context.SwitchStateServer(new Attack01() { target = target, cooldown = GetCooldown(), direction = someVector3 }); //example of switching state and data that may need to be serialized
            }
        }

        public override void NetworkSerialize<T999>(BufferSerializer<T999> serializer)
        {
            //this state can define its own serialization, and leave out whatever fields may not need to be serialized over the network
            target.NetworkSerialize(serializer); //allows the entity to be serialized as defined in that script?
            serializer.SerializeValue(ref followSpeed); //serialize other data specific to this EntityState
        }
    }
public abstract class Entity : NetworkBehaviour, INetworkSerializable
{
    public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
    {
        //Not implemented yet.
        //TODO - Just serialize "Entity" as an int = NetObjId, then find matching entity on client
        //dont send this to json, because we need a reference rather than a new object

        //NetworkObjectReference netObjRef = NetworkObject;
        //ushort netBehaviourId = NetworkBehaviourId;
        //netObjRef.NetworkSerialize(serializer);
        //serializer.SerializeValue(ref netBehaviourId);
    }
}
public class Skeleton : StandardEntity
    {
        public EntityState<Skeleton> currentState; //Idle, Follow, Attack, etc.

        protected override void Start()
        {
            SwitchStateServer(new Idle());
        }

        protected override void Update()
        {
            currentState.UpdateState();
        }

        public void SwitchState(EntityState<Skeleton> state)
        {
            if (currentState != null)
            {
                currentState.ExitState();
            }

            currentState = state;
            currentState.context = this;
            currentState.EnterState();
        }

        public void SwitchStateServer(EntityState<Skeleton> state)
        {
            if (!IsServer) return;
            SwitchStateServerX(state); //TODO - should also switch state on the server probably, unable to test until basic serialization structure is done
        }

        [ServerRpc]
        public void SwitchStateServerRPC(EntityState<Skeleton> state) => SwitchStateServerX(state);
        public void SwitchStateServerX(EntityState<Skeleton> state)
        {
            Debug.Log($"Server Send: {state}"); //logs the exact entity state (Idle, Follow, Attack, etc.)
            SwitchStateClientRPC(state);
        }

        [ClientRpc]
        public void SwitchStateClientRPC(EntityState<Skeleton> state) => SwitchStateClientX(state);
        public void SwitchStateClientX(EntityState<Skeleton> state)
        {
            Debug.Log($"Client Recieve: { state }"); //TODO - logs generic entity state
            SwitchState(state);
        }
    }
NoelStephensUnity commented 1 year ago

This is where I think you might run into issues with the current direction/approach:

    //TODO - Just serialize "Entity" as an int = NetObjId, then find matching entity on client
    //dont send this to json, **because we need a reference rather than a new object**

The thing to realize is that serializing will always construction a new instance on the reader/receiver side. So, in the end it looks like you are trying to replicate what NGO already does with NetworkVariables...which is identify "which" NetworkBehavior (and which NetworkObject) a NetworkVariable delta should be applied to.

What it looks like you want to do is have a state machine that will invoke unique behaviors on AI and update those behaviors on other clients?

I can help by providing an example state machine that bases its behavior on a derived ScriptableObject (AIScriptable) that you would create unique instances of and assign to a list for a unique AI NetworkBehaviour in the inspector view. Each unique AI NetworkBehaviour could have a completely unique set of AIScriptables or could have some unique ones and share other more common ones. Each AIScriptable would register a specific ID relative to the AI NetworkBehaviour it was assigned to and the AI NetworkBehaviour would hold a NetworkVariable (possibly int) that identifies the index of the assigned AIScriptable and would automatically synchronize with clients when the "AIScriptable state" changed...

The question you should ask yourself is: Do you really want the AI state to be updated on all clients or do you want the AI state to update on the server/host that presents the new state's behavior through its "in-game actions" (i.e. how it moves, if it attacks, its animations, all of these would typically be driven by the server and updated via transform, animations, and potentially sounds)?

SoulMuncher commented 1 year ago

@NoelStephensUnity Do you really want the AI state to be updated on all clients or do you want the AI state to update on the server/host that presents the new state's behavior through its "in-game actions"

I am fairly certain that I want the AI state to be updated and simulated on all clients. It's important to me that my game feels good to play despite potential network lag, and having enemies simulated on the client seems like the best way to go about that. Whether or not you get hit by something in the game should be determined entirely on your device and your screen, as opposed to some abstract game state on the server that you can't actually see. The game is not competitive and is entirely co-op, so I'm not worried about most potential issues with client authority such as cheating/server validation.

I also believe that if I can network the entity state changes and data within those new states, everything else (transform, animation, sound) will take care of itself, to an extent. This is a puzzle piece that would make nearly everything else fall into place. Once this is done, I have to figure out late join synchronization, and then the entire foundation of the game would be complete, and it'd just be content additions from that point forward. To rework everything with server-authoritative state simulation seems like more work for a worse experience?

Your scriptable object solution sounds nice for the state machine, but I don't see how it solves the issue of sending all of the relevant data within that state/AI Behavior. It can synchronize the state with an ID, which will update the state of an entity to "AIFollow" for example, but this will be a newly created "AIFollow", and will not have any of the important data passed through, such as what thing we're following exactly?

Synchronizing/Serializing entities as an ID was a side note, and something I haven't done enough looking into. It makes way more sense to pass a reference or identifier for the entity, rather than create a new one, at any time an "entity" would be serialized, but it sounds like you're saying it's not possible to do that? Even by using custom serialization or something like that?