using System.Collections; using System.Collections.Generic; using Unity.Services.Lobbies.Models; using Unity.Services.Lobbies; using UnityEngine; using System.Threading.Tasks; using System; using Unity.Services.Relay; using Unity.Netcode.Transports.UTP; using Unity.XR.CoreUtils.Bindings.Variables; using Unity.Services.Authentication; using UnityEngine.SceneManagement; namespace XRMultiplayer { /// /// This class manages the relationship between Lobby, Relay, and Unity Transport. /// public class LobbyManager : MonoBehaviour { // Constants for Lobby Data. public const string k_JoinCodeKeyIdentifier = "j"; public const string k_RegionKeyIdentifier = "r"; public const string k_BuildIdKeyIdentifier = "b"; public const string k_SceneKeyIdentifier = "s"; public const string k_EditorKeyIdentifier = "e"; static bool s_HideEditorInLobbies; [Tooltip("This will prevent joining into rooms that are being hosted in different scenes.\nThis should almost always be false.")] public bool allowDifferentScenes = false; [Tooltip("This will hide editor created rooms from external builds.\nNOTE: This will not hide editor created rooms from other editors.")] public bool hideEditorFromLobby = false; // Action that gets invoked when you fail to connect to a lobby. Primarily used for noting failure messages. public Action OnLobbyFailed; // The current connected lobby. public Lobby connectedLobby { get => m_ConnectedLobby; set => m_ConnectedLobby = value; } Lobby m_ConnectedLobby; // The Transport used for connection. UnityTransport m_Transport; // This routine keeps the lobby alive once joined (by default lobbies will close after 30 seconds of inactivity. Coroutine m_HeartBeatRoutine; /// /// Subscribe to this bindable string for status updates from this class /// public static IReadOnlyBindableVariable status { get => m_Status; } readonly static BindableVariable m_Status = new(""); const string k_DebugPrepend = "[Lobby Manager] "; /// /// See . /// private void Awake() { m_Transport = FindFirstObjectByType(); if (!Application.isEditor) { hideEditorFromLobby = false; } s_HideEditorInLobbies = hideEditorFromLobby; } /// /// Quick Join Function will try and find any lobbies via QuickJoinLobbyAsync(). /// If no lobbies are found then a new lobby is created. /// /// public async Task QuickJoinLobby() { m_Status.Value = "Checking For Existing Lobbies."; Utils.Log($"{k_DebugPrepend}{m_Status.Value}"); Lobby lobby; try { Utils.Log($"{k_DebugPrepend} Getting lobby via Quick Join"); lobby = await LobbyService.Instance.QuickJoinLobbyAsync(GetQuickJoinFilterOptions()); await SetupRelay(lobby); ConnectedToLobby(lobby); if (lobby != null) { m_ConnectedLobby = lobby; return lobby; } } catch { m_Status.Value = "No Available Lobbies. Creating New Lobby."; Utils.Log($"{k_DebugPrepend}{m_Status.Value}"); } // If no existing Lobbies, then create a new one. lobby = await CreateLobby(); return lobby; } /// /// Joins a lobby. /// /// Lobby to join. /// Lobby Code to join with. /// Returns the Lobby. public async Task JoinLobby(Lobby lobby = null, string roomCode = null) { try { // If Lobby is null, then get the new lobby based on room code lobby = await GetLobby(lobby, roomCode); await SetupRelay(lobby); ConnectedToLobby(lobby); return lobby; } catch (Exception e) { string failureMessage = "Failed to Join Lobby."; Utils.Log($"{k_DebugPrepend}{e.Message}", 1); if (e is LobbyServiceException) { string message = e.Message.ToLower(); if (message.Contains("Rate limit".ToLower())) failureMessage = "Rate limit exceeded. Please try again later."; else if (message.Contains("Lobby not found".ToLower())) failureMessage = "Lobby not found. Please try a new Lobby."; else failureMessage = e.Message; } Utils.Log($"{k_DebugPrepend}{failureMessage}\n\n{e}", 1); OnLobbyFailed?.Invoke($"{failureMessage}"); return null; } } /// /// This function will try to create a lobby and host a networked session. /// /// public async Task CreateLobby(string roomName = null, bool isPrivate = false, int playerCount = XRINetworkGameManager.maxPlayers) { try { m_Status.Value = "Creating Relay"; // Creates a new Allocation based on the defined max players above var alloc = await RelayService.Instance.CreateAllocationAsync(XRINetworkGameManager.maxPlayers); m_Status.Value = "Creating Join Code"; // Get a join code based on the Allocation var joinCode = await RelayService.Instance.GetJoinCodeAsync(alloc.AllocationId); // Creates Lobby Options Dictionary for other clients to find and join var options = new CreateLobbyOptions { // Set the Data to be used for lobby filtering Data = new Dictionary { { // Set Join Code Key k_JoinCodeKeyIdentifier, new DataObject(DataObject.VisibilityOptions.Public, joinCode) }, { // Set Region Key k_RegionKeyIdentifier, new DataObject(DataObject.VisibilityOptions.Public, alloc.Region) }, { // Set Build ID Key k_BuildIdKeyIdentifier, new DataObject(DataObject.VisibilityOptions.Public, Application.version, DataObject.IndexOptions.S1) }, { // Set Scene Key k_SceneKeyIdentifier, new DataObject(DataObject.VisibilityOptions.Public, SceneManager.GetActiveScene().name, DataObject.IndexOptions.S2) }, { // Set Editor Key k_EditorKeyIdentifier, new DataObject(DataObject.VisibilityOptions.Public, hideEditorFromLobby.ToString(), DataObject.IndexOptions.S3) }, }, IsPrivate = isPrivate, }; m_Status.Value = "Creating Lobby"; // Creates the Lobby with the specified max players and lobby options. Currently just naming "General Lobby" string lobbyName = string.IsNullOrEmpty(roomName) ? $"{XRINetworkGameManager.LocalPlayerName.Value}'s Room" : $"{roomName}"; // RATE LIMIT: 2 request per 6 seconds var lobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, playerCount, options); Utils.Log($"{k_DebugPrepend}Created Lobby with Join Code: {joinCode}, Region: {alloc.Region}, Build ID: {Application.version}, Scene: {SceneManager.GetActiveScene().name}, Editor: {hideEditorFromLobby}"); // Stop the heartbeat routine if one exists, and starts a new one. This keeps the lobby active for visibility if (m_HeartBeatRoutine != null) StopCoroutine(m_HeartBeatRoutine); m_HeartBeatRoutine = StartCoroutine(LobbyHeartbeatCoroutine(lobby.Id)); //Populate the transport data with the relay info for the host (IP, port, etc...) m_Transport.SetHostRelayData(alloc.RelayServer.IpV4, (ushort)alloc.RelayServer.Port, alloc.AllocationIdBytes, alloc.Key, alloc.ConnectionData); ConnectedToLobby(lobby); return lobby; } catch (Exception e) { string failureMessage = "Failed to Create Lobby. Please try again."; Utils.Log($"{k_DebugPrepend}{failureMessage}\n\n{e}", 1); // Debug.LogWarning($"[XRMPT] {failureMessage}\n\n{e}"); OnLobbyFailed?.Invoke(failureMessage); return null; } } async Task SetupRelay(Lobby lobby) { m_Status.Value = "Connecting To Relay"; // Get the Join Allocation for the lobby based on the key var alloc = await RelayService.Instance.JoinAllocationAsync(lobby.Data[k_JoinCodeKeyIdentifier].Value); // Set the transport client data (IP, port, etc..) m_Transport.SetClientRelayData ( alloc.RelayServer.IpV4, (ushort)alloc.RelayServer.Port, alloc.AllocationIdBytes, alloc.Key, alloc.ConnectionData, alloc.HostConnectionData ); return; } QuickJoinLobbyOptions GetQuickJoinFilterOptions() { QuickJoinLobbyOptions options = new QuickJoinLobbyOptions(); // Create Filter Option to prevent showing any application versions that are not the same. QueryFilter applicationVersionIdFilter = new QueryFilter(field: QueryFilter.FieldOptions.S1, value: Application.version, QueryFilter.OpOptions.EQ); // Create Filter Option for different scenes. QueryFilter sceneNameFilter = new QueryFilter(field: QueryFilter.FieldOptions.S2, value: SceneManager.GetActiveScene().name, QueryFilter.OpOptions.EQ); // Create Filter Option for hiding editor created rooms from builds. QueryFilter editorFilter = new QueryFilter(field: QueryFilter.FieldOptions.S3, value: hideEditorFromLobby.ToString(), QueryFilter.OpOptions.EQ); options.Filter = new List { applicationVersionIdFilter, sceneNameFilter, editorFilter }; return options; } public async void ReconnectToLobby() { if (Application.isPlaying) { await LobbyService.Instance.ReconnectToLobbyAsync(m_ConnectedLobby.Id); } } /// /// This function will get a lobby based on the passed in parameters. /// /// Lobby to join. /// Lobby Code to join with. /// Returns the Lobby. async Task GetLobby(Lobby lobby = null, string roomCode = null) { if (roomCode != null) { // RATE LIMIT: 2 request per 6 seconds Utils.Log($"{k_DebugPrepend} Getting lobby via Code: {roomCode}"); return await LobbyService.Instance.JoinLobbyByCodeAsync(roomCode); } else if (lobby != null) { // RATE LIMIT: 2 request per 6 seconds Utils.Log($"{k_DebugPrepend} Getting lobby via Lobby Id: {lobby.Id}"); return await LobbyService.Instance.JoinLobbyByIdAsync(lobby.Id); } else { // RATE LIMIT: 1 request per 10 seconds Utils.Log($"{k_DebugPrepend} Getting lobby via Quick Join"); return await LobbyService.Instance.QuickJoinLobbyAsync(GetQuickJoinFilterOptions()); } } /// /// Called after setting transport data with relay allocations when either Creating a new lobby or joining an existing lobby. /// void ConnectedToLobby(Lobby lobby) { m_ConnectedLobby = lobby; m_Status.Value = "Connected To Lobby"; } /// /// Heartbeat used to keep the lobby alive. By default the lobby will shut down after 30 seconds on inactivity. /// /// Id for the specific lobby to keep alive. /// Time to wait between pings. /// IEnumerator LobbyHeartbeatCoroutine(string lobbyId, float waitTimeSeconds = 15.0f) { // Setup a new wait based on wait time in seconds var delay = new WaitForSecondsRealtime(waitTimeSeconds); while (true) { // Continuously ping the lobby to keep it alive LobbyService.Instance.SendHeartbeatPingAsync(lobbyId); Utils.Log($"{k_DebugPrepend}Sending Heartbeat Ping for Lobby {lobbyId}"); yield return delay; } } /// /// Changes the existing lobbies name. /// /// Name to change the lobby to. public async void UpdateLobbyName(string lobbyName) { if (m_ConnectedLobby != null) { try { UpdateLobbyOptions options = new() { Name = lobbyName, HostId = AuthenticationService.Instance.PlayerId }; await LobbyService.Instance.UpdateLobbyAsync(m_ConnectedLobby.Id, options); XRINetworkGameManager.ConnectedRoomName.Value = lobbyName; } catch (LobbyServiceException e) { Utils.Log($"{k_DebugPrepend}{e}"); } } else { Utils.Log($"{k_DebugPrepend}Connected Lobby is null"); } } /// /// Updates the privacy setting for the current room. /// /// Whether or not to make the room private. public async void UpdateRoomPrivacy(bool privateRoom) { if (m_ConnectedLobby != null) { try { UpdateLobbyOptions options = new() { IsPrivate = privateRoom }; await LobbyService.Instance.UpdateLobbyAsync(m_ConnectedLobby.Id, options); } catch (LobbyServiceException e) { Utils.Log($"{k_DebugPrepend}{e}"); } } else { Utils.Log($"{k_DebugPrepend}Connected Lobby is null"); } } /// /// Called when leaving a room. /// If Hosting, this function deletes the lobby for everyone. /// If a client, this function removes the client from the lobby. /// /// /// public async Task RemovePlayerFromLobby(string playerId) { // Stop heartbeat if active (only runs on host) if (m_HeartBeatRoutine != null) StopCoroutine(m_HeartBeatRoutine); try { if (m_ConnectedLobby != null) { // Check if Lobby Host is current Player if (m_ConnectedLobby.HostId == playerId) { // Delete Lobby if current owner Utils.Log($"{k_DebugPrepend}Owner of lobby, shutting down."); await LobbyService.Instance.DeleteLobbyAsync(m_ConnectedLobby.Id); m_ConnectedLobby = null; } else { //Remove from lobby await RemoveFromLobby(playerId); } return true; } } catch (Exception e) { Utils.Log($"{k_DebugPrepend}Error on Lobby Shutdown:\n\n {e}"); } return false; } /// /// Attempts to remove the current player from the lobby /// async Task RemoveFromLobby(string playerId) { // If lobby id exists try to remove player from if (!string.IsNullOrEmpty(m_ConnectedLobby.Id)) { try { await LobbyService.Instance.RemovePlayerAsync(m_ConnectedLobby.Id, playerId); m_ConnectedLobby = null; Utils.Log($"{k_DebugPrepend}Successfully removed player from Lobby."); return true; } catch (Exception e) { Utils.Log($"{k_DebugPrepend}Failed to remove player from lobby.\n\n{e}"); } } return false; } public static async Task GetLobbiesAsync() { // Use these options to apply things like filters, ordering, etc... // Additionally you can add your own filters like below to have more control over the data. QueryLobbiesOptions lobbyOptions = new QueryLobbiesOptions(); return await LobbyService.Instance.QueryLobbiesAsync(lobbyOptions); } public static bool CheckForLobbyFilter(Lobby lobby) { // If the lobby is not in the same scene, skip it if (lobby.Data.TryGetValue(k_SceneKeyIdentifier, out DataObject sceneData)) { if (sceneData.Value != SceneManager.GetActiveScene().name) { return true; } } if (lobby.Data.TryGetValue(k_EditorKeyIdentifier, out DataObject editorData)) { // If the lobby is an editor lobby is set to filter return true if (editorData.Value == "True" & !s_HideEditorInLobbies) { return true; } } return false; } public static bool CheckForIncompatibilityFilter(Lobby lobby) { if (lobby.Data.TryGetValue(k_BuildIdKeyIdentifier, out DataObject data)) { //Filter out lobbies that are on different build versions if (data.Value != Application.version) { return true; } } return false; } public static bool CanJoinLobby(Lobby lobby) { return (XRINetworkGameManager.Instance.lobbyManager.connectedLobby == null) || (XRINetworkGameManager.Instance.lobbyManager.connectedLobby != null && lobby.Id != XRINetworkGameManager.Instance.lobbyManager.connectedLobby.Id); } } }