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
}
}