Skip to content

Commit 1f4a077

Browse files
feat: add singleplayer transport component [backport of 3473] (#3475)
This PR includes the `SinglePlayerTransport` that provides users with the ability to start a single player game if: - using either network topology - using the `SinglePlayerTransport` - starting a session as a host <!-- Add short version of the JIRA ticket to the PR title (e.g. "feat: new shiny feature [MTT-123]") --> ## Changelog - Added: `SinglePlayerTransport` that provides the ability to start as a host for a single player network session. ## Testing and Documentation - Includes SinglePlayerTransportTests integration tests. - Requires adding section to public documentation ([PR-1476](Unity-Technologies/com.unity.multiplayer.docs#1476)). ## Backport This is a backport of #3473 <!-- If this is a backport: - Add the following to the PR title: "\[Backport\] ..." . - Link to the original PR. If this needs a backport - state this here If a backport is not needed please provide the reason why. If the "Backports" section is not present it will lead to a CI test failure. -->
1 parent ea046c5 commit 1f4a077

File tree

6 files changed

+383
-0
lines changed

6 files changed

+383
-0
lines changed

com.unity.netcode.gameobjects/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Additional documentation and release notes are available at [Multiplayer Documen
1010

1111
### Added
1212

13+
- Added `SinglePlayerTransport` that provides the ability to start as a host for a single player network session. (#3475)
1314
- 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)
1415

1516
### Fixed

com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer.meta

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace Unity.Netcode.Transports.SinglePlayer
5+
{
6+
/// <summary>
7+
/// A transport that can be used to run a Netcode for GameObjects session in "single player" mode
8+
/// by assigning this transport to the <see cref="NetworkConfig.NetworkTransport"/> property before
9+
/// starting as a host.
10+
/// </summary>
11+
/// <remarks>
12+
/// You can only start as a host when using this transport.
13+
/// </remarks>
14+
public class SinglePlayerTransport : NetworkTransport
15+
{
16+
/// <inheritdoc/>
17+
public override ulong ServerClientId { get; } = 0;
18+
19+
internal static string NotStartingAsHostErrorMessage = $"When using {nameof(SinglePlayerTransport)}, you must start a hosted session so both client and server are available locally.";
20+
21+
private struct MessageData
22+
{
23+
public ulong FromClientId;
24+
public ArraySegment<byte> Payload;
25+
public NetworkEvent Event;
26+
public float AvailableTime;
27+
}
28+
29+
private static Dictionary<ulong, Queue<MessageData>> s_MessageQueue = new Dictionary<ulong, Queue<MessageData>>();
30+
31+
private bool m_Initialized;
32+
private ulong m_TransportId = 0;
33+
private NetworkManager m_NetworkManager;
34+
35+
36+
/// <inheritdoc/>
37+
public override void Send(ulong clientId, ArraySegment<byte> payload, NetworkDelivery networkDelivery)
38+
{
39+
var copy = new byte[payload.Array.Length];
40+
Array.Copy(payload.Array, copy, payload.Array.Length);
41+
s_MessageQueue[clientId].Enqueue(new MessageData
42+
{
43+
FromClientId = m_TransportId,
44+
Payload = new ArraySegment<byte>(copy, payload.Offset, payload.Count),
45+
Event = NetworkEvent.Data,
46+
AvailableTime = (float)m_NetworkManager.LocalTime.FixedTime,
47+
});
48+
}
49+
50+
/// <inheritdoc/>
51+
public override NetworkEvent PollEvent(out ulong clientId, out ArraySegment<byte> payload, out float receiveTime)
52+
{
53+
if (s_MessageQueue[m_TransportId].Count > 0)
54+
{
55+
var data = s_MessageQueue[m_TransportId].Peek();
56+
if (data.AvailableTime > m_NetworkManager.LocalTime.FixedTime)
57+
{
58+
clientId = 0;
59+
payload = new ArraySegment<byte>();
60+
receiveTime = 0;
61+
return NetworkEvent.Nothing;
62+
}
63+
64+
s_MessageQueue[m_TransportId].Dequeue();
65+
clientId = data.FromClientId;
66+
payload = data.Payload;
67+
receiveTime = m_NetworkManager.LocalTime.TimeAsFloat;
68+
if (m_NetworkManager.IsServer && data.Event == NetworkEvent.Connect)
69+
{
70+
s_MessageQueue[data.FromClientId].Enqueue(new MessageData { Event = NetworkEvent.Connect, FromClientId = ServerClientId, Payload = new ArraySegment<byte>() });
71+
}
72+
return data.Event;
73+
}
74+
clientId = 0;
75+
payload = new ArraySegment<byte>();
76+
receiveTime = 0;
77+
return NetworkEvent.Nothing;
78+
}
79+
80+
/// <inheritdoc/>
81+
/// <remarks>
82+
/// This will always return false for <see cref="SinglePlayerTransport"/>.
83+
/// Always use <see cref="StartServer"/>.
84+
/// </remarks>
85+
public override bool StartClient()
86+
{
87+
NetworkLog.LogError(NotStartingAsHostErrorMessage);
88+
return false;
89+
}
90+
91+
/// <inheritdoc/>
92+
/// <remarks>
93+
/// Always use <see cref="NetworkManager.StartHost"/> when hosting a local single player session.
94+
/// </remarks>
95+
public override bool StartServer()
96+
{
97+
s_MessageQueue[ServerClientId] = new Queue<MessageData>();
98+
if (!m_NetworkManager.LocalClient.IsHost && m_NetworkManager.LocalClient.IsServer)
99+
{
100+
NetworkLog.LogError(NotStartingAsHostErrorMessage);
101+
return false;
102+
}
103+
return true;
104+
}
105+
106+
/// <inheritdoc/>
107+
public override void DisconnectRemoteClient(ulong clientId)
108+
{
109+
s_MessageQueue[clientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment<byte>() });
110+
}
111+
112+
/// <inheritdoc/>
113+
public override void DisconnectLocalClient()
114+
{
115+
s_MessageQueue[ServerClientId].Enqueue(new MessageData { Event = NetworkEvent.Disconnect, FromClientId = m_TransportId, Payload = new ArraySegment<byte>() });
116+
}
117+
118+
/// <inheritdoc/>
119+
/// <remarks>
120+
/// Will always return 0 since this transport is for a local single player session.
121+
/// </remarks>
122+
public override ulong GetCurrentRtt(ulong clientId)
123+
{
124+
return 0;
125+
}
126+
127+
/// <inheritdoc/>
128+
public override void Shutdown()
129+
{
130+
s_MessageQueue.Clear();
131+
m_TransportId = 0;
132+
}
133+
134+
/// <inheritdoc/>
135+
public override void Initialize(NetworkManager networkManager = null)
136+
{
137+
s_MessageQueue.Clear();
138+
m_NetworkManager = networkManager;
139+
}
140+
}
141+
}

com.unity.netcode.gameobjects/Runtime/Transports/SinglePlayer/SinglePlayerTransport.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
4+
using NUnit.Framework;
5+
using Unity.Netcode.TestHelpers.Runtime;
6+
using Unity.Netcode.Transports.SinglePlayer;
7+
using UnityEngine;
8+
using UnityEngine.TestTools;
9+
using Random = UnityEngine.Random;
10+
11+
namespace Unity.Netcode.RuntimeTests
12+
{
13+
internal class SinglePlayerTransportTests : NetcodeIntegrationTest
14+
{
15+
protected override int NumberOfClients => 0;
16+
17+
public struct SerializableStruct : INetworkSerializable, IEquatable<SerializableStruct>
18+
{
19+
public bool BoolValue;
20+
public ulong ULongValue;
21+
22+
public bool Equals(SerializableStruct other)
23+
{
24+
return other.BoolValue == BoolValue && other.ULongValue == ULongValue;
25+
}
26+
27+
public void NetworkSerialize<T>(BufferSerializer<T> serializer) where T : IReaderWriter
28+
{
29+
serializer.SerializeValue(ref BoolValue);
30+
serializer.SerializeValue(ref ULongValue);
31+
}
32+
}
33+
34+
public class SinglePlayerTestComponent : NetworkBehaviour
35+
{
36+
private enum SpawnStates
37+
{
38+
PreSpawn,
39+
Spawn,
40+
PostSpawn,
41+
}
42+
43+
private enum RpcInvocations
44+
{
45+
SendToServerRpc,
46+
SendToEveryoneRpc,
47+
SendToOwnerRpc,
48+
}
49+
50+
private Dictionary<SpawnStates, int> m_SpawnStateInvoked = new Dictionary<SpawnStates, int>();
51+
private Dictionary<RpcInvocations, int> m_RpcInvocations = new Dictionary<RpcInvocations, int>();
52+
private NetworkVariable<int> m_IntValue = new NetworkVariable<int>();
53+
private NetworkVariable<SerializableStruct> m_SerializableValue = new NetworkVariable<SerializableStruct>();
54+
55+
56+
private void SpawnStateInvoked(SpawnStates spawnState)
57+
{
58+
if (!m_SpawnStateInvoked.ContainsKey(spawnState))
59+
{
60+
m_SpawnStateInvoked.Add(spawnState, 1);
61+
}
62+
else
63+
{
64+
m_SpawnStateInvoked[spawnState]++;
65+
}
66+
}
67+
68+
private void RpcInvoked(RpcInvocations rpcInvocation)
69+
{
70+
if (!m_RpcInvocations.ContainsKey(rpcInvocation))
71+
{
72+
m_RpcInvocations.Add(rpcInvocation, 1);
73+
}
74+
else
75+
{
76+
m_RpcInvocations[rpcInvocation]++;
77+
}
78+
}
79+
80+
private void ValidateValues(int someIntValue, SerializableStruct someValues)
81+
{
82+
Assert.IsTrue(m_IntValue.Value == someIntValue);
83+
Assert.IsTrue(someValues.BoolValue == m_SerializableValue.Value.BoolValue);
84+
Assert.IsTrue(someValues.ULongValue == m_SerializableValue.Value.ULongValue);
85+
}
86+
87+
[Rpc(SendTo.Server)]
88+
private void SendToServerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
89+
{
90+
ValidateValues(someIntValue, someValues);
91+
RpcInvoked(RpcInvocations.SendToServerRpc);
92+
}
93+
94+
[Rpc(SendTo.Everyone)]
95+
private void SendToEveryoneRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
96+
{
97+
ValidateValues(someIntValue, someValues);
98+
RpcInvoked(RpcInvocations.SendToEveryoneRpc);
99+
}
100+
101+
[Rpc(SendTo.Owner)]
102+
private void SendToOwnerRpc(int someIntValue, SerializableStruct someValues, RpcParams rpcParams = default)
103+
{
104+
ValidateValues(someIntValue, someValues);
105+
RpcInvoked(RpcInvocations.SendToOwnerRpc);
106+
}
107+
108+
109+
protected override void OnNetworkPreSpawn(ref NetworkManager networkManager)
110+
{
111+
SpawnStateInvoked(SpawnStates.PreSpawn);
112+
base.OnNetworkPreSpawn(ref networkManager);
113+
}
114+
115+
public override void OnNetworkSpawn()
116+
{
117+
SpawnStateInvoked(SpawnStates.Spawn);
118+
m_IntValue.Value = Random.Range(0, 100);
119+
m_SerializableValue.Value = new SerializableStruct()
120+
{
121+
BoolValue = Random.Range(0, 100) >= 50.0 ? true : false,
122+
ULongValue = (ulong)Random.Range(0, 100000),
123+
};
124+
base.OnNetworkSpawn();
125+
}
126+
127+
protected override void OnNetworkPostSpawn()
128+
{
129+
SpawnStateInvoked(SpawnStates.PostSpawn);
130+
SendToServerRpc(m_IntValue.Value, m_SerializableValue.Value);
131+
SendToEveryoneRpc(m_IntValue.Value, m_SerializableValue.Value);
132+
SendToOwnerRpc(m_IntValue.Value, m_SerializableValue.Value);
133+
base.OnNetworkPostSpawn();
134+
}
135+
136+
public void ValidateStatesAndRpcInvocations()
137+
{
138+
foreach (var entry in m_SpawnStateInvoked)
139+
{
140+
Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!");
141+
}
142+
foreach (var entry in m_RpcInvocations)
143+
{
144+
Assert.True(entry.Value == 1, $"{entry.Key} failed with {entry.Value} invocations!");
145+
}
146+
}
147+
}
148+
149+
private GameObject m_PrefabToSpawn;
150+
private bool m_CanStartHost;
151+
152+
protected override IEnumerator OnSetup()
153+
{
154+
m_CanStartHost = false;
155+
return base.OnSetup();
156+
}
157+
158+
protected override void OnCreatePlayerPrefab()
159+
{
160+
m_PlayerPrefab.AddComponent<SinglePlayerTestComponent>();
161+
base.OnCreatePlayerPrefab();
162+
}
163+
164+
protected override void OnServerAndClientsCreated()
165+
{
166+
var singlePlayerTransport = m_ServerNetworkManager.gameObject.AddComponent<SinglePlayerTransport>();
167+
m_ServerNetworkManager.NetworkConfig.NetworkTransport = singlePlayerTransport;
168+
m_PrefabToSpawn = CreateNetworkObjectPrefab("TestObject");
169+
m_PrefabToSpawn.AddComponent<SinglePlayerTestComponent>();
170+
base.OnServerAndClientsCreated();
171+
}
172+
173+
protected override bool CanStartServerAndClients()
174+
{
175+
return m_CanStartHost;
176+
}
177+
178+
[UnityTest]
179+
public IEnumerator StartSinglePlayerAndSpawn()
180+
{
181+
m_CanStartHost = true;
182+
183+
yield return StartServerAndClients();
184+
185+
var spawnedInstance = SpawnObject(m_PrefabToSpawn, m_ServerNetworkManager).GetComponent<NetworkObject>();
186+
var testComponent = spawnedInstance.GetComponent<SinglePlayerTestComponent>();
187+
yield return s_DefaultWaitForTick;
188+
var playerTestComponent = m_ServerNetworkManager.LocalClient.PlayerObject.GetComponent<SinglePlayerTestComponent>();
189+
testComponent.ValidateStatesAndRpcInvocations();
190+
playerTestComponent.ValidateStatesAndRpcInvocations();
191+
}
192+
193+
[UnityTest]
194+
public IEnumerator StartSinglePlayerAsClientError()
195+
{
196+
LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}");
197+
LogAssert.Expect(LogType.Error, $"[Netcode] Client is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!");
198+
Assert.IsFalse(m_ServerNetworkManager.StartClient());
199+
yield return null;
200+
}
201+
202+
[UnityTest]
203+
public IEnumerator StartSinglePlayerAsServerError()
204+
{
205+
LogAssert.Expect(LogType.Error, $"[Netcode] {SinglePlayerTransport.NotStartingAsHostErrorMessage}");
206+
LogAssert.Expect(LogType.Error, $"[Netcode] Server is shutting down due to network transport start failure of {nameof(SinglePlayerTransport)}!");
207+
Assert.IsFalse(m_ServerNetworkManager.StartServer());
208+
yield return null;
209+
}
210+
}
211+
}

com.unity.netcode.gameobjects/Tests/Runtime/Transports/SinglePlayerTransportTests.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)