using UnityEngine; using Normal.Realtime; using TMPro; using System.Collections; using System; using UnityEngine.SceneManagement; using UnityEngine.Serialization; namespace BalloonBatsQuest { public class NormcoreRoomManager : MonoBehaviour { /// #region Constants private const int TOTAL_ROOMS = 10000; public const string DefaultRoomConfiguration = "default"; public const string QuickmatchRoomConfiguration = "large"; public const int DefaultCustomRoomCapacity = 8; public const int DefaultQuickmatchCapacity = 40; private const float c_SessionInitTimeout = 15f; // Timeout for waiting on PlayerSessionManager initialization [Range(1, 100)] [SerializeField] private int m_MaxPlayersPerRoom = DefaultCustomRoomCapacity; [SerializeField, Range(1, 200)] private int m_QuickmatchMaxPlayers = DefaultQuickmatchCapacity; private const string ROOM_PREFIX = "Room "; #endregion #region Private Fields [SerializeField] private Realtime m_Realtime; private int m_CurrentRoomNumber = 1; private bool m_IsConnecting = false; private RealtimeAvatarManager m_AvatarManager; private bool m_ShouldCheckOverflow = false; private bool m_IsQuickmatchRoom = false; private bool m_LastConnectWasQuickmatch = false; // Room search flow private int m_TargetRoomNumber = 1; private bool m_IsSearchingForAvailableRoom = false; private int m_QueuedNextRoomNumber = -1; [Header("Admin Debugging")] [Tooltip("When enabled, admins join Quickmatch instead of the admin room.")] [FormerlySerializedAs("m_AdminDebugStartInRoom1")] [InspectorName("Admin Debug: Start In Quickmatch")] [SerializeField] private bool m_AdminDebugStartInQuickmatch = false; [Header("Quickmatch")] [Tooltip("Base name for quickmatch room groups (final group = prefix + index).")] [SerializeField] private string m_QuickmatchGroupPrefix = "Quickmatch-Lobby-"; [Tooltip("Group index appended to the prefix for quickmatch grouping.")] [SerializeField, Range(1, 100)] private int m_QuickmatchGroupIndex = 1; [Tooltip("Retry delay when a quickmatch room is full or not found.")] [SerializeField, Range(0.1f, 5f)] private float m_QuickmatchRetryDelay = 1.5f; [Header("Special Rooms")] [Tooltip("Non-numeric quarantine room name. Overflow checks are disabled in this room.")] [SerializeField] private string m_QuarantineRoomName = "Room_Quarantine"; [Tooltip("Admin room name for moderators/admins. If empty, defaults to 'Room 9999'.")] [SerializeField] private string m_AdminRoomName = "Room 9999"; #endregion [Header("Formatting")] [Tooltip("Digits to display for numeric room code (e.g., 4 => 0001).")] [SerializeField, Range(1, 8)] private int m_CodeLength = 4; [Space(20)] #region UI Fields [Header("World Text References (TMP 3D)")] [Tooltip("3D TMP text to display room number (e.g., 0001)")] [SerializeField] private TextMeshPro m_RoomInfoText; [Tooltip("3D TMP text to display connection status")] [SerializeField] private TextMeshPro m_ConnectionStatusText; [Tooltip("3D TMP text to display room capacity (players/max)")] [SerializeField] private TextMeshPro m_CapacityInfoText; #endregion #region Unity Lifecycle private void Start() { if (m_Realtime == null) m_Realtime = GetComponent(); // Find and subscribe to avatar manager (for overflow checks) m_AvatarManager = FindAnyObjectByType(); if (m_AvatarManager != null) { m_AvatarManager.avatarCreated += OnAvatarCreated; m_AvatarManager.avatarDestroyed += OnAvatarDestroyed; } // Subscribe to LocalPlayerRegistry events for accurate player count tracking LocalPlayerRegistry.OnPlayerJoined += OnRegistryPlayerJoined; LocalPlayerRegistry.OnPlayerLeft += OnRegistryPlayerLeft; // Register event handlers m_Realtime.didConnectToRoom += DidConnectToRoom; m_Realtime.didDisconnectFromRoom += DidDisconnectFromRoom; m_Realtime.didDisconnectFromRoomWithEvent += DidDisconnectFromRoomWithEvent; // Wait for session initialization before attempting to connect StartCoroutine(WaitForSessionInitialization()); } private IEnumerator WaitForSessionInitialization() { // Wait for PlayerSessionManager to be initialized (with timeout to prevent infinite freeze) float waitTime = 0f; bool sessionInitialized = false; while (waitTime < c_SessionInitTimeout) { if (PlayerSessionManager.Instance != null && PlayerSessionManager.Instance.IsInitialized) { sessionInitialized = true; break; } waitTime += Time.deltaTime; // Log every 2 seconds while waiting if (waitTime % 2f < Time.deltaTime) { Debug.LogWarning($"[NormcoreRoomManager] Still waiting for PlayerSessionManager... ({waitTime:F1}s). Instance exists: {PlayerSessionManager.Instance != null}, IsInitialized: {PlayerSessionManager.Instance?.IsInitialized}"); } yield return null; } if (!sessionInitialized) { Debug.LogError($"[NormcoreRoomManager] PlayerSessionManager initialization timed out after {c_SessionInitTimeout}s. Proceeding with quickmatch connection."); ConnectToQuickmatch(); yield break; } // Check if we are in the Tutorial scene if (SceneManager.GetActiveScene().name.Equals("Tutorial", StringComparison.OrdinalIgnoreCase)) { // Cache reference to avoid null issues between yields var sessionManager = PlayerSessionManager.Instance; if (sessionManager == null) { Debug.LogError("[NormcoreRoomManager] PlayerSessionManager became null in Tutorial scene. Falling back to standard room logic."); } else { // Wait for profile to be loaded (needed for PlayerId) - also with timeout float profileWaitTime = 0f; while (sessionManager != null && sessionManager.Profile == null && profileWaitTime < c_SessionInitTimeout) { profileWaitTime += Time.deltaTime; yield return null; // Re-check reference after yield in case it was destroyed sessionManager = PlayerSessionManager.Instance; } if (sessionManager == null || sessionManager.Profile == null) { Debug.LogError("[NormcoreRoomManager] Profile loading timed out or session became null in Tutorial scene. Falling back to standard room logic."); } else { string playerId = sessionManager.PlayerId; if (!string.IsNullOrEmpty(playerId)) { string tutorialRoomName = "Tutorial_" + playerId; Debug.Log($"[NormcoreRoomManager] Connecting to unique tutorial room: {tutorialRoomName}"); ConnectToRoomByName(tutorialRoomName); yield break; } else { Debug.LogError("[NormcoreRoomManager] Player ID missing in Tutorial scene. Falling back to standard room logic."); } } } } // Admin users go to dedicated admin room unless debug override targets quickmatch if (IsCurrentUserAdmin()) { if (m_AdminDebugStartInQuickmatch) { ConnectToQuickmatch(); } else { ConnectToRoomByName(GetAdminRoomName()); } } else { ConnectToQuickmatch(); } } private void OnDestroy() { if (m_Realtime != null) { m_Realtime.didConnectToRoom -= DidConnectToRoom; m_Realtime.didDisconnectFromRoom -= DidDisconnectFromRoom; m_Realtime.didDisconnectFromRoomWithEvent -= DidDisconnectFromRoomWithEvent; } if (m_AvatarManager != null) { m_AvatarManager.avatarCreated -= OnAvatarCreated; m_AvatarManager.avatarDestroyed -= OnAvatarDestroyed; } // Unsubscribe from LocalPlayerRegistry events LocalPlayerRegistry.OnPlayerJoined -= OnRegistryPlayerJoined; LocalPlayerRegistry.OnPlayerLeft -= OnRegistryPlayerLeft; } #endregion #region Room Connection private Room.ConnectOptions BuildDefaultConnectOptions() { return new Room.ConnectOptions { roomServerOptions = new Room.RoomServerOptions { configuration = DefaultRoomConfiguration } }; } private Room.ConnectOptions BuildQuickmatchConnectOptions() { return new Room.ConnectOptions { roomServerOptions = new Room.RoomServerOptions { configuration = QuickmatchRoomConfiguration } }; } private string GetQuickmatchRoomGroupName() { return $"{m_QuickmatchGroupPrefix}{m_QuickmatchGroupIndex}"; } private void ConnectToQuickmatch() { if (m_IsConnecting) return; m_IsConnecting = true; m_IsSearchingForAvailableRoom = false; m_QueuedNextRoomNumber = -1; m_LastConnectWasQuickmatch = true; m_IsQuickmatchRoom = false; string groupName = GetQuickmatchRoomGroupName(); int capacity = Mathf.Clamp(m_QuickmatchMaxPlayers, 1, 200); var options = BuildQuickmatchConnectOptions(); m_Realtime.ConnectToNextAvailableQuickmatchRoom(groupName, capacity, options); UpdateUI(); } public void ConnectToRoom(int roomNumber) { if (m_IsConnecting || roomNumber < 1 || roomNumber > TOTAL_ROOMS) return; m_IsConnecting = true; m_IsSearchingForAvailableRoom = true; // enable search chain until we find a non-full room m_TargetRoomNumber = roomNumber; m_QueuedNextRoomNumber = -1; string roomName = ROOM_PREFIX + roomNumber; // Disconnect from current room if connected if (IsConnectedToRoom()) { m_Realtime.Disconnect(); } m_LastConnectWasQuickmatch = false; m_IsQuickmatchRoom = false; // Connect to new room m_Realtime.Connect(roomName, BuildDefaultConnectOptions()); // Reflect connecting state in UI immediately UpdateUI(); } /// /// Connect to a specific room by name (for moderator rooms or other custom rooms) /// public void ConnectToRoomByName(string roomName) { if (m_IsConnecting) { Debug.LogWarning($"[NormcoreRoomManager] Already connecting (m_IsConnecting = true). Ignoring request."); return; } if (string.IsNullOrEmpty(roomName)) { Debug.LogWarning($"[NormcoreRoomManager] Room name is null or empty. Ignoring request."); return; } m_IsConnecting = true; m_IsSearchingForAvailableRoom = false; // explicit room name should not auto-search m_QueuedNextRoomNumber = -1; // Disconnect from current room if connected if (IsConnectedToRoom()) { Debug.Log($"[NormcoreRoomManager] Disconnecting from current room: {m_Realtime.room.name}"); m_Realtime.Disconnect(); } m_LastConnectWasQuickmatch = false; m_IsQuickmatchRoom = false; // Connect to specified room m_Realtime.Connect(roomName, BuildDefaultConnectOptions()); // Reflect connecting state in UI immediately UpdateUI(); } private void DidConnectToRoom(Realtime realtime) { m_IsConnecting = false; m_CurrentRoomNumber = GetRoomNumberFromName(realtime.room.name); m_QueuedNextRoomNumber = -1; m_IsQuickmatchRoom = m_LastConnectWasQuickmatch; // Set flag to check overflow when avatar is created (skip for special/non-numeric rooms) m_ShouldCheckOverflow = !IsInSpecialRoom(); // Update UI immediately with initial state UpdateUI(); } private void OnAvatarCreated(RealtimeAvatarManager avatarManager, RealtimeAvatar avatar, bool isLocalAvatar) { // Only check overflow when the LOCAL player's avatar is created // This prevents all players from trying to move when the room becomes full if (isLocalAvatar) { // Check overflow when avatars are created (after connection is established) if (m_ShouldCheckOverflow) { m_ShouldCheckOverflow = false; CheckAndHandleRoomOverflow(); } else { // Also check overflow for subsequent avatar creations (when more players join) CheckAndHandleRoomOverflow(); } } } private void OnAvatarDestroyed(RealtimeAvatarManager avatarManager, RealtimeAvatar avatar, bool isLocalAvatar) { // Avatar destroyed events are handled by LocalPlayerRegistry // We only need avatar events for overflow checking } private bool IsCurrentUserAdmin() { try { var psm = PlayerSessionManager.Instance; if (psm == null) return false; // Primary: server-backed ModeratorConfig if (!string.IsNullOrEmpty(psm.PlayerId) && ModeratorConfig.IsAdmin(psm.PlayerId)) return true; // Fallback: profile role string var role = psm.Profile != null ? psm.Profile.role : null; return !string.IsNullOrEmpty(role) && string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase); } catch (Exception) { return false; } } private void CheckAndHandleRoomOverflow() { // Do not auto-hop in special or non-numeric rooms (e.g., quarantine) if (IsInSpecialRoom()) { m_IsSearchingForAvailableRoom = false; return; } // Allow admins to stay in full rooms - skip overflow check for admins if (IsCurrentUserAdmin()) { m_IsSearchingForAvailableRoom = false; return; } int currentPlayerCount = GetCurrentPlayerCount(); // Compute effective max for current room int effectiveMax = GetEffectiveMaxPlayersForRoom(m_CurrentRoomNumber); // If current room is full, try to switch to the next room if (currentPlayerCount > effectiveMax) { int nextRoom = m_CurrentRoomNumber + 1; if (nextRoom <= TOTAL_ROOMS) { m_QueuedNextRoomNumber = nextRoom; // mark expected disconnect during hop m_TargetRoomNumber = nextRoom; ConnectToRoom(nextRoom); } else { // All rooms are full - stay in current room m_IsSearchingForAvailableRoom = false; } return; } else { // This room is within capacity; stop searching m_IsSearchingForAvailableRoom = false; } } private void DidDisconnectFromRoom(Realtime realtime) { Debug.Log("[NormcoreRoomManager] DidDisconnectFromRoom"); // Update UI to reflect disconnection m_IsConnecting = false; m_IsQuickmatchRoom = false; // If we intentionally disconnected to hop to a specific next room, ignore if (m_QueuedNextRoomNumber != -1) { Debug.Log($"[NormcoreRoomManager] Intentional disconnect for room hop (queued room: {m_QueuedNextRoomNumber})"); m_QueuedNextRoomNumber = -1; UpdateUI(); return; } // If we are searching for an available room and the connection was refused or dropped pre-join, // attempt the next numbered room. if (m_IsSearchingForAvailableRoom) { Debug.Log("[NormcoreRoomManager] Searching for available room, trying next room..."); int nextRoom = m_TargetRoomNumber + 1; if (nextRoom <= TOTAL_ROOMS) { m_TargetRoomNumber = nextRoom; ConnectToRoom(nextRoom); return; } Debug.LogWarning("[NormcoreRoomManager] No more rooms to try, stopping search"); m_IsSearchingForAvailableRoom = false; } UpdateUI(); } private void DidDisconnectFromRoomWithEvent(Realtime realtime, DisconnectEvent disconnectEvent) { m_IsConnecting = false; m_IsQuickmatchRoom = false; if (disconnectEvent is QuickmatchRoomFull || disconnectEvent is QuickmatchRoomNotFound) { Debug.LogWarning($"[NormcoreRoomManager] Quickmatch disconnect event: {disconnectEvent?.GetType().Name}. Retrying after delay."); StartCoroutine(RetryQuickmatchAfterDelay()); return; } } private IEnumerator RetryQuickmatchAfterDelay() { yield return new WaitForSeconds(m_QuickmatchRetryDelay); ConnectToQuickmatch(); } private int GetRoomNumberFromName(string roomName) { if (roomName.StartsWith(ROOM_PREFIX)) { string numberPart = roomName.Substring(ROOM_PREFIX.Length); if (int.TryParse(numberPart, out int roomNumber)) { return roomNumber; } } return -1; // Unknown } private bool IsInSpecialRoom() { if (m_Realtime == null || !m_Realtime.connected || m_Realtime.room == null) return false; string roomName = m_Realtime.room.name; // Non-numeric room or explicitly configured quarantine name bool isNonNumeric = GetRoomNumberFromName(roomName) < 0; if (isNonNumeric) return true; if (!string.IsNullOrEmpty(m_QuarantineRoomName) && string.Equals(roomName, m_QuarantineRoomName, StringComparison.Ordinal)) return true; return false; } private void OnRegistryPlayerJoined(int clientId, LocalPlayerRegistry.PlayerData playerData) { // Update UI when a player joins UpdateUI(); } private void OnRegistryPlayerLeft(int clientId) { // Update UI when a player leaves UpdateUI(); } #endregion #region UI Methods private void UpdateUI() { UpdateRoomInfo(); UpdateConnectionStatus(); } private string FormatCurrentRoomNumber() { if (!m_Realtime || !m_Realtime.connected || m_Realtime.room == null) return new string('-', m_CodeLength); string roomName = m_Realtime.room.name; // Expecting names like "Room X" where X is an int int roomNum = GetRoomNumberFromName(roomName); if (roomNum < 0) return new string('-', m_CodeLength); int clampedLength = Mathf.Clamp(m_CodeLength, 1, 8); string format = "D" + clampedLength; return roomNum.ToString(format); } private void UpdateRoomInfo() { if (m_RoomInfoText == null) return; // Format the room number using the configured code length string formatted = FormatCurrentRoomNumber(); m_RoomInfoText.text = formatted; // Also update capacity info text if available if (m_CapacityInfoText != null) { int playerCount = GetCurrentPlayerCount(); int effectiveMax = GetEffectiveMaxPlayersForRoom(m_CurrentRoomNumber); m_CapacityInfoText.text = $"{playerCount}/{effectiveMax}"; } } private void UpdateConnectionStatus() { if (m_ConnectionStatusText == null) return; // Show clear connection state if (m_IsConnecting) { m_ConnectionStatusText.text = "Connecting"; return; } if (m_Realtime != null && m_Realtime.connected) { m_ConnectionStatusText.text = "Connected"; return; } m_ConnectionStatusText.text = "Disconnected"; } #endregion #region Public Methods public string GetCurrentRoomName() { return m_Realtime.connected ? m_Realtime.room.name : string.Empty; } /// /// Returns the prefix used for numbered room names (e.g., "Room "). /// public string GetRoomNamePrefix() { return ROOM_PREFIX; } /// /// Returns the configured admin room name. If the inspector field is empty, /// falls back to "Room 9999". If only digits are provided (e.g., "9999"), /// the standard prefix is applied. /// public string GetAdminRoomName() { string configured = m_AdminRoomName; if (string.IsNullOrWhiteSpace(configured)) return ROOM_PREFIX + "9999"; configured = configured.Trim(); // If only digits, apply prefix if (int.TryParse(configured, out int numericCode)) return ROOM_PREFIX + numericCode; // If it already includes the standard prefix, use as-is if (configured.StartsWith(ROOM_PREFIX, StringComparison.Ordinal)) return configured; // Otherwise, assume the user provided a full custom name return configured; } public int GetCurrentPlayerCount() { if (m_Realtime == null || !m_Realtime.connected || m_Realtime.room == null) return 0; // Use LocalPlayerRegistry as the source of truth for player count if (LocalPlayerRegistry.Instance != null) { return LocalPlayerRegistry.Instance.PlayerCount; } // Fallback - use avatar manager if registry not available if (m_AvatarManager != null) { return m_AvatarManager.avatars.Count; } // Final fallback - just return 0 return 0; } public bool IsConnectedToRoom() { return m_Realtime.connected; } public int GetCurrentRoomNumber() { return m_CurrentRoomNumber; } public bool IsConnecting() { return m_IsConnecting; } public int GetTotalRooms() { return TOTAL_ROOMS; } /// /// Enables or disables admin debug routing to Quickmatch at startup. /// public void SetAdminDebugStartInQuickmatch(bool enabled) { m_AdminDebugStartInQuickmatch = enabled; } /// /// Backward-compatible setter for legacy naming. /// [Obsolete("Use SetAdminDebugStartInQuickmatch instead.")] public void SetAdminDebugStartInRoom1(bool enabled) { m_AdminDebugStartInQuickmatch = enabled; } /// /// Returns the effective max players for a given room number, applying special caps. /// public int GetEffectiveMaxPlayersForRoom(int roomNumber) { if (m_IsQuickmatchRoom) return Mathf.Clamp(m_QuickmatchMaxPlayers, 1, 200); return m_MaxPlayersPerRoom; } /// /// Returns the configured maximum number of players per room. /// public int GetMaxPlayersPerRoom() { return m_MaxPlayersPerRoom; } /// /// Returns the configured number of digits used for room codes. /// public int GetRoomCodeLength() { return m_CodeLength; } #endregion } }