using System.Collections.Generic;
using Unity.Networking.Transport;
using UnityEngine;
namespace LobbyRelaySample.relay
{
public enum Approval { OK = 0, GameAlreadyStarted }
///
/// This observes the local player and updates remote players over Relay when there are local changes, demonstrating basic data transfer over the Unity Transport (UTP).
/// Created after the connection to Relay has been confirmed.
///
public class RelayUtpClient : MonoBehaviour // This is a MonoBehaviour merely to have access to Update.
{
protected LobbyUser m_localUser;
protected LocalLobby m_localLobby;
protected NetworkDriver m_networkDriver;
protected List m_connections; // For clients, this has just one member, but for hosts it will have more.
protected bool m_IsRelayConnected { get { return m_localLobby.RelayServer != null; } }
protected bool m_hasSentInitialMessage = false;
private const float k_heartbeatPeriod = 5;
protected enum MsgType { Ping = 0, NewPlayer, PlayerApprovalState, ReadyState, PlayerName, Emote, StartCountdown, CancelCountdown, ConfirmInGame, EndInGame, PlayerDisconnect }
public virtual void Initialize(NetworkDriver networkDriver, List connections, LobbyUser localUser, LocalLobby localLobby)
{
m_localUser = localUser;
m_localLobby = localLobby;
m_localUser.onChanged += OnLocalChange;
m_networkDriver = networkDriver;
m_connections = connections;
Locator.Get.UpdateSlow.Subscribe(UpdateSlow, k_heartbeatPeriod);
}
protected virtual void Uninitialize()
{
m_localUser.onChanged -= OnLocalChange;
Leave();
Locator.Get.UpdateSlow.Unsubscribe(UpdateSlow);
m_connections.Clear();
m_networkDriver.Dispose();
}
public void OnDestroy()
{
Uninitialize();
}
private void OnLocalChange(LobbyUser localUser)
{
if (m_connections.Count == 0) // This could be the case for the host alone in the lobby.
return;
foreach (NetworkConnection conn in m_connections)
DoUserUpdate(m_networkDriver, conn, m_localUser);
}
private void Update()
{
OnUpdate();
}
///
/// Clients need to send any data over UTP periodically, or else Relay will remove them from the allocation.
///
private void UpdateSlow(float dt)
{
if (!m_IsRelayConnected) // If disconnected from Relay for some reason, we *want* this client to timeout.
return;
foreach (NetworkConnection connection in m_connections)
WriteByte(m_networkDriver, connection, "0", MsgType.Ping, 0); // The ID doesn't matter here, so send a minimal number of bytes.
}
protected virtual void OnUpdate()
{
if (!m_hasSentInitialMessage)
ReceiveNetworkEvents(m_networkDriver); // Just on the first execution, make sure to handle any events that accumulated while completing the connection.
m_networkDriver.ScheduleUpdate().Complete(); // This pumps all messages, which pings the Relay allocation and keeps it alive. It should be called no more often than ReceiveNetworkEvents.
ReceiveNetworkEvents(m_networkDriver); // This reads the message queue which was just updated.
if (!m_hasSentInitialMessage)
SendInitialMessage(m_networkDriver, m_connections[0]); // On a client, the 0th (and only) connection is to the host.
}
private void ReceiveNetworkEvents(NetworkDriver driver)
{
NetworkConnection conn;
DataStreamReader strm;
NetworkEvent.Type cmd;
while ((cmd = driver.PopEvent(out conn, out strm)) != NetworkEvent.Type.Empty) // NetworkConnection also has PopEvent, but NetworkDriver.PopEvent automatically includes new connections.
{
ProcessNetworkEvent(conn, strm, cmd);
}
}
// See the Write* methods for the expected event format.
private void ProcessNetworkEvent(NetworkConnection conn, DataStreamReader strm, NetworkEvent.Type cmd)
{
if (cmd == NetworkEvent.Type.Data)
{
List msgContents = new List(ReadMessageContents(ref strm));
if (msgContents.Count < 3) // We require at a minimum - Message type, the length of the user ID, and the user ID.
return;
MsgType msgType = (MsgType)msgContents[0];
int idLength = msgContents[1];
if (msgContents.Count < idLength + 2)
{ UnityEngine.Debug.LogWarning($"Relay client processed message of length {idLength}, but contents were of length {msgContents.Count}.");
return;
}
string id = System.Text.Encoding.UTF8.GetString(msgContents.GetRange(2, idLength).ToArray());
if (!CanProcessDataEventFor(conn, msgType, id))
return;
msgContents.RemoveRange(0, 2 + idLength);
if (msgType == MsgType.PlayerApprovalState)
{
Approval approval = (Approval)msgContents[0];
if (approval == Approval.OK && !m_localUser.IsApproved)
OnApproved(m_networkDriver, conn);
else if (approval == Approval.GameAlreadyStarted)
Locator.Get.Messenger.OnReceiveMessage(MessageType.DisplayErrorPopup, "Rejected: Game has already started.");
}
else if (msgType == MsgType.PlayerName)
{
int nameLength = msgContents[0];
string name = System.Text.Encoding.UTF8.GetString(msgContents.GetRange(1, nameLength).ToArray());
m_localLobby.LobbyUsers[id].DisplayName = name;
}
else if (msgType == MsgType.Emote)
{
EmoteType emote = (EmoteType)msgContents[0];
m_localLobby.LobbyUsers[id].Emote = emote;
}
else if (msgType == MsgType.ReadyState)
{
UserStatus status = (UserStatus)msgContents[0];
m_localLobby.LobbyUsers[id].UserStatus = status;
}
else if (msgType == MsgType.StartCountdown)
Locator.Get.Messenger.OnReceiveMessage(MessageType.StartCountdown, null);
else if (msgType == MsgType.CancelCountdown)
Locator.Get.Messenger.OnReceiveMessage(MessageType.CancelCountdown, null);
else if (msgType == MsgType.ConfirmInGame)
Locator.Get.Messenger.OnReceiveMessage(MessageType.ConfirmInGameState, null);
else if (msgType == MsgType.EndInGame)
Locator.Get.Messenger.OnReceiveMessage(MessageType.EndGame, null);
ProcessNetworkEventDataAdditional(conn, msgType, id);
}
else if (cmd == NetworkEvent.Type.Disconnect)
ProcessDisconnectEvent(conn, strm);
}
protected virtual bool CanProcessDataEventFor(NetworkConnection conn, MsgType type, string id)
{
// Don't react to our own messages. Also, don't need to hold onto messages if the ID is absent; clients should be initialized and in the lobby before they send events.
// (Note that this enforces lobby membership before processing any events besides an approval request, so a client is unable to fully use Relay unless they're in the lobby.)
return id != m_localUser.ID && (m_localUser.IsApproved && m_localLobby.LobbyUsers.ContainsKey(id) || type == MsgType.PlayerApprovalState);
}
protected virtual void ProcessNetworkEventDataAdditional(NetworkConnection conn, MsgType msgType, string id) { }
protected virtual void ProcessDisconnectEvent(NetworkConnection conn, DataStreamReader strm)
{
// The host disconnected, and Relay does not support host migration. So, all clients should disconnect.
string msg;
if (m_IsRelayConnected)
msg = "Host disconnected! Leaving the lobby.";
else
msg = "Connection to host was lost. Leaving the lobby.";
Debug.LogError(msg);
Locator.Get.Messenger.OnReceiveMessage(MessageType.DisplayErrorPopup, msg);
Leave();
Locator.Get.Messenger.OnReceiveMessage(MessageType.ChangeGameState, GameState.JoinMenu);
}
///
/// UTP uses raw pointers for efficiency (i.e. C-style byte* instead of byte[]).
/// ReadMessageContents converts them back to byte arrays, assuming the stream contains 1 byte for array length followed by contents.
/// Any actual pointer manipulation and so forth happens service-side, so we simply need to convert back to a byte array here.
///
unsafe private byte[] ReadMessageContents(ref DataStreamReader strm) // unsafe is required to access the pointer.
{
int length = strm.Length;
byte[] bytes = new byte[length];
fixed (byte* ptr = bytes)
{
strm.ReadBytes(ptr, length);
}
return bytes;
}
///
/// Once a client is connected, send a message out alerting the host.
///
private void SendInitialMessage(NetworkDriver driver, NetworkConnection connection)
{
WriteByte(driver, connection, m_localUser.ID, MsgType.NewPlayer, 0);
m_hasSentInitialMessage = true;
}
private void OnApproved(NetworkDriver driver, NetworkConnection connection)
{
m_localUser.IsApproved = true;
ForceFullUserUpdate(driver, connection, m_localUser);
}
///
/// When player data is updated, send out events for just the data that actually changed.
///
private void DoUserUpdate(NetworkDriver driver, NetworkConnection connection, LobbyUser user)
{
// Only update with actual changes. (If multiple change at once, we send messages for each separately, but that shouldn't happen often.)
if (0 < (user.LastChanged & LobbyUser.UserMembers.DisplayName))
WriteString(driver, connection, user.ID, MsgType.PlayerName, user.DisplayName);
if (0 < (user.LastChanged & LobbyUser.UserMembers.Emote))
WriteByte(driver, connection, user.ID, MsgType.Emote, (byte)user.Emote);
if (0 < (user.LastChanged & LobbyUser.UserMembers.UserStatus))
WriteByte(driver, connection, user.ID, MsgType.ReadyState, (byte)user.UserStatus);
}
///
/// Sometimes (e.g. when a new player joins), we need to send out the full current state of this player.
///
protected void ForceFullUserUpdate(NetworkDriver driver, NetworkConnection connection, LobbyUser user)
{
// Note that it would be better to send a single message with the full state, but for the sake of shorter code we'll leave that out here.
WriteString(driver, connection, user.ID, MsgType.PlayerName, user.DisplayName);
WriteByte(driver, connection, user.ID, MsgType.Emote, (byte)user.Emote);
WriteByte(driver, connection, user.ID, MsgType.ReadyState, (byte)user.UserStatus);
}
///
/// Write string data as: [1 byte: msgType] [1 byte: id length N] [N bytes: id] [1 byte: string length M] [M bytes: string]
///
protected void WriteString(NetworkDriver driver, NetworkConnection connection, string id, MsgType msgType, string str)
{
byte[] idBytes = System.Text.Encoding.UTF8.GetBytes(id);
byte[] strBytes = System.Text.Encoding.UTF8.GetBytes(str);
List message = new List(idBytes.Length + strBytes.Length + 3); // Extra 3 bytes for the msgType plus the ID and message lengths.
message.Add((byte)msgType);
message.Add((byte)idBytes.Length);
message.AddRange(idBytes);
message.Add((byte)strBytes.Length);
message.AddRange(strBytes);
SendMessageData(driver, connection, message);
}
///
/// Write byte data as: [1 byte: msgType] [1 byte: id length N] [N bytes: id] [1 byte: data]
///
protected void WriteByte(NetworkDriver driver, NetworkConnection connection, string id, MsgType msgType, byte value)
{
byte[] idBytes = System.Text.Encoding.UTF8.GetBytes(id);
List message = new List(idBytes.Length + 3); // Extra 3 bytes for the msgType, ID length, and the byte value.
message.Add((byte)msgType);
message.Add((byte)idBytes.Length);
message.AddRange(idBytes);
message.Add(value);
SendMessageData(driver, connection, message);
}
private void SendMessageData(NetworkDriver driver, NetworkConnection connection, List message)
{
if (driver.BeginSend(connection, out var dataStream) == 0)
{
byte[] bytes = message.ToArray();
unsafe // Similarly to ReadMessageContents, our data must be converted to a pointer before being sent.
{
fixed (byte* bytesPtr = bytes)
{
dataStream.WriteBytes(bytesPtr, message.Count);
driver.EndSend(dataStream);
}
}
}
}
///
/// Disconnect from Relay, usually while leaving the lobby. (You can also call this elsewhere to see how Lobby will detect a Relay disconnect automatically.)
///
public virtual void Leave()
{
foreach (NetworkConnection connection in m_connections)
// If the client calls Disconnect, the host might not become aware right away (depending on when the PubSub messages get pumped), so send a message over UTP instead.
WriteByte(m_networkDriver, connection, m_localUser.ID, MsgType.PlayerDisconnect, 0);
m_localLobby.RelayServer = null;
}
}
}