diff --git a/com.unity.netcode.gameobjects/CHANGELOG.md b/com.unity.netcode.gameobjects/CHANGELOG.md index b96f523884..3d8cc3cc01 100644 --- a/com.unity.netcode.gameobjects/CHANGELOG.md +++ b/com.unity.netcode.gameobjects/CHANGELOG.md @@ -10,6 +10,7 @@ Additional documentation and release notes are available at [Multiplayer Documen ### Added +- Added `SinglePlayerTransport` that provides the ability to start as a host for a single player network session. (#3475) - When using UnityTransport >=2.4 and Unity >= 6000.1.0a1, SetConnectionData will accept a fully qualified hostname instead of an IP as a connect address on the client side. (#3440) ### Fixed diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta new file mode 100644 index 0000000000..602b45af91 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 889368c8893d63b4aa3cf53ff3427dd4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs new file mode 100644 index 0000000000..b7b22e764d --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; + +namespace Unity.Netcode.Transports.SinglePlayer +{ + /// + /// A transport that can be used to run a Netcode for GameObjects session in "single player" mode + /// by assigning this transport to the property before + /// starting as a host. + /// + /// + /// You can only start as a host when using this transport. + /// + public class SinglePlayerTransport : NetworkTransport + { + /// + public override ulong ServerClientId { get; } = 0; + + internal static string NotStartingAsHostErrorMessage = $"When using {nameof(SinglePlayerTransport)}, you must start a hosted session so both client and server are available locally."; + + private struct MessageData + { + public ulong FromClientId; + public ArraySegment Payload; + public NetworkEvent Event; + public float AvailableTime; + } + + private static Dictionary> s_MessageQueue = new Dictionary>(); + + private bool m_Initialized; + private ulong m_TransportId = 0; + private NetworkManager m_NetworkManager; + + + /// + public override void Send(ulong clientId, ArraySegment payload, NetworkDelivery networkDelivery) + { + var copy = new byte[payload.Array.Length]; + Array.Copy(payload.Array, copy, payload.Array.Length); + s_MessageQueue[clientId].Enqueue(new MessageData + { + FromClientId = m_TransportId, + Payload = new ArraySegment(copy, payload.Offset, payload.Count), + Event = NetworkEvent.Data, + AvailableTime = (float)m_NetworkManager.LocalTime.FixedTime, + }); + } + + /// + public override NetworkEvent PollEvent(out ulong clientId, out ArraySegment payload, out float receiveTime) + { + if (s_MessageQueue[m_TransportId].Count > 0) + { + var data = s_MessageQueue[m_TransportId].Peek(); + if (data.AvailableTime > m_NetworkManager.LocalTime.FixedTime) + { + clientId = 0; + payload = new ArraySegment(); + receiveTime = 0; + return NetworkEvent.Nothing; + } + + s_MessageQueue[m_TransportId].Dequeue(); + clientId = data.FromClientId; + payload = data.Payload; + receiveTime = m_NetworkManager.LocalTime.TimeAsFloat; + if (m_NetworkManager.IsServer && data.Event == NetworkEvent.Connect) + { + s_MessageQueue[data.FromClientId].Enqueue(new MessageData { Event = NetworkEvent.Connect, FromClientId = ServerClientId, Payload = new ArraySegment() }); + } + return data.Event; + } + clientId = 0; + payload = new ArraySegment(); + receiveTime = 0; + return NetworkEvent.Nothing; + } + + /// + /// + /// This will always return false for . + /// Always use . + /// + public override bool StartClient() + { + NetworkLog.LogError(NotStartingAsHostErrorMessage); + return false; + } + + /// + /// + /// Always use when hosting a local single player session. + /// + public override bool StartServer() + { + s_MessageQueue[ServerClientId] = new Queue(); + if (!m_NetworkManager.LocalClient.IsHost && m_NetworkManager.LocalClient.IsServer) + { + NetworkLog.LogError(NotStartingAsHostErrorMessage); + return false; + } + return true; + } + + /// + public override void DisconnectRemoteClient(ulong clientId) + { + s_MessageQueue[clientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment() }); + } + + /// + public override void DisconnectLocalClient() + { + s_MessageQueue[ServerClientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment() }); + } + + /// + /// + /// Will always return 0 since this transport is for a local single player session. + /// + public override ulong GetCurrentRtt(ulong clientId) + { + return 0; + } + + /// + public override void Shutdown() + { + s_MessageQueue.Clear(); + m_TransportId = 0; + } + + /// + public override void Initialize(NetworkManager networkManager = null) + { + s_MessageQueue.Clear(); + m_NetworkManager = networkManager; + } + } +} diff --git a/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta new file mode 100644 index 0000000000..f25e5bfc84 --- /dev/null +++ b/com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f0dbadd1461dfad4094dd12411d30d7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs new file mode 100644 index 0000000000..4f198c73d2 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using Unity.Netcode.TestHelpers.Runtime; +using Unity.Netcode.Transports.SinglePlayer; +using UnityEngine; +using UnityEngine.TestTools; +using Random = UnityEngine.Random; + +namespace Unity.Netcode.RuntimeTests +{ + internal class SinglePlayerTransportTests : NetcodeIntegrationTest + { + protected override int NumberOfClients => 0; + + public struct SerializableStruct : INetworkSerializable, IEquatable + { + public bool BoolValue; + public ulong ULongValue; + + public bool Equals(SerializableStruct other) + { + return other.BoolValue == BoolValue && other.ULongValue == ULongValue; + } + + public void NetworkSerialize(BufferSerializer serializer) where T : IReaderWriter + { + serializer.SerializeValue(ref BoolValue); + serializer.SerializeValue(ref ULongValue); + } + } + + public class SinglePlayerTestComponent : NetworkBehaviour + { + private enum SpawnStates + { + PreSpawn, + Spawn, + PostSpawn, + } + + private enum RpcInvocations + { + SendToServerRpc, + SendToEveryoneRpc, + SendToOwnerRpc, + } + + private Dictionary m_SpawnStateInvoked = new Dictionary(); + private Dictionary m_RpcInvocations = new Dictionary(); + private NetworkVariable m_IntValue = new NetworkVariable(); + private NetworkVariable m_SerializableValue = new NetworkVariable(); + + + private void SpawnStateInvoked(SpawnStates spawnState) + { + if (!m_SpawnStateInvoked.ContainsKey(spawnState)) + { + m_SpawnStateInvoked.Add(spawnState, 1); + } + else + { + m_SpawnStateInvoked[spawnState]++; + } + } + + private void RpcInvoked(RpcInvocations rpcInvocation) + { + if (!m_RpcInvocations.ContainsKey(rpcInvocation)) + { + m_RpcInvocations.Add(rpcInvocation, 1); + } + else + { + m_RpcInvocations[rpcInvocation]++; + } + } + + private void ValidateValues(int someIntValue, SerializableStruct someValues) + { + Assert.IsTrue(m_IntValue.Value == someIntValue); + Assert.IsTrue(someValues.BoolValue == m_SerializableValue.Value.BoolValue); + Assert.IsTrue(someValues.ULongValue == m_SerializableValue.Value.ULongValue); + } + + [Rpc(SendTo.Server)] + private void SendToServerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default) + { + ValidateValues(someIntValue, someValues); + RpcInvoked(RpcInvocations.SendToServerRpc); + } + + [Rpc(SendTo.Everyone)] + private void SendToEveryoneRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default) + { + ValidateValues(someIntValue, someValues); + RpcInvoked(RpcInvocations.SendToEveryoneRpc); + } + + [Rpc(SendTo.Owner)] + private void SendToOwnerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default) + { + ValidateValues(someIntValue, someValues); + RpcInvoked(RpcInvocations.SendToOwnerRpc); + } + + + protected override void OnNetworkPreSpawn(ref NetworkManager networkManager) + { + SpawnStateInvoked(SpawnStates.PreSpawn); + base.OnNetworkPreSpawn(ref networkManager); + } + + public override void OnNetworkSpawn() + { + SpawnStateInvoked(SpawnStates.Spawn); + m_IntValue.Value = Random.Range(0, 100); + m_SerializableValue.Value = new SerializableStruct() + { + BoolValue = Random.Range(0, 100) >= 50.0 ? true : false, + ULongValue = (ulong)Random.Range(0, 100000), + }; + base.OnNetworkSpawn(); + } + + protected override void OnNetworkPostSpawn() + { + SpawnStateInvoked(SpawnStates.PostSpawn); + SendToServerRpc(m_IntValue.Value, m_SerializableValue.Value); + SendToEveryoneRpc(m_IntValue.Value, m_SerializableValue.Value); + SendToOwnerRpc(m_IntValue.Value, m_SerializableValue.Value); + base.OnNetworkPostSpawn(); + } + + public void ValidateStatesAndRpcInvocations() + { + foreach (var entry in m_SpawnStateInvoked) + { + Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!"); + } + foreach (var entry in m_RpcInvocations) + { + Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!"); + } + } + } + + private GameObject m_PrefabToSpawn; + private bool m_CanStartHost; + + protected override IEnumerator OnSetup() + { + m_CanStartHost = false; + return base.OnSetup(); + } + + protected override void OnCreatePlayerPrefab() + { + m_PlayerPrefab.AddComponent(); + base.OnCreatePlayerPrefab(); + } + + protected override void OnServerAndClientsCreated() + { + var singlePlayerTransport = m_ServerNetworkManager.gameObject.AddComponent(); + m_ServerNetworkManager.NetworkConfig.NetworkTransport = singlePlayerTransport; + m_PrefabToSpawn = CreateNetworkObjectPrefab("TestObject"); + m_PrefabToSpawn.AddComponent(); + base.OnServerAndClientsCreated(); + } + + protected override bool CanStartServerAndClients() + { + return m_CanStartHost; + } + + [UnityTest] + public IEnumerator StartSinglePlayerAndSpawn() + { + m_CanStartHost = true; + + yield return StartServerAndClients(); + + var spawnedInstance = SpawnObject(m_PrefabToSpawn, m_ServerNetworkManager).GetComponent(); + var testComponent = spawnedInstance.GetComponent(); + yield return s_DefaultWaitForTick; + var playerTestComponent = m_ServerNetworkManager.LocalClient.PlayerObject.GetComponent(); + testComponent.ValidateStatesAndRpcInvocations(); + playerTestComponent.ValidateStatesAndRpcInvocations(); + } + + [UnityTest] + public IEnumerator StartSinglePlayerAsClientError() + { + LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}"); + LogAssert.Expect(LogType.Error, $"[Netcode] Client is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!"); + Assert.IsFalse(m_ServerNetworkManager.StartClient()); + yield return null; + } + + [UnityTest] + public IEnumerator StartSinglePlayerAsServerError() + { + LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}"); + LogAssert.Expect(LogType.Error, $"[Netcode] Server is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!"); + Assert.IsFalse(m_ServerNetworkManager.StartServer()); + yield return null; + } + } +} diff --git a/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta new file mode 100644 index 0000000000..5c7a643dd7 --- /dev/null +++ b/com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6a485adbf124d44da6b526871aff1a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: