diff --git a/Assets/Altzone/Scripts/Photon/GameTypeEnum.cs b/Assets/Altzone/Scripts/Photon/GameTypeEnum.cs index 52c75fcc0..4852ef622 100644 --- a/Assets/Altzone/Scripts/Photon/GameTypeEnum.cs +++ b/Assets/Altzone/Scripts/Photon/GameTypeEnum.cs @@ -9,6 +9,7 @@ public enum GameType Custom = 0, Random2v2 = 1, Clan2v2 = 2, + FriendLobby = 3, } } diff --git a/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs b/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs new file mode 100644 index 000000000..f8879d3d7 --- /dev/null +++ b/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; + +namespace Altzone.Scripts.Lobby +{ + internal sealed class JoinAttemptTracker + { + private sealed class JoinAttemptInfo + { + public string RoomName = string.Empty; + public string[] ExpectedUsers = null; + public float StartTime = 0f; + public bool Completed = false; + public bool Success = false; + public float CompletionTime = 0f; + public short? FailureCode = null; + public string FailureMessage = null; + } + + private readonly object _lock = new object(); + private int _joinAttemptCounter = 0; + private int _currentJoinAttemptId = 0; + private readonly Dictionary _joinAttempts = new Dictionary(); + + public int CurrentJoinAttemptId + { + get + { + lock (_lock) + { + return _currentJoinAttemptId; + } + } + } + + public int LastIssuedAttemptId + { + get + { + lock (_lock) + { + return _joinAttemptCounter; + } + } + } + + public int BeginJoinAttempt(string roomName, string[] expectedUsers = null) + { + int id = ++_joinAttemptCounter; + JoinAttemptInfo info = new JoinAttemptInfo() + { + RoomName = roomName ?? string.Empty, + ExpectedUsers = expectedUsers, + StartTime = Time.time, + Completed = false, + Success = false + }; + + lock (_lock) + { + _joinAttempts[id] = info; + _currentJoinAttemptId = id; + } + + Debug.Log($"JoinAttempt[{id}] BEGIN room='{info.RoomName}' teammates={expectedUsers?.Length ?? 0}"); + return id; + } + + public void MarkJoinAttemptSuccess(int id) + { + lock (_lock) + { + if (id == 0) id = _currentJoinAttemptId; + if (id == 0) return; + + if (_joinAttempts.TryGetValue(id, out JoinAttemptInfo info)) + { + info.Completed = true; + info.Success = true; + info.CompletionTime = Time.time; + Debug.Log($"JoinAttempt[{id}] SUCCESS room='{info.RoomName}'"); + } + + if (_currentJoinAttemptId == id) _currentJoinAttemptId = 0; + _joinAttempts.Remove(id); + } + } + + public void MarkJoinAttemptFailure(int id, short returnCode, string message) + { + lock (_lock) + { + if (id == 0) id = _currentJoinAttemptId; + if (id == 0) + { + Debug.LogWarning($"JoinAttempt: failure callback with no current attempt (code={returnCode} msg={message})"); + return; + } + + if (_joinAttempts.TryGetValue(id, out JoinAttemptInfo info)) + { + info.Completed = true; + info.Success = false; + info.FailureCode = returnCode; + info.FailureMessage = message; + info.CompletionTime = Time.time; + Debug.Log($"JoinAttempt[{id}] FAILED room='{info.RoomName}' code={returnCode} msg={message}"); + } + + if (_currentJoinAttemptId == id) _currentJoinAttemptId = 0; + _joinAttempts.Remove(id); + } + } + + public bool IsAttemptCompleted(int id) + { + lock (_lock) + { + if (id == 0) id = _currentJoinAttemptId; + return id != 0 && _joinAttempts.TryGetValue(id, out JoinAttemptInfo info) && info.Completed; + } + } + + public bool TryGetFailedJoinAttempt(int id, out string failureMessage) + { + lock (_lock) + { + if (id == 0) id = _currentJoinAttemptId; + if (id != 0 && _joinAttempts.TryGetValue(id, out JoinAttemptInfo info) && info.Completed && !info.Success) + { + failureMessage = info.FailureMessage; + return true; + } + } + + failureMessage = null; + return false; + } + + public int FindJoinAttemptIdForRoomName(string roomName) + { + roomName = roomName ?? string.Empty; + lock (_lock) + { + if (_currentJoinAttemptId != 0 + && _joinAttempts.TryGetValue(_currentJoinAttemptId, out JoinAttemptInfo current) + && string.Equals(current.RoomName, roomName, StringComparison.Ordinal)) + { + return _currentJoinAttemptId; + } + + foreach (KeyValuePair kvp in _joinAttempts) + { + if (string.Equals(kvp.Value.RoomName, roomName, StringComparison.Ordinal)) + { + return kvp.Key; + } + } + } + + return 0; + } + } +} \ No newline at end of file diff --git a/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs.meta b/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs.meta new file mode 100644 index 000000000..88fb19eb0 --- /dev/null +++ b/Assets/Altzone/Scripts/Photon/JoinAttemptTracker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 610d3eda80e213848ade145aefa4019e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Altzone/Scripts/Photon/LobbyManager.cs b/Assets/Altzone/Scripts/Photon/LobbyManager.cs index d73fc0888..55058bc89 100644 --- a/Assets/Altzone/Scripts/Photon/LobbyManager.cs +++ b/Assets/Altzone/Scripts/Photon/LobbyManager.cs @@ -102,6 +102,12 @@ public class LobbyManager : MonoBehaviour, ILobbyCallbacks, IMatchmakingCallback private const float MatchmakingJoinTimeoutSeconds = 5f; // Marker for matchmaking rooms that were created from queue timeout flow. private const string QueueFormedMatchKey = "qfm"; + // When true, clients must enter queue first and never directly create/join matchmaking rooms. + private static bool UseQueueAuthoritativeMatchmaking = true; + // Flattened queue duo list [duo1UserA, duo1UserB, duo2UserA, duo2UserB, ...]. + private const string QueueDuoPairsKey = "qdp"; + // Flattened queue solo-pair list [pair1UserA, pair1UserB, pair2UserA, pair2UserB, ...]. + private const string QueueSoloPairsKey = "qsp"; // Delay before requeueing after leaving a matchmaking room due to timeout. private const float MatchmakingRequeueDelaySeconds = 2f; // Maximum automatic requeue attempts (0 = unlimited) @@ -119,7 +125,6 @@ public class LobbyManager : MonoBehaviour, ILobbyCallbacks, IMatchmakingCallback private Coroutine _matchmakingHolder = null; private GameType _currentMatchmakingGameType = GameType.Random2v2; private Coroutine _followLeaderHolder = null; - private Coroutine _canBattleStartCheckHolder = null; private Coroutine _formingMatchHolder = null; private Coroutine _startGameHolder = null; // Holder for the client-side StartQuantum coroutine so it can be stopped if needed @@ -128,14 +133,44 @@ public class LobbyManager : MonoBehaviour, ILobbyCallbacks, IMatchmakingCallback private Coroutine _serviceHolder = null; private Coroutine _autoJoinHolder = null; private Coroutine _verifyPositionsHolder = null; + private Coroutine _canBattleStartCheckHolder = null; private Coroutine _queueTimerHolder = null; + // Tracks last computed number of eligible solo players in queue (for diagnostics / selection caps) + private int _queuedSoloCount = 0; + private readonly JoinAttemptTracker _joinAttemptTracker = new JoinAttemptTracker(); private const float QueueWaitSeconds = 30f; - // Flag set by OnJoinRoomFailed to signal a join attempt failure to waiting coroutines - private bool _joinRoomFailed = false; - + private const float QueueReadyStartDelaySeconds = 2f; + // Increased grace window to reduce race conditions that can split queued duos. + private const float QueuePendingLeaderGraceSeconds = 20f; + // Last logged diagnostic state keys for SelectQueueFollowersForMatch (track separately per diagnostic section) + private string _lastSelectQueuePlayersKey = string.Empty; + private string _lastSelectQueueLeaderPropsKey = string.Empty; + private string _lastSelectQueueRecentJoinKey = string.Empty; + // Backwards-compat fallback key (unused) + private string _lastSelectQueueFollowersState = string.Empty; + // Grace interval to consider a player as "just joined" so selection defers briefly + // allowing a duo partner to arrive and avoid splitting pairs. + private const float QueueNewJoinGraceSeconds = 1f; // Timestamp of the last CancelGameStart handling (used to detect quick rejoins) private float _lastStartCancelTime = -100f; private string[] _teammates = null; + private bool _isPremadeMatchmakingFlow = false; + private string _premadeTeammateUserId = string.Empty; + private string _lastAutoInviteRoomName = string.Empty; + private float _lastAutoInviteJoinTime = -100f; + private string _pendingInRoomInviteRoomName = string.Empty; + private string _pendingAcceptedInRoomInviteRoomName = string.Empty; + private float _pendingAcceptedInRoomInviteStartTime = -100f; + private readonly Dictionary _declinedInRoomInviteUntil = new(); + private readonly Dictionary _queuePendingLeaderUntil = new(StringComparer.Ordinal); + private readonly Dictionary _queuePendingExpectedUserUntil = new(StringComparer.Ordinal); + // Record first-seen timestamps for users observed in a matchmaking room to + // detect very recent joins and avoid splitting transient duo joins. + private readonly Dictionary _queuePlayerFirstSeenAt = new(StringComparer.Ordinal); + private const float InRoomInvitePromptThrottleSeconds = 5f; + private const float InRoomInviteDeclineCooldownSeconds = 30f; + private const float InRoomInviteValiditySeconds = 60f; + private const float InRoomInviteJoinTimeoutSeconds = 12f; private List _friendList; @@ -151,17 +186,70 @@ public class LobbyManager : MonoBehaviour, ILobbyCallbacks, IMatchmakingCallback private float _lastCountdownStartTime = -100f; // If a master leaves during start, defer returning to the LobbyRoom UI until master switch completes private bool _deferReturnToLobbyRoomOnMasterSwitch = false; + // True after StartGame has been received for the current room; used to avoid pre-match leave/requeue logic after match start. + private bool _matchHasStartedInCurrentRoom = false; + // Debounce UI/event spam so StartMatchmaking cannot re-enter in the same transition window. + private float _lastStartMatchmakingAcceptedTime = -100f; + // Tracks whether a full-room join failure is already being recovered by LobbyManager. + private bool _joinFailureAutoRequeueInFlight = false; public static LobbyManager Instance { get; private set; } public bool IsStartFinished {set => _isStartFinished = value; } + public bool IsJoinFailureAutoRequeueInFlight => _joinFailureAutoRequeueInFlight; public static bool IsActive { get => _isActive;} private static bool _isActive = false; private static bool _gamePlayedOut = false; - // Explicit handshake: BattleStart UI marks itself ready from OnEnable. private static bool _battleStartUiReady = false; public bool RunnerActive => _runner != null; + + private void LogSelectQueueStateIfChanged(string tag, string key, string msg) + { + try + { + bool changed = false; + if (string.Equals(tag, "players", StringComparison.Ordinal)) + { + if (!string.Equals(_lastSelectQueuePlayersKey, key, StringComparison.Ordinal)) + { + _lastSelectQueuePlayersKey = key; + changed = true; + } + } + else if (string.Equals(tag, "leaderProps", StringComparison.Ordinal)) + { + if (!string.Equals(_lastSelectQueueLeaderPropsKey, key, StringComparison.Ordinal)) + { + _lastSelectQueueLeaderPropsKey = key; + changed = true; + } + } + else if (string.Equals(tag, "recentJoin", StringComparison.Ordinal)) + { + if (!string.Equals(_lastSelectQueueRecentJoinKey, key, StringComparison.Ordinal)) + { + _lastSelectQueueRecentJoinKey = key; + changed = true; + } + } + else + { + if (!string.Equals(_lastSelectQueueFollowersState, key, StringComparison.Ordinal)) + { + _lastSelectQueueFollowersState = key; + changed = true; + } + } + + if (changed) + { + Debug.Log(msg); + } + } + catch { } + } + #endregion #region Delegates & Events @@ -265,6 +353,12 @@ public class LobbyManager : MonoBehaviour, ILobbyCallbacks, IMatchmakingCallback public delegate void KickedOutOfTheRoom(GetKickedEvent.ReasonType reason); public static event KickedOutOfTheRoom OnKickedOutOfTheRoom; + public delegate void InRoomInviteReceived(InRoomInviteInfo inviteInfo); + public static event InRoomInviteReceived OnInRoomInviteReceived; + + public delegate void InRoomInviteJoinFailed(string roomName, short returnCode, string message); + public static event InRoomInviteJoinFailed OnInRoomInviteJoinFailed; + #endregion #region Public API & Helpers @@ -285,13 +379,29 @@ public static void NotifyBattleStartUiReady() _battleStartUiReady = true; } + private bool IsGameStartTransitionActive() + { + // BattleID can remain set after match end, so rely on transient runtime flags instead. + return !_gamePlayedOut && (_countdownActive || _startGameHolder != null || _startQuantumHolder != null); + } + private void QueueCustomBattleStartCheck() { if (_canBattleStartCheckHolder != null) return; if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) return; if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) return; - if (!TryGetRoomGameType(PhotonRealtimeClient.CurrentRoom, out GameType gameType) || gameType != GameType.Custom) return; + GameType gameType; + try + { + gameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + catch + { + return; + } + + if (gameType != GameType.Custom) return; _canBattleStartCheckHolder = StartCoroutine(CheckIfBattleCanStart()); } @@ -302,7 +412,7 @@ private IEnumerator CheckIfBattleCanStart() yield return new WaitUntil(() => _posChangeQueue.Count == 0 && !_playerPosChangeInProgress); if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) yield break; - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; + if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; if (_startGameHolder != null || _startQuantumHolder != null) yield break; Room room = PhotonRealtimeClient.CurrentRoom; @@ -323,27 +433,142 @@ private IEnumerator CheckIfBattleCanStart() } } + public void AcceptInRoomInvite(string roomName) + { + if (string.IsNullOrEmpty(roomName)) return; + if (!PhotonRealtimeClient.InLobby || PhotonRealtimeClient.InRoom) return; + + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + if (string.IsNullOrEmpty(localUserId)) return; + + _pendingInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteRoomName = roomName; + _pendingAcceptedInRoomInviteStartTime = Time.time; + _lastAutoInviteRoomName = roomName; + _lastAutoInviteJoinTime = Time.time; + Debug.Log($"AcceptInRoomInvite: joining room '{roomName}'."); + PhotonRealtimeClient.JoinRoom(roomName, new[] { localUserId }); + } + + public void DeclineInRoomInvite(string roomName) + { + if (string.IsNullOrEmpty(roomName)) return; + + _declinedInRoomInviteUntil[roomName] = Time.time + InRoomInviteDeclineCooldownSeconds; + if (_pendingInRoomInviteRoomName == roomName) + { + _pendingInRoomInviteRoomName = string.Empty; + } + if (_pendingAcceptedInRoomInviteRoomName == roomName) + { + _pendingAcceptedInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteStartTime = -100f; + } + Debug.Log($"DeclineInRoomInvite: declined room '{roomName}'."); + } + + private bool IsInRoomInviteDeclinedRecently(string roomName) + { + if (!_declinedInRoomInviteUntil.TryGetValue(roomName, out float until)) return false; + + if (Time.time > until) + { + _declinedInRoomInviteUntil.Remove(roomName); + return false; + } + + return true; + } + private IEnumerator FormMatchFromQueue(string[] selected, int roomGameTypeInt, string clanName, int soulhomeRank) { + bool queuePremadeMode = false; + string queuePremadeUserId1 = string.Empty; + string queuePremadeUserId2 = string.Empty; + int queuePremadeTargetGameType = roomGameTypeInt; + string queueLocalTeammateUserId = string.Empty; + List<(string userId1, string userId2)> queueCompleteDuoPairs = new(); + List<(string userId1, string userId2)> queueSoloPairBlocks = new(); + bool reservationFailed = false; + try { + try + { + if (PhotonRealtimeClient.CurrentRoom != null) + { + queuePremadeMode = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + queuePremadeUserId1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + queuePremadeUserId2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + queuePremadeTargetGameType = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, roomGameTypeInt); + } + + string localUserIdForQueuePair = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (!string.IsNullOrEmpty(localUserIdForQueuePair)) + { + TryGetQueueLocalTeammateUserId(localUserIdForQueuePair, out queueLocalTeammateUserId); + } + + HashSet participantUserIds = new(StringComparer.Ordinal); + if (!string.IsNullOrEmpty(localUserIdForQueuePair)) participantUserIds.Add(localUserIdForQueuePair); + if (selected != null) + { + foreach (string uid in selected) + { + if (!string.IsNullOrEmpty(uid)) participantUserIds.Add(uid); + } + } + + queueCompleteDuoPairs = GetQueueCompleteDuoPairsForParticipants(participantUserIds); + queueSoloPairBlocks = GetQueueSoloPairBlocksForParticipants(participantUserIds, queueCompleteDuoPairs); + } + catch (Exception ex) + { + Debug.LogWarning($"FormMatchFromQueue: failed to read premade metadata from queue room: {ex.Message}"); + } + // Notify queue members that leader is forming a match so they can start follow flow + bool preNotifySent = false; try { if (PhotonRealtimeClient.InRoom) { + // diagnostic snapshot before pre-notify + try + { + string sel = selected == null ? "null" : string.Join(",", selected); + string pairs = queueCompleteDuoPairs == null ? "null" : string.Join(";", queueCompleteDuoPairs.Select(p => p.userId1 + "/" + p.userId2)); + Debug.Log($"FormMatchFromQueue: pre-notify: selected=[{sel}], queuePremadeMode={queuePremadeMode}, queueLocalTeammate={queueLocalTeammateUserId}, queuePairs=[{pairs}], queueSoloBlocks={queueSoloPairBlocks.Count}"); + } + catch { } // payload: { leaderUserId, expectedUsers[] } + // DO NOT include room name in pre-notify; followers will wait for explicit room name in post-notify. + // With sequence-numbered room names, including a deterministic pre-notify room name causes followers + // to attempt joining a non-existent room before the actual sequenced room is created. + SafeRaiseEvent( PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, new object[] { PhotonRealtimeClient.LocalPlayer.UserId, selected }, new RaiseEventArgs { Receivers = ReceiverGroup.Others }, SendOptions.SendReliable ); - Debug.Log("FormMatchFromQueue: pre-notify RoomChangeRequested sent to queue members before leaving."); + Debug.Log("FormMatchFromQueue: pre-notify RoomChangeRequested (no-room) sent to queue members before leaving."); + preNotifySent = true; } } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: pre-notify failed: {ex.Message}"); } + // Perform the small yield-based wait outside of the try/catch to avoid CS1626 + // (cannot yield inside a try with a catch). Only wait if we actually sent pre-notify. + if (preNotifySent) + { + float preNotifyWaitStart = Time.time; + while (Time.time - preNotifyWaitStart < 0.5f) + { + yield return null; + } + } + float waitStart = Time.time; // If we are not on MasterServer and in lobby, leave the current room and wait until we're back in lobby on MasterServer if (PhotonRealtimeClient.Client == null || PhotonRealtimeClient.Client.Server != ServerConnection.MasterServer || !PhotonRealtimeClient.InLobby || !PhotonRealtimeClient.Client.IsConnectedAndReady) @@ -362,16 +587,35 @@ private IEnumerator FormMatchFromQueue(string[] selected, int roomGameTypeInt, s yield break; } + // Final safety: if there are pending duo signals from other leaders/followers, + // abort forming a new room to avoid splitting an in-flight duo handoff. + int pendingLeaders = 0; + int pendingExpectedUsers = 0; + try + { + pendingLeaders = GetQueuePendingLeaderCount(); + pendingExpectedUsers = GetQueuePendingExpectedUserCount(); + } + catch { } + + if (pendingLeaders > 0 || pendingExpectedUsers > 0) + { + Debug.LogWarning($"FormMatchFromQueue: deferring match creation due to pending duo signals (leaders={pendingLeaders}, expectedUsers={pendingExpectedUsers})."); + reservationFailed = true; + yield break; + } + bool created = false; + // Use deterministic server-side join-or-create to avoid leader create races. if ((GameType)roomGameTypeInt == GameType.Clan2v2) { - created = PhotonRealtimeClient.CreateClan2v2LobbyRoom(clanName, soulhomeRank, selected, true); - Debug.Log($"FormMatchFromQueue: CreateClan2v2LobbyRoom returned: {created}"); + created = PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Clan2v2, selected, clanName, soulhomeRank); + Debug.Log($"FormMatchFromQueue: JoinOrCreateMatchmakingRoom(Clan2v2) returned: {created}"); } else { - created = PhotonRealtimeClient.CreateRandom2v2LobbyRoom(selected, true); - Debug.Log($"FormMatchFromQueue: CreateRandom2v2LobbyRoom returned: {created}"); + created = PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Random2v2, selected); + Debug.Log($"FormMatchFromQueue: JoinOrCreateMatchmakingRoom(Random2v2) returned: {created}"); } string createdRoomName = null; @@ -387,19 +631,196 @@ private IEnumerator FormMatchFromQueue(string[] selected, int roomGameTypeInt, s { createdRoomName = PhotonRealtimeClient.CurrentRoom.Name; Debug.Log($"FormMatchFromQueue: master joined new room '{createdRoomName}'"); + // Post-join atomic safety: re-check pending duo signals that may have + // arrived after the pre-notify / pre-create check. If any pending + // leader or expected-user signals are present, abort and requeue + // to avoid splitting an in-flight duo handoff. + try + { + int pendingLeadersPostJoin = 0; + int pendingExpectedUsersPostJoin = 0; + try { pendingLeadersPostJoin = GetQueuePendingLeaderCount(); } catch { } + try { pendingExpectedUsersPostJoin = GetQueuePendingExpectedUserCount(); } catch { } + if (pendingLeadersPostJoin > 0 || pendingExpectedUsersPostJoin > 0) + { + Debug.LogWarning($"FormMatchFromQueue: aborting post-join due to pending duo signals (leaders={pendingLeadersPostJoin}, expectedUsers={pendingExpectedUsersPostJoin}). Leaving created room and requeueing leader."); + reservationFailed = true; + try { if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: failed to leave room after post-join abort: {ex.Message}"); } + } + } + catch { } // Mark self as leader for followers and start leader matchmaking wait loop so bot backfill and game start proceed try { try { PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, PhotonRealtimeClient.LocalPlayer.UserId); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: {ex.Message}"); } try { OnRoomLeaderChanged?.Invoke(true); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: {ex.Message}"); } // Record how many expected users leader requested so WaitForMatchmakingPlayers can decide join timeouts - try { PhotonRealtimeClient.CurrentRoom.SetCustomProperty("qe", selected.Length); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: {ex.Message}"); } - try { if (selected != null && selected.Length > 0) PhotonRealtimeClient.CurrentRoom.SetCustomProperty("eu", selected); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: {ex.Message}"); } - try { PhotonRealtimeClient.CurrentRoom.SetCustomProperty(QueueFormedMatchKey, true); } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: {ex.Message}"); } + try + { + PhotonHashtable queueExpectedProps = new() + { + { "qe", selected?.Length ?? 0 }, + { QueueFormedMatchKey, true } + }; + if (selected != null && selected.Length > 0) + { + queueExpectedProps["eu"] = selected; + } + PhotonRealtimeClient.CurrentRoom.SetCustomProperties(queueExpectedProps); + try + { + int _qe = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); + string[] _eu = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); + Debug.Log($"FormMatchFromQueue: applied queue expected props qe={_qe}, eu=[{(_eu==null?"":string.Join(",", _eu))}]"); + } + catch { } + } + catch (Exception ex) + { + Debug.LogWarning($"FormMatchFromQueue: failed to apply queue expected-user metadata: {ex.Message}"); + } + try + { + if (queueCompleteDuoPairs.Count > 0) + { + string[] flatPairs = queueCompleteDuoPairs.SelectMany(pair => new[] { pair.userId1, pair.userId2 }).ToArray(); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(QueueDuoPairsKey, flatPairs); + + reservationFailed = false; + foreach (var pair in queueCompleteDuoPairs) + { + if (string.IsNullOrEmpty(pair.userId1) || string.IsNullOrEmpty(pair.userId2) || pair.userId1 == pair.userId2) continue; + if (!TryReservePremadePairToSameSide(pair.userId1, pair.userId2, out _)) + { + Debug.LogWarning($"FormMatchFromQueue: failed queue duo side reservation for ({pair.userId1},{pair.userId2}). Aborting match creation and requeueing leader."); + reservationFailed = true; + break; + } + } + + if (reservationFailed) + { + try + { + // Leave the created room; actual requeue will happen after the outer try/finally. + if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); + } + catch (Exception ex) + { + Debug.LogWarning($"FormMatchFromQueue: failed to leave room after reservation failure: {ex.Message}"); + } + } + } + + // Safety: never persist a mixed composition where a solo pair + // contains a member that is part of a recorded duo pair. + if (ContainsMixedDuoSoloPair(queueCompleteDuoPairs, queueSoloPairBlocks)) + { + Debug.LogWarning("FormMatchFromQueue: mixed duo/solo block detected in computed queue blocks; aborting match creation and requeueing leader."); + reservationFailed = true; + try { if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); } catch { } + } + + if (!reservationFailed && queueSoloPairBlocks.Count > 0) + { + string[] flatSoloPairs = queueSoloPairBlocks.SelectMany(pair => new[] { pair.userId1, pair.userId2 }).ToArray(); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(QueueSoloPairsKey, flatSoloPairs); + } + } + catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: failed to apply queue pair reservations: {ex.Message}"); } + + // Preserve premade metadata when a premade duo came from queue to keep same-side reservation logic active. + try + { + HashSet matchUserIds = new(); + if (!string.IsNullOrEmpty(PhotonRealtimeClient.LocalPlayer?.UserId)) + { + matchUserIds.Add(PhotonRealtimeClient.LocalPlayer.UserId); + } + if (selected != null) + { + foreach (string uid in selected) + { + if (!string.IsNullOrEmpty(uid)) matchUserIds.Add(uid); + } + } + + bool queuePremadePairInThisMatch = queuePremadeMode + && !string.IsNullOrEmpty(queuePremadeUserId1) + && !string.IsNullOrEmpty(queuePremadeUserId2) + && matchUserIds.Contains(queuePremadeUserId1) + && matchUserIds.Contains(queuePremadeUserId2); + + bool queueLocalPairInThisMatch = !string.IsNullOrEmpty(queueLocalTeammateUserId) + && !string.IsNullOrEmpty(PhotonRealtimeClient.LocalPlayer?.UserId) + && matchUserIds.Contains(PhotonRealtimeClient.LocalPlayer.UserId) + && matchUserIds.Contains(queueLocalTeammateUserId); + + bool localPremadePairInThisMatch = _isPremadeMatchmakingFlow + && !string.IsNullOrEmpty(_premadeTeammateUserId) + && !string.IsNullOrEmpty(PhotonRealtimeClient.LocalPlayer?.UserId) + && matchUserIds.Contains(PhotonRealtimeClient.LocalPlayer.UserId) + && matchUserIds.Contains(_premadeTeammateUserId); + + if (queueLocalPairInThisMatch || localPremadePairInThisMatch || queuePremadePairInThisMatch) + { + string premadeUserId1; + string premadeUserId2; + int premadeTargetGameType; + + if (queueLocalPairInThisMatch) + { + premadeUserId1 = PhotonRealtimeClient.LocalPlayer.UserId; + premadeUserId2 = queueLocalTeammateUserId; + premadeTargetGameType = roomGameTypeInt; + } + else if (localPremadePairInThisMatch) + { + premadeUserId1 = PhotonRealtimeClient.LocalPlayer.UserId; + premadeUserId2 = _premadeTeammateUserId; + premadeTargetGameType = roomGameTypeInt; + } + else + { + premadeUserId1 = queuePremadeUserId1; + premadeUserId2 = queuePremadeUserId2; + premadeTargetGameType = queuePremadeTargetGameType; + } + + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, premadeTargetGameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, PhotonRealtimeClient.LocalPlayer.UserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, premadeUserId1); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, premadeUserId2); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + + // Keep runtime fields in sync for any downstream logic that still checks them. + _isPremadeMatchmakingFlow = true; + if (premadeUserId1 == PhotonRealtimeClient.LocalPlayer.UserId) _premadeTeammateUserId = premadeUserId2; + else if (premadeUserId2 == PhotonRealtimeClient.LocalPlayer.UserId) _premadeTeammateUserId = premadeUserId1; + } + } + catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: failed to set premade metadata: {ex.Message}"); } + if (PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient && _matchmakingHolder == null) { _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); } + + // Diagnostic snapshot after applying reservations and before notifying followers + try + { + string p1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, string.Empty); + string p2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, string.Empty); + string p3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, string.Empty); + string p4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, string.Empty); + string[] duoPairs = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueDuoPairsKey, null); + bool prem = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + string prem1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string prem2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + Debug.Log($"FormMatchFromQueue: post-reservation snapshot: pos1={p1},pos2={p2},pos3={p3},pos4={p4}, queueDuoPairs=[{(duoPairs==null?"null":string.Join(",",duoPairs))}], premadeMode={prem}, prem1={prem1}, prem2={prem2}"); + } + catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: failed to log post-reservation snapshot: {ex.Message}"); } } catch (Exception ex) { Debug.LogWarning($"FormMatchFromQueue: failed to prepare leader matchmaking: {ex.Message}"); } } @@ -428,6 +849,13 @@ private IEnumerator FormMatchFromQueue(string[] selected, int roomGameTypeInt, s { _formingMatchHolder = null; } + + if (reservationFailed) + { + // Perform requeue outside of try/catch/finally to allow yielding safely. + yield return StartCoroutine(RequeueToPersistentQueue((GameType)roomGameTypeInt, queuePremadeMode, queuePremadeUserId1, queuePremadeUserId2, queuePremadeTargetGameType)); + yield break; + } } // Removed unused helper GetRoomCreationTimestamp: no references found and logic is deprecated. @@ -451,172 +879,152 @@ private bool SafeRaiseEvent(byte eventCode, object content, RaiseEventArgs raise return false; } - private void SafeStopCoroutine(ref Coroutine holder) + private bool CanMutateRoomPropertiesNow(string context = null, bool logWhenNotReady = false) { - if (holder == null) + var client = PhotonRealtimeClient.Client; + bool ready = client != null + && client.Server == ServerConnection.GameServer + && client.IsConnectedAndReady + && PhotonRealtimeClient.InRoom + && PhotonRealtimeClient.CurrentRoom != null + && client.State != ClientState.Leaving + && client.State != ClientState.DisconnectingFromGameServer + && client.State != ClientState.DisconnectingFromMasterServer; + + if (!ready && logWhenNotReady) { - return; + string source = string.IsNullOrEmpty(context) ? "RoomProperties" : context; + Debug.LogWarning($"{source}: skipping room property write - client not ready (State={client?.State}, Server={client?.Server}, Ready={client?.IsConnectedAndReady}, InRoom={PhotonRealtimeClient.InRoom})"); } - StopCoroutine(holder); - holder = null; + return ready; } - private static string[] GetExpectedUsers(Room room) + private int GetFirstFreePositionWithoutVerification() { - if (room == null) - { - return null; - } + if (PhotonRealtimeClient.CurrentRoom == null) return PlayerPositionGuest; - string[] expectedUsers = room.GetCustomProperty("eu", null); - if ((expectedUsers == null || expectedUsers.Length == 0) - && room.ExpectedUsers != null - && room.ExpectedUsers.Length > 0) + int maxPlayers = PhotonRealtimeClient.CurrentRoom.MaxPlayers; + int[] candidatePositions = maxPlayers >= 4 + ? new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 } + : new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2 }; + + foreach (int position in candidatePositions) { - expectedUsers = room.ExpectedUsers; + string value = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GetPositionKey(position), string.Empty); + if (string.IsNullOrEmpty(value)) return position; } - return expectedUsers; + return PlayerPositionGuest; } - private static bool HasExpectedUsersConfigured(string[] expectedUsers) + private int GetReservedRoomPositionForUser(string userId) { - return expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); - } + if (string.IsNullOrEmpty(userId) || PhotonRealtimeClient.CurrentRoom == null) return PlayerPositionGuest; - private static bool AreExpectedUsersPresent(Room room, string[] expectedUsers) - { - if (room == null || expectedUsers == null || expectedUsers.Length == 0) - { - return false; - } + int maxPlayers = PhotonRealtimeClient.CurrentRoom.MaxPlayers; + int[] candidatePositions = maxPlayers >= 4 + ? new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 } + : new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2 }; - foreach (string uid in expectedUsers) + foreach (int position in candidatePositions) { - if (string.IsNullOrEmpty(uid)) - { - continue; - } - - bool present = room.Players.Values.Any(p => p.UserId == uid); - if (!present) - { - return false; - } + string value = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GetPositionKey(position), string.Empty); + if (string.Equals(value, userId, StringComparison.Ordinal)) return position; } - return true; + return PlayerPositionGuest; } - private static string FormatUserList(string[] userIds) + private void ClearStaleHumanPositionReservations(string context) { - return userIds == null ? "null" : string.Join(",", userIds); - } + if (!CanMutateRoomPropertiesNow()) return; - private static LobbyPhotonHashtable CreateSingleRoomProperty(string key, object value) - { - return new LobbyPhotonHashtable(new Dictionary { { key, value } }); - } + try + { + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null) return; - private bool TrySetRoomProperty(string key, object value) - { - return PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(CreateSingleRoomProperty(key, value)); - } + var existingUserIds = new HashSet(room.Players.Values.Select(p => p.UserId)); + string[] posKeys = { + PhotonBattleRoom.PlayerPositionKey1, + PhotonBattleRoom.PlayerPositionKey2, + PhotonBattleRoom.PlayerPositionKey3, + PhotonBattleRoom.PlayerPositionKey4 + }; - private bool TrySetRoomProperty(string key, object value, object expectedValue) - { - return PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties( - CreateSingleRoomProperty(key, value), - CreateSingleRoomProperty(key, expectedValue)); - } + foreach (string key in posKeys) + { + string val = room.GetCustomProperty(key, string.Empty); + if (string.IsNullOrEmpty(val) || val == "Bot") continue; + if (existingUserIds.Contains(val)) continue; - private void TrySetLocalPlayerPositionProperty(int position, string logPrefix) - { - try - { - PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PlayerPositionKey, position); + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { key, "" } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { key, val } }); + if (PhotonRealtimeClient.LobbyCurrentRoom != null && PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue)) + { + Debug.Log($"{context}: cleared stale position {key} (value {val})."); + } + } } catch (Exception ex) { - Debug.LogWarning($"{logPrefix}: failed to set local player property: {ex.Message}"); + Debug.LogWarning($"{context}: failed to clear stale reservations: {ex.Message}"); } } - private static string GetPositionReservationFailureMessage(int position) - { - return $"Failed to reserve the position {position}. This likely because somebody already is in this position."; - } - - private static void LogPositionReservationFailed(int position) - { - Debug.LogWarning(GetPositionReservationFailureMessage(position)); - } - - private static bool IsPositionOccupied(int position) - { - return !PhotonBattleRoom.CheckIfPositionIsFree(position); - } - - private static void LogRequestedPositionNotFree() - { - Debug.LogWarning("Requested position is not free."); - } - - private static void LogRequestedPositionAlreadyEmpty() - { - Debug.LogWarning("Requested is already empty."); - } + // AutoJoinLargestMatchmakingRoom removed: client-side opportunistic joining + // is now fully deprecated in favor of centralized queue-based matchmaking. - private static void LogPositionUnavailableForRequest(int position) + // Requeue the local player into the persistent queue room for the given game type. + private IEnumerator RequeueToPersistentQueue(GameType gameType, bool premadeMode = false, string premadeUserId1 = "", string premadeUserId2 = "", int premadeTargetGameType = -1) { - if (PhotonBattleRoom.CheckIfPositionHasBot(position)) + try { - Debug.LogWarning($"Failed to reserve the position {position} because there is a bot in the slot."); - return; - } - - LogPositionReservationFailed(position); - } + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (premadeTargetGameType < 0) premadeTargetGameType = (int)gameType; - private IEnumerator WaitForPropertySync( - Func isSynced, - Func shouldStopWaiting = null, - Action onStopWaiting = null, - float timeoutSeconds = 1f, - float pollIntervalSeconds = 0.1f, - Action onCompleted = null) - { - bool success = false; - float timeout = Time.time + timeoutSeconds; - while (Time.time < timeout) - { - if (isSynced()) + // Snapshot premade info before leaving room so non-master requeues can preserve same-side constraints. + try { - success = true; - break; + if ((!premadeMode || string.IsNullOrEmpty(premadeUserId1) || string.IsNullOrEmpty(premadeUserId2)) && PhotonRealtimeClient.CurrentRoom != null) + { + bool roomPremadeMode = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + string roomPremadeUserId1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string roomPremadeUserId2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + int roomPremadeTargetGameType = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)gameType); + + if (roomPremadeMode && !string.IsNullOrEmpty(roomPremadeUserId1) && !string.IsNullOrEmpty(roomPremadeUserId2)) + { + premadeMode = true; + premadeUserId1 = roomPremadeUserId1; + premadeUserId2 = roomPremadeUserId2; + premadeTargetGameType = roomPremadeTargetGameType; + } + } } + catch (Exception ex) { Debug.LogWarning($"RequeueToPersistentQueue: failed to snapshot room premade metadata: {ex.Message}"); } - if (shouldStopWaiting != null && shouldStopWaiting()) + if ((!premadeMode || string.IsNullOrEmpty(premadeUserId1) || string.IsNullOrEmpty(premadeUserId2)) + && _isPremadeMatchmakingFlow + && !string.IsNullOrEmpty(localUserId) + && !string.IsNullOrEmpty(_premadeTeammateUserId)) { - onStopWaiting?.Invoke(); - break; + premadeMode = true; + premadeUserId1 = localUserId; + premadeUserId2 = _premadeTeammateUserId; + premadeTargetGameType = (int)gameType; } - yield return new WaitForSeconds(pollIntervalSeconds); - } - - onCompleted?.Invoke(success); - } - - // AutoJoinLargestMatchmakingRoom removed: client-side opportunistic joining - // is now fully deprecated in favor of centralized queue-based matchmaking. + if (premadeMode) + { + bool localInPair = !string.IsNullOrEmpty(localUserId) && (localUserId == premadeUserId1 || localUserId == premadeUserId2); + if (!localInPair || string.IsNullOrEmpty(premadeUserId1) || string.IsNullOrEmpty(premadeUserId2) || premadeUserId1 == premadeUserId2) + { + premadeMode = false; + } + } - // Requeue the local player into the persistent queue room for the given game type. - private IEnumerator RequeueToPersistentQueue(GameType gameType) - { - try - { Debug.Log($"RequeueToPersistentQueue: rejoining persistent queue for {gameType}"); try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"RequeueToPersistentQueue: failed to stop matchmaking coroutines: {ex.Message}"); } try { StopHolderCoroutines(); } catch (Exception ex) { Debug.LogWarning($"RequeueToPersistentQueue: failed to stop holder coroutines: {ex.Message}"); } @@ -642,22 +1050,57 @@ private IEnumerator RequeueToPersistentQueue(GameType gameType) if (!joined) { - Debug.LogWarning("RequeueToPersistentQueue: JoinOrCreateQueueRoom failed; attempting server-side fallback JoinOrCreateMatchmakingRoom."); - try { PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(gameType, _teammates); } catch (Exception ex) { Debug.LogWarning($"RequeueToPersistentQueue: JoinOrCreateMatchmakingRoom failed: {ex.Message}"); } + Debug.LogWarning("RequeueToPersistentQueue: JoinOrCreateQueueRoom failed; aborting requeue (no direct matchmaking-room fallback in queue-authoritative flow)."); } - } - else - { - Debug.LogWarning("RequeueToPersistentQueue: not connected to MasterServer lobby; cannot join queue."); - } - } - finally - { - _autoJoinHolder = null; - } - } - - private void StartQueueTimer() + else + { + float joinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStart < 6f) + { + yield return null; + } + + if (premadeMode && PhotonRealtimeClient.InRoom && PhotonRealtimeClient.CurrentRoom != null) + { + bool isQueueRoom = false; + try { isQueueRoom = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.IsQueueKey, false); } catch { } + if (isQueueRoom) + { + try + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, premadeTargetGameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, premadeUserId1); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, premadeUserId2); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + + _isPremadeMatchmakingFlow = true; + if (localUserId == premadeUserId1) _premadeTeammateUserId = premadeUserId2; + else if (localUserId == premadeUserId2) _premadeTeammateUserId = premadeUserId1; + + Debug.Log($"RequeueToPersistentQueue: preserved premade metadata in queue room ({premadeUserId1},{premadeUserId2})."); + } + catch (Exception ex) + { + Debug.LogWarning($"RequeueToPersistentQueue: failed to preserve premade metadata: {ex.Message}"); + } + } + } + } + } + else + { + Debug.LogWarning("RequeueToPersistentQueue: not connected to MasterServer lobby; cannot join queue."); + } + } + finally + { + _autoJoinHolder = null; + } + } + + private void StartQueueTimer() { try { @@ -680,1339 +1123,4692 @@ private void StopQueueTimer() catch (Exception ex) { Debug.LogWarning($"StopQueueTimer: failed to stop: {ex.Message}"); } } - private static bool IsQueueRoom(Room room) + private int GetQueueRequiredFollowerCount(int roomGameTypeInt) { - return room != null - && room.CustomProperties != null - && room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) - && room.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool isQueue - && isQueue; + switch ((GameType)roomGameTypeInt) + { + case GameType.Random2v2: + case GameType.Clan2v2: + default: + return 3; + } } - private static bool IsCustomRoom(Room room) + private string GetQueuePlayerLeaderId(Player player) { - return TryGetRoomGameType(room, out GameType gameType) && gameType == GameType.Custom; + if (player == null) return string.Empty; + try + { + return player.GetCustomProperty(PhotonBattleRoom.LeaderIdKey, string.Empty) ?? string.Empty; + } + catch + { + return string.Empty; + } } - private static bool TryGetRoomGameType(Room room, out GameType gameType) + private bool HasRecordedQueueDuoPair(string userIdA, string userIdB) { - gameType = default; - if (room == null || room.CustomProperties == null || !room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) - { - return false; - } + if (string.IsNullOrEmpty(userIdA) || string.IsNullOrEmpty(userIdB) || userIdA == userIdB) return false; try { - gameType = (GameType)room.GetCustomProperty(PhotonBattleRoom.GameTypeKey); - return true; + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null) return false; + + string[] queueDuoFlat = room.GetCustomProperty(QueueDuoPairsKey, null); + if (queueDuoFlat == null || queueDuoFlat.Length < 2) return false; + + for (int i = 0; i + 1 < queueDuoFlat.Length; i += 2) + { + string pairUserId1 = queueDuoFlat[i] ?? string.Empty; + string pairUserId2 = queueDuoFlat[i + 1] ?? string.Empty; + if ((pairUserId1 == userIdA && pairUserId2 == userIdB) || (pairUserId1 == userIdB && pairUserId2 == userIdA)) + { + return true; + } + } } catch { - return false; } - } - private static GameType GetRoomType(Room room, GameType fallback = GameType.Random2v2) - { - return TryGetRoomGameType(room, out GameType gameType) ? gameType : fallback; + return false; } - private static bool IsMatchmakingRoom() + private bool ShouldLeaveQueueWhenDuoPartnerLeaves() { - return PhotonRealtimeClient.InMatchmakingRoom; - } + if (PhotonRealtimeClient.LocalPlayer == null) { Debug.Log("ShouldLeaveQueueWhenDuoPartnerLeaves: LocalPlayer is null"); return false; } + if (!IsQueueRoom(PhotonRealtimeClient.CurrentRoom)) { Debug.Log("ShouldLeaveQueueWhenDuoPartnerLeaves: not a queue room"); return false; } - private IEnumerator QueueTimerCoroutine() - { + string localUserId = PhotonRealtimeClient.LocalPlayer.UserId ?? string.Empty; + if (string.IsNullOrEmpty(localUserId)) { Debug.Log("ShouldLeaveQueueWhenDuoPartnerLeaves: localUserId is empty"); return false; } + + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) { Debug.Log("ShouldLeaveQueueWhenDuoPartnerLeaves: room or players is null"); return false; } + + HashSet currentHumanUserIds = new( + room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); + + // Check 1: Leader ID points to missing player + string localLeaderId = GetQueuePlayerLeaderId(PhotonRealtimeClient.LocalPlayer); + if (!string.IsNullOrEmpty(localLeaderId) && !currentHumanUserIds.Contains(localLeaderId) && localLeaderId != localUserId) + { + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: leader check passed (localLeaderId={localLeaderId}, notInRoom=true)"); + return true; + } + + // Check 2: Room premade mode with missing partner try { - float start = Time.time; - while (Time.time - start < QueueWaitSeconds) + bool roomPremadeMode = room.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + string premadeUserId1 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string premadeUserId2 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: premade check - mode={roomPremadeMode}, userId1={premadeUserId1}, userId2={premadeUserId2}, localUserId={localUserId}"); + + if (roomPremadeMode) { - if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + if (localUserId == premadeUserId1 || localUserId == premadeUserId2) { - _queueTimerHolder = null; - yield break; + string premadePartnerId = localUserId == premadeUserId1 ? premadeUserId2 : premadeUserId1; + bool partnerMissing = string.IsNullOrEmpty(premadePartnerId) || !currentHumanUserIds.Contains(premadePartnerId); + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: premade partner check - partnerId={premadePartnerId}, partnerMissing={partnerMissing}"); + if (partnerMissing) return true; } + } + } + catch (Exception ex) + { + Debug.LogWarning($"ShouldLeaveQueueWhenDuoPartnerLeaves: premade check error: {ex}"); + } - try - { - if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - _queueTimerHolder = null; - yield break; - } + // Check 3: QueueDuoPairs array + string[] queueDuoFlat = room.GetCustomProperty(QueueDuoPairsKey, null); + if (queueDuoFlat == null || queueDuoFlat.Length < 2) + { + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: queueDuoFlat is null or too short (length={(queueDuoFlat?.Length ?? 0)})"); + return false; + } - var room = PhotonRealtimeClient.CurrentRoom; - if (!IsQueueRoom(room)) - { - _queueTimerHolder = null; - yield break; - } - } - catch (Exception ex) { Debug.LogWarning($"StartQueueTimer: loop check failed: {ex.Message}"); _queueTimerHolder = null; yield break; } + for (int i = 0; i + 1 < queueDuoFlat.Length; i += 2) + { + string pairUserId1 = queueDuoFlat[i] ?? string.Empty; + string pairUserId2 = queueDuoFlat[i + 1] ?? string.Empty; + if (string.IsNullOrEmpty(pairUserId1) || string.IsNullOrEmpty(pairUserId2) || pairUserId1 == pairUserId2) continue; - yield return null; - } + if (pairUserId1 != localUserId && pairUserId2 != localUserId) continue; - // Time expired: form match from queue - List selected = new(); - try - { - foreach (var p in PhotonRealtimeClient.CurrentRoom.Players.OrderBy(p => p.Key)) - { - if (p.Value == null) continue; - if (p.Value.UserId == PhotonRealtimeClient.LocalPlayer.UserId) continue; - // Random2v2 / Clan2v2 rooms are 4-player matches total, so only keep 3 followers. - if (selected.Count >= 3) break; - selected.Add(p.Value.UserId); - } + string partnerUserId = pairUserId1 == localUserId ? pairUserId2 : pairUserId1; + if (string.IsNullOrEmpty(partnerUserId)) + { + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: queueDuoFlat check - partner is empty"); + return false; } - catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to enumerate players: {ex.Message}"); } + bool partnerStillInRoom = currentHumanUserIds.Contains(partnerUserId); + Debug.Log($"ShouldLeaveQueueWhenDuoPartnerLeaves: queueDuoFlat check - partner={partnerUserId}, inRoom={partnerStillInRoom}"); + if (!partnerStillInRoom) return true; + } + + Debug.Log("ShouldLeaveQueueWhenDuoPartnerLeaves: all checks failed, returning false"); + return false; + } - int gameTypeInt = (int)GameType.Random2v2; - string clanName = string.Empty; - int soulhomeRank = 0; - try { gameTypeInt = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read game type: {ex.Message}"); } - try { clanName = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read clan name: {ex.Message}"); } - try { soulhomeRank = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read soulhome rank: {ex.Message}"); } + private void ClearQueueDuoBreakupSignals() + { + // Clear local pending signal dictionaries + _queuePendingLeaderUntil.Clear(); + _queuePendingExpectedUserUntil.Clear(); - Debug.Log($"QueueTimerCoroutine: Queue wait expired after {QueueWaitSeconds}s, forming match for {selected.Count} players."); + // Clear room-level duo metadata to prevent queue selection from deferring + Room room = PhotonRealtimeClient.CurrentRoom; + if (room != null && IsQueueRoom(room)) + { try { - _formingMatchHolder = StartCoroutine(FormMatchFromQueue(selected.ToArray(), gameTypeInt, clanName, soulhomeRank)); + // Clear premade mode flags and user IDs + room.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + room.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); + + // Clear the flattened duo pairs array + room.SetCustomProperty(QueueDuoPairsKey, new string[0]); + + Debug.Log($"ClearQueueDuoBreakupSignals: cleared local signals and room premade metadata."); } catch (Exception ex) { - Debug.LogWarning($"QueueTimerCoroutine: FormMatchFromQueue failed to start: {ex.Message}"); + Debug.LogWarning($"ClearQueueDuoBreakupSignals: error clearing room metadata: {ex}"); } } - finally + } + + private bool IsQueueRoom(Room room) + { + if (room == null || room.CustomProperties == null) return false; + try { - _queueTimerHolder = null; + return room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) + && room.GetCustomProperty(PhotonBattleRoom.IsQueueKey); + } + catch + { + return false; } } - - private void StopHolderCoroutines() + + private bool IsQueueFormedExpectedUserFlowRoom(Room room) { - SafeStopCoroutine(ref _reserveFreePositionHolder); - SafeStopCoroutine(ref _requestPositionChangeHolder); + if (room == null) return false; - bool hadMatchmakingHolder = _matchmakingHolder != null; - SafeStopCoroutine(ref _matchmakingHolder); - if (hadMatchmakingHolder) + try { - _teammates = null; + bool queueFormedMatch = room.GetCustomProperty(QueueFormedMatchKey, false); + int expectedFollowers = room.GetCustomProperty("qe", 0); + string[] expectedUsers = room.GetCustomProperty("eu", null); + string[] photonExpectedUsers = room.ExpectedUsers; + + bool hasExpectedUsers = expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + bool hasPhotonExpectedUsers = photonExpectedUsers != null && photonExpectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + + return queueFormedMatch || expectedFollowers > 0 || hasExpectedUsers || hasPhotonExpectedUsers; + } + catch + { + return false; } + } - SafeStopCoroutine(ref _autoJoinHolder); - SafeStopCoroutine(ref _followLeaderHolder); - SafeStopCoroutine(ref _canBattleStartCheckHolder); + private bool IsInQueueFormedExpectedUserMatchmakingFlow() + { + if (!PhotonRealtimeClient.InMatchmakingRoom) return false; + return IsQueueFormedExpectedUserFlowRoom(PhotonRealtimeClient.CurrentRoom); } - private IEnumerator LeaveAndAutoRequeue(GameType gameType) + private bool HasPendingQueueDuoSignals() { try { - Debug.Log($"LeaveAndAutoRequeue: preparing to leave and requeue for {gameType}"); + int pendingExpectedUserCount = GetQueuePendingExpectedUserCount(); + int pendingLeaderCount = GetQueuePendingLeaderCount(); + return pendingExpectedUserCount > 0 || pendingLeaderCount > 0; + } + catch + { + return false; + } + } - // Stop any existing matchmaking/holder coroutines to avoid conflicts - try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"LeaveAndAutoRequeue: failed to stop matchmaking coroutines: {ex.Message}"); } - try { StopHolderCoroutines(); } catch (Exception ex) { Debug.LogWarning($"LeaveAndAutoRequeue: failed to stop holder coroutines: {ex.Message}"); } + private int GetQueueOrphanFollowerCount() + { + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) return 0; - // Leave current room and wait until in lobby - if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); - yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + HashSet humanUserIds = new( + room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); - // Show main menu - OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); + int orphanCount = 0; + foreach (Player player in room.Players.Values) + { + if (player == null || string.IsNullOrEmpty(player.UserId) || player.UserId == "Bot") continue; - // Slightly longer delay to let UI and network state settle - yield return new WaitForSeconds(0.5f); + string leaderId = GetQueuePlayerLeaderId(player); + if (string.IsNullOrEmpty(leaderId) || leaderId == player.UserId) continue; + if (humanUserIds.Contains(leaderId)) continue; - // If local player is master, attempt to start matchmaking flow (master may already have started it) - if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) + orphanCount++; + } + + return orphanCount; + } + + private int GetQueuePendingLeaderCount() + { + if (_queuePendingLeaderUntil.Count == 0) return 0; + + float now = Time.time; + int pendingCount = 0; + List staleLeaderIds = null; + + foreach (var kv in _queuePendingLeaderUntil) + { + string leaderUserId = kv.Key; + float validUntil = kv.Value; + + // Expired or invalid entries should be removed + if (string.IsNullOrEmpty(leaderUserId) || validUntil <= now) { - if (_matchmakingHolder != null) - { - StopCoroutine(_matchmakingHolder); - _matchmakingHolder = null; - } - _matchmakingHolder = StartCoroutine(StartMatchmaking(gameType)); + staleLeaderIds ??= new List(); + staleLeaderIds.Add(leaderUserId); + continue; } - else + + // Queue-room visibility alone is not a completion signal; keep the entry + // pending until the grace window expires or an explicit room transition clears it. + pendingCount++; + } + + if (staleLeaderIds != null) + { + foreach (string leaderId in staleLeaderIds) { - // Non-master: try to auto-join the largest available matchmaking room (skip for Custom game type) - Debug.Log($"LeaveAndAutoRequeue: non-master starting auto-join for {gameType}"); - if (gameType != GameType.Custom) - { - if (_autoJoinHolder != null) - { - StopCoroutine(_autoJoinHolder); - _autoJoinHolder = null; - } - _autoJoinHolder = StartCoroutine(RequeueToPersistentQueue(gameType)); - } - else - { - Debug.Log("LeaveAndAutoRequeue: skipping auto-join for Custom game type."); - } + if (string.IsNullOrEmpty(leaderId)) continue; + _queuePendingLeaderUntil.Remove(leaderId); } } - finally { } + + return pendingCount; } - #endregion + private int GetQueuePendingExpectedUserCount() + { + if (_queuePendingExpectedUserUntil.Count == 0) return 0; - #region Unity Lifecycle & Activation + float now = Time.time; + int pendingCount = 0; + List staleExpectedUserIds = null; - private void Awake() - { - if (Instance != null && Instance != this) + foreach (var kv in _queuePendingExpectedUserUntil) { - Destroy(gameObject); + string expectedUserId = kv.Key; + float validUntil = kv.Value; + + if (string.IsNullOrEmpty(expectedUserId) || validUntil <= now) + { + staleExpectedUserIds ??= new List(); + staleExpectedUserIds.Add(expectedUserId); + } + else + { + // A queued expected user arriving in the current room does not mean the + // handoff is done yet; keep the signal until timeout or room transition. + pendingCount++; + } } - else + + if (staleExpectedUserIds != null) { - Instance = this; - DontDestroyOnLoad(gameObject); - _isActive = false; - if (!_isActive) Activate(); + foreach (string expectedUserId in staleExpectedUserIds) + { + if (string.IsNullOrEmpty(expectedUserId)) continue; + _queuePendingExpectedUserUntil.Remove(expectedUserId); + } } - } - public void OnEnable() - { - if (!_isActive) Activate(); + return pendingCount; } - public void OnDisable() + private bool TryTransferQueueMaster(string newMasterUserId, string reason) { try { - if (PhotonRealtimeClient.Client != null) + if (string.IsNullOrEmpty(newMasterUserId)) return false; + if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) return false; + + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) return false; + + Player candidate = room.Players.Values.FirstOrDefault(p => p != null && p.UserId == newMasterUserId); + if (candidate == null) return false; + + var lobbyPlayer = PhotonRealtimeClient.LobbyCurrentRoom?.GetPlayer(candidate.ActorNumber); + if (lobbyPlayer == null) return false; + + if (PhotonRealtimeClient.LobbyCurrentRoom.SetMasterClient(lobbyPlayer)) { - PhotonRealtimeClient.RemoveCallbackTarget(this); - try { PhotonRealtimeClient.Client.StateChanged -= OnStateChange; } catch (Exception ex) { Debug.LogWarning($"OnDisable: failed to unsubscribe StateChanged: {ex.Message}"); } + Debug.Log($"QueueTimerCoroutine: transferred master to {newMasterUserId} (actor {candidate.ActorNumber}) because {reason}."); + return true; } + + Debug.LogWarning($"QueueTimerCoroutine: SetMasterClient returned false when transferring to {newMasterUserId}."); } catch (Exception ex) { - Debug.LogWarning($"OnDisable: unsubscribe failed: {ex.Message}"); + Debug.LogWarning($"QueueTimerCoroutine: failed to transfer master to {newMasterUserId}: {ex.Message}"); } - this.Unsubscribe(); - _isActive = false; - if (_serviceHolder != null) + + return false; + } + + private bool TryGetQueueLocalTeammateUserId(string localUserId, out string teammateUserId) + { + teammateUserId = string.Empty; + if (string.IsNullOrEmpty(localUserId)) return false; + + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) return false; + + Dictionary playersById = room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .GroupBy(p => p.UserId) + .Select(g => g.First()) + .ToDictionary(p => p.UserId, p => p); + + if (!playersById.TryGetValue(localUserId, out Player localPlayer) || localPlayer == null) { - try - { - StopCoroutine(_serviceHolder); - } - catch (Exception ex) + return false; + } + + string localLeaderId = GetQueuePlayerLeaderId(localPlayer); + if (!string.IsNullOrEmpty(localLeaderId) && localLeaderId != localUserId) + { + if (playersById.ContainsKey(localLeaderId)) { - Debug.LogWarning($"OnDisable: failed to stop service coroutine: {ex.Message}"); + teammateUserId = localLeaderId; + return true; } - _serviceHolder = null; + + return false; + } + + List followers = playersById.Values + .Where(p => p.UserId != localUserId) + .Where(p => GetQueuePlayerLeaderId(p) == localUserId) + .OrderBy(p => p.ActorNumber) + .Select(p => p.UserId) + .ToList(); + + if (followers.Count == 1) + { + teammateUserId = followers[0]; + return true; } + + return false; } - private void OnApplicationQuit() + private List<(string userId1, string userId2)> GetQueueCompleteDuoPairsForParticipants(ICollection participantUserIds) { - if (PhotonRealtimeClient.Client != null) - { - try - { - if (PhotonRealtimeClient.Client.InRoom) - { - PhotonRealtimeClient.LeaveRoom(); - } - else if (PhotonRealtimeClient.InLobby) - { - PhotonRealtimeClient.LeaveLobby(); - } - } - catch (Exception ex) - { - Debug.LogWarning($"OnApplicationQuit: leave failed: {ex.Message}"); - } - } - } - public void Activate() - { - if (_isActive) { Debug.LogWarning("LobbyManager is already active."); return; } - _isActive = true; - PhotonRealtimeClient.AddCallbackTarget(this); - if (PhotonRealtimeClient.Client != null) - { - try { PhotonRealtimeClient.Client.StateChanged += OnStateChange; } catch (Exception ex) { Debug.LogWarning($"Activate: failed to subscribe StateChanged: {ex.Message}"); } - } - this.Subscribe(OnReserveFreePositionEvent); - this.Subscribe(OnPlayerPosEvent); - this.Subscribe(OnBotToggleEvent); - this.Subscribe(OnBotFillToggleEvent); - this.Subscribe(OnStartRoomEvent); - this.Subscribe(OnStartPlayingEvent); - this.Subscribe(OnStartRaidTestEvent); - this.Subscribe(OnStartMatchmakingEvent); - this.Subscribe(OnStopMatchmakingEvent); - this.Subscribe(OnGetKickedEvent); - if (_serviceHolder == null) _serviceHolder = StartCoroutine(Service()); + List<(string userId1, string userId2)> result = new(); + if (participantUserIds == null || participantUserIds.Count < 2) return result; - GameConfig gameConfig = GameConfig.Get(); - PlayerSettings playerSettings = gameConfig.PlayerSettings; - string photonRegion = string.IsNullOrEmpty(playerSettings.PhotonRegion) ? null : playerSettings.PhotonRegion; - StartCoroutine(StartLobby(playerSettings.PlayerGuid, playerSettings.PhotonRegion)); - } + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) return result; - private IEnumerator Service() - { - while (true) - { - PhotonRealtimeClient.Client?.Service(); - //Debug.LogWarning("."); - yield return new WaitForSeconds(0.05f); - } - } + HashSet participantSet = new(participantUserIds.Where(id => !string.IsNullOrEmpty(id)), StringComparer.Ordinal); + if (participantSet.Count < 2) return result; - private IEnumerator StartLobby(string playerGuid, string photonRegion) - { - ClientState networkClientState = PhotonRealtimeClient.NetworkClientState; - Debug.Log($"{networkClientState}"); - var delay = new WaitForSeconds(0.1f); - while (!PhotonRealtimeClient.Client.InLobby) + Dictionary playersById = room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .GroupBy(p => p.UserId) + .Select(g => g.First()) + .ToDictionary(p => p.UserId, p => p); + + HashSet addedUsers = new(StringComparer.Ordinal); + foreach (Player leader in playersById.Values.OrderBy(p => p.ActorNumber)) { - if (networkClientState != PhotonRealtimeClient.NetworkClientState) - { - // Even with delay we must reduce NetworkClientState logging to only when it changes to avoid flooding (on slower connections). - networkClientState = PhotonRealtimeClient.NetworkClientState; - Debug.Log($"{networkClientState}"); - } - if (PhotonRealtimeClient.Client.InRoom) - { - PhotonRealtimeClient.LeaveRoom(); - } - else if (PhotonRealtimeClient.CanConnect) - { - DataStore store = Storefront.Get(); - PlayerData playerData = null; - store.GetPlayerData(playerGuid, p => playerData = p); - yield return new WaitUntil(() => playerData != null); - PhotonRealtimeClient.Client.UserId = playerData.Id; - PhotonRealtimeClient.Connect(playerData.Name, photonRegion); - } - else if (PhotonRealtimeClient.CanJoinLobby) - { - PhotonRealtimeClient.JoinLobbyWithWrapper(null); - } - yield return delay; - } - } + if (leader == null || !participantSet.Contains(leader.UserId)) continue; - private void OnStateChange(ClientState arg1, ClientState arg2) - { - Debug.Log(arg1 + " -> " + arg2); - } + List followers = playersById.Values + .Where(p => p != null && p.UserId != leader.UserId) + .Where(p => participantSet.Contains(p.UserId)) + .Where(p => GetQueuePlayerLeaderId(p) == leader.UserId) + .OrderBy(p => p.ActorNumber) + .ToList(); - #endregion + if (followers.Count != 1) continue; + string followerId = followers[0].UserId; + if (string.IsNullOrEmpty(followerId)) continue; + if (!addedUsers.Add(leader.UserId)) continue; + if (!addedUsers.Add(followerId)) + { + addedUsers.Remove(leader.UserId); + continue; + } + result.Add((leader.UserId, followerId)); + } - #region Matchmaking - /// - /// Stops any active matchmaking or follow leader coroutines. - /// Call this before leaving a room when switching game types. - /// - public void StopMatchmakingCoroutines() - { - SafeStopCoroutine(ref _matchmakingHolder); - SafeStopCoroutine(ref _followLeaderHolder); - SafeStopCoroutine(ref _startGameHolder); - SafeStopCoroutine(ref _autoJoinHolder); + return result; } - /// - /// Leader-side matchmaking entry point. - /// Flow: lock current room -> gather teammate/position context -> notify followers -> leave to lobby -> join or create a matchmaking room. - /// - private IEnumerator StartMatchmaking(GameType gameType, bool broadcastRoomChange = true) + private List<(string userId1, string userId2)> GetQueueSoloPairBlocksForParticipants( + ICollection participantUserIds, + ICollection<(string userId1, string userId2)> completeDuoPairs) { - // remember which game type we're matchmaking for so failure handlers can requeue - _currentMatchmakingGameType = gameType; - bool keepHolder = false; - try - { - // Closing the room so that no others can join - PhotonRealtimeClient.CurrentRoom.IsOpen = false; + List<(string userId1, string userId2)> result = new(); + if (participantUserIds == null || participantUserIds.Count < 2) return result; - // Saving custom properties from the room to the variables - string clanName = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); - int soulhomeRank = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0); + Room room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.Players == null) return result; - string positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, ""); - string positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, ""); - string positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, ""); - string positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, ""); + HashSet participantSet = new(participantUserIds.Where(id => !string.IsNullOrEmpty(id)), StringComparer.Ordinal); + if (participantSet.Count < 2) return result; - // Saving other player's userids to enter the new game room together with master client - List expectedUsers = new(); - foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) + HashSet duoMemberIds = new(StringComparer.Ordinal); + if (completeDuoPairs != null) { - if (player.Value.UserId != PhotonRealtimeClient.LocalPlayer.UserId) - { - expectedUsers.Add(player.Value.UserId); - } - - // Saving clan name and soulhome rank to player's custom properties in case the matchmaking leader leaves - if (!string.IsNullOrEmpty(clanName)) + foreach (var pair in completeDuoPairs) { - player.Value.SetCustomProperty(PhotonBattleRoom.ClanNameKey, clanName); - player.Value.SetCustomProperty(PhotonBattleRoom.SoulhomeRank, soulhomeRank); + if (!string.IsNullOrEmpty(pair.userId1)) duoMemberIds.Add(pair.userId1); + if (!string.IsNullOrEmpty(pair.userId2)) duoMemberIds.Add(pair.userId2); } } - _teammates = expectedUsers.ToArray(); - // Sending other players in the room the room change request, setting own leader id key as own userid to indicate being the leader - PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, PhotonRealtimeClient.LocalPlayer.UserId); - try { OnRoomLeaderChanged?.Invoke(true); } catch (Exception ex) { Debug.LogWarning($"StartMatchmaking: OnRoomLeaderChanged invocation failed: {ex.Message}"); } + List soloParticipants = room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Where(p => participantSet.Contains(p.UserId)) + .Where(p => !duoMemberIds.Contains(p.UserId)) + .GroupBy(p => p.UserId) + .Select(g => g.First()) + .OrderBy(p => p.ActorNumber) + .ToList(); - if (broadcastRoomChange && PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.GameServer && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) + for (int i = 0; i + 1 < soloParticipants.Count; i += 2) { - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, - PhotonRealtimeClient.LocalPlayer.UserId, - new RaiseEventArgs { Receivers = ReceiverGroup.Others }, - SendOptions.SendReliable - ); + string userId1 = soloParticipants[i].UserId; + string userId2 = soloParticipants[i + 1].UserId; + if (string.IsNullOrEmpty(userId1) || string.IsNullOrEmpty(userId2) || userId1 == userId2) continue; + result.Add((userId1, userId2)); } - else if (broadcastRoomChange) + + // Defensive cleanup: ensure no duo members slipped into solo pair results. + if (duoMemberIds.Count > 0) { - Debug.Log($"Skipping RoomChangeRequested broadcast (StartMatchmaking): Server={PhotonRealtimeClient.Client?.Server}, IsConnectedAndReady={PhotonRealtimeClient.Client?.IsConnectedAndReady}, InRoom={PhotonRealtimeClient.InRoom}"); + result.RemoveAll(p => duoMemberIds.Contains(p.userId1) || duoMemberIds.Contains(p.userId2)); } - // Nulling room list and leaving room so that client can get room list - CurrentRooms = null; - PhotonRealtimeClient.LeaveRoom(); + return result; + } - // Wait for lobby and initial room listing; room search below depends on CurrentRooms. - yield return new WaitUntil(() => PhotonRealtimeClient.InLobby && CurrentRooms != null); + private bool ContainsMixedDuoSoloPair(IEnumerable<(string userId1, string userId2)> duoPairs, IEnumerable<(string userId1, string userId2)> soloPairs) + { + if (duoPairs == null || soloPairs == null) return false; + var duoMembers = new HashSet(StringComparer.Ordinal); + foreach (var d in duoPairs) + { + if (!string.IsNullOrEmpty(d.userId1)) duoMembers.Add(d.userId1); + if (!string.IsNullOrEmpty(d.userId2)) duoMembers.Add(d.userId2); + } + foreach (var s in soloPairs) + { + if (!string.IsNullOrEmpty(s.userId1) && duoMembers.Contains(s.userId1)) return true; + if (!string.IsNullOrEmpty(s.userId2) && duoMembers.Contains(s.userId2)) return true; + } + return false; + } - // Searching for suitable room and attempting to join each candidate. - // If a JoinRoom attempt fails (room filled/closed) we continue searching other rooms. - bool roomFound = false; - bool joinedExistingRoom = false; - // Use a shorter per-room timeout for Random2v2 to reduce delays when iterating many candidates. - float joinAttemptTimeout = gameType == GameType.Random2v2 ? 2f : 5f; // seconds to wait for a join to succeed before trying next room + private bool IsTwoPlayerBlockQueueMode(int roomGameTypeInt) + { + GameType gameType = (GameType)roomGameTypeInt; + return gameType == GameType.Random2v2 || gameType == GameType.Clan2v2; + } - if (CurrentRooms != null && CurrentRooms.Count > 0) + private List SelectQueueFollowersFromTwoPlayerBlocks( + int requiredFollowers, + string localUserId, + List realPlayers, + Dictionary playersById, + List<(Player leader, Player follower)> completeDuos, + HashSet incompleteMemberIds, + HashSet orphanFollowerIds, + out string preferredMasterUserId, + out int eligibleSoloCount) + { + preferredMasterUserId = string.Empty; + eligibleSoloCount = 0; + List selected = new(); + bool hasPendingQueueDuoSignals = HasPendingQueueDuoSignals(); + bool allowOrphanAsStaleSolo = realPlayers != null + && realPlayers.Count <= requiredFollowers + 1 + && !hasPendingQueueDuoSignals; + + // If any orphan follower currently reports a LeaderId, treat them as a duo member + // and do not allow treating them as a stale solo pair. This prevents splitting + // a follower who explicitly references a leader (even if that leader momentarily + // isn't visible to the master selection snapshot). + try { - // Sort candidates by descending player count (prefer fuller rooms) and deterministic tie-breaker - var roomsList = CurrentRooms.OrderByDescending(r => r.PlayerCount).ThenBy(r => r.Name).ToList(); - - foreach (LobbyRoomInfo room in roomsList) + if (allowOrphanAsStaleSolo && orphanFollowerIds != null && orphanFollowerIds.Count > 0) { - // Checking if the room has a game type and matchmaking key in the first place - if (!room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey) || !room.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) + foreach (var orphanId in orphanFollowerIds) { - continue; + if (string.IsNullOrEmpty(orphanId)) continue; + if (!playersById.TryGetValue(orphanId, out Player orphanPlayer) || orphanPlayer == null) continue; + string orphanLeaderId = GetQueuePlayerLeaderId(orphanPlayer); + if (!string.IsNullOrEmpty(orphanLeaderId)) + { + Debug.Log($"SelectQueueFollowersFromTwoPlayerBlocks: orphan follower {orphanId} has leader mapping {orphanLeaderId}; not treating orphan as stale solo."); + allowOrphanAsStaleSolo = false; + break; + } } + } + } + catch { } - // Checking that the game type matches and that the room is a matchmaking room - if ((GameType)room.CustomProperties[PhotonBattleRoom.GameTypeKey] != gameType || (bool)room.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] == false) - { - continue; - } + HashSet completeDuoMemberIds = new(); + foreach (var duo in completeDuos) + { + completeDuoMemberIds.Add(duo.leader.UserId); + completeDuoMemberIds.Add(duo.follower.UserId); + } - // Decide if we should attempt to join this room - bool shouldTryJoin = false; - switch (gameType) + bool localInCompleteDuo = completeDuoMemberIds.Contains(localUserId); + string localLeaderId = GetQueuePlayerLeaderId(playersById[localUserId]); + bool localIsFollower = !string.IsNullOrEmpty(localLeaderId) && localLeaderId != localUserId; + + if (localIsFollower) + { + bool localLeaderMissing = !playersById.ContainsKey(localLeaderId); + bool localShouldNotFollowAsMaster = PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient; + if (localLeaderMissing || localShouldNotFollowAsMaster) + { + Debug.Log($"SelectQueueFollowersFromTwoPlayerBlocks: clearing stale local leader reference '{localLeaderId}' (leaderMissing={localLeaderMissing}, localIsMaster={localShouldNotFollowAsMaster})."); + localLeaderId = string.Empty; + localIsFollower = false; + try { - case GameType.Clan2v2: - if ((string)room.CustomProperties[PhotonBattleRoom.ClanNameKey] != clanName && room.MaxPlayers - room.PlayerCount >= _teammates.Length + 1) + if (PhotonRealtimeClient.LocalPlayer != null) + { + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - shouldTryJoin = true; + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, localUserId); } - break; - case GameType.Random2v2: - if (room.MaxPlayers - room.PlayerCount >= _teammates.Length + 1) + else { - shouldTryJoin = true; + PhotonRealtimeClient.LocalPlayer.RemoveCustomProperty(PhotonBattleRoom.LeaderIdKey); } - break; - } - - if (!shouldTryJoin) continue; - - // Attempt join and wait until success, explicit failure, or timeout - _joinRoomFailed = false; - PhotonRealtimeClient.JoinRoom(room.Name, _teammates); - float joinStart = Time.time; - yield return new WaitUntil(() => PhotonRealtimeClient.InRoom || _joinRoomFailed || Time.time > joinStart + joinAttemptTimeout); - - if (PhotonRealtimeClient.InRoom) - { - roomFound = true; - joinedExistingRoom = true; - break; + } } - else + catch (Exception ex) { - Debug.LogWarning($"JoinRoom failed or timed out for '{room.Name}', trying next candidate."); - // try next room + Debug.LogWarning($"SelectQueueFollowersFromTwoPlayerBlocks: failed to clear stale local leader reference: {ex.Message}"); } } } - // If no candidate worked, let backend pick or create a suitable room atomically. - if (!joinedExistingRoom) + if (localIsFollower) { - switch (gameType) + if (!string.IsNullOrEmpty(localLeaderId) && playersById.ContainsKey(localLeaderId)) { - case GameType.Clan2v2: - PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Clan2v2, _teammates, clanName, soulhomeRank); - break; - case GameType.Random2v2: - PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Random2v2, _teammates); - break; + preferredMasterUserId = localLeaderId; } + else + { + preferredMasterUserId = completeDuos + .Select(d => d.leader.UserId) + .FirstOrDefault(id => !string.IsNullOrEmpty(id) && id != localUserId) ?? string.Empty; + } + + return selected; } - // Block until our matchmaking-room join completes. - yield return new WaitUntil(() => PhotonRealtimeClient.InRoom); + if (orphanFollowerIds != null && orphanFollowerIds.Count > 0 && hasPendingQueueDuoSignals) + { + Debug.Log("SelectQueueFollowersFromTwoPlayerBlocks: deferring selection because orphan followers exist while pending queue duo handoff signals are active."); + return new List(); + } - // If room was found setting room properties - if (roomFound) + // Build local follower map so we can check if a user has followers + // without relying on outer-scope variables. + Dictionary> localFollowersByLeader = new(); + foreach (Player p in realPlayers) { - switch (gameType) + string leaderId = GetQueuePlayerLeaderId(p); + if (string.IsNullOrEmpty(leaderId) || leaderId == p.UserId) continue; + if (!localFollowersByLeader.TryGetValue(leaderId, out List lst)) { - case GameType.Clan2v2: - // Setting clan name as opponent clan - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey, clanName); + lst = new List(); + localFollowersByLeader[leaderId] = lst; + } + lst.Add(p); + } - // Setting own and teammate positions from old room to position keys 3 and 4 - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue1); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue2); - break; + List eligibleSoloPlayers = realPlayers + .Where(p => !completeDuoMemberIds.Contains(p.UserId)) + .Where(p => !incompleteMemberIds.Contains(p.UserId)) + .Where(p => + { + if (orphanFollowerIds != null && orphanFollowerIds.Contains(p.UserId)) return allowOrphanAsStaleSolo; + string leaderId = GetQueuePlayerLeaderId(p); + return string.IsNullOrEmpty(leaderId) || leaderId == p.UserId; + }) + .ToList(); - case GameType.Random2v2: - if (_teammates.Length == 0) // If queuing solo - { - StartCoroutine(ReserveFreePosition()); - } - else // Queuing with a teammate TODO: untested code, when queueing with teammate is possible test this and fix any issues + // Exclude very recent solo joins from eligibility unless they show duo relation + try + { + float now = Time.time; + var filtered = new List(); + foreach (var p in eligibleSoloPlayers) + { + if (!string.IsNullOrEmpty(p?.UserId) && _queuePlayerFirstSeenAt.TryGetValue(p.UserId, out float firstAt) + && now - firstAt < QueueNewJoinGraceSeconds) + { + // Keep recently-joined player only if they appear to be part of a duo + string leaderId = GetQueuePlayerLeaderId(p); + bool hasFollower = localFollowersByLeader.TryGetValue(p.UserId, out var fl) && fl != null && fl.Count > 0; + if (string.IsNullOrEmpty(leaderId) && !hasFollower) { - // Checking if position is free and if so setting userid from old room to that position - if (PhotonBattleRoom.CheckIfPositionIsFree(PhotonBattleRoom.PlayerPosition3)) - { - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue3); - } - else // If position is not free - { - // Moving the player at the position to the first free position (should be either 1 or 2 since room max players is 4) - int freePosition = PhotonLobbyRoom.GetFirstFreePlayerPos(); - if (!PhotonLobbyRoom.IsValidPlayerPos(freePosition)) yield break; - string newRoomPositionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(freePosition), newRoomPositionValue3); - } - - if (PhotonBattleRoom.CheckIfPositionIsFree(PhotonBattleRoom.PlayerPosition4)) - { - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue4); - } - else - { - int freePosition = PhotonLobbyRoom.GetFirstFreePlayerPos(); - if (!PhotonLobbyRoom.IsValidPlayerPos(freePosition)) yield break; - string newRoomPositionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(freePosition), newRoomPositionValue4); - } + // skip this just-joined solo player for now + continue; } - break; + } + filtered.Add(p); } + + eligibleSoloPlayers = filtered; } - else if (!roomFound) // Initializing new created room properties - { - // Setting player positions from the old room - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, positionValue1); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, positionValue2); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue3); - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue4); - } + catch { } - // Stopping coroutine if not a master client - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; + bool localEligibleSolo = eligibleSoloPlayers.Any(p => p.UserId == localUserId); + eligibleSoloCount = eligibleSoloPlayers.Count(p => p.UserId != localUserId); - _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); - keepHolder = true; - } - finally - { - if (!keepHolder) _matchmakingHolder = null; - } + // Solo selection caps (legacy): if exactly 3 solos are eligible, limit selection to 2 + // but only when there are duos present (to avoid splitting them). If no duos are + // present (four solos total), allow selecting enough solos to form two solo pairs. + // If there's exactly one eligible solo (excluding the local user) but the local + // master is itself an eligible solo, allow pairing the local+other solo — do not + // apply the restrictive solo cap in that case. + int soloCap; + if (eligibleSoloCount == 3) + { + soloCap = (completeDuos != null && completeDuos.Count > 0) ? 2 : int.MaxValue; + } + else if (eligibleSoloCount == 1) + { + soloCap = localEligibleSolo ? int.MaxValue : 0; + } + else soloCap = int.MaxValue; + int solosSelected = 0; - } + // If there's exactly one eligible solo (excluding the local user) and the local + // player is not an eligible solo, avoid pairing that lone solo into a match — but + // only defer when the available complete duos cannot satisfy the required followers + // by themselves. If duos alone suffice, allow selection to proceed. + if (eligibleSoloCount == 1 && !localEligibleSolo) + { + int duoPlayers = completeDuos.Count * 2; + if (duoPlayers < requiredFollowers) + { + Debug.Log("SelectQueueFollowersFromTwoPlayerBlocks: exactly one eligible solo present and duos insufficient; deferring selection to avoid pairing lone solo."); + return new List(); + } + } - /// - /// Master-side wait loop that decides when a matchmaking room can start. - /// Handles: missing expected users -> short requeue, long Random2v2 wait -> bot backfill, then start countdown/gameplay. - /// - private IEnumerator WaitForMatchmakingPlayers() - { - try + if (!localInCompleteDuo && completeDuos.Count >= 2) { - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; + preferredMasterUserId = completeDuos + .Where(d => d.leader.UserId != localUserId && d.follower.UserId != localUserId) + .Select(d => d.leader.UserId) + .FirstOrDefault() ?? string.Empty; - if (TryGetRoomGameType(PhotonRealtimeClient.CurrentRoom, out GameType currentGameType) - && currentGameType == GameType.Custom) + if (!string.IsNullOrEmpty(preferredMasterUserId)) { - Debug.Log("WaitForMatchmakingPlayers: skipping because current room is Custom."); - yield break; + return selected; } + } - bool gameStarting = false; - float waitStartTime = Time.time; - bool botBackfillApplied = false; - try + HashSet localBlockMembers = new(StringComparer.Ordinal) { localUserId }; + HashSet selectedSet = new(StringComparer.Ordinal); + + if (localInCompleteDuo) + { + var localDuo = completeDuos.FirstOrDefault(d => d.leader.UserId == localUserId || d.follower.UserId == localUserId); + if (localDuo.leader == null || localDuo.follower == null) { - if (PhotonRealtimeClient.InRoom && PhotonRealtimeClient.CurrentRoom != null) - { - botBackfillApplied = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey, false); - } + return new List(); } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read initial BotFillKey state: {ex.Message}"); } - do - { - // Checking every 0,5s if we can start gameplay - bool canStartGameplay = false; - do + string teammateId = localDuo.leader.UserId == localUserId ? localDuo.follower.UserId : localDuo.leader.UserId; + if (string.IsNullOrEmpty(teammateId) || teammateId == localUserId) { - yield return new WaitForSeconds(0.5f); - - // If we lost the room (race during master switch/leave), stop waiting - if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) - { - Debug.LogWarning("WaitForMatchmakingPlayers: Not in a room anymore, aborting matchmaking wait."); - yield break; - } + return new List(); + } - // Check if matchmaking timeout expired and fill remaining slots with bots (Random2v2 only) - currentGameType = GetRoomType(PhotonRealtimeClient.CurrentRoom); + localBlockMembers.Add(teammateId); + selectedSet.Add(teammateId); + selected.Add(teammateId); + } + else + { + if (!localEligibleSolo) + { + return new List(); + } - // Short join timeout: if after MatchmakingJoinTimeoutSeconds the countdown hasn't started, - // master should instruct all clients to leave and requeue so the group can reform. - int expectedFollowers = 0; - try { expectedFollowers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); } catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read expected followers: {ex.Message}"); expectedFollowers = 0; } + Player localSoloPartner = eligibleSoloPlayers.FirstOrDefault(p => p.UserId != localUserId); + if (localSoloPartner == null || string.IsNullOrEmpty(localSoloPartner.UserId)) + { + return new List(); + } - // Track whether expected users are missing. Use explicit custom property first - // and fall back to Photon slot-reservation metadata. - bool expectedUsersMissing = true; - string[] expectedUsers = null; - try + localBlockMembers.Add(localSoloPartner.UserId); + if (selectedSet.Add(localSoloPartner.UserId)) { - expectedUsers = GetExpectedUsers(PhotonRealtimeClient.CurrentRoom); - if (HasExpectedUsersConfigured(expectedUsers)) - { - expectedUsersMissing = !AreExpectedUsersPresent(PhotonRealtimeClient.CurrentRoom, expectedUsers); - } - else + if (soloCap != int.MaxValue && solosSelected >= soloCap) { - expectedUsersMissing = expectedFollowers > 0; + // cannot select solos due to cap; abort selection + return new List(); } + selected.Add(localSoloPartner.UserId); + solosSelected++; } - catch (Exception ex) - { - expectedUsersMissing = true; - Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate expected users presence: {ex.Message}"); - } + } - bool expectedUsersConfigured = HasExpectedUsersConfigured(expectedUsers); - bool expectedPlayersRequired = expectedFollowers > 0 || expectedUsersConfigured; + var secondDuo = completeDuos.FirstOrDefault(d => + !localBlockMembers.Contains(d.leader.UserId) + && !localBlockMembers.Contains(d.follower.UserId)); - // Detailed diagnostics to investigate premature requeue issues - try + if (secondDuo.leader != null && secondDuo.follower != null) + { + if (selectedSet.Add(secondDuo.leader.UserId)) selected.Add(secondDuo.leader.UserId); + if (selectedSet.Add(secondDuo.follower.UserId)) selected.Add(secondDuo.follower.UserId); + } + else + { + List remainingSolos = eligibleSoloPlayers + .Where(p => p.UserId != localUserId) + .Where(p => !localBlockMembers.Contains(p.UserId)) + .ToList(); + + // need two solos to complete the block; respect solo cap when selecting + int need = 2; + int canTake = soloCap == int.MaxValue ? need : Math.Max(0, soloCap - solosSelected); + if (remainingSolos.Count < need || canTake < need) { - var currentUserIds = PhotonRealtimeClient.CurrentRoom.Players.Values.Select(p => p.UserId).ToArray(); - Debug.Log($"WaitForMatchmakingPlayers: expectedFollowers={expectedFollowers}, expectedPlayersRequired={expectedPlayersRequired}, expectedUsers=[{FormatUserList(expectedUsers)}], currentPlayers=[{FormatUserList(currentUserIds)}], expectedUsersMissing={expectedUsersMissing}"); + return new List(); } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: diagnostics failed: {ex.Message}"); } - // Short-timeout branch for queue-formed groups: if expected users do not arrive quickly, - // cancel this start attempt and requeue everyone together. - if (!botBackfillApplied - && Time.time - waitStartTime >= MatchmakingJoinTimeoutSeconds - && expectedPlayersRequired - && expectedUsersMissing) - { - bool recheckFound = false; - if (expectedUsersConfigured) - { - // If expected users appear missing, allow a brief grace window to re-check - // (helps with join/property propagation races). - float recheckStart = Time.time; - while (Time.time - recheckStart < 1.0f) // up to 1s grace - { - yield return new WaitForSeconds(0.15f); - try - { - var nowExpected = GetExpectedUsers(PhotonRealtimeClient.CurrentRoom); - if (HasExpectedUsersConfigured(nowExpected) && AreExpectedUsersPresent(PhotonRealtimeClient.CurrentRoom, nowExpected)) - { - recheckFound = true; - expectedUsersMissing = false; - Debug.Log("WaitForMatchmakingPlayers: grace re-check found expected users present; skipping short requeue."); - break; - } - } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: recheck failed: {ex.Message}"); } - } - } + if (selectedSet.Add(remainingSolos[0].UserId)) { selected.Add(remainingSolos[0].UserId); solosSelected++; } + if (selectedSet.Add(remainingSolos[1].UserId)) { selected.Add(remainingSolos[1].UserId); solosSelected++; } + } - if (!recheckFound) - { - if (!_countdownActive) - { - Debug.Log($"Matchmaking short timeout ({MatchmakingJoinTimeoutSeconds}s) reached and countdown not started; master will request requeue."); + if (selected.Count != requiredFollowers) + { + return new List(); + } - // Notify all clients to requeue - try - { - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.CancelGameStart, - new object[] { true, (int)currentGameType }, - new RaiseEventArgs { Receivers = ReceiverGroup.All }, - SendOptions.SendReliable - ); - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to broadcast CancelGameStart requeue: {ex.Message}"); - } + HashSet participantIds = new(StringComparer.Ordinal) { localUserId }; + foreach (string userId in selected) + { + if (!string.IsNullOrEmpty(userId)) participantIds.Add(userId); + } - // Master leaves and requeues (LeaveAndAutoRequeue will handle master vs non-master paths). - try - { - StartCoroutine(LeaveAndAutoRequeue(currentGameType)); - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to start LeaveAndAutoRequeue after short timeout: {ex.Message}"); - } + bool selectedContainsOrphanFollower = orphanFollowerIds != null && participantIds.Any(orphanFollowerIds.Contains); + if (selectedContainsOrphanFollower && !allowOrphanAsStaleSolo) + { + Debug.LogWarning("SelectQueueFollowersFromTwoPlayerBlocks: selected composition contains orphan follower, retrying."); + return new List(); + } + if (selectedContainsOrphanFollower && allowOrphanAsStaleSolo) + { + Debug.Log("SelectQueueFollowersFromTwoPlayerBlocks: allowing orphan follower as stale solo in exact-size queue composition."); + } - yield break; - } - else - { - // Continue waiting as expected users are now present - } - } - } + // If the room contains an available complete duo pair that is not included + // in the current participant set, and our current selection doesn't include + // any duo member, defer selection so the duo can be preserved for the + // next formation attempt. This prevents splitting duos when solos would + // otherwise fill the match. + try + { + bool hasAvailableUnselectedDuo = completeDuos != null && completeDuos.Any(d => + d.leader != null && d.follower != null + && playersById.ContainsKey(d.leader.UserId) + && playersById.ContainsKey(d.follower.UserId) + && !participantIds.Contains(d.leader.UserId) + && !participantIds.Contains(d.follower.UserId) + ); - // Fast-path: expected followers arrived, so fill any remaining slots with bots immediately - // instead of waiting the full matchmaking timeout. - if (!botBackfillApplied && currentGameType == GameType.Random2v2) + if (hasAvailableUnselectedDuo) + { + bool anySelectedIsDuoMember = false; + if (completeDuos != null) { - bool appliedEarly = false; - try + foreach (var d in completeDuos) { - if (expectedUsers != null && expectedUsers.Length > 0 && !expectedUsersMissing) + if (d.leader == null || d.follower == null) continue; + if (participantIds.Contains(d.leader.UserId) || participantIds.Contains(d.follower.UserId)) { - Debug.Log("WaitForMatchmakingPlayers: all expected users present; applying bot backfill immediately."); - appliedEarly = true; + anySelectedIsDuoMember = true; + break; } } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to determine early bot backfill: {ex.Message}"); } - - if (appliedEarly) - { - Debug.Log($"Matchmaking: applying early botfill to complete room."); - FillFreeGameplayPositionsWithBots(PhotonRealtimeClient.CurrentRoom); - try { PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.BotFillKey, true); } catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to set BotFillKey: {ex.Message}"); } - botBackfillApplied = true; - } } - // Queue-formed solo room already waited in queue; skip duplicated long botfill wait. - float effectiveBotfillTimeoutSeconds = MatchmakingTimeoutSeconds; - try + if (!anySelectedIsDuoMember) { - bool queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); - if (queueFormedMatch && !expectedPlayersRequired) - { - // Queue already consumed the long wait; apply botfill immediately in queue-formed solo matchmaking rooms. - effectiveBotfillTimeoutSeconds = 0f; - } + Debug.LogWarning("SelectQueueFollowersFromTwoPlayerBlocks: deferring selection because selecting only solos would leave available duo behind."); + return new List(); } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate queue-formed match timeout: {ex.Message}"); } + } + } + catch { } - if (!botBackfillApplied && currentGameType == GameType.Random2v2 && Time.time - waitStartTime >= effectiveBotfillTimeoutSeconds) - { - Debug.Log($"Matchmaking timeout ({effectiveBotfillTimeoutSeconds}s) reached for Random2v2. Filling remaining slots with bots."); + List<(string userId1, string userId2)> selectedDuoPairs = GetQueueCompleteDuoPairsForParticipants(participantIds); + List<(string userId1, string userId2)> selectedSoloPairs = GetQueueSoloPairBlocksForParticipants(participantIds, selectedDuoPairs); + int coveredParticipants = selectedDuoPairs.Count * 2 + selectedSoloPairs.Count * 2; + if (coveredParticipants != participantIds.Count) + { + Debug.LogWarning($"SelectQueueFollowersFromTwoPlayerBlocks: selected composition failed block validation (covered={coveredParticipants}, participants={participantIds.Count})."); + return new List(); + } - FillFreeGameplayPositionsWithBots(PhotonRealtimeClient.CurrentRoom); + // Ensure we never mix a solo pair with a single member of a duo pair + if (ContainsMixedDuoSoloPair(selectedDuoPairs, selectedSoloPairs)) + { + Debug.LogWarning("SelectQueueFollowersFromTwoPlayerBlocks: selected composition mixes solo with duo member; rejecting selection."); + return new List(); + } - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.BotFillKey, true); - botBackfillApplied = true; - } + return selected; + } - if (!botBackfillApplied) - { - // Normal path: wait for real players to fill the room - try - { - if (PhotonRealtimeClient.CurrentRoom.PlayerCount != PhotonRealtimeClient.CurrentRoom.MaxPlayers) continue; - } - catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to check player counts: {ex.Message}"); continue; } - } + private List SelectQueueFollowersForMatch(int roomGameTypeInt, int requiredFollowers, out string preferredMasterUserId, out int completeDuoCount, out int eligibleSoloCount, out int orphanFollowerCount, out string singleEligibleSoloUserId) + { + preferredMasterUserId = string.Empty; + completeDuoCount = 0; + eligibleSoloCount = 0; + orphanFollowerCount = 0; + singleEligibleSoloUserId = string.Empty; + List selected = new(); - // At this point either the room is full or we've applied bot backfill. - // Proceed to mapping player -> room position keys even if some position slots are not yet set. - canStartGameplay = true; + Room room = PhotonRealtimeClient.CurrentRoom; + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (room == null || string.IsNullOrEmpty(localUserId) || requiredFollowers <= 0 || room.Players == null) + { + return selected; + } - } while (!canStartGameplay); + List realPlayers = room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .OrderBy(p => p.ActorNumber) + .ToList(); + Dictionary playersById = new(); + foreach (Player p in realPlayers) + { + if (!playersById.ContainsKey(p.UserId)) playersById[p.UserId] = p; + } - // Updating player positions from room to player properties, and waiting that they have been synced - if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + if (!playersById.ContainsKey(localUserId)) + { + return selected; + } + + // Track first-seen timestamps for players in the matchmaking room so we can + // detect very recent joins and avoid splitting pairs by deferring selection + // for transient newcomers. + float _qnow = Time.time; + try + { + foreach (var uid in playersById.Keys) { - Debug.LogWarning("WaitForMatchmakingPlayers: CurrentRoom lost before setting positions; aborting."); - yield break; + if (string.IsNullOrEmpty(uid)) continue; + if (!_queuePlayerFirstSeenAt.ContainsKey(uid)) _queuePlayerFirstSeenAt[uid] = _qnow; } - string positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1); - string positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2); - string positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); - string positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); + // Remove stale entries for users no longer present + var stale = _queuePlayerFirstSeenAt.Keys.Where(k => !playersById.ContainsKey(k)).ToList(); + foreach (var k in stale) _queuePlayerFirstSeenAt.Remove(k); + } + catch { } - foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) + void ClearStaleQueuePremadeMetadata(string reason) + { + try { - int position = PhotonBattleRoom.PlayerPositionGuest; + if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) return; + if (room == null) return; + if (!room.GetCustomProperty(PhotonBattleRoom.IsQueueKey, false)) return; - if (player.Value.UserId == positionValue1) position = PhotonBattleRoom.PlayerPosition1; - else if (player.Value.UserId == positionValue2) position = PhotonBattleRoom.PlayerPosition2; - else if (player.Value.UserId == positionValue3) position = PhotonBattleRoom.PlayerPosition3; - else if (player.Value.UserId == positionValue4) position = PhotonBattleRoom.PlayerPosition4; - else + // Log premade ids before clearing so we can diagnose one-sided/stale metadata + try { - // If player isn't in any position, getting the first free player position. - // This method checks for duplicate and missing players - position = PhotonLobbyRoom.GetFirstFreePlayerPos(new(player.Value)); // TODO: if Clan2v2 ensure that player ends on the correct side - if (!PhotonLobbyRoom.IsValidPlayerPos(position)) continue; - string positionKey = PhotonBattleRoom.GetPositionKey(position); - - // Setting position to room and waiting until it's synced - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(positionKey, player.Value.UserId); - yield return new WaitUntil(() => PhotonRealtimeClient.CurrentRoom.GetCustomProperty(positionKey) == player.Value.UserId); + string premade1 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string premade2 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + Debug.Log($"SelectQueueFollowersForMatch: clearing stale queue premade metadata for room '{room?.Name}' (premade1={premade1}, premade2={premade2}) reason={reason}."); } + catch { } - // Setting position to player properties and waiting until it's synced - player.Value.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey, position); - yield return new WaitUntil(() => player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey) == position); + room.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + room.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, string.Empty); + room.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); } - - // Checking that the clan names are in order - GameType roomGameType = GetRoomType(PhotonRealtimeClient.CurrentRoom); - if (roomGameType == GameType.Clan2v2) + catch (Exception ex) { - string primaryClan = string.Empty; - string opponentClan = string.Empty; - - foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) - { - int playerPos = player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey); - - if (playerPos == PhotonBattleRoom.PlayerPosition1) - { - primaryClan = player.Value.GetCustomProperty(PhotonBattleRoom.ClanNameKey, string.Empty); - } - else if (playerPos == PhotonBattleRoom.PlayerPosition3) - { - opponentClan = player.Value.GetCustomProperty(PhotonBattleRoom.ClanNameKey, string.Empty); - } - } - if (PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey) != primaryClan) - { - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanNameKey, primaryClan); - } + Debug.LogWarning($"SelectQueueFollowersForMatch: failed to clear stale queue premade metadata: {ex.Message}"); + } + } - if (PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey) != opponentClan) - { - PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey, opponentClan); - } + Dictionary> followersByLeader = new(); + HashSet incompleteMemberIds = new(); + HashSet orphanFollowerIds = new(StringComparer.Ordinal); - _blueTeamName = primaryClan; - _redTeamName = opponentClan; - } + foreach (Player p in realPlayers) + { + string leaderId = GetQueuePlayerLeaderId(p); + if (string.IsNullOrEmpty(leaderId) || leaderId == p.UserId) continue; - // For Random2v2 ensure team names are set (they aren't set by the Clan2v2 block above) - if (roomGameType == GameType.Random2v2) + if (!followersByLeader.TryGetValue(leaderId, out List followers)) { - if (string.IsNullOrWhiteSpace(_blueTeamName)) _blueTeamName = "Team Alpha"; - if (string.IsNullOrWhiteSpace(_redTeamName)) _redTeamName = "Team Beta"; + followers = new List(); + followersByLeader[leaderId] = followers; } - // Set BattleID for matchmaking rooms (StartRoomEvent is not published for matchmaking rooms) - if (!PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID) - || string.IsNullOrEmpty(PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.BattleID))) + followers.Add(p); + } + + List<(Player leader, Player follower)> completeDuos = new(); + foreach (var kv in followersByLeader.OrderBy(kv => playersById.ContainsKey(kv.Key) ? playersById[kv.Key].ActorNumber : int.MaxValue)) + { + if (!playersById.TryGetValue(kv.Key, out Player leader) || leader == null) { - PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable + foreach (Player orphanFollower in kv.Value) { - { PhotonBattleRoom.BattleID, PhotonRealtimeClient.CurrentRoom.Name.Replace(' ', '_') + "_" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() } - }); - yield return null; + if (orphanFollower != null && !string.IsNullOrEmpty(orphanFollower.UserId)) orphanFollowerIds.Add(orphanFollower.UserId); + } + continue; } - // Starting gameplay coroutine if all positions are filled (real players + bots), else we loop again - int botCount = PhotonBattleRoom.GetBotCount(); - if (PhotonRealtimeClient.CurrentRoom.PlayerCount + botCount >= PhotonRealtimeClient.CurrentRoom.MaxPlayers) + if (kv.Value.Count == 1 && kv.Value[0] != null && kv.Value[0].UserId != leader.UserId) { - if (_startGameHolder != null) + completeDuos.Add((leader, kv.Value[0])); + } + else + { + incompleteMemberIds.Add(leader.UserId); + foreach (Player follower in kv.Value) { - StopCoroutine(_startGameHolder); - _startGameHolder = null; + if (follower != null && !string.IsNullOrEmpty(follower.UserId)) incompleteMemberIds.Add(follower.UserId); } - _startGameHolder = StartCoroutine(StartTheGameplay(_isCloseRoomOnGameStart, _blueTeamName, _redTeamName)); - gameStarting = true; } - - } while (!gameStarting); } - finally - { - _matchmakingHolder = null; - } - } - // Follower safety-net: if countdown does not begin soon after joining matchmaking, - // leave and requeue to avoid getting stuck in a stale room. - private IEnumerator MatchmakingJoinWatcher(GameType gameType, float timeoutSeconds) - { + // Merge any persisted queue duo pairs from room metadata. This helps preserve + // duos even when LeaderId links are temporarily missing on one or both members. try { - Debug.Log($"MatchmakingJoinWatcher: started for gameType={gameType}, timeout={timeoutSeconds}s"); - float start = Time.time; - while (Time.time - start < timeoutSeconds) + string[] queueDuoFlat = room.GetCustomProperty(QueueDuoPairsKey, null); + if (queueDuoFlat != null && queueDuoFlat.Length >= 2) { - if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) yield break; + HashSet usedMembers = new(StringComparer.Ordinal); + HashSet existingKeys = new(StringComparer.Ordinal); - // If countdown started after we began watching, cancel watcher - if (_lastCountdownStartTime >= start) + foreach (var duo in completeDuos) { - _autoRequeueAttempts = 0; - yield break; + if (duo.leader == null || duo.follower == null) continue; + if (!string.IsNullOrEmpty(duo.leader.UserId)) usedMembers.Add(duo.leader.UserId); + if (!string.IsNullOrEmpty(duo.follower.UserId)) usedMembers.Add(duo.follower.UserId); + + string existingKey = string.CompareOrdinal(duo.leader.UserId, duo.follower.UserId) <= 0 + ? $"{duo.leader.UserId}|{duo.follower.UserId}" + : $"{duo.follower.UserId}|{duo.leader.UserId}"; + existingKeys.Add(existingKey); } - yield return new WaitForSeconds(0.5f); + for (int i = 0; i + 1 < queueDuoFlat.Length; i += 2) + { + string userId1 = queueDuoFlat[i] ?? string.Empty; + string userId2 = queueDuoFlat[i + 1] ?? string.Empty; + if (string.IsNullOrEmpty(userId1) || string.IsNullOrEmpty(userId2) || userId1 == userId2) continue; + + if (!playersById.TryGetValue(userId1, out Player player1) || player1 == null) continue; + if (!playersById.TryGetValue(userId2, out Player player2) || player2 == null) continue; + + string key = string.CompareOrdinal(userId1, userId2) <= 0 + ? $"{userId1}|{userId2}" + : $"{userId2}|{userId1}"; + + if (existingKeys.Contains(key)) continue; + if (usedMembers.Contains(userId1) || usedMembers.Contains(userId2)) continue; + + completeDuos.Add((player1, player2)); + usedMembers.Add(userId1); + usedMembers.Add(userId2); + existingKeys.Add(key); + incompleteMemberIds.Remove(userId1); + incompleteMemberIds.Remove(userId2); + } } + } + catch { } - // Timeout reached: countdown did not start; leave and requeue - GameType requeueGameType = GetRoomType(PhotonRealtimeClient.CurrentRoom); - Debug.Log($"MatchmakingJoinWatcher: countdown did not start within {timeoutSeconds}s in room '{PhotonRealtimeClient.CurrentRoom?.Name}'; leaving and requeueing for {requeueGameType}."); + // Diagnostic: log selection state to help debug missing/partial duo selection + try + { + var duoPairs = completeDuos + .Select(d => (d.leader?.UserId ?? string.Empty) + "/" + (d.follower?.UserId ?? string.Empty)) + .OrderBy(s => s) + .ToArray(); - _autoRequeueAttempts++; - if (MaxAutoRequeueAttempts > 0 && _autoRequeueAttempts > MaxAutoRequeueAttempts) + var followerMap = followersByLeader + .OrderBy(kv => kv.Key) + .Select(kv => kv.Key + ":[" + string.Join(",", kv.Value.Where(p => p != null).Select(p => p.UserId).OrderBy(id => id)) + "]") + .ToArray(); + + bool pending = HasPendingQueueDuoSignals(); + + // Avoid spamming the logs when only a single human player is in the queue + if (playersById.Count > 1 || pending) { - Debug.LogWarning($"MatchmakingJoinWatcher: exceeded max auto-requeue attempts ({MaxAutoRequeueAttempts}); not requeueing."); - yield break; + var playerKeysOrdered = playersById.Keys.OrderBy(k => k).ToArray(); + string key = $"p:{string.Join(",", playerKeysOrdered)}|duos:{string.Join(";", duoPairs)}|inc:{string.Join(",", incompleteMemberIds.OrderBy(id => id))}|orph:{string.Join(",", orphanFollowerIds.OrderBy(id => id))}|followers:{string.Join(",", followerMap)}|pending:{pending}"; + string msg = $"SelectQueueFollowersForMatch: players=[{string.Join(",", playerKeysOrdered)}], completeDuos=[{string.Join(";", duoPairs)}], incomplete=[{string.Join(",", incompleteMemberIds)}], orphans=[{string.Join(",", orphanFollowerIds)}], followersByLeader=[{string.Join(",", followerMap)}], pendingSignals={pending}"; + LogSelectQueueStateIfChanged("players", key, msg); } - yield return new WaitForSeconds(MatchmakingRequeueDelaySeconds); + // Additional diagnostics: show each player's recorded LeaderId and any pending duo signals try { - StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); - } - catch (Exception ex) - { - Debug.LogWarning($"MatchmakingJoinWatcher: failed to start LeaveAndAutoRequeue: {ex.Message}"); + var leaderProps = playersById.Values + .OrderBy(p => p.UserId) + .Select(p => (p.UserId ?? string.Empty) + "->" + (GetQueuePlayerLeaderId(p) ?? string.Empty)) + .ToArray(); + + string pendingLeaders = string.Empty; + string pendingExpected = string.Empty; + try { pendingLeaders = string.Join(",", _queuePendingLeaderUntil.Keys.OrderBy(k => k)); } catch { } + try { pendingExpected = string.Join(",", _queuePendingExpectedUserUntil.Keys.OrderBy(k => k)); } catch { } + + { + string key2 = $"lp:{string.Join(",", leaderProps)}|pl:{pendingLeaders}|pe:{pendingExpected}"; + string msg2 = $"SelectQueueFollowersForMatch: leaderProps=[{string.Join(",", leaderProps)}], pendingLeaders=[{pendingLeaders}], pendingExpected=[{pendingExpected}]"; + LogSelectQueueStateIfChanged("leaderProps", key2, msg2); + } } + catch { } } - finally + catch { } + + bool roomPremadeMode = false; + string roomPremadeUserId1 = string.Empty; + string roomPremadeUserId2 = string.Empty; + + try { - _joinTimeoutWatcherHolder = null; + roomPremadeMode = room.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + roomPremadeUserId1 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + roomPremadeUserId2 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); } - } + catch { } - /// - /// Follower-side room handoff after RoomChangeRequested. - /// Join priority: explicit leader room name -> leader via friend lookup -> best matchmaking fallback -> join/create fallback. - /// - private IEnumerator FollowLeaderToNewRoom(string leaderUserId, string leaderRoomName = null) - { + if (orphanFollowerIds.Count > 0) + { + // A follower with missing leader is stale queue metadata; treat as solo candidate. + Debug.Log($"SelectQueueFollowersForMatch: ignoring {orphanFollowerIds.Count} orphan leader references, treating as solos."); + } + + bool hasPendingQueueDuoSignals = HasPendingQueueDuoSignals(); + + // Count very recent joins observed in the room (within grace window). + int recentJoinCount = 0; try { - // Don't follow leader away from this room if we're in a Custom game. - try - { - if (IsCustomRoom(PhotonRealtimeClient.CurrentRoom)) - { - Debug.Log("FollowLeaderToNewRoom: current room is Custom, will not leave."); - yield break; - } - } - catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: failed to evaluate room type: {ex.Message}"); } + float now = Time.time; + recentJoinCount = playersById.Keys.Count(uid => _queuePlayerFirstSeenAt.TryGetValue(uid, out float t) && now - t < QueueNewJoinGraceSeconds); + } + catch { recentJoinCount = 0; } - string oldRoomName = PhotonRealtimeClient.CurrentRoom?.Name ?? string.Empty; + // If there are extra humans beyond the exact-size case, pending duo signals, + // or very recent joins, treat the situation as transient/orphan and propagate + // a non-zero orphanFollowerCount so higher-level logic defers forming matches + // to avoid splitting duo joins. + bool considerTransientOrphans = realPlayers.Count > requiredFollowers + 1 || hasPendingQueueDuoSignals || recentJoinCount > 0; + if (considerTransientOrphans) + { + orphanFollowerCount = orphanFollowerIds.Count > 0 ? orphanFollowerIds.Count : recentJoinCount; + } + else + { + orphanFollowerCount = 0; + } - // Leave current room and wait until in lobby - if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); - yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + // Diagnostic: show recent-join detection results to help debug transient joins + try + { + float nowDbg = Time.time; + var ages = _queuePlayerFirstSeenAt + .Select(kv => (kv.Key ?? string.Empty) + ":" + (nowDbg - kv.Value).ToString("F2")) + .ToArray(); + string key3 = $"recentJoin:{recentJoinCount}|transient:{considerTransientOrphans}|orphanCount:{orphanFollowerCount}"; + string msg3 = $"SelectQueueFollowersForMatch: recentJoinCount={recentJoinCount}, considerTransientOrphans={considerTransientOrphans}, orphanFollowerCount={orphanFollowerCount}, firstSeenAges=[{string.Join(",", ages)}]"; + LogSelectQueueStateIfChanged("recentJoin", key3, msg3); + } + catch { } - // If leaderRoomName provided, try to join it directly - bool newRoomJoined = false; - if (!string.IsNullOrEmpty(leaderRoomName)) + try + { + if (roomPremadeMode) { - if (PhotonRealtimeClient.InLobby) - { - Debug.Log($"FollowLeaderToNewRoom: direct room join requested: {leaderRoomName}"); - PhotonRealtimeClient.JoinRoom(leaderRoomName); - float joinStartDirect = Time.time; - while (!PhotonRealtimeClient.InRoom && Time.time - joinStartDirect < 6f) - { - yield return null; - } - if (PhotonRealtimeClient.InRoom) - { - newRoomJoined = true; - } - } - } + bool user1Present = !string.IsNullOrEmpty(roomPremadeUserId1) && playersById.ContainsKey(roomPremadeUserId1); + bool user2Present = !string.IsNullOrEmpty(roomPremadeUserId2) && playersById.ContainsKey(roomPremadeUserId2); - // Try to find leader via friends list; fallback to joining the matchmaking room with most players - int attempts = 0; - do - { - attempts++; - _friendList = null; - // Only call OpFindFriends when the client is connected and the leader is not this client. - if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.IsConnectedAndReady && - !string.IsNullOrEmpty(leaderUserId) && PhotonRealtimeClient.LocalPlayer != null && leaderUserId != PhotonRealtimeClient.LocalPlayer.UserId) - { - PhotonRealtimeClient.Client.OpFindFriends(new string[1] { leaderUserId }); - yield return new WaitUntil(() => _friendList != null ); - } - else - { - // Skip friends lookup and continue with fallback to room list. - _friendList = new List(); - } + bool invalidPair = string.IsNullOrEmpty(roomPremadeUserId1) + || string.IsNullOrEmpty(roomPremadeUserId2) + || roomPremadeUserId1 == roomPremadeUserId2; - foreach (FriendInfo friend in _friendList) + if (invalidPair || (!user1Present && !user2Present)) { - if (friend.UserId == leaderUserId && friend.IsInRoom && friend.Room != oldRoomName) - { - PhotonRealtimeClient.JoinRoom(friend.Room); - newRoomJoined = true; - break; - } + ClearStaleQueuePremadeMetadata(invalidPair ? "invalid ids" : "no premade users present"); } - - if (!newRoomJoined) + else if (user1Present ^ user2Present) { - // Fallback: join the matchmaking room with the most players (excluding the old room) - yield return new WaitUntil(() => CurrentRooms != null); - LobbyRoomInfo bestRoom = null; - foreach (var room in CurrentRooms) + if (IsTwoPlayerBlockQueueMode(roomGameTypeInt)) { - try + bool extraHumansPresent = realPlayers.Count > requiredFollowers + 1; + if (extraHumansPresent) { - if (!room.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) continue; - if (!(room.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] is bool isMm) || !isMm) continue; + // In larger queues, one-sided premade may indicate teammate still arriving. + string presentPremadeUserId = user1Present ? roomPremadeUserId1 : roomPremadeUserId2; + if (!string.IsNullOrEmpty(presentPremadeUserId)) + { + incompleteMemberIds.Add(presentPremadeUserId); } - catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: reading room properties failed: {ex.Message}"); continue; } - - if (room.Name == oldRoomName) continue; - if (bestRoom == null) - { - bestRoom = room; + Debug.Log("SelectQueueFollowersForMatch: one-sided premade metadata detected in two-player-block mode with extra humans; deferring selection for present premade user."); } else { - if (room.PlayerCount > bestRoom.PlayerCount) + // In exact-size queues, avoid immediately clearing premade metadata for a single missing teammate. + // Defer clearing and treat the present premade user as "incomplete" for a short grace period + // so the duo is not split while the missing user arrives. + string presentPremadeUserId = user1Present ? roomPremadeUserId1 : roomPremadeUserId2; + string missingPremadeUserId = user1Present ? roomPremadeUserId2 : roomPremadeUserId1; + + // If a pending expected-user signal already exists for the missing user or global pending duo signals + // are active, mark the present user as incomplete and defer clearing. + bool pendingForMissing = false; + try { pendingForMissing = !string.IsNullOrEmpty(missingPremadeUserId) && _queuePendingExpectedUserUntil.ContainsKey(missingPremadeUserId); } catch { pendingForMissing = false; } + + if (pendingForMissing || HasPendingQueueDuoSignals()) { - bestRoom = room; + if (!string.IsNullOrEmpty(presentPremadeUserId)) incompleteMemberIds.Add(presentPremadeUserId); + Debug.Log($"SelectQueueFollowersForMatch: one-sided premade metadata detected in exact-size queue; deferring clear due to pending expected signal (missing={missingPremadeUserId})."); } - else if (room.PlayerCount == bestRoom.PlayerCount) + else { - // Tie-breaker: choose randomly to distribute players across equal rooms. - if (UnityEngine.Random.value > 0.5f) + // Install a short pending expected-user window to avoid immediate clearing by other clients + try { - bestRoom = room; + if (!string.IsNullOrEmpty(missingPremadeUserId)) + { + _queuePendingExpectedUserUntil[missingPremadeUserId] = Time.time + QueuePendingLeaderGraceSeconds; + } } + catch { } + + if (!string.IsNullOrEmpty(presentPremadeUserId)) incompleteMemberIds.Add(presentPremadeUserId); + Debug.Log($"SelectQueueFollowersForMatch: one-sided premade metadata detected in exact-size queue; deferring clear and marking present user incomplete (missing={missingPremadeUserId})."); } } } - - if (bestRoom != null) + else { - PhotonRealtimeClient.JoinRoom(bestRoom.Name); - newRoomJoined = true; - break; + // In non-block modes, stale one-sided metadata should not block queue progress. + Debug.Log("SelectQueueFollowersForMatch: stale one-sided premade metadata detected, clearing and continuing."); + ClearStaleQueuePremadeMetadata("one premade user missing"); } } - - // Small delay to avoid tight loop; give state time to update - yield return new WaitForSeconds(0.5f); - } while (!newRoomJoined && attempts < 10); - - // If we couldn't join the leader's room or any candidate, create/join a new matchmaking room - bool attemptedFollowJoinCreate = false; - try - { - if (!newRoomJoined && PhotonRealtimeClient.InLobby) + else { - Debug.Log("FollowLeaderToNewRoom: failed to join leader room; attempting server-side JoinOrCreate matchmaking room."); + // Both premade users are present in the queue room: treat them as a complete duo try { - Debug.Log($"FollowLeaderToNewRoom: calling JoinOrCreateMatchmakingRoom, teammates count={_teammates?.Length ?? 0}"); - PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Random2v2, _teammates); - Debug.Log("FollowLeaderToNewRoom: JoinOrCreateMatchmakingRoom call returned; awaiting join result..."); - } - catch (Exception ex) - { - Debug.LogWarning($"FollowLeaderToNewRoom: JoinOrCreateMatchmakingRoom failed: {ex.Message}"); + if (!string.IsNullOrEmpty(roomPremadeUserId1) && !string.IsNullOrEmpty(roomPremadeUserId2) + && playersById.ContainsKey(roomPremadeUserId1) && playersById.ContainsKey(roomPremadeUserId2)) + { + bool exists = completeDuos.Any(d => + (d.leader != null && d.follower != null && ((d.leader.UserId == roomPremadeUserId1 && d.follower.UserId == roomPremadeUserId2) + || (d.leader.UserId == roomPremadeUserId2 && d.follower.UserId == roomPremadeUserId1)))); + + if (!exists) + { + Player p1 = playersById[roomPremadeUserId1]; + Player p2 = playersById[roomPremadeUserId2]; + if (p1 != null && p2 != null) + { + completeDuos.Add((p1, p2)); + incompleteMemberIds.Remove(roomPremadeUserId1); + incompleteMemberIds.Remove(roomPremadeUserId2); + Debug.Log($"SelectQueueFollowersForMatch: preserving premade pair ({roomPremadeUserId1},{roomPremadeUserId2}) as complete duo for selection."); + } + } + } } - attemptedFollowJoinCreate = true; + catch { } } } - catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: unexpected error: {ex.Message}"); } + } + catch { } - if (attemptedFollowJoinCreate) + // Compute final count after all duo enrichment sources (leader/follower, + // queue metadata and premade metadata) have been applied. + completeDuoCount = completeDuos.Count; + + if (IsTwoPlayerBlockQueueMode(roomGameTypeInt)) + { + return SelectQueueFollowersFromTwoPlayerBlocks( + requiredFollowers, + localUserId, + realPlayers, + playersById, + completeDuos, + incompleteMemberIds, + orphanFollowerIds, + out preferredMasterUserId, + out eligibleSoloCount); + } + + HashSet completeDuoMemberIds = new(); + foreach (var duo in completeDuos) + { + completeDuoMemberIds.Add(duo.leader.UserId); + completeDuoMemberIds.Add(duo.follower.UserId); + } + + bool localInCompleteDuo = completeDuoMemberIds.Contains(localUserId); + string localLeaderId = GetQueuePlayerLeaderId(playersById[localUserId]); + bool localIsFollower = !string.IsNullOrEmpty(localLeaderId) && localLeaderId != localUserId; + + if (localIsFollower) + { + bool localLeaderMissing = !playersById.ContainsKey(localLeaderId); + bool localShouldNotFollowAsMaster = PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient; + if (localLeaderMissing || localShouldNotFollowAsMaster) { - float joinStart = Time.time; - while (!PhotonRealtimeClient.InRoom && Time.time - joinStart < 5f) - { - yield return null; - } - Debug.Log($"FollowLeaderToNewRoom: join attempt finished. InRoom={PhotonRealtimeClient.InRoom}"); - // If still not in a room, wait for MasterServer lobby and retry once - if (!PhotonRealtimeClient.InRoom) + Debug.Log($"SelectQueueFollowersForMatch: clearing stale local leader reference '{localLeaderId}' (leaderMissing={localLeaderMissing}, localIsMaster={localShouldNotFollowAsMaster})."); + localLeaderId = string.Empty; + localIsFollower = false; + try { - float waitStart = Time.time; - while ((PhotonRealtimeClient.Client == null || PhotonRealtimeClient.Client.Server != ServerConnection.MasterServer || !PhotonRealtimeClient.InLobby) && Time.time - waitStart < 5f) - { - yield return null; - } - - if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.MasterServer && PhotonRealtimeClient.InLobby) + if (PhotonRealtimeClient.LocalPlayer != null) { - try - { - PhotonRealtimeClient.JoinRandomOrCreateRandom2v2Room(_teammates, true); - } - catch (Exception ex) + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - Debug.LogWarning($"FollowLeaderToNewRoom: second JoinRandomOrCreate failed: {ex.Message}"); + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, localUserId); } - - float joinStart2 = Time.time; - while (!PhotonRealtimeClient.InRoom && Time.time - joinStart2 < 5f) + else { - yield return null; + PhotonRealtimeClient.LocalPlayer.RemoveCustomProperty(PhotonBattleRoom.LeaderIdKey); } } } + catch (Exception ex) + { + Debug.LogWarning($"SelectQueueFollowersForMatch: failed to clear stale local leader reference: {ex.Message}"); + } } } - finally - { - _followLeaderHolder = null; - } - } - private IEnumerator LeaveMatchmaking() - { - // Safely read the matchmaking room game type; PhotonExtensions handles null room now. - GameType matchmakingRoomGameType = GameType.Random2v2; - if (PhotonRealtimeClient.CurrentRoom != null) + if (localIsFollower) { - try + if (!string.IsNullOrEmpty(localLeaderId) && playersById.ContainsKey(localLeaderId)) { - matchmakingRoomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + preferredMasterUserId = localLeaderId; } - catch (Exception ex) + else { - Debug.LogWarning($"Failed to read matchmaking room game type: {ex.Message}"); + preferredMasterUserId = completeDuos + .Select(d => d.leader.UserId) + .FirstOrDefault(id => !string.IsNullOrEmpty(id) && id != localUserId) ?? string.Empty; } + + return selected; } - if (_matchmakingHolder != null) + if (!localInCompleteDuo && completeDuos.Count >= 2) { - StopCoroutine(_matchmakingHolder); - _matchmakingHolder = null; + preferredMasterUserId = completeDuos + .Where(d => d.leader.UserId != localUserId && d.follower.UserId != localUserId) + .Select(d => d.leader.UserId) + .FirstOrDefault() ?? string.Empty; + + return selected; } - OnMatchmakingStopped?.Invoke(); + HashSet selectedSet = new(); - // If we're currently in a room, leave it and wait for lobby. Otherwise wait until we are in lobby. - if (PhotonRealtimeClient.InRoom) + if (localInCompleteDuo) { - PhotonRealtimeClient.LeaveRoom(); - yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + foreach (var duo in completeDuos) + { + if (duo.leader.UserId != localUserId && duo.follower.UserId != localUserId) continue; + + string teammateId = duo.leader.UserId == localUserId ? duo.follower.UserId : duo.leader.UserId; + if (!string.IsNullOrEmpty(teammateId) && teammateId != localUserId && selectedSet.Add(teammateId)) + { + selected.Add(teammateId); + } + break; + } } - else + + foreach (var duo in completeDuos.OrderBy(d => d.leader.ActorNumber)) { - if (!PhotonRealtimeClient.InLobby) + string leaderId = duo.leader.UserId; + string followerId = duo.follower.UserId; + + if (leaderId == localUserId || followerId == localUserId) continue; + + int blockCount = 0; + if (!selectedSet.Contains(leaderId)) blockCount++; + if (!selectedSet.Contains(followerId)) blockCount++; + if (blockCount == 0) continue; + if (selected.Count + blockCount > requiredFollowers) continue; + + if (selectedSet.Add(leaderId)) selected.Add(leaderId); + if (selectedSet.Add(followerId)) selected.Add(followerId); + + if (selected.Count >= requiredFollowers) { - yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + return selected; } } - // Creating back the non-matchmaking room which the teammates can join (only for Clan2v2) - switch (matchmakingRoomGameType) + List eligibleSolos = realPlayers + .Where(p => p.UserId != localUserId) + .Where(p => !completeDuoMemberIds.Contains(p.UserId)) + .Where(p => !incompleteMemberIds.Contains(p.UserId)) + .Where(p => + { + if (orphanFollowerIds.Contains(p.UserId)) return true; + string leaderId = GetQueuePlayerLeaderId(p); + return string.IsNullOrEmpty(leaderId) || leaderId == p.UserId; + }) + .Select(p => p.UserId) + .ToList(); + + eligibleSoloCount = eligibleSolos.Count; + // expose single eligible solo id so callers can invite them when forming a queue-formed match + try { singleEligibleSoloUserId = eligibleSoloCount == 1 ? (eligibleSolos.Count > 0 ? eligibleSolos[0] : string.Empty) : string.Empty; } catch { singleEligibleSoloUserId = string.Empty; } + try { - case GameType.Clan2v2: + Debug.Log($"SelectQueueFollowersForMatch: eligibleSoloCount={eligibleSoloCount}, singleEligibleSoloUserId='{singleEligibleSoloUserId}', eligibleSolos=[{string.Join(",", eligibleSolos)}]"); + } + catch { } + // keep a record of how many solos are currently eligible in the queue + _queuedSoloCount = eligibleSoloCount; + + // Solo selection caps: + // - If exactly 3 solos are eligible, limit selection to 2 (legacy behavior) + // - If exactly 1 solo is eligible, select 0 solos to avoid pairing a lone solo into a match + int soloCap; + if (eligibleSoloCount == 3) soloCap = 2; + else if (eligibleSoloCount == 1) soloCap = 0; + else soloCap = int.MaxValue; + int solosSelected = 0; + + foreach (string soloUserId in eligibleSolos) + { + // enforce cap for solos + if (soloCap != int.MaxValue && solosSelected >= soloCap) { - string clanName = PhotonRealtimeClient.LocalLobbyPlayer?.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); - int soulhomeRank = PhotonRealtimeClient.LocalLobbyPlayer?.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0) ?? 0; - PhotonRealtimeClient.CreateClan2v2LobbyRoom(clanName, soulhomeRank, _teammates); break; } - } - } - #endregion - #region Game Start & Quantum + if (!selectedSet.Add(soloUserId)) continue; - private void OnStartRoomEvent(StartRoomEvent data) - { - Debug.Log($"onEvent {data}"); - StartCoroutine(OnStartRoom()); + selected.Add(soloUserId); + solosSelected++; + + if (selected.Count >= requiredFollowers) + { + break; + } + } + + return selected; } - private IEnumerator OnStartRoom() + private bool ValidateQueueTwoPlayerBlockComposition(Room room, out string reason) { - float startTime =Time.time; - yield return new WaitUntil(() => PhotonRealtimeClient.Client.InRoom || Time.time > startTime+10); - if (!PhotonRealtimeClient.Client.InRoom) + reason = string.Empty; + if (room == null || room.Players == null) { - Debug.LogWarning("Failed to join a room in time."); - PhotonRealtimeClient.LeaveRoom(); - yield break; + reason = "room missing"; + return false; } - if(PhotonRealtimeClient.LocalPlayer.IsMasterClient) PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable { { BattleID, PhotonRealtimeClient.CurrentRoom.Name.Replace(' ', '_') + "_" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() } }); - //WindowManager.Get().ShowWindow(_roomWindow); - OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); - } - private void OnStartPlayingEvent(StartPlayingEvent data) - { - Debug.Log($"onEvent {data}"); - if (_startGameHolder != null) + HashSet humanUserIds = new( + room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); + + if (humanUserIds.Count == 0) { - StopCoroutine(_startGameHolder); - _startGameHolder = null; + reason = "no human players"; + return false; } - _startGameHolder = StartCoroutine(StartTheGameplay(_isCloseRoomOnGameStart, _blueTeamName, _redTeamName)); - } - private void OnStartRaidTestEvent(StartRaidTestEvent data) - { - Debug.Log($"onEvent {data}"); - StartCoroutine(StartTheRaidTestRoom()); - } + // Only enforce strict two-block coverage for full-human 2v2 rooms. + if (humanUserIds.Count < 4) return true; + if (humanUserIds.Count > 4) + { + reason = $"unexpected human count {humanUserIds.Count}"; + return false; + } - private void OnStartMatchmakingEvent(StartMatchmakingEvent data) - { - Debug.Log($"onEvent {data}"); + List<(string userId1, string userId2)> blocks = new(); + HashSet seenPairKeys = new(StringComparer.Ordinal); - if (!PhotonRealtimeClient.InRoom) return; + void AddFlatPairs(string[] flatPairs) + { + if (flatPairs == null || flatPairs.Length < 2) return; - // Starting matchmaking coroutine - if (_matchmakingHolder == null) + for (int i = 0; i + 1 < flatPairs.Length; i += 2) + { + string userId1 = flatPairs[i]; + string userId2 = flatPairs[i + 1]; + if (string.IsNullOrEmpty(userId1) || string.IsNullOrEmpty(userId2) || userId1 == userId2) continue; + + string key = string.CompareOrdinal(userId1, userId2) <= 0 + ? $"{userId1}|{userId2}" + : $"{userId2}|{userId1}"; + if (!seenPairKeys.Add(key)) continue; + + blocks.Add((userId1, userId2)); + } + } + + try { - _matchmakingHolder = StartCoroutine(StartMatchmaking(data.SelectedGameType)); + AddFlatPairs(room.GetCustomProperty(QueueDuoPairsKey, null)); + AddFlatPairs(room.GetCustomProperty(QueueSoloPairsKey, null)); + } + catch (Exception ex) + { + reason = $"failed to read block metadata: {ex.Message}"; + return false; } - } - private void OnStopMatchmakingEvent(StopMatchmakingEvent data) - { - Debug.Log($"onEvent {data}"); + // Build duo pair key set and member set so we can detect mixed blocks + HashSet duoPairKeysNormalized = new(StringComparer.Ordinal); + HashSet duoMemberIds = new(StringComparer.Ordinal); try { - // If we're in a persistent queue room and the local player is the master, - // transfer master to another real player instead of leaving so the queue stays alive. - var currentRoom = PhotonRealtimeClient.CurrentRoom; - bool isQueueRoom = IsQueueRoom(currentRoom); - if (!data.ForceLeave && isQueueRoom && PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient) + var duoFlat = room.GetCustomProperty(QueueDuoPairsKey, null); + if (duoFlat != null) { - var others = PhotonRealtimeClient.PlayerListOthers; - // pick first other real player (has UserId and not a bot) - Player candidate = null; - foreach (var p in others) + for (int i = 0; i + 1 < duoFlat.Length; i += 2) { - if (p == null) continue; - if (string.IsNullOrEmpty(p.UserId)) continue; - // skip reserved "Bot" user ids - if (p.UserId == "Bot") continue; - candidate = p; - break; + string u1 = duoFlat[i] ?? string.Empty; + string u2 = duoFlat[i + 1] ?? string.Empty; + if (string.IsNullOrEmpty(u1) || string.IsNullOrEmpty(u2) || u1 == u2) continue; + string key = string.CompareOrdinal(u1, u2) <= 0 ? $"{u1}|{u2}" : $"{u2}|{u1}"; + duoPairKeysNormalized.Add(key); + duoMemberIds.Add(u1); + duoMemberIds.Add(u2); } + } + } + catch { } - if (candidate != null) + if (blocks.Count < 2) + { + reason = $"insufficient blocks ({blocks.Count})"; + return false; + } + + HashSet coveredHumans = new(StringComparer.Ordinal); + int presentBlockCount = 0; + foreach (var block in blocks) + { + bool blockPresent = humanUserIds.Contains(block.userId1) && humanUserIds.Contains(block.userId2); + if (!blockPresent) continue; + + // If either member of the present block is known to be part of a duo, + // the pair must match a recorded duo pair exactly. This rejects mixed + // blocks that would pair a solo with half of a duo. + try + { + bool hasDuoMember = duoMemberIds.Contains(block.userId1) || duoMemberIds.Contains(block.userId2); + if (hasDuoMember) { - try - { - var lobbyPlayer = PhotonRealtimeClient.LobbyCurrentRoom.GetPlayer(candidate.ActorNumber); - if (lobbyPlayer != null && PhotonRealtimeClient.LobbyCurrentRoom.SetMasterClient(lobbyPlayer)) - { - Debug.Log($"Queue: transferred master to {candidate.UserId} (actor {candidate.ActorNumber})"); - // OnMasterClientSwitched will handle updating LeaderIdKey and UI on all clients. - return; - } - else - { - Debug.LogWarning("Queue: SetMasterClient returned false; falling back to normal leave."); - } - } - catch (System.Exception ex) + string normKey = string.CompareOrdinal(block.userId1, block.userId2) <= 0 + ? $"{block.userId1}|{block.userId2}" + : $"{block.userId2}|{block.userId1}"; + if (!duoPairKeysNormalized.Contains(normKey)) { - Debug.LogWarning($"Queue: failed to set new master: {ex.Message}"); + reason = $"mixed duo/solo block detected ({block.userId1}/{block.userId2})"; + return false; } } - else - { - Debug.Log("Queue: no suitable candidate found for master transfer; leaving matchmaking."); - } } + catch { } - // Only send RoomChangeRequested if we're connected to the Game server, ready and in a room. - if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.GameServer && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) - { - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, - PhotonRealtimeClient.LocalPlayer.UserId, - new RaiseEventArgs { Receivers = ReceiverGroup.Others }, - SendOptions.SendReliable - ); - } - else - { - Debug.Log($"Skipping RoomChangeRequested broadcast: Server={PhotonRealtimeClient.Client?.Server}, IsConnectedAndReady={PhotonRealtimeClient.Client?.IsConnectedAndReady}, InRoom={PhotonRealtimeClient.InRoom}"); - } + presentBlockCount++; + coveredHumans.Add(block.userId1); + coveredHumans.Add(block.userId2); + } - StartCoroutine(LeaveMatchmaking()); + if (presentBlockCount < 2) + { + reason = $"present blocks {presentBlockCount}"; + return false; } - catch (System.Exception ex) + + if (coveredHumans.Count != humanUserIds.Count) { - Debug.LogWarning($"OnStopMatchmakingEvent: unexpected error: {ex.Message}"); - StartCoroutine(LeaveMatchmaking()); + reason = $"covered humans {coveredHumans.Count}/{humanUserIds.Count}"; + return false; } + + return true; } - private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, string redTeamName) + private bool ShouldDeferTwoPlayerBlockStartForMultiDuo(int requiredFollowers, ICollection selectedFollowers, out string reason) { - try + reason = string.Empty; + + Room room = PhotonRealtimeClient.CurrentRoom; + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (room == null || room.Players == null || string.IsNullOrEmpty(localUserId)) { - // Do not start gameplay from a queue room; queue rooms are for waiting only. - try + return false; + } + + HashSet humanUserIds = new( + room.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); + + int requiredTotal = requiredFollowers + 1; + if (humanUserIds.Count <= requiredTotal) + { + return false; + } + + List<(string userId1, string userId2)> roomCompleteDuoPairs = GetQueueCompleteDuoPairsForParticipants(humanUserIds); + if (roomCompleteDuoPairs.Count < 2) + { + return false; + } + + HashSet selectedParticipantIds = new(StringComparer.Ordinal) { localUserId }; + if (selectedFollowers != null) + { + foreach (string userId in selectedFollowers) { - var currentRoom = PhotonRealtimeClient.CurrentRoom; - if (IsQueueRoom(currentRoom)) - { - Debug.Log("StartTheGameplay: aborting start because current room is a queue room."); - yield break; - } + if (!string.IsNullOrEmpty(userId)) selectedParticipantIds.Add(userId); + } + } + + List<(string userId1, string userId2)> selectedCompleteDuoPairs = GetQueueCompleteDuoPairsForParticipants(selectedParticipantIds); + if (selectedCompleteDuoPairs.Count >= 2) + { + return false; + } + + reason = $"roomHumans={humanUserIds.Count}, roomCompleteDuos={roomCompleteDuoPairs.Count}, selectedCompleteDuos={selectedCompleteDuoPairs.Count}, requiredTotal={requiredTotal}"; + return true; + } + + private bool ShouldDeferTwoPlayerBlockStartForPendingQueueDuo(int requiredFollowers, ICollection selectedFollowers, out string reason) + { + reason = string.Empty; + + Room room = PhotonRealtimeClient.CurrentRoom; + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (room == null || room.Players == null || string.IsNullOrEmpty(localUserId)) + { + return false; + } + + int humanCount = room.Players.Values + .Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + + int requiredTotal = requiredFollowers + 1; + if (humanCount < requiredTotal) + { + return false; + } + + int pendingLeaderCount = GetQueuePendingLeaderCount(); + int pendingExpectedUserCount = GetQueuePendingExpectedUserCount(); + + bool shouldDeferForPendingSignals = pendingExpectedUserCount > 0 + || (humanCount > requiredTotal && pendingLeaderCount > 0); + + if (!shouldDeferForPendingSignals) + { + return false; + } + + HashSet selectedParticipantIds = new(StringComparer.Ordinal) { localUserId }; + if (selectedFollowers != null) + { + foreach (string userId in selectedFollowers) + { + if (!string.IsNullOrEmpty(userId)) selectedParticipantIds.Add(userId); + } + } + + int selectedCompleteDuoCount = GetQueueCompleteDuoPairsForParticipants(selectedParticipantIds).Count; + if (selectedCompleteDuoCount >= 2) + { + return false; + } + + reason = $"humanCount={humanCount}, requiredTotal={requiredTotal}, pendingLeaders={pendingLeaderCount}, pendingExpectedUsers={pendingExpectedUserCount}, selectedCompleteDuos={selectedCompleteDuoCount}"; + return true; + } + + private bool ShouldDeferTwoPlayerBlockEarlyStartForOneSidedPremadeExactSize(int requiredFollowers, int completeDuoCount, out string reason) + { + reason = string.Empty; + + if (completeDuoCount != 1) + { + return false; + } + + Room room = PhotonRealtimeClient.CurrentRoom; + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (room == null || room.Players == null || string.IsNullOrEmpty(localUserId)) + { + return false; + } + + int requiredTotal = requiredFollowers + 1; + int humanCount = room.Players.Values.Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + if (humanCount != requiredTotal) + { + return false; + } + + // Only guard the case where the current queue master is not part of a duo pair. + if (TryGetQueueLocalTeammateUserId(localUserId, out _)) + { + return false; + } + + bool premadeMode = false; + string premadeUserId1 = string.Empty; + string premadeUserId2 = string.Empty; + try + { + premadeMode = room.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + premadeUserId1 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + premadeUserId2 = room.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + } + catch + { + return false; + } + + if (!premadeMode) + { + return false; + } + + bool user1Present = !string.IsNullOrEmpty(premadeUserId1) && room.Players.Values.Any(p => p != null && p.UserId == premadeUserId1); + bool user2Present = !string.IsNullOrEmpty(premadeUserId2) && room.Players.Values.Any(p => p != null && p.UserId == premadeUserId2); + if (!(user1Present ^ user2Present)) + { + return false; + } + + string presentPremadeUser = user1Present ? premadeUserId1 : premadeUserId2; + string missingPremadeUser = user1Present ? premadeUserId2 : premadeUserId1; + + reason = $"humanCount={humanCount}, requiredTotal={requiredTotal}, completeDuos={completeDuoCount}, localSoloMaster={localUserId}, presentPremadeUser={presentPremadeUser}, missingPremadeUser={missingPremadeUser}"; + return true; + } + + private IEnumerator QueueTimerCoroutine() + { + try + { + while (true) + { + float start = Time.time; + while (Time.time - start < QueueWaitSeconds) + { + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + _queueTimerHolder = null; + yield break; + } + + try + { + if (PhotonRealtimeClient.LocalPlayer == null || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + _queueTimerHolder = null; + yield break; + } + + var room = PhotonRealtimeClient.CurrentRoom; + if (room == null || room.CustomProperties == null || !room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) || !room.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) + { + _queueTimerHolder = null; + yield break; + } + } + catch (Exception ex) { Debug.LogWarning($"StartQueueTimer: loop check failed: {ex.Message}"); _queueTimerHolder = null; yield break; } + + try + { + if (_formingMatchHolder == null && Time.time - start >= QueueReadyStartDelaySeconds) + { + int loopGameTypeInt = (int)GameType.Random2v2; + string loopClanName = string.Empty; + int loopSoulhomeRank = 0; + try { loopGameTypeInt = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch { } + try { loopClanName = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); } catch { } + try { loopSoulhomeRank = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0); } catch { } + + int loopRequiredFollowers = GetQueueRequiredFollowerCount(loopGameTypeInt); + string loopPreferredMasterUserId; + int loopCompleteDuoCount; + int loopEligibleSoloCount; + int loopOrphanFollowerCount; + string loopSingleEligibleSoloUserId; + List loopSelected = SelectQueueFollowersForMatch(loopGameTypeInt, loopRequiredFollowers, out loopPreferredMasterUserId, out loopCompleteDuoCount, out loopEligibleSoloCount, out loopOrphanFollowerCount, out loopSingleEligibleSoloUserId); + + if (!string.IsNullOrEmpty(loopPreferredMasterUserId)) + { + if (TryTransferQueueMaster(loopPreferredMasterUserId, $"early duo-priority selection (completeDuos={loopCompleteDuoCount})")) + { + yield break; + } + } + + if (loopSelected.Count >= loopRequiredFollowers) + { + bool twoPlayerBlockMode = IsTwoPlayerBlockQueueMode(loopGameTypeInt); + if (twoPlayerBlockMode && ShouldDeferTwoPlayerBlockStartForMultiDuo(loopRequiredFollowers, loopSelected, out string loopMultiDuoReason)) + { + Debug.Log($"QueueTimerCoroutine: early readiness deferred to preserve complete duo pairs ({loopMultiDuoReason})."); + } + else if (twoPlayerBlockMode && ShouldDeferTwoPlayerBlockStartForPendingQueueDuo(loopRequiredFollowers, loopSelected, out string loopPendingDuoReason)) + { + Debug.Log($"QueueTimerCoroutine: early readiness deferred for pending queue duo handoff ({loopPendingDuoReason})."); + } + else if (twoPlayerBlockMode && ShouldDeferTwoPlayerBlockEarlyStartForOneSidedPremadeExactSize(loopRequiredFollowers, loopCompleteDuoCount, out string oneSidedPremadeReason)) + { + Debug.Log($"QueueTimerCoroutine: early readiness deferred due to one-sided premade metadata in exact-size queue ({oneSidedPremadeReason})."); + } + else + { + int loopHumanCount = 0; + if (twoPlayerBlockMode && loopCompleteDuoCount == 1) + { + try + { + if (PhotonRealtimeClient.CurrentRoom?.Players != null) + { + loopHumanCount = PhotonRealtimeClient.CurrentRoom.Players.Values + .Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + } + } + catch { } + + int loopOrphanRawCount = 0; + try { loopOrphanRawCount = GetQueueOrphanFollowerCount(); } catch { } + + bool localHasQueueTeammate = false; + try { localHasQueueTeammate = TryGetQueueLocalTeammateUserId(PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty, out _); } catch { } + + bool deferTransientOrphanEarly = loopHumanCount == loopRequiredFollowers + 1 + && loopOrphanRawCount > 0 + && !localHasQueueTeammate; + + if (deferTransientOrphanEarly) + { + Debug.Log($"QueueTimerCoroutine: early readiness deferred for transient orphan state (orphanRawCount={loopOrphanRawCount}, humanCount={loopHumanCount}, requiredTotal={loopRequiredFollowers + 1}, localHasQueueTeammate={localHasQueueTeammate})."); + } + else if (loopOrphanFollowerCount > 0) + { + Debug.Log($"QueueTimerCoroutine: early readiness deferred for one-duo block composition (orphans={loopOrphanFollowerCount}, humanCount={loopHumanCount}, requiredTotal={loopRequiredFollowers + 1}); waiting for queue timeout to avoid splitting pending duo joins."); + } + else + { + // If we have enough selected followers and there are no orphan followers + // or pending duo signals, it's safe to form the match immediately even + // if the room currently contains extra humans beyond the exact-size case. + if (loopSelected.Count >= loopRequiredFollowers && !HasPendingQueueDuoSignals()) + { + bool allowForm = true; + try + { + Room curr = PhotonRealtimeClient.CurrentRoom; + if (curr != null) + { + // Build quick playersById map + Dictionary playersById = curr.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .GroupBy(p => p.UserId) + .Select(g => g.First()) + .ToDictionary(p => p.UserId, p => p); + + // Check room premade metadata: if a premade pair is present in the room + // but not both included in the selected set, defer formation to avoid splitting. + try + { + bool roomPremadeMode = curr.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + if (roomPremadeMode) + { + string prem1 = curr.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string prem2 = curr.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + if (!string.IsNullOrEmpty(prem1) && !string.IsNullOrEmpty(prem2) + && playersById.ContainsKey(prem1) && playersById.ContainsKey(prem2)) + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + bool p1Sel = prem1 == localUserId || loopSelected.Contains(prem1); + bool p2Sel = prem2 == localUserId || loopSelected.Contains(prem2); + if (!(p1Sel && p2Sel)) + { + allowForm = false; + Debug.Log($"QueueTimerCoroutine: deferring formation because premade pair ({prem1},{prem2}) present but not both selected; selected=[{string.Join(",", loopSelected)}]."); + } + } + } + } + catch { } + + // Also check leader/follower mappings: if any selected user has a queued teammate + // (via LeaderIdKey mapping) present in the room but that teammate is not selected, + // defer formation to avoid splitting. + if (allowForm) + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + foreach (string uid in loopSelected) + { + try + { + if (TryGetQueueLocalTeammateUserId(uid, out string buddy) && !string.IsNullOrEmpty(buddy) && playersById.ContainsKey(buddy)) + { + bool buddySelected = buddy == localUserId || loopSelected.Contains(buddy); + if (!buddySelected) + { + allowForm = false; + Debug.Log($"QueueTimerCoroutine: deferring formation because teammate {buddy} of selected user {uid} is present but not selected."); + break; + } + } + } + catch { } + } + } + } + } + catch { } + + if (allowForm) + { + Debug.Log($"QueueTimerCoroutine: one-duo composition safe to form; forming match with followers [{string.Join(",", loopSelected)}]."); + try + { + _formingMatchHolder = StartCoroutine(FormMatchFromQueue(loopSelected.ToArray(), loopGameTypeInt, loopClanName, loopSoulhomeRank)); + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: failed to start FormMatchFromQueue: {ex.Message}"); + } + yield break; + } + else + { + Debug.Log($"QueueTimerCoroutine: early formation deferred due to premade/teammate integrity; selected=[{string.Join(",", loopSelected)}]."); + } + } + + Debug.Log($"QueueTimerCoroutine: early readiness deferred for one-duo composition (orphans={loopOrphanFollowerCount}, humanCount={loopHumanCount}, requiredTotal={loopRequiredFollowers + 1}); waiting for queue timeout to preserve duo integrity."); + } + } + else + { + Debug.Log($"QueueTimerCoroutine: queue became ready before timeout, forming match with followers [{string.Join(",", loopSelected)}]."); + _formingMatchHolder = StartCoroutine(FormMatchFromQueue(loopSelected.ToArray(), loopGameTypeInt, loopClanName, loopSoulhomeRank)); + yield break; + } + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: early readiness check failed: {ex.Message}"); + } + + yield return null; + } + + int gameTypeInt = (int)GameType.Random2v2; + string clanName = string.Empty; + int soulhomeRank = 0; + try { gameTypeInt = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read game type: {ex.Message}"); } + try { clanName = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read clan name: {ex.Message}"); } + try { soulhomeRank = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0); } catch (Exception ex) { Debug.LogWarning($"QueueTimerCoroutine: failed to read soulhome rank: {ex.Message}"); } + + int requiredFollowers = GetQueueRequiredFollowerCount(gameTypeInt); + string preferredMasterUserId; + int completeDuoCount; + int eligibleSoloCount; + int orphanFollowerCount; + string singleEligibleSoloUserId; + List selected = SelectQueueFollowersForMatch(gameTypeInt, requiredFollowers, out preferredMasterUserId, out completeDuoCount, out eligibleSoloCount, out orphanFollowerCount, out singleEligibleSoloUserId); + try + { + Debug.Log($"QueueTimerCoroutine: selection debug -> preferredMaster='{preferredMasterUserId}', completeDuos={completeDuoCount}, eligibleSolos={eligibleSoloCount}, singleEligibleSoloUserId='{singleEligibleSoloUserId}', selectedCount={selected.Count}, selected=[{string.Join(",", selected)}]"); + } + catch { } + + if (!string.IsNullOrEmpty(preferredMasterUserId)) + { + if (TryTransferQueueMaster(preferredMasterUserId, $"duo-priority selection (completeDuos={completeDuoCount})")) + { + yield break; + } + } + + if (selected.Count < requiredFollowers) + { + // If there are no selected followers, try to preserve any present duo(s) + // by adding their member IDs so the leader can persist expected-users + // and allow bots to fill remaining slots. + if (selected.Count == 0 && completeDuoCount > 0) + { + try + { + var roomScan = PhotonRealtimeClient.CurrentRoom; + if (roomScan?.Players != null) + { + var humanUserIds = roomScan.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId) + .ToList(); + + var roomPairs = GetQueueCompleteDuoPairsForParticipants(humanUserIds); + var localId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + + if (roomPairs != null && roomPairs.Count > 0) + { + // Prefer a pair that doesn't include the local user (leader). If none, + // fall back to the first available pair and add the non-local member. + (string userId1, string userId2) chosen = (null, null); + foreach (var p in roomPairs) + { + if (p.userId1 != localId && p.userId2 != localId) + { + chosen = p; + break; + } + } + if (chosen.userId1 == null) chosen = roomPairs[0]; + + if (!string.IsNullOrEmpty(chosen.userId1) && !string.IsNullOrEmpty(chosen.userId2)) + { + if (chosen.userId1 != localId) selected.Add(chosen.userId1); + if (chosen.userId2 != localId) selected.Add(chosen.userId2); + Debug.Log($"QueueTimerCoroutine: Queue wait expired; added duo [{string.Join(",", selected)}] to selected to preserve duo and allow botfill (requiredFollowers={requiredFollowers})."); + } + } + + // If the duo helper could not re-identify a pair, still salvage the timeout by + // seeding the match from the visible non-local humans. This lets botfill proceed + // for duo+solo compositions instead of retrying forever on a transient duo lookup miss. + /* if (selected.Count == 0) + { + var broadFallbackCandidates = humanUserIds + .Where(uid => !string.IsNullOrEmpty(uid) && uid != localId) + .Where(uid => uid != "Bot") + .Take(requiredFollowers) + .ToList(); + + if (broadFallbackCandidates.Count > 0) + { + foreach (var uid in broadFallbackCandidates) selected.Add(uid); + Debug.Log($"QueueTimerCoroutine: Queue wait expired; broad fallback selected [{string.Join(",", broadFallbackCandidates)}] for botfill (requiredFollowers={requiredFollowers})."); + } + } */ + + // If adding the duo's member(s) still leaves us short of required followers, + // try to include any available solo humans so the master can persist expected-users + // and let bots fill the remainder. This covers the case where the local master + // is part of a duo and a lone solo is present in the room. + try + { + if (selected.Count > 0 && selected.Count < requiredFollowers && PhotonRealtimeClient.CurrentRoom?.Players != null) + { + // Prefer the singleEligibleSoloUserId when provided by the selector and not already selected. + var localIdCheck = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + if (!string.IsNullOrEmpty(singleEligibleSoloUserId) && !selected.Contains(singleEligibleSoloUserId) && singleEligibleSoloUserId != localIdCheck) + { + selected.Add(singleEligibleSoloUserId); + Debug.Log($"QueueTimerCoroutine: Queue wait expired; added solo '{singleEligibleSoloUserId}' alongside duo to selected (requiredFollowers={requiredFollowers})."); + } + else + { + var extraCandidates = PhotonRealtimeClient.CurrentRoom.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot" && p.UserId != PhotonRealtimeClient.LocalPlayer?.UserId) + .Select(p => p.UserId) + .Where(uid => !selected.Contains(uid)) + .Distinct() + .Take(requiredFollowers - selected.Count) + .ToList(); + + if (extraCandidates.Count > 0) + { + foreach (var uid in extraCandidates) selected.Add(uid); + Debug.Log($"QueueTimerCoroutine: Queue wait expired; added solos [{string.Join(",", extraCandidates)}] alongside duo to selected (requiredFollowers={requiredFollowers})."); + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: failed to add solo alongside duo fallback: {ex.Message}"); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: failed to locate duo fallback: {ex.Message}"); + } + } + + // If we have no selected followers and no complete duos, attempt to salvage + // the timeout by including any available solo humans so the leader can + // persist `qe`/`eu` and WaitForMatchmakingPlayers will preserve them while bots fill. + // NOTE: allow the fallback even when the selector reported 0 eligible solos + // (eligibleSoloCount==0) so transient/filtered solos are still considered + // for botfill when the wait expires. + if (selected.Count == 0 && completeDuoCount == 0) + { + // Prefer the singleEligibleSoloUserId when provided by the selector. + if (eligibleSoloCount == 1 && !string.IsNullOrEmpty(singleEligibleSoloUserId)) + { + selected.Add(singleEligibleSoloUserId); + Debug.Log($"QueueTimerCoroutine: Queue wait expired with lone solo present; adding solo '{singleEligibleSoloUserId}' to selected and forming queue match (requiredFollowers={requiredFollowers})."); + } + else + { + // Best-effort: scan the room for non-bot, non-local human players and add + // up to `requiredFollowers` of them to `selected` so they become expected. + try + { + var roomScan = PhotonRealtimeClient.CurrentRoom; + if (roomScan?.Players != null) + { + var candidates = roomScan.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot" && p.UserId != PhotonRealtimeClient.LocalPlayer?.UserId) + .Select(p => p.UserId) + .Where(uid => !selected.Contains(uid)) + .Distinct() + .Take(requiredFollowers) + .ToList(); + + if (candidates.Count > 0) + { + foreach (var uid in candidates) selected.Add(uid); + Debug.Log($"QueueTimerCoroutine: Queue wait expired; added eligible solos [{string.Join(",", candidates)}] to selected (requiredFollowers={requiredFollowers})."); + } + else + { + Debug.Log($"QueueTimerCoroutine: Queue wait expired but no eligible solo candidates were found in room; forming queue match to trigger botfill (requiredFollowers={requiredFollowers}, eligibleSolos={eligibleSoloCount})."); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: failed to locate eligible solos fallback: {ex.Message}"); + } + } + } + else if (selected.Count == 0) + { + Debug.Log($"QueueTimerCoroutine: Queue wait expired but not enough eligible players (requiredFollowers={requiredFollowers}, selectedFollowers={selected.Count}, completeDuos={completeDuoCount}, eligibleSolos={eligibleSoloCount}). Retrying."); + continue; + } + } + + if (IsTwoPlayerBlockQueueMode(gameTypeInt) && ShouldDeferTwoPlayerBlockStartForMultiDuo(requiredFollowers, selected, out string timeoutMultiDuoReason)) + { + Debug.Log($"QueueTimerCoroutine: timeout selection deferred to preserve complete duo pairs ({timeoutMultiDuoReason})."); + continue; + } + + if (IsTwoPlayerBlockQueueMode(gameTypeInt) && ShouldDeferTwoPlayerBlockStartForPendingQueueDuo(requiredFollowers, selected, out string timeoutPendingDuoReason)) + { + Debug.Log($"QueueTimerCoroutine: timeout selection deferred for pending queue duo handoff ({timeoutPendingDuoReason})."); + continue; + } + + if (IsTwoPlayerBlockQueueMode(gameTypeInt) && ShouldDeferTwoPlayerBlockEarlyStartForOneSidedPremadeExactSize(requiredFollowers, completeDuoCount, out string timeoutOneSidedPremadeReason)) + { + Debug.Log($"QueueTimerCoroutine: timeout selection deferred due to one-sided premade metadata in exact-size queue ({timeoutOneSidedPremadeReason})."); + continue; + } + + if (IsTwoPlayerBlockQueueMode(gameTypeInt) && completeDuoCount == 1) + { + int humanCount = 0; + try + { + if (PhotonRealtimeClient.CurrentRoom?.Players != null) + { + humanCount = PhotonRealtimeClient.CurrentRoom.Players.Values + .Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + } + } + catch { } + + bool deferOneDuoTimeout = orphanFollowerCount > 0 || humanCount > requiredFollowers + 1; + if (deferOneDuoTimeout) + { + Debug.Log($"QueueTimerCoroutine: timeout selection deferred because one-duo composition may split pending duo joins (orphans={orphanFollowerCount}, humanCount={humanCount}, requiredTotal={requiredFollowers + 1}). Retrying for complete duo pairs."); + continue; + } + } + + if (_formingMatchHolder != null) + { + Debug.Log("QueueTimerCoroutine: match formation already in progress; retrying in next queue cycle."); + continue; + } + + // Before forming match, verify duo integrity: do not form if a premade pair + // is present in the room but not both included in the selected followers, + // or if any selected user has a queued teammate present but not selected. + bool allowFinalForm = true; + try + { + Room curr = PhotonRealtimeClient.CurrentRoom; + if (curr != null) + { + Dictionary playersById = curr.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .GroupBy(p => p.UserId) + .Select(g => g.First()) + .ToDictionary(p => p.UserId, p => p); + + try + { + bool roomPremadeMode = curr.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + if (roomPremadeMode) + { + string prem1 = curr.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string prem2 = curr.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + if (!string.IsNullOrEmpty(prem1) && !string.IsNullOrEmpty(prem2) + && playersById.ContainsKey(prem1) && playersById.ContainsKey(prem2)) + { + // Consider a premade user "selected" if they are either in the + // selected followers list or are the local/master user forming + // the match. This avoids deferring formation when the local + // master is part of the premade pair but not present in + // the `selected` array (expected-users should only list + // followers, not the leader). + string localId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + bool p1Sel = selected.Contains(prem1) || prem1 == localId; + bool p2Sel = selected.Contains(prem2) || prem2 == localId; + if (!(p1Sel && p2Sel)) + { + allowFinalForm = false; + Debug.Log($"QueueTimerCoroutine: deferring final formation because premade pair ({prem1},{prem2}) present but not both included among participants; selected=[{string.Join(",", selected)}], local={localId}."); + } + } + } + } + catch { } + + if (allowFinalForm) + { + string localId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + foreach (string uid in selected) + { + try + { + if (TryGetQueueLocalTeammateUserId(uid, out string buddy) && !string.IsNullOrEmpty(buddy) && playersById.ContainsKey(buddy)) + { + // If the buddy is the local/master user, treat them as implicitly + // present and selected (the leader isn't listed among followers). + if (!selected.Contains(buddy) && buddy != localId) + { + allowFinalForm = false; + Debug.Log($"QueueTimerCoroutine: deferring final formation because teammate {buddy} of selected user {uid} is present but not selected."); + break; + } + } + } + catch { } + } + } + } + } + catch { } + + if (!allowFinalForm) + { + Debug.Log($"QueueTimerCoroutine: final formation deferred due to premade/teammate integrity; selected=[{string.Join(",", selected)}]."); + continue; + } + + Debug.Log($"QueueTimerCoroutine: Queue wait expired after {QueueWaitSeconds}s, forming match with followers [{string.Join(",", selected)}]."); + try + { + _formingMatchHolder = StartCoroutine(FormMatchFromQueue(selected.ToArray(), gameTypeInt, clanName, soulhomeRank)); + yield break; + } + catch (Exception ex) + { + Debug.LogWarning($"QueueTimerCoroutine: FormMatchFromQueue failed to start: {ex.Message}"); + } + } + } + finally + { + _queueTimerHolder = null; + } + } + + private void StopHolderCoroutines() + { + if (_reserveFreePositionHolder != null) + { + StopCoroutine(_reserveFreePositionHolder); + _reserveFreePositionHolder = null; + } + + if (_requestPositionChangeHolder != null) + { + StopCoroutine(_requestPositionChangeHolder); + _requestPositionChangeHolder = null; + } + + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + _teammates = null; + _premadeTeammateUserId = string.Empty; + _isPremadeMatchmakingFlow = false; + } + + if (_autoJoinHolder != null) + { + StopCoroutine(_autoJoinHolder); + _autoJoinHolder = null; + } + + if (_followLeaderHolder != null) + { + StopCoroutine(_followLeaderHolder); + _followLeaderHolder = null; + } + + if (_canBattleStartCheckHolder != null) + { + StopCoroutine(_canBattleStartCheckHolder); + _canBattleStartCheckHolder = null; + } + } + + private IEnumerator LeaveAndAutoRequeue(GameType gameType) + { + try + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + bool requeuePremadeMode = false; + string requeuePremadeUserId1 = string.Empty; + string requeuePremadeUserId2 = string.Empty; + int requeuePremadeTargetGameType = (int)gameType; + + // Capture premade metadata before stopping coroutines/leave so requeue preserves same-side pairing. + try + { + if (PhotonRealtimeClient.CurrentRoom != null) + { + requeuePremadeMode = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + requeuePremadeUserId1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + requeuePremadeUserId2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + requeuePremadeTargetGameType = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)gameType); + } + } + catch (Exception ex) { Debug.LogWarning($"LeaveAndAutoRequeue: failed to read premade metadata from current room: {ex.Message}"); } + + if ((!requeuePremadeMode || string.IsNullOrEmpty(requeuePremadeUserId1) || string.IsNullOrEmpty(requeuePremadeUserId2)) + && _isPremadeMatchmakingFlow + && !string.IsNullOrEmpty(localUserId) + && !string.IsNullOrEmpty(_premadeTeammateUserId)) + { + requeuePremadeMode = true; + requeuePremadeUserId1 = localUserId; + requeuePremadeUserId2 = _premadeTeammateUserId; + requeuePremadeTargetGameType = (int)gameType; + } + + if (requeuePremadeMode) + { + bool localInPair = !string.IsNullOrEmpty(localUserId) && (localUserId == requeuePremadeUserId1 || localUserId == requeuePremadeUserId2); + if (!localInPair || string.IsNullOrEmpty(requeuePremadeUserId1) || string.IsNullOrEmpty(requeuePremadeUserId2) || requeuePremadeUserId1 == requeuePremadeUserId2) + { + requeuePremadeMode = false; + } + } + + Debug.Log($"LeaveAndAutoRequeue: preparing to leave and requeue for {gameType}"); + + // Stop any existing matchmaking/holder coroutines to avoid conflicts + try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"LeaveAndAutoRequeue: failed to stop matchmaking coroutines: {ex.Message}"); } + try { StopHolderCoroutines(); } catch (Exception ex) { Debug.LogWarning($"LeaveAndAutoRequeue: failed to stop holder coroutines: {ex.Message}"); } + + // Leave current room and wait until in lobby + if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + + // Show main menu + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); + + // Slightly longer delay to let UI and network state settle + yield return new WaitForSeconds(0.5f); + + // If local player is master, attempt to start matchmaking flow (master may already have started it) + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + if (requeuePremadeMode) + { + _isPremadeMatchmakingFlow = true; + if (localUserId == requeuePremadeUserId1) _premadeTeammateUserId = requeuePremadeUserId2; + else if (localUserId == requeuePremadeUserId2) _premadeTeammateUserId = requeuePremadeUserId1; + } + + // Keep requeue behavior symmetric for all clients so everyone returns to + // the persistent queue room and queue timer can form the next match. + if (_autoJoinHolder != null) + { + StopCoroutine(_autoJoinHolder); + _autoJoinHolder = null; + } + _autoJoinHolder = StartCoroutine(RequeueToPersistentQueue(gameType, requeuePremadeMode, requeuePremadeUserId1, requeuePremadeUserId2, requeuePremadeTargetGameType)); + } + else + { + // Non-master: try to auto-join the largest available matchmaking room (skip for Custom game type) + Debug.Log($"LeaveAndAutoRequeue: non-master starting auto-join for {gameType}"); + if (gameType != GameType.Custom) + { + if (_autoJoinHolder != null) + { + StopCoroutine(_autoJoinHolder); + _autoJoinHolder = null; + } + _autoJoinHolder = StartCoroutine(RequeueToPersistentQueue(gameType, requeuePremadeMode, requeuePremadeUserId1, requeuePremadeUserId2, requeuePremadeTargetGameType)); + } + else + { + Debug.Log("LeaveAndAutoRequeue: skipping auto-join for Custom game type."); + } + } + } + finally { } + } + + #endregion + + #region Unity Lifecycle & Activation + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + } + else + { + Instance = this; + DontDestroyOnLoad(gameObject); + _isActive = false; + if (!_isActive) Activate(); + } + } + + public void OnEnable() + { + if (!_isActive) Activate(); + } + + public void OnDisable() + { + try + { + if (PhotonRealtimeClient.Client != null) + { + PhotonRealtimeClient.RemoveCallbackTarget(this); + try { PhotonRealtimeClient.Client.StateChanged -= OnStateChange; } catch (Exception ex) { Debug.LogWarning($"OnDisable: failed to unsubscribe StateChanged: {ex.Message}"); } + } + } + catch (Exception ex) + { + Debug.LogWarning($"OnDisable: unsubscribe failed: {ex.Message}"); + } + this.Unsubscribe(); + _isActive = false; + if (_serviceHolder != null) + { + try + { + StopCoroutine(_serviceHolder); + } + catch (Exception ex) + { + Debug.LogWarning($"OnDisable: failed to stop service coroutine: {ex.Message}"); + } + _serviceHolder = null; + } + } + + private void OnApplicationQuit() + { + if (PhotonRealtimeClient.Client != null) + { + try + { + if (PhotonRealtimeClient.Client.InRoom) + { + PhotonRealtimeClient.LeaveRoom(); + } + else if (PhotonRealtimeClient.InLobby) + { + PhotonRealtimeClient.LeaveLobby(); + } + } + catch (Exception ex) + { + Debug.LogWarning($"OnApplicationQuit: leave failed: {ex.Message}"); + } + } + } + public void Activate() + { + if (_isActive) { Debug.LogWarning("LobbyManager is already active."); return; } + _isActive = true; + PhotonRealtimeClient.AddCallbackTarget(this); + if (PhotonRealtimeClient.Client != null) + { + try { PhotonRealtimeClient.Client.StateChanged += OnStateChange; } catch (Exception ex) { Debug.LogWarning($"Activate: failed to subscribe StateChanged: {ex.Message}"); } + } + this.Subscribe(OnReserveFreePositionEvent); + this.Subscribe(OnPlayerPosEvent); + this.Subscribe(OnBotToggleEvent); + this.Subscribe(OnBotFillToggleEvent); + this.Subscribe(OnStartRoomEvent); + this.Subscribe(OnStartPlayingEvent); + this.Subscribe(OnStartRaidTestEvent); + this.Subscribe(OnStartMatchmakingEvent); + this.Subscribe(OnStopMatchmakingEvent); + this.Subscribe(OnGetKickedEvent); + if (_serviceHolder == null) _serviceHolder = StartCoroutine(Service()); + + GameConfig gameConfig = GameConfig.Get(); + PlayerSettings playerSettings = gameConfig.PlayerSettings; + string photonRegion = string.IsNullOrEmpty(playerSettings.PhotonRegion) ? null : playerSettings.PhotonRegion; + StartCoroutine(StartLobby(playerSettings.PlayerGuid, playerSettings.PhotonRegion)); + } + + private IEnumerator Service() + { + while (true) + { + PhotonRealtimeClient.Client?.Service(); + //Debug.LogWarning("."); + yield return new WaitForSeconds(0.05f); + } + } + + private IEnumerator StartLobby(string playerGuid, string photonRegion) + { + ClientState networkClientState = PhotonRealtimeClient.NetworkClientState; + Debug.Log($"{networkClientState}"); + var delay = new WaitForSeconds(0.1f); + while (!PhotonRealtimeClient.Client.InLobby) + { + if (networkClientState != PhotonRealtimeClient.NetworkClientState) + { + // Even with delay we must reduce NetworkClientState logging to only when it changes to avoid flooding (on slower connections). + networkClientState = PhotonRealtimeClient.NetworkClientState; + Debug.Log($"{networkClientState}"); + } + if (PhotonRealtimeClient.Client.InRoom) + { + PhotonRealtimeClient.LeaveRoom(); + } + else if (PhotonRealtimeClient.CanConnect) + { + DataStore store = Storefront.Get(); + PlayerData playerData = null; + store.GetPlayerData(playerGuid, p => playerData = p); + yield return new WaitUntil(() => playerData != null); + PhotonRealtimeClient.Client.UserId = playerData.Id; + PhotonRealtimeClient.Connect(playerData.Name, photonRegion); + } + else if (PhotonRealtimeClient.CanJoinLobby) + { + PhotonRealtimeClient.JoinLobbyWithWrapper(null); + } + yield return delay; + } + } + + private void OnStateChange(ClientState arg1, ClientState arg2) + { + Debug.Log(arg1 + " -> " + arg2); + } + + #endregion + + + + + #region Matchmaking + /// + /// Stops any active matchmaking or follow leader coroutines. + /// Call this before leaving a room when switching game types. + /// + public void StopMatchmakingCoroutines() + { + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + + if (_followLeaderHolder != null) + { + StopCoroutine(_followLeaderHolder); + _followLeaderHolder = null; + } + + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + if (_autoJoinHolder != null) + { + StopCoroutine(_autoJoinHolder); + _autoJoinHolder = null; + } + + if (_canBattleStartCheckHolder != null) + { + StopCoroutine(_canBattleStartCheckHolder); + _canBattleStartCheckHolder = null; + } + } + + /// + /// Leader-side matchmaking entry point. + /// Flow: lock current room -> gather teammate/position context -> notify followers -> leave to lobby -> join or create a matchmaking room. + /// + private IEnumerator StartMatchmaking(GameType gameType, bool broadcastRoomChange = true) + { + // remember which game type we're matchmaking for so failure handlers can requeue + _currentMatchmakingGameType = gameType; + bool keepHolder = false; + try + { + string localUserId = PhotonRealtimeClient.LocalPlayer.UserId; + + // Closing the room so that no others can join + PhotonRealtimeClient.CurrentRoom.IsOpen = false; + + // Saving custom properties from the room to the variables + string clanName = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); + int soulhomeRank = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0); + + string positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, ""); + string positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, ""); + string positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, ""); + string positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, ""); + + // Saving other player's userids to enter the new game room together with master client + List expectedUsers = new(); + foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) + { + if (player.Value.UserId != PhotonRealtimeClient.LocalPlayer.UserId) + { + expectedUsers.Add(player.Value.UserId); + } + + // Saving clan name and soulhome rank to player's custom properties in case the matchmaking leader leaves + if (!string.IsNullOrEmpty(clanName)) + { + player.Value.SetCustomProperty(PhotonBattleRoom.ClanNameKey, clanName); + player.Value.SetCustomProperty(PhotonBattleRoom.SoulhomeRank, soulhomeRank); + } + } + _teammates = expectedUsers.ToArray(); + if (_isPremadeMatchmakingFlow) + { + string resolvedPremadeTeammateUserId = string.Empty; + bool teammateFound = TryGetQueueLocalTeammateUserId(localUserId, out resolvedPremadeTeammateUserId); + if (!teammateFound || string.IsNullOrEmpty(resolvedPremadeTeammateUserId)) + { + if (!string.IsNullOrEmpty(_premadeTeammateUserId) + && PhotonRealtimeClient.CurrentRoom?.Players?.Values.Any(p => p != null && p.UserId == _premadeTeammateUserId) == true) + { + resolvedPremadeTeammateUserId = _premadeTeammateUserId; + } + else + { + string[] visibleTeammates = expectedUsers + .Where(id => !string.IsNullOrEmpty(id)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (visibleTeammates.Length == 1) + { + resolvedPremadeTeammateUserId = visibleTeammates[0]; + } + } + } + + _premadeTeammateUserId = resolvedPremadeTeammateUserId ?? string.Empty; + _teammates = string.IsNullOrEmpty(_premadeTeammateUserId) + ? Array.Empty() + : new[] { _premadeTeammateUserId }; + } + else + { + _premadeTeammateUserId = string.Empty; + } + + // Sending other players in the room the room change request, setting own leader id key as own userid to indicate being the leader + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, PhotonRealtimeClient.LocalPlayer.UserId); + try { OnRoomLeaderChanged?.Invoke(true); } catch (Exception ex) { Debug.LogWarning($"StartMatchmaking: OnRoomLeaderChanged invocation failed: {ex.Message}"); } + + if (broadcastRoomChange && PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.GameServer && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) + { + object roomChangePayload = PhotonRealtimeClient.LocalPlayer.UserId; + if (_isPremadeMatchmakingFlow) + { + roomChangePayload = new object[] { localUserId, _teammates, $"Queue_{gameType}" }; + } + + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, + roomChangePayload, + new RaiseEventArgs { Receivers = ReceiverGroup.Others }, + SendOptions.SendReliable + ); + } + else if (broadcastRoomChange) + { + Debug.Log($"Skipping RoomChangeRequested broadcast (StartMatchmaking): Server={PhotonRealtimeClient.Client?.Server}, IsConnectedAndReady={PhotonRealtimeClient.Client?.IsConnectedAndReady}, InRoom={PhotonRealtimeClient.InRoom}"); + } + + // Nulling room list and leaving room so that client can get room list + CurrentRooms = null; + PhotonRealtimeClient.LeaveRoom(); + + // Wait for lobby and initial room listing; room search below depends on CurrentRooms. + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby && CurrentRooms != null); + + // Queue-authoritative flow: all clients enter queue room first and queue owner forms matches. + if (UseQueueAuthoritativeMatchmaking) + { + bool queueJoinRequested = false; + try + { + queueJoinRequested = PhotonRealtimeClient.JoinOrCreateQueueRoom(gameType); + } + catch (Exception ex) + { + Debug.LogWarning($"StartMatchmaking: JoinOrCreateQueueRoom failed: {ex.Message}"); + } + + if (queueJoinRequested) + { + float queueJoinStart = Time.time; + yield return new WaitUntil(() => PhotonRealtimeClient.InRoom || Time.time > queueJoinStart + 8f); + + bool joinedQueueRoom = false; + string queueRoomName = string.Empty; + try + { + joinedQueueRoom = PhotonRealtimeClient.InRoom + && PhotonRealtimeClient.CurrentRoom != null + && PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.IsQueueKey); + if (joinedQueueRoom) + { + queueRoomName = PhotonRealtimeClient.CurrentRoom.Name; + } + } + catch (Exception ex) + { + Debug.LogWarning($"StartMatchmaking: failed to verify queue room join: {ex.Message}"); + } + + if (joinedQueueRoom) + { + try + { + if (!string.IsNullOrEmpty(_premadeTeammateUserId)) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)gameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, _premadeTeammateUserId); + } + PhotonRealtimeClient.CurrentRoom.SetCustomProperty("qe", _teammates?.Length ?? 0); + if (_teammates != null && _teammates.Length > 0) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty("eu", _teammates); + } + } + catch (Exception ex) + { + Debug.LogWarning($"StartMatchmaking: failed to stamp queue metadata: {ex.Message}"); + } + + if (broadcastRoomChange && !string.IsNullOrEmpty(queueRoomName)) + { + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, + new object[] { localUserId, _teammates, queueRoomName }, + new RaiseEventArgs { Receivers = ReceiverGroup.Others }, + SendOptions.SendReliable + ); + } + + // Queue room handling takes over from OnJoinedRoom (queue timer / queue master logic). + yield break; + } + + Debug.LogWarning("StartMatchmaking: queue join did not land in a queue room."); + } + else + { + Debug.LogWarning("StartMatchmaking: queue join request failed."); + } + + // Ensure failure path starts from lobby if queue join attempt left us in a room. + if (PhotonRealtimeClient.InRoom) + { + PhotonRealtimeClient.LeaveRoom(); + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + } + + Debug.LogWarning("StartMatchmaking: aborting matchmaking start because queue-authoritative entry failed."); + OnFailedToStartMatchmakingGame?.Invoke(); + yield break; + } + + // Searching for suitable room and attempting to join each candidate. + // If a JoinRoom attempt fails (room filled/closed) we continue searching other rooms. + bool roomFound = false; + bool joinedExistingRoom = false; + // Use a shorter per-room timeout for Random2v2 to reduce delays when iterating many candidates. + float joinAttemptTimeout = gameType == GameType.Random2v2 ? 2f : 5f; // seconds to wait for a join to succeed before trying next room + + if (CurrentRooms != null && CurrentRooms.Count > 0) + { + // Sort candidates by descending player count (prefer fuller rooms) and deterministic tie-breaker + var roomsList = CurrentRooms.OrderByDescending(r => r.PlayerCount).ThenBy(r => r.Name).ToList(); + + foreach (LobbyRoomInfo room in roomsList) + { + // Checking if the room has a game type and matchmaking key in the first place + if (!room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey) || !room.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) + { + continue; + } + + // Checking that the game type matches and that the room is a matchmaking room + if ((GameType)room.CustomProperties[PhotonBattleRoom.GameTypeKey] != gameType || (bool)room.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] == false) + { + continue; + } + + // Decide if we should attempt to join this room + bool shouldTryJoin = false; + switch (gameType) + { + case GameType.Clan2v2: + if ((string)room.CustomProperties[PhotonBattleRoom.ClanNameKey] != clanName && room.MaxPlayers - room.PlayerCount >= _teammates.Length + 1) + { + shouldTryJoin = true; + } + break; + case GameType.Random2v2: + if (room.MaxPlayers - room.PlayerCount >= _teammates.Length + 1) + { + shouldTryJoin = !_isPremadeMatchmakingFlow || RoomHasSameSideCapacityForPremade(room); + } + break; + } + + if (!shouldTryJoin) continue; + + // Attempt join and wait until success or timeout. Use scoped attempt id for diagnostics. + int joinAttemptId = _joinAttemptTracker.BeginJoinAttempt(room.Name, _teammates); + try { PhotonRealtimeClient.JoinRoom(room.Name, _teammates); } catch (Exception ex) { Debug.LogWarning($"JoinAttempt[{joinAttemptId}]: JoinRoom threw: {ex.Message}"); _joinAttemptTracker.MarkJoinAttemptFailure(joinAttemptId, -1, ex.Message); } + float joinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStart < joinAttemptTimeout) + { + if (_joinAttemptTracker.IsAttemptCompleted(joinAttemptId)) break; + yield return null; + } + + if (PhotonRealtimeClient.InRoom) + { + Debug.Log($"JoinAttempt[{joinAttemptId}]: joined room '{PhotonRealtimeClient.CurrentRoom?.Name}'"); + roomFound = true; + joinedExistingRoom = true; + break; + } + else + { + if (_joinAttemptTracker.TryGetFailedJoinAttempt(joinAttemptId, out string failMsg)) + { + Debug.LogWarning($"JoinAttempt[{joinAttemptId}]: failed (error) joining '{room.Name}': {failMsg}"); + } + else + { + Debug.LogWarning($"JoinAttempt[{joinAttemptId}]: timed out after {joinAttemptTimeout}s for '{room.Name}', trying next candidate."); + } + // try next room + } + } + } + + // If no candidate worked, let backend pick or create a suitable room atomically. + if (!joinedExistingRoom) + { + switch (gameType) + { + case GameType.Clan2v2: + if (_isPremadeMatchmakingFlow) + { + PhotonRealtimeClient.JoinRandomOrCreateClan2v2Room(clanName, soulhomeRank, _teammates, true); + } + else + { + PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Clan2v2, _teammates, clanName, soulhomeRank); + } + break; + case GameType.Random2v2: + if (_isPremadeMatchmakingFlow) + { + PhotonRealtimeClient.JoinRandomOrCreateRandom2v2Room(_teammates, true); + } + else + { + PhotonRealtimeClient.JoinOrCreateMatchmakingRoom(GameType.Random2v2, _teammates); + } + break; + } + } + + // Block until our matchmaking-room join completes. + yield return new WaitUntil(() => PhotonRealtimeClient.InRoom); + + if (_isPremadeMatchmakingFlow && !string.IsNullOrEmpty(_premadeTeammateUserId)) + { + try + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)gameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, _premadeTeammateUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + } + catch (Exception ex) + { + Debug.LogWarning($"StartMatchmaking: failed to set premade matchmaking room properties: {ex.Message}"); + } + } + + // Reconcile split groups caused by near-simultaneous room creation. + // Disabled: deterministically converge toward one shared room (commented out). + /* + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey) + && PhotonRealtimeClient.CurrentRoom.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] is bool curIsMm && curIsMm) + { + string myRoom = PhotonRealtimeClient.CurrentRoom.Name; + int myCount = PhotonRealtimeClient.CurrentRoom.PlayerCount; + int reconcileAttempts = 0; + while (reconcileAttempts < 3) + { + reconcileAttempts++; + if (CurrentRooms == null || CurrentRooms.Count == 0) + { + float waitStart = Time.time; + while ((CurrentRooms == null || CurrentRooms.Count == 0) && Time.time - waitStart < 1f) + { + yield return null; + } + if (CurrentRooms == null || CurrentRooms.Count == 0) break; + } + + // Count total matchmaking rooms that have at least one player (including our own) + int totalMatchmakingRoomsWithPlayers = 0; + foreach (var r in CurrentRooms) + { + try + { + if (!r.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) continue; + if (!(r.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] is bool isMm) || !isMm) continue; + } + catch (Exception ex) { Debug.LogWarning($"ReconcileMatchmakingRooms: reading room properties failed: {ex.Message}"); continue; } + + if (r.PlayerCount > 0) totalMatchmakingRoomsWithPlayers++; + } + + // If there are fewer than 2 rooms with players, no need to consolidate + if (totalMatchmakingRoomsWithPlayers < 2) break; + + // Select deterministic target (highest player count, lexicographic name tie-breaker) + LobbyRoomInfo target = null; + foreach (var r in CurrentRooms) + { + try + { + if (!r.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) continue; + if (!(r.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] is bool isMm) || !isMm) continue; + } + catch (Exception ex) { Debug.LogWarning($"ReconcileMatchmakingRooms: reading room properties failed: {ex.Message}"); continue; } + + if (r.Name == myRoom) continue; + if (r.MaxPlayers - r.PlayerCount <= 0) continue; + + if (target == null) + { + target = r; + } + else + { + if (r.PlayerCount > target.PlayerCount) target = r; + else if (r.PlayerCount == target.PlayerCount && string.CompareOrdinal(r.Name, target.Name) < 0) target = r; + } + } + + if (target == null) break; + + // Always try to move into the chosen target when multiple non-empty matchmaking rooms exist + string targetName = target.Name; + PhotonRealtimeClient.LeaveRoom(); + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby || !PhotonRealtimeClient.InRoom); + if (PhotonRealtimeClient.InLobby) + { + PhotonRealtimeClient.JoinRoom(targetName); + float joinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStart < 5f) + { + yield return null; + } + + if (PhotonRealtimeClient.InRoom && PhotonRealtimeClient.CurrentRoom?.Name == targetName) + { + // moved into shared room + break; + } + else + { + // try rejoining original room if still desired + if (!PhotonRealtimeClient.InRoom && !string.IsNullOrEmpty(myRoom)) + { + PhotonRealtimeClient.JoinRoom(myRoom); + float rejoinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - rejoinStart < 3f) + { + yield return null; + } + if (PhotonRealtimeClient.InRoom) myCount = PhotonRealtimeClient.CurrentRoom.PlayerCount; + } + } + } + } + } + } + finally { } + */ + + // If room was found setting room properties + if (roomFound) + { + switch (gameType) + { + case GameType.Clan2v2: + // Setting clan name as opponent clan + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey, clanName); + + // Setting own and teammate positions from old room to position keys 3 and 4 + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue1); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue2); + break; + + case GameType.Random2v2: + if (_teammates.Length == 0) // If queuing solo + { + StartCoroutine(ReserveFreePosition()); + } + else if (_isPremadeMatchmakingFlow && !string.IsNullOrEmpty(_premadeTeammateUserId)) + { + if (!TryReservePremadePairToSameSide(localUserId, _premadeTeammateUserId, out _)) + { + Debug.LogWarning("StartMatchmaking: no same-side capacity for premade duo, requeueing."); + StartCoroutine(LeaveAndAutoRequeue(gameType)); + yield break; + } + } + else + { + /* + // Queuing with a teammate TODO: untested code, when queueing with teammate is possible test this and fix any issues + { + // Checking if position is free and if so setting userid from old room to that position + if (PhotonBattleRoom.CheckIfPositionIsFree(PhotonBattleRoom.PlayerPosition3)) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue3); + } + else // If position is not free + { + // Moving the player at the position to the first free position (should be either 1 or 2 since room max players is 4) + int freePosition = PhotonLobbyRoom.GetFirstFreePlayerPos(); + if (!PhotonLobbyRoom.IsValidPlayerPos(freePosition)) yield break; + string newRoomPositionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(freePosition), newRoomPositionValue3); + } + + if (PhotonBattleRoom.CheckIfPositionIsFree(PhotonBattleRoom.PlayerPosition4)) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue4); + } + else + { + int freePosition = PhotonLobbyRoom.GetFirstFreePlayerPos(); + if (!PhotonLobbyRoom.IsValidPlayerPos(freePosition)) yield break; + string newRoomPositionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(freePosition), newRoomPositionValue4); + } + } + */ + } + break; + } + } + else if (!roomFound) // Initializing new created room properties + { + // Setting player positions from the old room + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, positionValue1); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, positionValue2); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, positionValue3); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, positionValue4); + } + + // Stopping coroutine if not a master client + if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; + + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + keepHolder = true; + } + finally + { + if (!keepHolder) _matchmakingHolder = null; + } + + } + + private bool RoomHasSameSideCapacityForPremade(LobbyRoomInfo room) + { + if (room == null || room.CustomProperties == null) return false; + + string positionValue1 = room.CustomProperties.ContainsKey(PhotonBattleRoom.PlayerPositionKey1) + ? room.CustomProperties[PhotonBattleRoom.PlayerPositionKey1]?.ToString() + : string.Empty; + string positionValue2 = room.CustomProperties.ContainsKey(PhotonBattleRoom.PlayerPositionKey2) + ? room.CustomProperties[PhotonBattleRoom.PlayerPositionKey2]?.ToString() + : string.Empty; + string positionValue3 = room.CustomProperties.ContainsKey(PhotonBattleRoom.PlayerPositionKey3) + ? room.CustomProperties[PhotonBattleRoom.PlayerPositionKey3]?.ToString() + : string.Empty; + string positionValue4 = room.CustomProperties.ContainsKey(PhotonBattleRoom.PlayerPositionKey4) + ? room.CustomProperties[PhotonBattleRoom.PlayerPositionKey4]?.ToString() + : string.Empty; + + int alphaFree = (string.IsNullOrEmpty(positionValue1) ? 1 : 0) + (string.IsNullOrEmpty(positionValue2) ? 1 : 0); + int betaFree = (string.IsNullOrEmpty(positionValue3) ? 1 : 0) + (string.IsNullOrEmpty(positionValue4) ? 1 : 0); + return alphaFree >= 2 || betaFree >= 2; + } + + private bool TryReservePremadePairToSameSide(string userId1, string userId2, out int teamNumber) + { + teamNumber = PhotonBattleRoom.NoTeamValue; + if (string.IsNullOrEmpty(userId1) || string.IsNullOrEmpty(userId2) || userId1 == userId2) + { + Debug.LogWarning($"TryReservePremadePairToSameSide: invalid user ids ({userId1},{userId2})."); + return false; + } + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + Debug.LogWarning($"TryReservePremadePairToSameSide: not in room or CurrentRoom null while attempting ({userId1},{userId2})."); + return false; + } + if (!CanMutateRoomPropertiesNow()) + { + Debug.LogWarning($"TryReservePremadePairToSameSide: cannot mutate room properties now for ({userId1},{userId2})."); + return false; + } + + Dictionary currentMap = new() + { + { PhotonBattleRoom.PlayerPosition1, PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, string.Empty) }, + { PhotonBattleRoom.PlayerPosition2, PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, string.Empty) }, + { PhotonBattleRoom.PlayerPosition3, PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, string.Empty) }, + { PhotonBattleRoom.PlayerPosition4, PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, string.Empty) }, + }; + + try + { + string snapshot = $"pos1={currentMap[PhotonBattleRoom.PlayerPosition1]},pos2={currentMap[PhotonBattleRoom.PlayerPosition2]},pos3={currentMap[PhotonBattleRoom.PlayerPosition3]},pos4={currentMap[PhotonBattleRoom.PlayerPosition4]}"; + Debug.Log($"TryReservePremadePairToSameSide: currentMap for room '{PhotonRealtimeClient.CurrentRoom?.Name}': {snapshot} - attempting to reserve ({userId1},{userId2})."); + } + catch { } + + bool IsBotValue(string value) => string.Equals(value, "Bot", StringComparison.Ordinal); + + int FindPosition(Dictionary map, string userId) + { + foreach (var kvp in map) + { + if (kvp.Value == userId) return kvp.Key; + } + return PlayerPositionGuest; + } + + int GetTeamFromPosition(int position) + { + if (position == PhotonBattleRoom.PlayerPosition1 || position == PhotonBattleRoom.PlayerPosition2) return PhotonBattleRoom.TeamAlphaValue; + if (position == PhotonBattleRoom.PlayerPosition3 || position == PhotonBattleRoom.PlayerPosition4) return PhotonBattleRoom.TeamBetaValue; + return PhotonBattleRoom.NoTeamValue; + } + + bool MapsEqual(Dictionary first, Dictionary second) + { + foreach (int key in first.Keys) + { + string firstValue = first[key] ?? string.Empty; + string secondValue = second[key] ?? string.Empty; + if (!string.Equals(firstValue, secondValue, StringComparison.Ordinal)) return false; + } + return true; + } + + bool TryBuildTeamMap(Dictionary sourceMap, int targetTeamNumber, out Dictionary resultMap, out int displacedBots) + { + resultMap = null; + displacedBots = int.MaxValue; + + int[] teamSlots = targetTeamNumber == PhotonBattleRoom.TeamAlphaValue + ? new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2 } + : new[] { PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 }; + + int[] otherSlots = targetTeamNumber == PhotonBattleRoom.TeamAlphaValue + ? new[] { PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 } + : new[] { PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2 }; + + Dictionary workingMap = new(sourceMap); + + // Capture everyone currently on the target side (including humans) so we can rebalance + // to the opposite side if a full room has two duos split across teams. + List displacedValues = new(); + foreach (int slot in teamSlots) + { + string slotValue = workingMap[slot]; + if (!string.IsNullOrEmpty(slotValue)) displacedValues.Add(slotValue); + } + + workingMap[teamSlots[0]] = userId1; + workingMap[teamSlots[1]] = userId2; + + List otherSideCombined = new(); + HashSet seen = new(StringComparer.Ordinal); + + foreach (int slot in otherSlots) + { + string slotValue = workingMap[slot]; + if (string.IsNullOrEmpty(slotValue)) continue; + if (seen.Add(slotValue)) otherSideCombined.Add(slotValue); + } + + foreach (string displacedValue in displacedValues) + { + if (string.IsNullOrEmpty(displacedValue)) continue; + if (seen.Add(displacedValue)) otherSideCombined.Add(displacedValue); + } + + if (otherSideCombined.Count > otherSlots.Length) return false; + + foreach (int slot in otherSlots) + { + workingMap[slot] = string.Empty; + } + + for (int i = 0; i < otherSideCombined.Count; i++) + { + workingMap[otherSlots[i]] = otherSideCombined[i]; + } + + displacedBots = 0; + foreach (string value in displacedValues) + { + if (IsBotValue(value)) displacedBots++; + } + + if (displacedBots > 0) + { + int botCountOnOtherSide = 0; + foreach (int slot in otherSlots) + { + if (IsBotValue(workingMap[slot])) botCountOnOtherSide++; + } + if (botCountOnOtherSide < displacedBots) + { + return false; + } + } + + resultMap = workingMap; + return true; + } + + int userId1Position = FindPosition(currentMap, userId1); + int userId2Position = FindPosition(currentMap, userId2); + int userId1Team = GetTeamFromPosition(userId1Position); + int userId2Team = GetTeamFromPosition(userId2Position); + + // Already on same team: don't churn room properties. + if (userId1Team != PhotonBattleRoom.NoTeamValue && userId1Team == userId2Team) + { + teamNumber = userId1Team; + Debug.Log($"TryReservePremadePairToSameSide: users already on same team {teamNumber} ({userId1},{userId2})."); + return true; + } + + Dictionary baseMap = new(currentMap); + foreach (int key in baseMap.Keys.ToArray()) + { + if (baseMap[key] == userId1 || baseMap[key] == userId2) + { + baseMap[key] = string.Empty; + } + } + + bool alphaValid = TryBuildTeamMap(baseMap, PhotonBattleRoom.TeamAlphaValue, out Dictionary alphaMap, out int alphaDisplacedBots); + bool betaValid = TryBuildTeamMap(baseMap, PhotonBattleRoom.TeamBetaValue, out Dictionary betaMap, out int betaDisplacedBots); + + if (!alphaValid && !betaValid) + { + Debug.LogWarning($"TryReservePremadePairToSameSide: no valid target team for ({userId1},{userId2}) (alphaValid={alphaValid}, betaValid={betaValid})."); + return false; + } + + int alphaAffinity = (userId1Team == PhotonBattleRoom.TeamAlphaValue ? 1 : 0) + (userId2Team == PhotonBattleRoom.TeamAlphaValue ? 1 : 0); + int betaAffinity = (userId1Team == PhotonBattleRoom.TeamBetaValue ? 1 : 0) + (userId2Team == PhotonBattleRoom.TeamBetaValue ? 1 : 0); + + Dictionary targetMap; + if (alphaValid && betaValid) + { + if (alphaAffinity > betaAffinity) + { + teamNumber = PhotonBattleRoom.TeamAlphaValue; + targetMap = alphaMap; + } + else if (betaAffinity > alphaAffinity) + { + teamNumber = PhotonBattleRoom.TeamBetaValue; + targetMap = betaMap; + } + else if (alphaDisplacedBots < betaDisplacedBots) + { + teamNumber = PhotonBattleRoom.TeamAlphaValue; + targetMap = alphaMap; + } + else if (betaDisplacedBots < alphaDisplacedBots) + { + teamNumber = PhotonBattleRoom.TeamBetaValue; + targetMap = betaMap; + } + else + { + teamNumber = PhotonBattleRoom.TeamAlphaValue; + targetMap = alphaMap; + } + } + else if (alphaValid) + { + teamNumber = PhotonBattleRoom.TeamAlphaValue; + targetMap = alphaMap; + } + else + { + teamNumber = PhotonBattleRoom.TeamBetaValue; + targetMap = betaMap; + } + + if (MapsEqual(currentMap, targetMap)) return true; + + try + { + string targetSnapshot = $"t1={targetMap[PhotonBattleRoom.PlayerPosition1]},t2={targetMap[PhotonBattleRoom.PlayerPosition2]},t3={targetMap[PhotonBattleRoom.PlayerPosition3]},t4={targetMap[PhotonBattleRoom.PlayerPosition4]}"; + Debug.Log($"TryReservePremadePairToSameSide: applying targetMap for team {teamNumber}: {targetSnapshot}"); + } + catch { } + + foreach (int key in targetMap.Keys) + { + string currentValue = currentMap[key] ?? string.Empty; + string targetValue = targetMap[key] ?? string.Empty; + if (string.Equals(currentValue, targetValue, StringComparison.Ordinal)) continue; + + try + { + Debug.Log($"TryReservePremadePairToSameSide: setting position {key} -> '{targetValue}' (was '{currentValue}')"); + } + catch { } + + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(key), targetValue); + try + { + string rb = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GetPositionKey(key), string.Empty); + Debug.Log($"TryReservePremadePairToSameSide: readback position {key} -> '{rb}'"); + } + catch { } + } + + return true; + } + + /// + /// Master-side wait loop that decides when a matchmaking room can start. + /// Handles: missing expected users -> short requeue, long Random2v2 wait -> bot backfill, then start countdown/gameplay. + /// + private IEnumerator WaitForMatchmakingPlayers() + { + try + { + if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) yield break; + + bool gameStarting = false; + float waitStartTime = Time.time; + bool botBackfillApplied = false; + try + { + if (PhotonRealtimeClient.InRoom && PhotonRealtimeClient.CurrentRoom != null) + { + botBackfillApplied = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey, false); + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read initial BotFillKey state: {ex.Message}"); } + + do + { + // Checking every 0,5s if we can start gameplay + bool canStartGameplay = false; + do + { + yield return new WaitForSeconds(0.5f); + + // If we lost the room (race during master switch/leave), stop waiting + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + Debug.LogWarning("WaitForMatchmakingPlayers: Not in a room anymore, aborting matchmaking wait."); + yield break; + } + + // Check if matchmaking timeout expired and fill remaining slots with bots (Random2v2 only) + GameType currentGameType = GameType.Random2v2; + try + { + currentGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read game type: {ex.Message}"); } + + // Short join timeout: if after MatchmakingJoinTimeoutSeconds the countdown hasn't started, + // master should instruct all clients to leave and requeue so the group can reform. + int expectedFollowers = 0; + try { expectedFollowers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); } catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read expected followers: {ex.Message}"); expectedFollowers = 0; } + + // Track whether expected users are missing. Use explicit custom property first + // and fall back to Photon slot-reservation metadata. + bool expectedUsersMissing = true; + string[] expectedUsers = null; + try + { + expectedUsers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); + if ((expectedUsers == null || expectedUsers.Length == 0) && PhotonRealtimeClient.CurrentRoom.ExpectedUsers != null && PhotonRealtimeClient.CurrentRoom.ExpectedUsers.Length > 0) + { + expectedUsers = PhotonRealtimeClient.CurrentRoom.ExpectedUsers; + } + + if (expectedUsers != null && expectedUsers.Length > 0) + { + bool allPresent = true; + foreach (var uid in expectedUsers) + { + if (string.IsNullOrEmpty(uid)) continue; + bool present = PhotonRealtimeClient.CurrentRoom.Players.Values.Any(p => p.UserId == uid); + if (!present) { allPresent = false; break; } + } + expectedUsersMissing = !allPresent; + } + else + { + expectedUsersMissing = expectedFollowers > 0; + } + } + catch (Exception ex) + { + expectedUsersMissing = true; + Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate expected users presence: {ex.Message}"); + } + + bool expectedUsersConfigured = expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + bool expectedPlayersRequired = expectedFollowers > 0 || expectedUsersConfigured; + + // Detailed diagnostics to investigate premature requeue issues + try + { + var currentUserIds = PhotonRealtimeClient.CurrentRoom.Players.Values.Select(p => p.UserId).ToArray(); + Debug.Log($"WaitForMatchmakingPlayers: expectedFollowers={expectedFollowers}, expectedPlayersRequired={expectedPlayersRequired}, expectedUsers=[{(expectedUsers == null ? "null" : string.Join(",", expectedUsers))}], currentPlayers=[{string.Join(",", currentUserIds)}], expectedUsersMissing={expectedUsersMissing}"); + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: diagnostics failed: {ex.Message}"); } + + // Short-timeout branch for queue-formed groups: if expected users do not arrive quickly, + // cancel this start attempt and requeue everyone together. + if (!botBackfillApplied + && Time.time - waitStartTime >= MatchmakingJoinTimeoutSeconds + && expectedPlayersRequired + && expectedUsersMissing) + { + bool recheckFound = false; + if (expectedUsersConfigured) + { + // If expected users appear missing, allow a brief grace window to re-check + // (helps with join/property propagation races). + float recheckStart = Time.time; + while (Time.time - recheckStart < 1.0f) // up to 1s grace + { + yield return new WaitForSeconds(0.15f); + try + { + var nowExpected = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); + if ((nowExpected == null || nowExpected.Length == 0) && PhotonRealtimeClient.CurrentRoom.ExpectedUsers != null && PhotonRealtimeClient.CurrentRoom.ExpectedUsers.Length > 0) + { + nowExpected = PhotonRealtimeClient.CurrentRoom.ExpectedUsers; + } + + if (nowExpected != null && nowExpected.Length > 0) + { + bool allNowPresent = true; + foreach (var uid in nowExpected) + { + if (string.IsNullOrEmpty(uid)) continue; + bool present = PhotonRealtimeClient.CurrentRoom.Players.Values.Any(p => p.UserId == uid); + if (!present) { allNowPresent = false; break; } + } + if (allNowPresent) + { + recheckFound = true; + expectedUsersMissing = false; + Debug.Log("WaitForMatchmakingPlayers: grace re-check found expected users present; skipping short requeue."); + break; + } + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: recheck failed: {ex.Message}"); } + } + } + + if (!recheckFound) + { + if (!_countdownActive) + { + Debug.Log($"Matchmaking short timeout ({MatchmakingJoinTimeoutSeconds}s) reached and countdown not started; master will request requeue."); + + // Notify all clients to requeue + try + { + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.CancelGameStart, + new object[] { true, (int)currentGameType }, + new RaiseEventArgs { Receivers = ReceiverGroup.All }, + SendOptions.SendReliable + ); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to broadcast CancelGameStart requeue: {ex.Message}"); + } + + // Master leaves and requeues (LeaveAndAutoRequeue will handle master vs non-master paths). + try + { + StartCoroutine(LeaveAndAutoRequeue(currentGameType)); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to start LeaveAndAutoRequeue after short timeout: {ex.Message}"); + } + + yield break; + } + else + { + // Continue waiting as expected users are now present + } + } + } + + // Fast-path: expected followers arrived, so fill any remaining slots with bots immediately + // instead of waiting the full matchmaking timeout. + if (!botBackfillApplied && currentGameType == GameType.Random2v2) + { + ClearStaleHumanPositionReservations("WaitForMatchmakingPlayers"); + + bool expectedFollowersPresent = expectedUsers != null && expectedUsers.Length > 0 && !expectedUsersMissing; + if (expectedFollowersPresent) + { + bool shouldYieldAfterPremadeReservation = false; + try + { + bool premadeMode = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + string earlyPremadeUserId1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string earlyPremadeUserId2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + + if (premadeMode && !string.IsNullOrEmpty(earlyPremadeUserId1) && !string.IsNullOrEmpty(earlyPremadeUserId2)) + { + if (!TryReservePremadePairToSameSide(earlyPremadeUserId1, earlyPremadeUserId2, out _)) + { + Debug.LogWarning($"WaitForMatchmakingPlayers: failed premade same-side reservation before botfill for ({earlyPremadeUserId1},{earlyPremadeUserId2}), requeueing."); + StartCoroutine(LeaveAndAutoRequeue(currentGameType)); + yield break; + } + + shouldYieldAfterPremadeReservation = true; + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: premade reservation before botfill failed: {ex.Message}"); } + + if (shouldYieldAfterPremadeReservation) + { + // Let room properties settle before filling remaining slots with bots. + yield return null; + } + } + + bool appliedEarly = false; + try + { + if (expectedUsers != null && expectedUsers.Length > 0 && !expectedUsersMissing) + { + Debug.Log("WaitForMatchmakingPlayers: all expected users present; evaluating early botfill."); + appliedEarly = true; + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to determine early bot backfill: {ex.Message}"); } + + if (appliedEarly) + { + int currentHumanCount = 0; + int currentMaxPlayers = 0; + try + { + currentHumanCount = PhotonRealtimeClient.CurrentRoom.Players.Values + .Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + currentMaxPlayers = PhotonRealtimeClient.CurrentRoom.MaxPlayers; + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate early botfill human count: {ex.Message}"); } + + if (currentMaxPlayers > 0 && currentHumanCount >= currentMaxPlayers) + { + Debug.Log($"WaitForMatchmakingPlayers: all expected users present and room already full with humans ({currentHumanCount}/{currentMaxPlayers}); skipping early botfill."); + } + else + { + Debug.Log($"Matchmaking: applying early botfill to complete room."); + int[] positions = { + PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, + PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 + }; + foreach (int pos in positions) + { + if (PhotonBattleRoom.CheckIfPositionIsFree(pos)) + { + string posKey = PhotonBattleRoom.GetPositionKey(pos); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(posKey, "Bot"); + } + } + try { PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.BotFillKey, true); } catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to set BotFillKey: {ex.Message}"); } + botBackfillApplied = true; + } + } + } + + // Queue-formed solo room already waited in queue; skip duplicated long botfill wait. + float effectiveBotfillTimeoutSeconds = MatchmakingTimeoutSeconds; + try + { + bool queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); + if (queueFormedMatch && !expectedPlayersRequired) + { + // Queue already consumed the long wait; apply botfill immediately in queue-formed solo matchmaking rooms. + effectiveBotfillTimeoutSeconds = 0f; + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate queue-formed match timeout: {ex.Message}"); } + + if (!botBackfillApplied && currentGameType == GameType.Random2v2 && Time.time - waitStartTime >= effectiveBotfillTimeoutSeconds) + { + int timeoutHumanCount = 0; + int timeoutMaxPlayers = 0; + try + { + timeoutHumanCount = PhotonRealtimeClient.CurrentRoom.Players.Values + .Count(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); + timeoutMaxPlayers = PhotonRealtimeClient.CurrentRoom.MaxPlayers; + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to evaluate timeout botfill human count: {ex.Message}"); } + + if (timeoutMaxPlayers > 0 && timeoutHumanCount >= timeoutMaxPlayers) + { + Debug.Log($"WaitForMatchmakingPlayers: botfill timeout reached but room already full with humans ({timeoutHumanCount}/{timeoutMaxPlayers}); skipping botfill."); + } + else + { + Debug.Log($"Matchmaking timeout ({effectiveBotfillTimeoutSeconds}s) reached for Random2v2. Filling remaining slots with bots."); + + int[] positions = { + PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, + PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 + }; + foreach (int pos in positions) + { + if (PhotonBattleRoom.CheckIfPositionIsFree(pos)) + { + string posKey = PhotonBattleRoom.GetPositionKey(pos); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(posKey, "Bot"); + } + } + + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.BotFillKey, true); + botBackfillApplied = true; + } + } + + if (!botBackfillApplied) + { + // Normal path: wait for real players to fill the room + try + { + if (PhotonRealtimeClient.CurrentRoom.PlayerCount != PhotonRealtimeClient.CurrentRoom.MaxPlayers) continue; + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to check player counts: {ex.Message}"); continue; } + } + + // At this point either the room is full or we've applied bot backfill. + // Proceed to mapping player -> room position keys even if some position slots are not yet set. + canStartGameplay = true; + + } while (!canStartGameplay); + + + // Updating player positions from room to player properties, and waiting that they have been synced + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + Debug.LogWarning("WaitForMatchmakingPlayers: CurrentRoom lost before setting positions; aborting."); + yield break; + } + + string positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1); + string positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2); + string positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); + string positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); + bool refreshAfterQueueDuoReservation = false; + + try + { + string[] queueDuoPairs = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueDuoPairsKey, null); + if (queueDuoPairs != null && queueDuoPairs.Length >= 2) + { + bool changedByQueueDuoReservation = false; + for (int i = 0; i + 1 < queueDuoPairs.Length; i += 2) + { + string pairUserId1 = queueDuoPairs[i]; + string pairUserId2 = queueDuoPairs[i + 1]; + if (string.IsNullOrEmpty(pairUserId1) || string.IsNullOrEmpty(pairUserId2) || pairUserId1 == pairUserId2) continue; + + bool pairPresent = PhotonRealtimeClient.CurrentRoom.Players.Values.Any(p => p.UserId == pairUserId1) + && PhotonRealtimeClient.CurrentRoom.Players.Values.Any(p => p.UserId == pairUserId2); + if (!pairPresent) continue; + + if (!TryReservePremadePairToSameSide(pairUserId1, pairUserId2, out _)) + { + GameType requeueGameType = GameType.Random2v2; + try { requeueGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch { } + Debug.LogWarning($"WaitForMatchmakingPlayers: could not keep queue duo ({pairUserId1},{pairUserId2}) on same side, requeueing."); + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + yield break; + } + + changedByQueueDuoReservation = true; + } + + if (changedByQueueDuoReservation) + { + refreshAfterQueueDuoReservation = true; + } + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: queue duo same-side enforcement failed: {ex.Message}"); } + + if (refreshAfterQueueDuoReservation) + { + yield return null; + positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1); + positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2); + positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); + positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); + } + + bool isPremadeMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + string premadeUserId1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string premadeUserId2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + if (isPremadeMatch && !string.IsNullOrEmpty(premadeUserId1) && !string.IsNullOrEmpty(premadeUserId2)) + { + if (!TryReservePremadePairToSameSide(premadeUserId1, premadeUserId2, out _)) + { + GameType requeueGameType = GameType.Random2v2; + try { requeueGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch { } + Debug.LogWarning($"WaitForMatchmakingPlayers: could not keep premade pair ({premadeUserId1},{premadeUserId2}) on same side, requeueing."); + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + yield break; + } + + // Refresh position snapshot after forced pair reservation. + yield return null; + positionValue1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1); + positionValue2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2); + positionValue3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3); + positionValue4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4); + } + + try + { + bool queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); + int queueGameType = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey, (int)GameType.Random2v2); + if (queueFormedMatch && IsTwoPlayerBlockQueueMode(queueGameType)) + { + if (!ValidateQueueTwoPlayerBlockComposition(PhotonRealtimeClient.CurrentRoom, out string blockValidationReason)) + { + GameType requeueGameType = (GameType)queueGameType; + Debug.LogWarning($"WaitForMatchmakingPlayers: invalid two-player block composition ({blockValidationReason}), requeueing."); + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + yield break; + } + } + } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: block composition validation failed: {ex.Message}"); } + + foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) + { + int position = PhotonBattleRoom.PlayerPositionGuest; + + if (player.Value.UserId == positionValue1) position = PhotonBattleRoom.PlayerPosition1; + else if (player.Value.UserId == positionValue2) position = PhotonBattleRoom.PlayerPosition2; + else if (player.Value.UserId == positionValue3) position = PhotonBattleRoom.PlayerPosition3; + else if (player.Value.UserId == positionValue4) position = PhotonBattleRoom.PlayerPosition4; + else + { + // Prefer player's existing position if it can be reclaimed from empty/Bot value. + int preferredPosition = player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey, PhotonBattleRoom.PlayerPositionGuest); + if (PhotonLobbyRoom.IsValidPlayerPos(preferredPosition)) + { + string preferredKey = PhotonBattleRoom.GetPositionKey(preferredPosition); + string preferredValue = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(preferredKey, string.Empty); + if (string.IsNullOrEmpty(preferredValue) || preferredValue == "Bot" || preferredValue == player.Value.UserId) + { + position = preferredPosition; + } + } + + // Fall back to side-effect-free room scanning to avoid VerifyPlayerPositions clearing bot slots. + if (!PhotonLobbyRoom.IsValidPlayerPos(position)) + { + position = GetFirstFreePositionWithoutVerification(); // TODO: if Clan2v2 ensure that player ends on the correct side + } + + if (!PhotonLobbyRoom.IsValidPlayerPos(position)) continue; + string positionKey = PhotonBattleRoom.GetPositionKey(position); + + // Setting position to room and waiting until it's synced + if (PhotonRealtimeClient.CurrentRoom.GetCustomProperty(positionKey, string.Empty) != player.Value.UserId) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(positionKey, player.Value.UserId); + yield return new WaitUntil(() => PhotonRealtimeClient.CurrentRoom.GetCustomProperty(positionKey) == player.Value.UserId); + } + } + + // Setting position to player properties and waiting until it's synced + if (player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey, PhotonBattleRoom.PlayerPositionGuest) != position) + { + player.Value.SetCustomProperty(PhotonBattleRoom.PlayerPositionKey, position); + yield return new WaitUntil(() => player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey) == position); + } + } + + // Checking that the clan names are in order + GameType roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + if (roomGameType == GameType.Clan2v2) + { + string primaryClan = string.Empty; + string opponentClan = string.Empty; + + foreach (var player in PhotonRealtimeClient.CurrentRoom.Players) + { + int playerPos = player.Value.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey); + + if (playerPos == PhotonBattleRoom.PlayerPosition1) + { + primaryClan = player.Value.GetCustomProperty(PhotonBattleRoom.ClanNameKey, string.Empty); + } + else if (playerPos == PhotonBattleRoom.PlayerPosition3) + { + opponentClan = player.Value.GetCustomProperty(PhotonBattleRoom.ClanNameKey, string.Empty); + } + } + if (PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanNameKey) != primaryClan) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanNameKey, primaryClan); + } + + if (PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey) != opponentClan) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.ClanOpponentNameKey, opponentClan); + } + + _blueTeamName = primaryClan; + _redTeamName = opponentClan; + } + + // For Random2v2 ensure team names are set (they aren't set by the Clan2v2 block above) + if (roomGameType == GameType.Random2v2) + { + if (string.IsNullOrWhiteSpace(_blueTeamName)) _blueTeamName = "Team Alpha"; + if (string.IsNullOrWhiteSpace(_redTeamName)) _redTeamName = "Team Beta"; + } + + // Set BattleID for matchmaking rooms (StartRoomEvent is not published for matchmaking rooms) + if (!PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID) + || string.IsNullOrEmpty(PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.BattleID))) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable + { + { PhotonBattleRoom.BattleID, PhotonRealtimeClient.CurrentRoom.Name.Replace(' ', '_') + "_" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() } + }); + yield return null; + } + + // If botfill is active, reconcile any still-empty slots to Bot before start check. + bool botFillActive = false; + try { botFillActive = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey, false); } + catch (Exception ex) { Debug.LogWarning($"WaitForMatchmakingPlayers: failed to read BotFillKey before start check: {ex.Message}"); } + + // Bot-fill reconcile disabled. + /* + if (roomGameType == GameType.Random2v2 && botFillActive) + { + int[] positions = { + PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, + PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 + }; + foreach (int pos in positions) + { + if (PhotonBattleRoom.CheckIfPositionIsFree(pos)) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.GetPositionKey(pos), "Bot"); + } + } + } + */ + + // Starting gameplay coroutine if all positions are filled (real players + bots), else we loop again. + // When botfill is active in Random2v2, allow start even if bot position replication is still catching up. + int botCount = PhotonBattleRoom.GetBotCount(); + bool roomIsFullWithBots = PhotonRealtimeClient.CurrentRoom.PlayerCount + botCount >= PhotonRealtimeClient.CurrentRoom.MaxPlayers; + bool canStartWithBotFill = roomGameType == GameType.Random2v2 && botFillActive; + if (roomIsFullWithBots || canStartWithBotFill) + { + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + _startGameHolder = StartCoroutine(StartTheGameplay(_isCloseRoomOnGameStart, _blueTeamName, _redTeamName)); + gameStarting = true; + } + + } while (!gameStarting); + } + finally + { + _matchmakingHolder = null; + } + } + + // Follower safety-net: if countdown does not begin soon after joining matchmaking, + // leave and requeue to avoid getting stuck in a stale room. + private IEnumerator MatchmakingJoinWatcher(GameType gameType, float timeoutSeconds) + { + try + { + Debug.Log($"MatchmakingJoinWatcher: started for gameType={gameType}, timeout={timeoutSeconds}s"); + float start = Time.time; + while (Time.time - start < timeoutSeconds) + { + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) yield break; + + // Queue-formed rooms use master-side expected-user timeout handling; follower watcher must not force requeue. + try + { + bool queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); + int expectedFollowers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); + string[] expectedUsers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); + string[] photonExpectedUsers = PhotonRealtimeClient.CurrentRoom.ExpectedUsers; + bool hasExpectedUsers = expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + bool hasPhotonExpectedUsers = photonExpectedUsers != null && photonExpectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + if (queueFormedMatch || expectedFollowers > 0 || hasExpectedUsers || hasPhotonExpectedUsers) + { + Debug.Log("MatchmakingJoinWatcher: queue-formed expected-user flow detected, stopping follower watcher."); + _autoRequeueAttempts = 0; + yield break; + } + } + catch { } + + // If countdown started after we began watching, cancel watcher + if (_lastCountdownStartTime >= start) + { + _autoRequeueAttempts = 0; + yield break; + } + + yield return new WaitForSeconds(0.5f); + } + + // Timeout reached: countdown did not start; leave and requeue + GameType requeueGameType = GameType.Random2v2; + try { requeueGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch (Exception ex) { Debug.LogWarning($"MatchmakingJoinWatcher: failed to read game type: {ex.Message}"); } + Debug.Log($"MatchmakingJoinWatcher: countdown did not start within {timeoutSeconds}s in room '{PhotonRealtimeClient.CurrentRoom?.Name}'; leaving and requeueing for {requeueGameType}."); + + _autoRequeueAttempts++; + if (MaxAutoRequeueAttempts > 0 && _autoRequeueAttempts > MaxAutoRequeueAttempts) + { + Debug.LogWarning($"MatchmakingJoinWatcher: exceeded max auto-requeue attempts ({MaxAutoRequeueAttempts}); not requeueing."); + yield break; + } + + yield return new WaitForSeconds(MatchmakingRequeueDelaySeconds); + try + { + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + } + catch (Exception ex) + { + Debug.LogWarning($"MatchmakingJoinWatcher: failed to start LeaveAndAutoRequeue: {ex.Message}"); + } + } + finally + { + _joinTimeoutWatcherHolder = null; + } + } + + /// + /// Follower-side room handoff after RoomChangeRequested. + /// Join priority: explicit leader room name -> leader via friend lookup -> best matchmaking fallback -> join/create fallback. + /// + private IEnumerator FollowLeaderToNewRoom(string leaderUserId, string leaderRoomName = null, string[] expectedUsersOverride = null) + { + try + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + string[] followTeammates = expectedUsersOverride != null + ? expectedUsersOverride + .Where(uid => !string.IsNullOrEmpty(uid) && uid != localUserId) + .Distinct(StringComparer.Ordinal) + .ToArray() + : _teammates; + bool queueRoomRequested = !string.IsNullOrEmpty(leaderRoomName) && leaderRoomName.StartsWith("Queue_", StringComparison.Ordinal); + + // Duplicate handoff events are common during queue->match transitions. + // If we are already in the explicitly requested room, ignore the handoff. + if (!string.IsNullOrEmpty(leaderRoomName) + && PhotonRealtimeClient.InRoom + && PhotonRealtimeClient.CurrentRoom != null + && PhotonRealtimeClient.CurrentRoom.Name == leaderRoomName) + { + Debug.Log($"FollowLeaderToNewRoom: already in target room '{leaderRoomName}', ignoring duplicate handoff."); + yield break; + } + + if (string.IsNullOrEmpty(leaderRoomName) && IsInQueueFormedExpectedUserMatchmakingFlow()) + { + Debug.Log("FollowLeaderToNewRoom: ignoring stale targeted no-room handoff while already in queue-formed expected-user matchmaking flow."); + yield break; + } + + // Don't follow leader away from this room if we're in a Custom game. + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + if ((GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom) + { + Debug.Log("FollowLeaderToNewRoom: current room is Custom, will not leave."); + yield break; + } + } + } + catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: failed to evaluate room type: {ex.Message}"); } + + string oldRoomName = PhotonRealtimeClient.CurrentRoom?.Name ?? string.Empty; + + // Leave current room and wait until in lobby + if (PhotonRealtimeClient.InRoom) PhotonRealtimeClient.LeaveRoom(); + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + + // If leaderRoomName provided, try to join it directly + bool newRoomJoined = false; + if (!string.IsNullOrEmpty(leaderRoomName)) + { + if (PhotonRealtimeClient.InLobby) + { + int joinAttemptId = _joinAttemptTracker.BeginJoinAttempt(leaderRoomName, followTeammates); + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{joinAttemptId}] direct join requested: {leaderRoomName}"); + try { PhotonRealtimeClient.JoinRoom(leaderRoomName); } catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: JoinAttempt[{joinAttemptId}] JoinRoom threw: {ex.Message}"); _joinAttemptTracker.MarkJoinAttemptFailure(joinAttemptId, -1, ex.Message); } + float joinStartDirect = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStartDirect < 6f) + { + if (_joinAttemptTracker.IsAttemptCompleted(joinAttemptId)) break; + yield return null; + } + if (PhotonRealtimeClient.InRoom) + { + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{joinAttemptId}] succeeded, joined '{PhotonRealtimeClient.CurrentRoom?.Name}'"); + newRoomJoined = true; + } + else + { + if (_joinAttemptTracker.TryGetFailedJoinAttempt(joinAttemptId, out string failMsg)) Debug.LogWarning($"FollowLeaderToNewRoom: JoinAttempt[{joinAttemptId}] failed joining '{leaderRoomName}': {failMsg}"); + else Debug.LogWarning($"FollowLeaderToNewRoom: JoinAttempt[{joinAttemptId}] timed out joining '{leaderRoomName}'"); + } + } + + // Queue room may not be visible immediately after leader leaves old room. + // Retry joins and then fall back to JoinOrCreateQueueRoom so follower reliably converges. + if (!newRoomJoined && queueRoomRequested && PhotonRealtimeClient.InLobby) + { + float queueRetryStart = Time.time; + while (!newRoomJoined && Time.time - queueRetryStart < 8f) + { + try + { + int retryJoinAttemptId = _joinAttemptTracker.BeginJoinAttempt(leaderRoomName, followTeammates); + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{retryJoinAttemptId}] retry joining queue room: {leaderRoomName}"); + PhotonRealtimeClient.JoinRoom(leaderRoomName); + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: queue JoinRoom retry failed: {ex.Message}"); + } + + float retryJoinWaitStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - retryJoinWaitStart < 2.5f) + { + yield return null; + } + + if (PhotonRealtimeClient.InRoom) + { + newRoomJoined = true; + break; + } + + yield return new WaitForSeconds(0.4f); + } + + if (!newRoomJoined) + { + GameType queueGameType = GameType.Random2v2; + try + { + if (leaderRoomName.StartsWith("Queue_", StringComparison.Ordinal) + && Enum.TryParse(leaderRoomName.Substring("Queue_".Length), out GameType parsedQueueType)) + { + queueGameType = parsedQueueType; + } + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: queue game type parse failed: {ex.Message}"); + } + + bool joinedOrCreatedQueue = false; + try + { + int joinOrCreateId = _joinAttemptTracker.BeginJoinAttempt($"Queue_{queueGameType}", followTeammates); + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{joinOrCreateId}] JoinOrCreateQueueRoom({queueGameType})"); + joinedOrCreatedQueue = PhotonRealtimeClient.JoinOrCreateQueueRoom(queueGameType); + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: JoinOrCreateQueueRoom fallback failed: {ex.Message}"); + } + + if (joinedOrCreatedQueue) + { + float queueJoinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - queueJoinStart < 6f) + { + yield return null; + } + if (PhotonRealtimeClient.InRoom) + { + newRoomJoined = true; + } + } + } + } + } + + // Try to find leader via friends list; fallback to joining the matchmaking room with most players + int attempts = 0; + while (!newRoomJoined && attempts < 10 && !queueRoomRequested) + { + attempts++; + _friendList = null; + // Only call OpFindFriends when the client is connected and the leader is not this client. + if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.IsConnectedAndReady && + !string.IsNullOrEmpty(leaderUserId) && PhotonRealtimeClient.LocalPlayer != null && leaderUserId != PhotonRealtimeClient.LocalPlayer.UserId) + { + PhotonRealtimeClient.Client.OpFindFriends(new string[1] { leaderUserId }); + float friendLookupStart = Time.time; + while (_friendList == null && Time.time - friendLookupStart < 4f) + { + yield return null; + } + if (_friendList == null) + { + _friendList = new List(); + } + } + else + { + // Skip friends lookup and continue with fallback to room list. + _friendList = new List(); + } + + foreach (FriendInfo friend in _friendList) + { + if (friend.UserId == leaderUserId && friend.IsInRoom && friend.Room != oldRoomName) + { + PhotonRealtimeClient.JoinRoom(friend.Room); + newRoomJoined = true; + break; + } + } + + if (!newRoomJoined) + { + // Fallback: join the matchmaking room with the most players (excluding the old room) + yield return new WaitUntil(() => CurrentRooms != null); + LobbyRoomInfo bestRoom = null; + foreach (var room in CurrentRooms) + { + try + { + if (!room.CustomProperties.ContainsKey(PhotonBattleRoom.IsMatchmakingKey)) continue; + if (!(room.CustomProperties[PhotonBattleRoom.IsMatchmakingKey] is bool isMm) || !isMm) continue; + } + catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: reading room properties failed: {ex.Message}"); continue; } + + if (room.Name == oldRoomName) continue; + if (bestRoom == null) + { + bestRoom = room; + } + else + { + if (room.PlayerCount > bestRoom.PlayerCount) + { + bestRoom = room; + } + else if (room.PlayerCount == bestRoom.PlayerCount) + { + // Tie-breaker: choose randomly to distribute players across equal rooms. + if (UnityEngine.Random.value > 0.5f) + { + bestRoom = room; + } + } + } + } + + if (bestRoom != null) + { + PhotonRealtimeClient.JoinRoom(bestRoom.Name); + newRoomJoined = true; + break; + } + } + + // Small delay to avoid tight loop; give state time to update + yield return new WaitForSeconds(0.5f); + } + + // If we couldn't join the leader's room or any candidate, create/join a new matchmaking room + bool attemptedFollowJoinCreate = false; + try + { + if (!newRoomJoined && PhotonRealtimeClient.InLobby) + { + if (queueRoomRequested) + { + GameType queueGameType = GameType.Random2v2; + if (!string.IsNullOrEmpty(leaderRoomName) + && leaderRoomName.StartsWith("Queue_", StringComparison.Ordinal) + && Enum.TryParse(leaderRoomName.Substring("Queue_".Length), out GameType parsedQueueType)) + { + queueGameType = parsedQueueType; + } + Debug.Log($"FollowLeaderToNewRoom: failed initial queue join, using JoinOrCreateQueueRoom for {queueGameType}."); + PhotonRealtimeClient.JoinOrCreateQueueRoom(queueGameType); + } + else + { + Debug.Log("FollowLeaderToNewRoom: failed to join leader room; rejoining queue instead of creating a matchmaking room directly."); + try + { + int joinOrCreateId = _joinAttemptTracker.BeginJoinAttempt($"Queue_{_currentMatchmakingGameType}", followTeammates); + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{joinOrCreateId}] JoinOrCreateQueueRoom({_currentMatchmakingGameType})"); + PhotonRealtimeClient.JoinOrCreateQueueRoom(_currentMatchmakingGameType); + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: JoinOrCreateQueueRoom failed: {ex.Message}"); + } + } + + attemptedFollowJoinCreate = true; + } + } + catch (Exception ex) { Debug.LogWarning($"FollowLeaderToNewRoom: unexpected error: {ex.Message}"); } + + if (attemptedFollowJoinCreate) + { + // Wait for the most recent join attempt to complete or timeout + int observedAttemptId = 0; + observedAttemptId = _joinAttemptTracker.CurrentJoinAttemptId; + Debug.Log($"FollowLeaderToNewRoom: awaiting JoinAttempt[{observedAttemptId}] result..."); + float joinStart = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStart < 5f) + { + if (observedAttemptId != 0 && _joinAttemptTracker.IsAttemptCompleted(observedAttemptId)) break; + yield return null; + } + Debug.Log($"FollowLeaderToNewRoom: JoinAttempt[{observedAttemptId}] finished. InRoom={PhotonRealtimeClient.InRoom}"); + // If still not in a room, wait for MasterServer lobby and retry once + if (!PhotonRealtimeClient.InRoom) + { + float waitStart = Time.time; + while ((PhotonRealtimeClient.Client == null || PhotonRealtimeClient.Client.Server != ServerConnection.MasterServer || !PhotonRealtimeClient.InLobby) && Time.time - waitStart < 5f) + { + yield return null; + } + + if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.MasterServer && PhotonRealtimeClient.InLobby) + { + if (queueRoomRequested && !string.IsNullOrEmpty(leaderRoomName)) + { + try + { + GameType queueGameType = GameType.Random2v2; + if (leaderRoomName.StartsWith("Queue_", StringComparison.Ordinal) + && Enum.TryParse(leaderRoomName.Substring("Queue_".Length), out GameType parsedQueueType)) + { + queueGameType = parsedQueueType; + } + PhotonRealtimeClient.JoinOrCreateQueueRoom(queueGameType); + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: second queue JoinOrCreate failed: {ex.Message}"); + } + } + else + { + try + { + PhotonRealtimeClient.JoinOrCreateQueueRoom(_currentMatchmakingGameType); + } + catch (Exception ex) + { + Debug.LogWarning($"FollowLeaderToNewRoom: second queue JoinOrCreate failed: {ex.Message}"); + } + } + + float joinStart2 = Time.time; + while (!PhotonRealtimeClient.InRoom && Time.time - joinStart2 < 5f) + { + yield return null; + } + } + } + } + } + finally + { + _followLeaderHolder = null; + } + } + + private IEnumerator LeaveMatchmaking() + { + // Safely read the matchmaking room game type; PhotonExtensions handles null room now. + GameType matchmakingRoomGameType = GameType.Random2v2; + if (PhotonRealtimeClient.CurrentRoom != null) + { + try + { + matchmakingRoomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to read matchmaking room game type: {ex.Message}"); + } + } + + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + + OnMatchmakingStopped?.Invoke(); + + // If we're currently in a room, leave it and wait for lobby. Otherwise wait until we are in lobby. + if (PhotonRealtimeClient.InRoom) + { + PhotonRealtimeClient.LeaveRoom(); + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + } + else + { + if (!PhotonRealtimeClient.InLobby) + { + yield return new WaitUntil(() => PhotonRealtimeClient.InLobby); + } + } + + // Creating back the non-matchmaking room which the teammates can join (only for Clan2v2) + switch (matchmakingRoomGameType) + { + case GameType.Clan2v2: + { + string clanName = PhotonRealtimeClient.LocalLobbyPlayer?.GetCustomProperty(PhotonBattleRoom.ClanNameKey, ""); + int soulhomeRank = PhotonRealtimeClient.LocalLobbyPlayer?.GetCustomProperty(PhotonBattleRoom.SoulhomeRank, 0) ?? 0; + PhotonRealtimeClient.CreateClan2v2LobbyRoom(clanName, soulhomeRank, _teammates); + break; + } + } + } + #endregion + + #region Game Start & Quantum + + private void OnStartRoomEvent(StartRoomEvent data) + { + Debug.Log($"onEvent {data}"); + StartCoroutine(OnStartRoom()); + } + + private IEnumerator OnStartRoom() + { + float startTime =Time.time; + yield return new WaitUntil(() => PhotonRealtimeClient.Client.InRoom || Time.time > startTime+10); + if (!PhotonRealtimeClient.Client.InRoom) + { + Debug.LogWarning("Failed to join a room in time."); + PhotonRealtimeClient.LeaveRoom(); + yield break; + } + if(PhotonRealtimeClient.LocalPlayer.IsMasterClient) PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable { { BattleID, PhotonRealtimeClient.CurrentRoom.Name.Replace(' ', '_') + "_" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString() } }); + //WindowManager.Get().ShowWindow(_roomWindow); + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); + } + + private void OnStartPlayingEvent(StartPlayingEvent data) + { + Debug.Log($"onEvent {data}"); + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + _startGameHolder = StartCoroutine(StartTheGameplay(_isCloseRoomOnGameStart, _blueTeamName, _redTeamName)); + } + + private void OnStartRaidTestEvent(StartRaidTestEvent data) + { + Debug.Log($"onEvent {data}"); + StartCoroutine(StartTheRaidTestRoom()); + } + + private void OnStartMatchmakingEvent(StartMatchmakingEvent data) + { + Debug.Log($"onEvent {data}"); + + if (!PhotonRealtimeClient.InRoom) return; + + try + { + ClientState clientState = PhotonRealtimeClient.Client != null ? PhotonRealtimeClient.Client.State : ClientState.Disconnected; + if (clientState != ClientState.Joined) + { + Debug.Log($"OnStartMatchmakingEvent: ignoring start while client state is {clientState}."); + return; + } + } + catch { } + + if (Time.time - _lastStartMatchmakingAcceptedTime < 1.0f) + { + Debug.Log("OnStartMatchmakingEvent: ignored duplicate start (debounce)."); + return; + } + + try + { + if (PhotonRealtimeClient.CurrentRoom != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.IsQueueKey, false)) + { + Debug.Log("OnStartMatchmakingEvent: already in queue room, ignoring duplicate start request."); + return; + } + } + catch { } + + if (_matchmakingHolder != null) + { + Debug.Log("OnStartMatchmakingEvent: matchmaking already in progress, ignoring duplicate start request."); + return; + } + + // In premade in-room flow only the room master should start matchmaking. + if (data.IsPremadeInRoom && PhotonRealtimeClient.LocalPlayer != null && !PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + Debug.Log("OnStartMatchmakingEvent: ignoring premade start from non-master client."); + return; + } + + _isPremadeMatchmakingFlow = data.IsPremadeInRoom; + if (!_isPremadeMatchmakingFlow) + { + _premadeTeammateUserId = string.Empty; + } + + if (_isPremadeMatchmakingFlow && PhotonRealtimeClient.CurrentRoom != null) + { + try + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)data.SelectedGameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, PhotonRealtimeClient.LocalPlayer.UserId); + } + catch (Exception ex) + { + Debug.LogWarning($"OnStartMatchmakingEvent: failed to set premade properties: {ex.Message}"); + } + } + + // Starting matchmaking coroutine + if (_matchmakingHolder == null) + { + _lastStartMatchmakingAcceptedTime = Time.time; + _matchmakingHolder = StartCoroutine(StartMatchmaking(data.SelectedGameType)); + } + } + + private void OnStopMatchmakingEvent(StopMatchmakingEvent data) + { + Debug.Log($"onEvent {data}"); + _isPremadeMatchmakingFlow = false; + _premadeTeammateUserId = string.Empty; + try + { + // If we're in a persistent queue room and the local player is the master, + // transfer master to another real player instead of leaving so the queue stays alive. + var currentRoom = PhotonRealtimeClient.CurrentRoom; + bool isQueueRoom = currentRoom != null && currentRoom.CustomProperties != null && currentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (currentRoom.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb); + if (!data.ForceLeave && isQueueRoom && PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + var others = PhotonRealtimeClient.PlayerListOthers; + // pick first other real player (has UserId and not a bot) + Player candidate = null; + foreach (var p in others) + { + if (p == null) continue; + if (string.IsNullOrEmpty(p.UserId)) continue; + // skip reserved "Bot" user ids + if (p.UserId == "Bot") continue; + candidate = p; + break; + } + + if (candidate != null) + { + try + { + var lobbyPlayer = PhotonRealtimeClient.LobbyCurrentRoom.GetPlayer(candidate.ActorNumber); + if (lobbyPlayer != null && PhotonRealtimeClient.LobbyCurrentRoom.SetMasterClient(lobbyPlayer)) + { + Debug.Log($"Queue: transferred master to {candidate.UserId} (actor {candidate.ActorNumber})"); + // OnMasterClientSwitched will handle updating LeaderIdKey and UI on all clients. + return; + } + else + { + Debug.LogWarning("Queue: SetMasterClient returned false; falling back to normal leave."); + } + } + catch (System.Exception ex) + { + Debug.LogWarning($"Queue: failed to set new master: {ex.Message}"); + } + } + else + { + Debug.Log("Queue: no suitable candidate found for master transfer; leaving matchmaking."); + } + } + + // Only send RoomChangeRequested if we're connected to the Game server, ready and in a room. + if (PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.Server == ServerConnection.GameServer && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) + { + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, + PhotonRealtimeClient.LocalPlayer.UserId, + new RaiseEventArgs { Receivers = ReceiverGroup.Others }, + SendOptions.SendReliable + ); + } + else + { + Debug.Log($"Skipping RoomChangeRequested broadcast: Server={PhotonRealtimeClient.Client?.Server}, IsConnectedAndReady={PhotonRealtimeClient.Client?.IsConnectedAndReady}, InRoom={PhotonRealtimeClient.InRoom}"); + } + + // Stop holder coroutines to clear runtime teammate/premade state immediately + try { StopHolderCoroutines(); } catch (Exception ex) { Debug.LogWarning($"OnStopMatchmakingEvent: failed to stop holder coroutines: {ex.Message}"); } + + // If we are the master in a persistent queue room, clear stale premade metadata + try + { + var currentRoom2 = PhotonRealtimeClient.CurrentRoom; + if (currentRoom2 != null && PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + bool isQueueRoom2 = currentRoom2.CustomProperties != null && currentRoom2.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (currentRoom2.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb2 && qb2); + if (isQueueRoom2) + { + Debug.Log($"OnStopMatchmakingEvent: clearing premade metadata on queue room '{currentRoom2.Name}'"); + currentRoom2.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, false); + currentRoom2.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + currentRoom2.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + currentRoom2.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, string.Empty); + currentRoom2.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); + } + } + } + catch (Exception ex) { Debug.LogWarning($"OnStopMatchmakingEvent: failed to clear premade metadata: {ex.Message}"); } + + StartCoroutine(LeaveMatchmaking()); + } + catch (System.Exception ex) + { + Debug.LogWarning($"OnStopMatchmakingEvent: unexpected error: {ex.Message}"); + StartCoroutine(LeaveMatchmaking()); + } + } + + private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, string redTeamName) + { + try + { + // Do not start gameplay from a queue room; queue rooms are for waiting only. + try + { + var currentRoom = PhotonRealtimeClient.CurrentRoom; + if (currentRoom != null && currentRoom.CustomProperties != null && currentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (currentRoom.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) + { + Debug.Log("StartTheGameplay: aborting start because current room is a queue room."); + yield break; + } } catch (Exception ex) { Debug.LogWarning($"StartTheGameplay: failed to evaluate current room: {ex.Message}"); } // TODO: Select random characters if some are not selected @@ -2066,10 +5862,28 @@ private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, stri foreach (Player roomPlayer in players) { int playerPos = roomPlayer.GetCustomProperty(PlayerPositionKey, PlayerPositionGuest); - if (!PhotonLobbyRoom.IsValidPlayerPos(playerPos)) + bool hasSlotConflict = PhotonLobbyRoom.IsValidPlayerPos(playerPos) + && !string.IsNullOrEmpty(playerUserIds[playerPos - 1]) + && playerUserIds[playerPos - 1] != roomPlayer.UserId; + + if (!PhotonLobbyRoom.IsValidPlayerPos(playerPos) || hasSlotConflict) { // If player position is not valid we get new position for them, this method checks for duplicate and missing player positions int newPos = PhotonLobbyRoom.GetFirstFreePlayerPos(new(roomPlayer)); + if (!PhotonLobbyRoom.IsValidPlayerPos(newPos)) + { + newPos = GetFirstFreePositionWithoutVerification(); + } + + bool newPosConflict = PhotonLobbyRoom.IsValidPlayerPos(newPos) + && !string.IsNullOrEmpty(playerUserIds[newPos - 1]) + && playerUserIds[newPos - 1] != roomPlayer.UserId; + + if (newPosConflict) + { + newPos = PhotonBattleRoom.PlayerPositionGuest; + } + if (!PhotonLobbyRoom.IsValidPlayerPos(newPos)) continue; // Setting the new position to player and room properties and waiting until it's synced @@ -2088,6 +5902,48 @@ private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, stri playerCount += 1; } + if (PhotonRealtimeClient.InMatchmakingRoom) + { + HashSet mappedHumanUserIds = new( + playerUserIds.Where(uid => !string.IsNullOrEmpty(uid) && uid != "Bot"), + StringComparer.Ordinal); + + List missingHumanUserIds = players + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId) + .Where(uid => !mappedHumanUserIds.Contains(uid)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (missingHumanUserIds.Count > 0) + { + Debug.LogWarning($"StartTheGameplay: refusing partial start because {missingHumanUserIds.Count} human players are missing from start slots. missing=[{string.Join(",", missingHumanUserIds)}], slots=[{string.Join(",", playerUserIds)}]."); + + _lastStartCancelTime = Time.time; + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.CancelGameStart, + null, + new RaiseEventArgs { Receivers = ReceiverGroup.All }, + SendOptions.SendReliable + ); + + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + yield break; + } + } + int j = 1; foreach(PlayerType type in playerTypes) { @@ -2188,7 +6044,7 @@ private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, stri } // Validate that all expected real players are still present before raising StartGame. - if (IsMatchmakingRoom()) + if (PhotonRealtimeClient.InMatchmakingRoom) { bool missingPlayer = false; foreach (string uid in data.PlayerSlotUserIds) @@ -2247,6 +6103,7 @@ private IEnumerator StartTheGameplay(bool isCloseRoom, string blueTeamName, stri private void StartingGameFailed() { + _matchHasStartedInCurrentRoom = false; // Clear BattleID so room is not left in 'starting' state after a failed start try { @@ -2262,7 +6119,7 @@ private void StartingGameFailed() if (!PhotonRealtimeClient.CurrentRoom.IsOpen) PhotonRealtimeClient.OpenRoom(); - if (IsMatchmakingRoom()) + if (PhotonRealtimeClient.InMatchmakingRoom) { OnFailedToStartMatchmakingGame?.Invoke(); GameType gameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); @@ -2283,6 +6140,7 @@ private IEnumerator StartQuantum(StartGameData data) if (slotIndex < 0 || slotIndex >= RuntimePlayer.PlayerSlots.Length) { Debug.LogError($"Player userId '{userId}' not found in StartGameData.PlayerSlotUserIds: [{string.Join(", ", data.PlayerSlotUserIds)}]. Cannot start Quantum."); + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); yield break; } BattlePlayerSlot playerSlot = RuntimePlayer.PlayerSlots[slotIndex]; @@ -2292,6 +6150,7 @@ private IEnumerator StartQuantum(StartGameData data) if (battleMap == null) { Debug.LogError($"BattleMap with id '{data.MapId}' not found. Cannot start Quantum."); + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); yield break; } Map map = battleMap.Map; @@ -2378,16 +6237,28 @@ bool AreAllExpectedPlayersPresent() OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.BattleLoad); _isStartFinished = false; - // Wait until BattleStart UI explicitly reports it is enabled. + // Wait for BattleLoad UI to initialize and signal readiness. const float onStartUiReadyTimeout = 5f; - float subscribeStart = Time.time; - while (!_battleStartUiReady && Time.time - subscribeStart < onStartUiReadyTimeout) + float uiReadyStart = Time.time; + while (!_battleStartUiReady && Time.time - uiReadyStart < onStartUiReadyTimeout) { yield return null; } if (!_battleStartUiReady) { - Debug.LogWarning("StartQuantum: BattleStart UI did not report ready in time; proceeding."); + Debug.LogWarning("StartQuantum: Battle start UI was not ready after timeout; proceeding."); + } + + // Wait for UI to subscribe to OnStartTimeSet (timeout to avoid hanging) + const float onStartSubscribeTimeout = 5f; + float subscribeStart = Time.time; + while (OnStartTimeSet == null && Time.time - subscribeStart < onStartSubscribeTimeout) + { + yield return null; + } + if (OnStartTimeSet == null) + { + Debug.LogWarning("StartQuantum: OnStartTimeSet has no subscribers after timeout; proceeding without countdown UI."); } if (sendTime == 0) sendTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); @@ -2595,6 +6466,24 @@ private IEnumerator ReserveFreePosition(bool setToPlayerProperties = false) freePosition = PhotonLobbyRoom.GetFirstFreePlayerPos(); if (!PhotonLobbyRoom.IsValidPlayerPos(freePosition)) { + // Queue-formed rooms can be full with pre-reserved slots for expected users. + // If our user is already reserved, sync local player property instead of treating this as a kick. + int reservedPosition = GetReservedRoomPositionForUser(PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty); + if (PhotonLobbyRoom.IsValidPlayerPos(reservedPosition)) + { + success = true; + if (setToPlayerProperties) + { + try + { + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PlayerPositionKey, reservedPosition); + Debug.Log($"ReserveFreePosition: room is full but local user already reserved in slot {reservedPosition}; synchronized PlayerPositionKey."); + } + catch (Exception ex) { Debug.LogWarning($"ReserveFreePosition: failed to set reserved local position: {ex.Message}"); } + } + break; + } + this.Publish(new(GetKickedEvent.ReasonType.FullRoom)); yield break; } @@ -2602,27 +6491,43 @@ private IEnumerator ReserveFreePosition(bool setToPlayerProperties = false) string positionKey = PhotonBattleRoom.GetPositionKey(freePosition); // Try atomic reservation locally first (compare-and-swap on room property) + var positionProps = new LobbyPhotonHashtable(new Dictionary { { positionKey, PhotonRealtimeClient.LocalLobbyPlayer.UserId } }); + var expectedProps = new LobbyPhotonHashtable(new Dictionary { { positionKey, "" } }); + bool sent = false; try { - sent = TrySetRoomProperty(positionKey, PhotonRealtimeClient.LocalLobbyPlayer.UserId, ""); + sent = PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(positionProps, expectedProps); } catch (Exception ex) { Debug.LogWarning($"ReserveFreePosition: SetCustomProperties failed: {ex.Message}"); sent = false; } if (sent) { // Wait until property is confirmed set to our user id or someone else grabbed it - yield return WaitForPropertySync( - () => PhotonRealtimeClient.CurrentRoom.GetCustomProperty(positionKey, "") == PhotonRealtimeClient.LocalLobbyPlayer.UserId, - () => IsPositionOccupied(freePosition), - timeoutSeconds: 1f, - pollIntervalSeconds: 0f, - onCompleted: value => success = value); - - // If requested, also set the local player's PlayerPositionKey now that room reservation succeeded - if (success && setToPlayerProperties) + float timeout = Time.time + 1f; + while (Time.time < timeout) { - TrySetLocalPlayerPositionProperty(freePosition, "ReserveFreePosition"); + string positionValue = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(positionKey, ""); + if (positionValue == PhotonRealtimeClient.LocalLobbyPlayer.UserId) + { + success = true; + // If requested, also set the local player's PlayerPositionKey now that room reservation succeeded + if (setToPlayerProperties) + { + try + { + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PlayerPositionKey, freePosition); + } + catch (Exception ex) { Debug.LogWarning($"ReserveFreePosition: failed to set local player property: {ex.Message}"); } + } + break; + } + else if (!PhotonBattleRoom.CheckIfPositionIsFree(freePosition)) + { + // somebody else reserved it + break; + } + yield return null; } } else @@ -2641,7 +6546,11 @@ private IEnumerator ReserveFreePosition(bool setToPlayerProperties = false) if (success && setToPlayerProperties) { - TrySetLocalPlayerPositionProperty(freePosition, "ReserveFreePosition"); + try + { + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PlayerPositionKey, freePosition); + } + catch (Exception ex) { Debug.LogWarning($"ReserveFreePosition: failed to set local player property: {ex.Message}"); } } } @@ -2731,9 +6640,10 @@ private IEnumerator RequestPositionChange(int position) do { // Checking if the new position is free before raising event to master client - if (IsPositionOccupied(position)) + if (PhotonBattleRoom.CheckIfPositionIsFree(position) == false) { - LogPositionUnavailableForRequest(position); + if(PhotonBattleRoom.CheckIfPositionHasBot(position)) Debug.LogWarning($"Failed to reserve the position {position} because there is a bot in the slot."); + else Debug.LogWarning($"Failed to reserve the position {position}. This likely because somebody already is in this position."); yield break; } @@ -2764,111 +6674,109 @@ private IEnumerator SetPlayer(Player player, int playerPosition) if (_posChangeQueue.Contains(player.UserId)) yield break; _posChangeQueue.Add(player.UserId); - try - { - yield return new WaitUntil(() => !_playerPosChangeInProgress); - _playerPosChangeInProgress = true; - // Checking if any of the players in the room are already in the position (value is anything else than empty string) and if so return. - if (IsPositionOccupied(playerPosition)) - { - LogRequestedPositionNotFree(); - yield break; - } + yield return new WaitUntil(() => !_playerPosChangeInProgress); + + _playerPosChangeInProgress = true; + // Checking if any of the players in the room are already in the position (value is anything else than empty string) and if so return. + if (PhotonBattleRoom.CheckIfPositionIsFree(playerPosition) == false) + { + Debug.LogWarning("Requested position is not free."); + _posChangeQueue.Remove(player.UserId); + _playerPosChangeInProgress = false; + yield break; + } - Assert.IsTrue(PhotonLobbyRoom.IsValidGameplayPosOrGuest(playerPosition)); + Assert.IsTrue(PhotonLobbyRoom.IsValidGameplayPosOrGuest(playerPosition)); - // Initializing hash tables for setting the new position as taken - string newPositionKey = PhotonBattleRoom.GetPositionKey(playerPosition); + // Initializing hash tables for setting the new position as taken + string newPositionKey = PhotonBattleRoom.GetPositionKey(playerPosition); - if (!player.HasCustomProperty(PlayerPositionKey)) + if (!player.HasCustomProperty(PlayerPositionKey)) + { + Debug.Log($"setPlayer {PlayerPositionKey}={playerPosition}"); + var position = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, player.UserId } }); + var eValue = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "" } }); // Expecting the new position to be empty + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(position, eValue)) { - Debug.Log($"setPlayer {PlayerPositionKey}={playerPosition}"); - if (TrySetRoomProperty(newPositionKey, player.UserId, "")) + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) { - bool success = false; - yield return WaitForPropertySync( - () => PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey, "") == player.UserId, - () => IsPositionOccupied(playerPosition), - onCompleted: value => success = value); - - if (success) + string positionValue = PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey, ""); + if (positionValue == player.UserId) + { + success = true; + break; + } + else if (!PhotonBattleRoom.CheckIfPositionIsFree(playerPosition)) { - player.SetCustomProperty(PlayerPositionKey, playerPosition); + break; } + yield return new WaitForSeconds(0.1f); } - yield break; - } - - // Initializing hash tables for setting the previous position empty - int curValue = player.GetCustomProperty(PlayerPositionKey); - string previousPositionKey = PhotonBattleRoom.GetPositionKey(curValue); - - // Setting new position as taken - if (TrySetRoomProperty(newPositionKey, player.UserId, "")) - { - bool success = false; - yield return WaitForPropertySync( - () => PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == player.UserId, - () => IsPositionOccupied(playerPosition), - () => LogPositionReservationFailed(playerPosition), - onCompleted: value => success = value); - if (success) { - // Setting new position to player's custom properties player.SetCustomProperty(PlayerPositionKey, playerPosition); - - // Setting previous position empty - if (!TrySetRoomProperty(previousPositionKey, "", player.UserId)) - { - Debug.LogWarning($"Failed to free the position {curValue}. This likely because the player doesn't reserve it."); - } } } - else - { - LogPositionReservationFailed(playerPosition); - } - } - finally - { + _posChangeQueue.Remove(player.UserId); _playerPosChangeInProgress = false; + yield break; } - } - private void UpdateRoomOpenStateAfterBotToggle(bool botAdded) - { - Room room = PhotonRealtimeClient.CurrentRoom; - if (botAdded) - { - int playerCount = room.PlayerCount; - int botCount = PhotonBattleRoom.GetBotCount(); - if (playerCount + botCount >= room.MaxPlayers) PhotonRealtimeClient.CloseRoom(); - } - else - { - if (!room.IsOpen) PhotonRealtimeClient.OpenRoom(); - } - } + // Initializing hash tables for setting the previous position empty + int curValue = player.GetCustomProperty(PlayerPositionKey); + string previousPositionKey = PhotonBattleRoom.GetPositionKey(curValue); - private static void FillFreeGameplayPositionsWithBots(Room room) - { - int[] positions = { - PhotonBattleRoom.PlayerPosition1, PhotonBattleRoom.PlayerPosition2, - PhotonBattleRoom.PlayerPosition3, PhotonBattleRoom.PlayerPosition4 - }; + var newPosition = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, player.UserId } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "" } }); // Expecting the new position to be empty - foreach (int pos in positions) + // Setting new position as taken + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(newPosition, expectedValue)) { - if (!IsPositionOccupied(pos)) + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) + { + // Checking if the position is set to the player user id + if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == player.UserId) + { + success = true; + break; + } + else if (!PhotonBattleRoom.CheckIfPositionIsFree(playerPosition)) + { + Debug.LogWarning($"Failed to reserve the position {playerPosition}. This likely because somebody already is in this position."); + break; + } + yield return new WaitForSeconds(0.1f); + } + + if (success) { - string posKey = PhotonBattleRoom.GetPositionKey(pos); - room.SetCustomProperty(posKey, "Bot"); + // Setting new position to player's custom properties + player.SetCustomProperty(PlayerPositionKey, playerPosition); + + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { previousPositionKey, "" } }); + expectedValue = new LobbyPhotonHashtable(new Dictionary { { previousPositionKey, player.UserId } }); // Expected to have the player's id in the previous position + + // Setting previous position empty + if (!PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue)) + { + Debug.LogWarning($"Failed to free the position {curValue}. This likely because the player doesn't reserve it."); + } } } + else + { + Debug.LogWarning($"Failed to reserve the position {playerPosition}. This likely because somebody already is in this position."); + } + _posChangeQueue.Remove(player.UserId); + _playerPosChangeInProgress = false; + yield break; } private IEnumerator SetBot(int playerPosition, bool active) @@ -2876,171 +6784,240 @@ private IEnumerator SetBot(int playerPosition, bool active) yield return new WaitUntil(() => !_playerPosChangeInProgress); _playerPosChangeInProgress = true; - try + // Checking if any of the players in the room are already in the position (value is anything else than empty string) and if so return. + if (PhotonBattleRoom.CheckIfPositionIsFree(playerPosition) == false && active) { - // Checking if any of the players in the room are already in the position (value is anything else than empty string) and if so return. - bool positionIsFree = !IsPositionOccupied(playerPosition); - if (!positionIsFree && active) - { - LogRequestedPositionNotFree(); - yield break; - } - else if (positionIsFree && !active) - { - LogRequestedPositionAlreadyEmpty(); - yield break; - } + Debug.LogWarning("Requested position is not free."); + _playerPosChangeInProgress = false; + yield break; + } + else if (!PhotonBattleRoom.CheckIfPositionIsFree(playerPosition) == false && !active) + { + Debug.LogWarning("Requested is already empty."); + _playerPosChangeInProgress = false; + yield break; + } - Assert.IsTrue(PhotonLobbyRoom.IsValidGameplayPosOrGuest(playerPosition)); + Assert.IsTrue(PhotonLobbyRoom.IsValidGameplayPosOrGuest(playerPosition)); - // Preparing position key for room property updates. - string newPositionKey = PhotonBattleRoom.GetPositionKey(playerPosition); + // Initializing hash tables for setting the new position as taken + string newPositionKey = PhotonBattleRoom.GetPositionKey(playerPosition); - if (active) + LobbyPhotonHashtable newPosition; + LobbyPhotonHashtable expectedValue; + if (active) + { + newPosition = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "Bot" } }); + expectedValue = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "" } }); // Expecting the new position to be empty + + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(newPosition, expectedValue)) { - if (TrySetRoomProperty(newPositionKey, "Bot", "")) + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) { - bool success = false; - yield return WaitForPropertySync( - () => PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == "Bot", - () => IsPositionOccupied(playerPosition), - () => LogPositionReservationFailed(playerPosition), - onCompleted: value => success = value); - - if (success) + // Checking if the position is set to have a Bot + if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == "Bot") { - Debug.Log($"Set Bot to position {playerPosition}"); - UpdateRoomOpenStateAfterBotToggle(true); + success = true; + break; + } + else if (!PhotonBattleRoom.CheckIfPositionIsFree(playerPosition)) + { + Debug.LogWarning($"Failed to reserve the position {playerPosition}. This likely because somebody already is in this position."); + break; } + yield return new WaitForSeconds(0.1f); } - else + + if (success) { - LogPositionReservationFailed(playerPosition); + Debug.Log($"Set Bot to position {playerPosition}"); + Room room = PhotonRealtimeClient.CurrentRoom; + int playerCount = room.PlayerCount; + int botCount = PhotonBattleRoom.GetBotCount(); + if (playerCount + botCount >= room.MaxPlayers) PhotonRealtimeClient.CloseRoom(); } } else { - if (TrySetRoomProperty(newPositionKey, "", "Bot")) - { - bool success = false; - yield return WaitForPropertySync( - () => - { - // Keep legacy "slot already free" behavior as a successful completion. - if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == "") - { - return true; - } - - if (!IsPositionOccupied(playerPosition)) - { - Debug.LogWarning($"Slot is free? Wait? How did you end up here?"); - return true; - } - - return false; - }, - onCompleted: value => success = value); + Debug.LogWarning($"Failed to reserve the position {playerPosition}. This likely because somebody already is in this position."); + } + } + else + { + newPosition = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "" } }); + expectedValue = new LobbyPhotonHashtable(new Dictionary { { newPositionKey, "Bot" } }); // Expecting the position to have a bot - if (success) + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(newPosition, expectedValue)) + { + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) + { + // Checking if the position is set to have a Bot + if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(newPositionKey) == "") + { + success = true; + break; + } + else if (PhotonBattleRoom.CheckIfPositionIsFree(playerPosition)) { - Debug.Log($"Freed position {playerPosition}"); - UpdateRoomOpenStateAfterBotToggle(false); + Debug.LogWarning($"Slot is free? Wait? How did you end up here?"); + success = true; + break; } + yield return new WaitForSeconds(0.1f); } - else + + if (success) { - LogPositionReservationFailed(playerPosition); + Debug.Log($"Freed position {playerPosition}"); + Room room = PhotonRealtimeClient.CurrentRoom; + if (!room.IsOpen) PhotonRealtimeClient.OpenRoom(); } } + else + { + Debug.LogWarning($"Failed to reserve the position {playerPosition}. This likely because somebody already is in this position."); + } } - finally - { - _playerPosChangeInProgress = false; - } + + _playerPosChangeInProgress = false; + yield break; } private IEnumerator SetBotFill(bool active) { - if (TrySetRoomProperty(PhotonBattleRoom.BotFillKey, active)) + LobbyPhotonHashtable newValue; + if (active) { - bool success = false; - yield return WaitForPropertySync( - () => PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey) == active, - onCompleted: value => success = value); + newValue = new LobbyPhotonHashtable(new Dictionary { { PhotonBattleRoom.BotFillKey, true } }); - if (success) + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(newValue)) + { + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) + { + // Checking if the position is set to have a Bot + if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey) == true) + { + success = true; + break; + } + yield return new WaitForSeconds(0.1f); + } + + if (success) + { + Debug.Log($"Set BotFill to {active}"); + } + } + else { - Debug.Log($"Set BotFill to {active}"); + Debug.LogWarning($"Failed to activate bot fill. Something borke really bad."); } } else { - Debug.LogWarning($"Failed to activate bot fill. Something borke really bad."); - } - - } + newValue = new LobbyPhotonHashtable(new Dictionary { { PhotonBattleRoom.BotFillKey, false } }); - private IEnumerator VerifyRoomPositionsLoop() - { - try - { - while (PhotonRealtimeClient.InRoom && PhotonRealtimeClient.LocalPlayer.IsMasterClient) + if (PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(newValue)) { - try + float timeout = Time.time + 1f; + bool success = false; + while (Time.time < timeout) { - Room room = PhotonRealtimeClient.CurrentRoom; - ClearStalePlayerPositionKeys(room, "VerifyRoomPositionsLoop"); + // Checking if the position is set to have a Bot + if (PhotonRealtimeClient.LobbyCurrentRoom.GetCustomProperty(PhotonBattleRoom.BotFillKey) == false) + { + success = true; + break; + } + yield return new WaitForSeconds(0.1f); } - catch (Exception ex) + + if (success) { - Debug.LogWarning($"VerifyRoomPositionsLoop: unexpected error: {ex.Message}"); + Debug.Log($"Set BotFill to {active}"); } - yield return new WaitForSeconds(2f); + } + else + { + Debug.LogWarning($"Failed to activate bot fill. Something borke really bad."); } } - finally - { - _verifyPositionsHolder = null; - } + + yield break; } - private void ClearStalePlayerPositionKeys(Room room, string logPrefix) + private IEnumerator VerifyRoomPositionsLoop() { try { - if (room == null) return; - - var existingUserIds = new HashSet(room.Players.Values.Select(p => p.UserId)); - string[] posKeys = { - PhotonBattleRoom.PlayerPositionKey1, - PhotonBattleRoom.PlayerPositionKey2, - PhotonBattleRoom.PlayerPositionKey3, - PhotonBattleRoom.PlayerPositionKey4 - }; - - foreach (var key in posKeys) + while (PhotonRealtimeClient.InRoom && PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - string val = room.GetCustomProperty(key, ""); - if (string.IsNullOrEmpty(val)) continue; - if (val == "Bot") continue; - if (!existingUserIds.Contains(val)) + bool waitShortAndContinue = false; + try { - try + if (!CanMutateRoomPropertiesNow()) { - TrySetRoomProperty(key, "", val); - Debug.Log($"{logPrefix}: cleared stale position {key} (value {val})."); + waitShortAndContinue = true; } - catch (Exception ex) + else { - Debug.LogWarning($"{logPrefix}: failed to clear {key}: {ex.Message}"); + Room room = PhotonRealtimeClient.CurrentRoom; + if (room != null) + { + var existingUserIds = new HashSet(room.Players.Values.Select(p => p.UserId)); + string[] posKeys = { + PhotonBattleRoom.PlayerPositionKey1, + PhotonBattleRoom.PlayerPositionKey2, + PhotonBattleRoom.PlayerPositionKey3, + PhotonBattleRoom.PlayerPositionKey4 + }; + + foreach (var key in posKeys) + { + string val = room.GetCustomProperty(key, ""); + if (string.IsNullOrEmpty(val)) continue; + if (val == "Bot") continue; + if (!existingUserIds.Contains(val)) + { + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { key, "" } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { key, val } }); + try + { + PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue); + Debug.Log($"VerifyRoomPositionsLoop: cleared stale position {key} (value {val})."); + } + catch (Exception ex) + { + Debug.LogWarning($"VerifyRoomPositionsLoop: failed to clear {key}: {ex.Message}"); + } + } + } + } } } + catch (Exception ex) + { + Debug.LogWarning($"VerifyRoomPositionsLoop: unexpected error: {ex.Message}"); + } + + if (waitShortAndContinue) + { + yield return new WaitForSeconds(0.5f); + continue; + } + + yield return new WaitForSeconds(2f); } } - catch (Exception ex) + finally { - Debug.LogWarning($"{logPrefix}: failed to clean stale positions: {ex.Message}"); + _verifyPositionsHolder = null; } } @@ -3089,9 +7066,10 @@ private void OnGetKickedEvent(GetKickedEvent data) GameType requeueGameType = _currentMatchmakingGameType; try { - if (TryGetRoomGameType(PhotonRealtimeClient.CurrentRoom, out GameType currentGameType)) + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) { - requeueGameType = currentGameType; + requeueGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } } catch (Exception ex) { Debug.LogWarning($"OnGetKickedEvent: failed to read room game type: {ex.Message}"); } @@ -3138,229 +7116,228 @@ public void OnPlayerLeftRoom(Player otherPlayer) if (PhotonRealtimeClient.Client.State == ClientState.Leaving) return; - if (IsStartCountdownInProgressOnPlayerLeave()) - { - HandlePlayerLeftDuringStart(otherPlayer); - } - - if (TryClearDepartedPlayerPositionIfMaster(otherPlayer)) return; - - if (HandleMatchmakingSpecialCasesOnPlayerLeft(otherPlayer)) return; - - Room room = PhotonRealtimeClient.CurrentRoom; - int playerCount = room.PlayerCount; - int botCount = PhotonBattleRoom.GetBotCount(); - - TryResetAutoRequeueAttemptsOnPlayerLeft(); - - TryOpenRoomAfterPlayerLeft(room, playerCount, botCount); - - // Master: ensure any stale player position keys are cleared when any player leaves - if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - ClearStalePlayerPositionKeys(room, "OnPlayerLeftRoom"); - - TryFormQueueMatch(PhotonRealtimeClient.CurrentRoom, "Queue (player left)"); - } - - LobbyOnPlayerLeftRoom?.Invoke(new(otherPlayer)); - - // Ensure master continues matchmaking wait loop so countdowns can be restarted - if (IsMatchmakingRoom() && PhotonRealtimeClient.LocalPlayer.IsMasterClient && _matchmakingHolder == null) - { - _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); - } - } + // If a game start countdown or start flow is in progress, cancel it. + bool startCountdownInProgress = !_matchHasStartedInCurrentRoom && IsGameStartTransitionActive(); - private bool IsStartCountdownInProgressOnPlayerLeave() - { - bool startCountdownInProgress = _startGameHolder != null || _startQuantumHolder != null; - if (!startCountdownInProgress && PhotonRealtimeClient.CurrentRoom != null) + if (startCountdownInProgress) { + // If a player leaves during countdown, force all players to leave and requeue + GameType currentRoomGameType = GameType.Random2v2; try { - startCountdownInProgress = PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID); + if (PhotonRealtimeClient.CurrentRoom != null) + { + currentRoomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } } - catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to evaluate BattleID presence: {ex.Message}"); } - } + catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to read current room game type: {ex.Message}"); } - return startCountdownInProgress; - } - - private void HandlePlayerLeftDuringStart(Player otherPlayer) - { - // If a player leaves during countdown, force all players to leave and requeue. - GameType currentRoomGameType = GameType.Random2v2; - try - { - if (PhotonRealtimeClient.CurrentRoom != null) + // If this is a Custom game, keep existing behavior (do not force requeue) + bool isCustomRoom = false; + try { - currentRoomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + isCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; + } } - } - catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to read current room game type: {ex.Message}"); } - - // If this is a Custom game, keep existing behavior (do not force requeue). - bool isCustomRoom = false; - try - { - isCustomRoom = IsCustomRoom(PhotonRealtimeClient.CurrentRoom); - } - catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to determine if room is Custom: {ex.Message}"); } + catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to determine if room is Custom: {ex.Message}"); } - if (!isCustomRoom) - { - // Broadcast CancelGameStart with requeue instruction so clients know to requeue. - _lastStartCancelTime = Time.time; - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.CancelGameStart, - new object[] { true, (int)currentRoomGameType }, - new RaiseEventArgs { Receivers = ReceiverGroup.All }, - SendOptions.SendReliable - ); + if (!isCustomRoom) + { + // Broadcast CancelGameStart with requeue instruction so clients know to requeue + _lastStartCancelTime = Time.time; + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.CancelGameStart, + new object[] { true, (int)currentRoomGameType }, + new RaiseEventArgs { Receivers = ReceiverGroup.All }, + SendOptions.SendReliable + ); - // Stop any local start coroutines and matchmaking holders. - try { SafeStopCoroutine(ref _startGameHolder); } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop _startGameHolder: {ex.Message}"); } - try { SafeStopCoroutine(ref _startQuantumHolder); } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop _startQuantumHolder: {ex.Message}"); } - try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop matchmaking coroutines: {ex.Message}"); } + // Stop any local start coroutines and matchmaking holders + try { if (_startGameHolder != null) { StopCoroutine(_startGameHolder); _startGameHolder = null; } } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop _startGameHolder: {ex.Message}"); } + try { if (_startQuantumHolder != null) { StopCoroutine(_startQuantumHolder); _startQuantumHolder = null; } } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop _startQuantumHolder: {ex.Message}"); } + try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop matchmaking coroutines: {ex.Message}"); } - OnGameStartCancelled?.Invoke(); + OnGameStartCancelled?.Invoke(); - // All clients should leave and requeue (master will handle creating new room). - try - { - if (IsMatchmakingRoom()) + // All clients should leave and requeue (master will handle creating new room) + try { - StartCoroutine(LeaveAndAutoRequeue(currentRoomGameType)); + if (PhotonRealtimeClient.InMatchmakingRoom) + { + StartCoroutine(LeaveAndAutoRequeue(currentRoomGameType)); + } + else + { + StartingGameFailed(); + } } - else + catch (Exception ex) { - StartingGameFailed(); + Debug.LogWarning($"Failed to initiate LeaveAndAutoRequeue: {ex.Message}"); } } - catch (Exception ex) - { - Debug.LogWarning($"Failed to initiate LeaveAndAutoRequeue: {ex.Message}"); - } - } - else - { - // Preserve original behavior for Custom rooms: cancel the start attempt, but do not requeue. - if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - SafeStopCoroutine(ref _startGameHolder); - StartingGameFailed(); - } else { - SafeStopCoroutine(ref _startQuantumHolder); - OnGameStartCancelled?.Invoke(); - try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"CancelGameStart: StopMatchmakingCoroutines failed: {ex.Message}"); } - try + // Preserve original behavior for Custom rooms + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - if (!PhotonRealtimeClient.LocalPlayer.HasCustomProperty(PlayerPositionKey) && _reserveFreePositionHolder == null) + if (_startGameHolder != null) { - _reserveFreePositionHolder = StartCoroutine(ReserveFreePosition(true)); + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + + if (PhotonRealtimeClient.InMatchmakingRoom) + { + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + } + else + { + StartingGameFailed(); } } - catch (Exception ex) - { - Debug.LogWarning($"CancelGameStart: failed to reserve position: {ex.Message}"); - } - try + else { - if (otherPlayer != null && otherPlayer.IsMasterClient) + if (_startQuantumHolder != null) { - _deferReturnToLobbyRoomOnMasterSwitch = true; + StopCoroutine(_startQuantumHolder); + _startQuantumHolder = null; } - else + OnGameStartCancelled?.Invoke(); + try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"CancelGameStart: StopMatchmakingCoroutines failed: {ex.Message}"); } + try + { + if (!PhotonRealtimeClient.LocalPlayer.HasCustomProperty(PlayerPositionKey)) + { + if (_reserveFreePositionHolder == null) + { + _reserveFreePositionHolder = StartCoroutine(ReserveFreePosition(true)); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"CancelGameStart: failed to reserve position: {ex.Message}"); + } + try + { + if (otherPlayer != null && otherPlayer.IsMasterClient) + { + _deferReturnToLobbyRoomOnMasterSwitch = true; + } + else + { + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); + } + } + catch { OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); } } - catch + } + } + + if (!_matchHasStartedInCurrentRoom && IsQueueRoom(PhotonRealtimeClient.CurrentRoom)) + { + if (ShouldLeaveQueueWhenDuoPartnerLeaves()) + { + Debug.Log($"OnPlayerLeftRoom: queue duo partner left; leaving queue with local player {PhotonRealtimeClient.LocalPlayer?.UserId}."); + ClearQueueDuoBreakupSignals(); + StopQueueTimer(); + try { StopMatchmakingCoroutines(); } catch (Exception ex) { Debug.LogWarning($"OnPlayerLeftRoom: failed to stop matchmaking coroutines before duo leave: {ex.Message}"); } + if (PhotonRealtimeClient.InMatchmakingRoom) + { + StartCoroutine(LeaveMatchmaking()); + } + else { + Debug.Log("OnPlayerLeftRoom: duo breakup detected in queue room but InMatchmakingRoom=false; forcing LeaveRoom."); + // Fallback path bypasses LeaveMatchmaking(), so fire stop + UI events explicitly. + OnMatchmakingStopped?.Invoke(); OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); + if (PhotonRealtimeClient.Client.State != ClientState.Leaving) + { + PhotonRealtimeClient.LeaveRoom(); + } } + return; } } - } - private bool TryClearDepartedPlayerPositionIfMaster(Player otherPlayer) - { - // Clearing the player position in the room if player is master client. - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) + // Clearing the player position in the room if player is master client + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - return false; - } - - int otherPlayerPosition = otherPlayer.GetCustomProperty(PlayerPositionKey); - if (!PhotonLobbyRoom.IsValidPlayerPos(otherPlayerPosition)) return true; - string positionKey = PhotonBattleRoom.GetPositionKey(otherPlayerPosition); + int otherPlayerPosition = otherPlayer.GetCustomProperty(PlayerPositionKey); + if (!PhotonLobbyRoom.IsValidPlayerPos(otherPlayerPosition)) return; + string positionKey = PhotonBattleRoom.GetPositionKey(otherPlayerPosition); - TrySetRoomProperty(positionKey, "", otherPlayer.UserId); - if (_posChangeQueue.Contains(otherPlayer.UserId)) _posChangeQueue.Remove(otherPlayer.UserId); - - return false; - } + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { positionKey, "" } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { positionKey, otherPlayer.UserId } }); - private bool HandleMatchmakingSpecialCasesOnPlayerLeft(Player otherPlayer) - { - if (!(IsMatchmakingRoom() && _followLeaderHolder == null)) - { - return false; + if (CanMutateRoomPropertiesNow("OnPlayerLeftRoom: clear leaving player slot", true) && PhotonRealtimeClient.LobbyCurrentRoom != null) + { + PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue); + } + if(_posChangeQueue.Contains(otherPlayer.UserId)) _posChangeQueue.Remove(otherPlayer.UserId); } - // If the game type is clan 2v2 and the player who left was a teammate we leave the room, - // since you can't play the game mode without 2 person team from the same clan. - GameType roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); - if (roomGameType == GameType.Clan2v2) + if (!_matchHasStartedInCurrentRoom && PhotonRealtimeClient.InMatchmakingRoom && _followLeaderHolder == null) { - string ownClan = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.ClanNameKey); - string otherPlayerClan = otherPlayer.GetCustomProperty(PhotonBattleRoom.ClanNameKey); - - if (ownClan == otherPlayerClan) + // If the game type is clan 2v2 and the player who left was a teammate we leave the room, + // since you can't play the game mode without 2 person team from the same clan + GameType roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + if (roomGameType == GameType.Clan2v2) { - _teammates = null; - StartCoroutine(LeaveMatchmaking()); - OnClanMemberDisconnected?.Invoke(); - } + string ownClan = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.ClanNameKey); + string otherPlayerClan = otherPlayer.GetCustomProperty(PhotonBattleRoom.ClanNameKey); - return true; - } + if (ownClan == otherPlayerClan) + { + _teammates = null; + StartCoroutine(LeaveMatchmaking()); + OnClanMemberDisconnected?.Invoke(); + } + return; + } - // Checking if the other player who left was local player's leader. - string matchmakingLeaderId = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.LeaderIdKey, string.Empty); - if (matchmakingLeaderId == otherPlayer.UserId) - { - // Clear leader id; new master will be set in OnMasterClientSwitched. - try + // Checking if the other player who left was local player's leader + string matchmakingLeaderId = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.LeaderIdKey, string.Empty); + if (matchmakingLeaderId == otherPlayer.UserId) { - PhotonRealtimeClient.LocalPlayer.RemoveCustomProperty(PhotonBattleRoom.LeaderIdKey); + // Clear leader id; new master will be set in OnMasterClientSwitched + try + { + PhotonRealtimeClient.LocalPlayer.RemoveCustomProperty(PhotonBattleRoom.LeaderIdKey); + } + catch { } + OnRoomLeaderChanged?.Invoke(false); } - catch { } - OnRoomLeaderChanged?.Invoke(false); } - return false; - } + Room room = PhotonRealtimeClient.CurrentRoom; + int playerCount = room.PlayerCount; + int botCount = PhotonBattleRoom.GetBotCount(); - private void TryResetAutoRequeueAttemptsOnPlayerLeft() - { - // Reset auto-requeue attempts if enough human players are present. + // Reset auto-requeue attempts if enough human players are present try { - if (IsMatchmakingRoom() && PhotonRealtimeClient.CurrentRoom != null) + if (PhotonRealtimeClient.InMatchmakingRoom && PhotonRealtimeClient.CurrentRoom != null) { int humanPlayers = PhotonRealtimeClient.CurrentRoom.Players.Values.Count(p => !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot"); if (humanPlayers >= 4) _autoRequeueAttempts = 0; } } catch (Exception ex) { Debug.LogWarning($"LobbyManager: caught exception: {ex.Message}"); } - } - private void TryOpenRoomAfterPlayerLeft(Room room, int playerCount, int botCount) - { try { if (PhotonRealtimeClient.LocalPlayer.IsMasterClient && playerCount + botCount < room.MaxPlayers && !PhotonRealtimeClient.CurrentRoom.IsOpen) @@ -3372,11 +7349,107 @@ private void TryOpenRoomAfterPlayerLeft(Room room, int playerCount, int botCount { Debug.LogWarning($"OnPlayerLeftRoom: failed to open room: {ex.Message}"); } + + // Master: ensure any stale player position keys are cleared when any player leaves + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + try + { + if (room != null && CanMutateRoomPropertiesNow("OnPlayerLeftRoom: clear stale positions", true)) + { + var existingUserIds = new HashSet(room.Players.Values.Select(p => p.UserId)); + string[] posKeys = { + PhotonBattleRoom.PlayerPositionKey1, + PhotonBattleRoom.PlayerPositionKey2, + PhotonBattleRoom.PlayerPositionKey3, + PhotonBattleRoom.PlayerPositionKey4 + }; + + foreach (var key in posKeys) + { + string val = room.GetCustomProperty(key, ""); + if (string.IsNullOrEmpty(val)) continue; + if (val == "Bot") continue; + if (!existingUserIds.Contains(val)) + { + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { key, "" } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { key, val } }); + try + { + PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue); + Debug.Log($"Cleared stale position {key} (value {val}) on player leave."); + } + catch (Exception ex2) + { + Debug.LogWarning($"Failed to clear stale position {key}: {ex2.Message}"); + } + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"OnPlayerLeftRoom: failed to clean stale positions: {ex.Message}"); + } + + // Queue match formation is centralized in QueueTimerCoroutine. + try + { + Room qroom = PhotonRealtimeClient.CurrentRoom; + if (qroom != null && qroom.CustomProperties != null && qroom.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (qroom.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) + { + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + StartQueueTimer(); + } + } + } + catch (Exception ex) { Debug.LogWarning($"LobbyManager: caught exception: {ex.Message}"); } + } + + LobbyOnPlayerLeftRoom?.Invoke(new(otherPlayer)); + + // Ensure master continues matchmaking wait loop so countdowns can be restarted + if (PhotonRealtimeClient.InMatchmakingRoom && PhotonRealtimeClient.LocalPlayer.IsMasterClient && _matchmakingHolder == null) + { + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + } } public void OnJoinedRoom() { _gamePlayedOut = false; + _matchHasStartedInCurrentRoom = false; + _countdownActive = false; + _lastCountdownStartTime = -100f; + _pendingInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteStartTime = -100f; + + // Correlate OnJoinedRoom to any outstanding join attempt and mark it succeeded. + try + { + string joinedRoomName = PhotonRealtimeClient.CurrentRoom?.Name; + int matchedId = _joinAttemptTracker.FindJoinAttemptIdForRoomName(joinedRoomName); + // If we found a matching attempt, mark success; otherwise mark current attempt if present. + if (matchedId != 0) _joinAttemptTracker.MarkJoinAttemptSuccess(matchedId); + else _joinAttemptTracker.MarkJoinAttemptSuccess(0); + } + catch { } + + bool preserveQueuePendingSignals = false; + try + { + Room joinedRoom = PhotonRealtimeClient.CurrentRoom; + preserveQueuePendingSignals = IsQueueRoom(joinedRoom) || IsQueueFormedExpectedUserFlowRoom(joinedRoom); + } + catch { } + + if (!preserveQueuePendingSignals) + { + _queuePendingLeaderUntil.Clear(); + _queuePendingExpectedUserUntil.Clear(); + } // Enable: PhotonNetwork.CloseConnection needs to to work across all clients - to kick off invalid players! PhotonRealtimeClient.EnableCloseConnection = true; @@ -3386,7 +7459,7 @@ public void OnJoinedRoom() try { var currentRoom = PhotonRealtimeClient.CurrentRoom; - if (IsQueueRoom(currentRoom)) + if (currentRoom != null && currentRoom.CustomProperties != null && currentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (currentRoom.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) { bool isLeader = PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient; OnMatchmakingRoomEntered?.Invoke(isLeader); @@ -3405,7 +7478,23 @@ public void OnJoinedRoom() } catch { } - if (IsMatchmakingRoom()) + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey) + && (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.FriendLobby) + { + string invitedUserId = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, string.Empty); + if (!string.IsNullOrEmpty(invitedUserId) && invitedUserId == PhotonRealtimeClient.LocalPlayer.UserId) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, invitedUserId); + } + } + } + catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to update FriendLobby invite state: {ex.Message}"); } + + if (PhotonRealtimeClient.InMatchmakingRoom) { // If we previously got a CancelGameStart and now rejoined matchmaking, leave and return to main menu if (_returnToMainMenuOnMatchmakingRejoin) @@ -3438,28 +7527,93 @@ public void OnJoinedRoom() try { bool isQueueRoom = false; + bool queueExpectedJoinFlowActive = false; try { - isQueueRoom = IsQueueRoom(PhotonRealtimeClient.CurrentRoom); + var curr = PhotonRealtimeClient.CurrentRoom; + if (curr != null && curr.CustomProperties != null && curr.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (curr.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) + { + isQueueRoom = true; + } } catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to check isQueueRoom: {ex.Message}"); } - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient && !isQueueRoom) - { - GameType roomGameType = GetRoomType(PhotonRealtimeClient.CurrentRoom); + if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient && !isQueueRoom) + { + GameType roomGameType = GameType.Random2v2; + try { roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); } catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to read room game type: {ex.Message}"); } + bool queueFormedMatch = false; + int expectedFollowers = 0; + string[] expectedUsers = null; + string[] photonExpectedUsers = null; + try { queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); } catch { } + try { expectedFollowers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); } catch { } + try { expectedUsers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); } catch { } + try { photonExpectedUsers = PhotonRealtimeClient.CurrentRoom.ExpectedUsers; } catch { } + + bool hasExpectedUsers = expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + bool hasPhotonExpectedUsers = photonExpectedUsers != null && photonExpectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + if (!hasExpectedUsers && hasPhotonExpectedUsers) + { + expectedUsers = photonExpectedUsers; + hasExpectedUsers = true; + } + bool queueExpectedJoinFlowForWatcher = queueFormedMatch || expectedFollowers > 0 || hasExpectedUsers || hasPhotonExpectedUsers; + queueExpectedJoinFlowActive = queueExpectedJoinFlowForWatcher; + if (_joinTimeoutWatcherHolder != null) { StopCoroutine(_joinTimeoutWatcherHolder); _joinTimeoutWatcherHolder = null; } - float effectiveTimeout = MatchmakingJoinTimeoutSeconds; - Debug.Log($"OnJoinedRoom: non-master joined matchmaking room '{PhotonRealtimeClient.CurrentRoom?.Name}' with PlayerCount={PhotonRealtimeClient.CurrentRoom?.PlayerCount}, starting MatchmakingJoinWatcher(timeout={effectiveTimeout}s) (qe={PhotonRealtimeClient.CurrentRoom?.GetCustomProperty("qe", -999)})"); - _joinTimeoutWatcherHolder = StartCoroutine(MatchmakingJoinWatcher(roomGameType, effectiveTimeout)); - // Attempt to reserve a free position for non-master clients so start proceeds quicker - try + if (!queueExpectedJoinFlowForWatcher) + { + float effectiveTimeout = MatchmakingJoinTimeoutSeconds; + Debug.Log($"OnJoinedRoom: non-master joined matchmaking room '{PhotonRealtimeClient.CurrentRoom?.Name}' with PlayerCount={PhotonRealtimeClient.CurrentRoom?.PlayerCount}, starting MatchmakingJoinWatcher(timeout={effectiveTimeout}s) (qe={PhotonRealtimeClient.CurrentRoom?.GetCustomProperty("qe", -999)})"); + _joinTimeoutWatcherHolder = StartCoroutine(MatchmakingJoinWatcher(roomGameType, effectiveTimeout)); + } + else + { + Debug.Log($"OnJoinedRoom: non-master joined queue-formed matchmaking room '{PhotonRealtimeClient.CurrentRoom?.Name}' (qe={expectedFollowers}, euCount={(expectedUsers?.Length ?? 0)}, photonExpectedCount={(photonExpectedUsers?.Length ?? 0)}); skipping MatchmakingJoinWatcher."); + } + // Attempt to reserve a free position for non-master clients when no slot is already reserved. + try + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + int reservedPosition = GetReservedRoomPositionForUser(localUserId); + + if (PhotonLobbyRoom.IsValidPlayerPos(reservedPosition)) { - if (_reserveFreePositionHolder == null) + int localPosition = PhotonRealtimeClient.LocalPlayer.HasCustomProperty(PlayerPositionKey) + ? PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PlayerPositionKey) + : PlayerPositionGuest; + + if (localPosition != reservedPosition) { - _reserveFreePositionHolder = StartCoroutine(ReserveFreePosition(true)); + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PlayerPositionKey, reservedPosition); + Debug.Log($"OnJoinedRoom: synchronized local PlayerPositionKey to reserved room slot {reservedPosition}."); } } - catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to start ReserveFreePosition: {ex.Message}"); } + else if (!PhotonRealtimeClient.LocalPlayer.HasCustomProperty(PlayerPositionKey) && _reserveFreePositionHolder == null) + { + _reserveFreePositionHolder = StartCoroutine(ReserveFreePosition(true)); + } + } + catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to ensure local position reservation: {ex.Message}"); } + + // Diagnostic: snapshot room position keys and local player position after join + try + { + string sp1 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey1, string.Empty); + string sp2 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey2, string.Empty); + string sp3 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey3, string.Empty); + string sp4 = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PlayerPositionKey4, string.Empty); + int localPos = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PlayerPositionKey, PlayerPositionGuest); + Debug.Log($"OnJoinedRoom: join snapshot pos1={sp1},pos2={sp2},pos3={sp3},pos4={sp4}, localPlayerPos={localPos}"); + } + catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: failed to log join snapshot: {ex.Message}"); } + + if (queueExpectedJoinFlowActive) + { + // Queue-formed expected-user rooms can temporarily look sparse for followers; avoid false auto-requeue. + try { _autoRequeueAttempts = 0; } catch { } + } } } catch (Exception ex) { Debug.LogWarning($"OnJoinedRoom: start join watcher failed: {ex.Message}"); } @@ -3469,14 +7623,32 @@ public void OnJoinedRoom() bool inCustomRoom = false; try { - if (TryGetRoomGameType(PhotonRealtimeClient.CurrentRoom, out GameType currentGameType)) + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) { - inCustomRoom = currentGameType == GameType.Custom; + inCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; + } + } + catch { } + + bool queueExpectedJoinFlow = false; + try + { + if (PhotonRealtimeClient.CurrentRoom != null) + { + bool queueFormedMatch = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(QueueFormedMatchKey, false); + int expectedFollowers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("qe", 0); + string[] expectedUsers = PhotonRealtimeClient.CurrentRoom.GetCustomProperty("eu", null); + string[] photonExpectedUsers = PhotonRealtimeClient.CurrentRoom.ExpectedUsers; + + bool hasExpectedUsers = expectedUsers != null && expectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + bool hasPhotonExpectedUsers = photonExpectedUsers != null && photonExpectedUsers.Any(uid => !string.IsNullOrEmpty(uid)); + queueExpectedJoinFlow = queueFormedMatch || expectedFollowers > 0 || hasExpectedUsers || hasPhotonExpectedUsers; } } catch { } - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient && PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.PlayerCount <= 1 && !inCustomRoom) + if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient && PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.PlayerCount <= 1 && !inCustomRoom && !queueExpectedJoinFlow) { Debug.Log($"OnJoinedRoom: non-master appears alone in matchmaking room (PlayerCount={PhotonRealtimeClient.CurrentRoom.PlayerCount}); starting auto-requeue."); if (_autoJoinHolder == null) @@ -3493,14 +7665,22 @@ public void OnJoinedRoom() else { LobbyOnJoinedRoom?.Invoke(); - - QueueCustomBattleStartCheck(); } } public void OnLeftRoom() // IMatchmakingCallbacks { _gamePlayedOut = false; + _matchHasStartedInCurrentRoom = false; + _countdownActive = false; + _lastCountdownStartTime = -100f; + _pendingInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteStartTime = -100f; + _lastAutoInviteRoomName = string.Empty; + _lastAutoInviteJoinTime = -100f; + _queuePendingExpectedUserUntil.Clear(); + _queuePendingLeaderUntil.Clear(); // Clearing player position key from own custom properties if (PhotonRealtimeClient.LocalPlayer.HasCustomProperty(PlayerPositionKey)) PhotonRealtimeClient.LocalPlayer.RemoveCustomProperty(PlayerPositionKey); @@ -3532,6 +7712,13 @@ public void OnLeftRoom() // IMatchmakingCallbacks StopCoroutine(_joinTimeoutWatcherHolder); _joinTimeoutWatcherHolder = null; } + + if (_matchmakingHolder == null && _followLeaderHolder == null) + { + _isPremadeMatchmakingFlow = false; + _premadeTeammateUserId = string.Empty; + } + LobbyOnLeftRoom?.Invoke(); // Goto lobby if we left (in)voluntarily any room // - typically master client kicked us off before starting a new game as we did not qualify to participate. @@ -3563,8 +7750,121 @@ public void OnRoomListUpdate(List roomList) { lobbyRoomList.Add(new(roomInfo)); } + + if (!string.IsNullOrEmpty(_pendingAcceptedInRoomInviteRoomName) + && !PhotonRealtimeClient.InRoom + && _pendingAcceptedInRoomInviteStartTime > 0f + && Time.time - _pendingAcceptedInRoomInviteStartTime > InRoomInviteJoinTimeoutSeconds) + { + string timedOutRoomName = _pendingAcceptedInRoomInviteRoomName; + _pendingAcceptedInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteStartTime = -100f; + Debug.LogWarning($"Accepted InRoom invite join timed out for room '{timedOutRoomName}'."); + OnInRoomInviteJoinFailed?.Invoke(timedOutRoomName, -1, "timeout"); + } + LobbyOnRoomListUpdate?.Invoke(lobbyRoomList); + TryAutoJoinInRoomInvite(lobbyRoomList); + } + + private void TryAutoJoinInRoomInvite(List roomList) + { + if (roomList == null || roomList.Count == 0) return; + if (!PhotonRealtimeClient.InLobby || PhotonRealtimeClient.InRoom) return; + + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + if (string.IsNullOrEmpty(localUserId)) return; + + if (!string.IsNullOrEmpty(_pendingInRoomInviteRoomName) + && roomList.All(room => room == null || room.RemovedFromList || room.Name != _pendingInRoomInviteRoomName)) + { + _pendingInRoomInviteRoomName = string.Empty; + } + + foreach (LobbyRoomInfo room in roomList) + { + if (room == null || room.RemovedFromList || !room.IsOpen || room.CustomProperties == null) continue; + if (!room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) continue; + + GameType gameType; + try { gameType = (GameType)room.CustomProperties[PhotonBattleRoom.GameTypeKey]; } + catch { continue; } + + if (gameType != GameType.FriendLobby) continue; + + string invitedUserId = room.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeInvitedUserIdKey) + ? room.CustomProperties[PhotonBattleRoom.PremadeInvitedUserIdKey]?.ToString() + : string.Empty; + if (invitedUserId != localUserId) continue; + + int inviteState = PhotonBattleRoom.PremadeInviteStateNone; + if (room.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeInviteStateKey)) + { + try { inviteState = Convert.ToInt32(room.CustomProperties[PhotonBattleRoom.PremadeInviteStateKey]); } + catch { inviteState = PhotonBattleRoom.PremadeInviteStateNone; } + } + if (inviteState != PhotonBattleRoom.PremadeInviteStatePending) continue; + + long inviteTimestampMilliseconds = 0; + if (room.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeInviteTimestampKey)) + { + try { inviteTimestampMilliseconds = Convert.ToInt64(room.CustomProperties[PhotonBattleRoom.PremadeInviteTimestampKey]); } + catch { inviteTimestampMilliseconds = 0; } + } + + if (inviteTimestampMilliseconds > 0) + { + long nowMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (nowMilliseconds - inviteTimestampMilliseconds > (long)(InRoomInviteValiditySeconds * 1000f)) + { + continue; + } + } + + if (IsInRoomInviteDeclinedRecently(room.Name)) continue; + if (_pendingInRoomInviteRoomName == room.Name) continue; + + if (_lastAutoInviteRoomName == room.Name && Time.time - _lastAutoInviteJoinTime < InRoomInvitePromptThrottleSeconds) + { + continue; + } + + string leaderUserId = room.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeLeaderUserIdKey) + ? room.CustomProperties[PhotonBattleRoom.PremadeLeaderUserIdKey]?.ToString() + : string.Empty; + + GameType targetGameType = GameType.Random2v2; + if (room.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeTargetGameTypeKey)) + { + try { targetGameType = (GameType)Convert.ToInt32(room.CustomProperties[PhotonBattleRoom.PremadeTargetGameTypeKey]); } + catch { targetGameType = GameType.Random2v2; } + } + + InRoomInviteReceived inviteReceivedHandler = OnInRoomInviteReceived; + _lastAutoInviteRoomName = room.Name; + _lastAutoInviteJoinTime = Time.time; + + if (inviteReceivedHandler == null) + { + Debug.LogWarning($"Detected pending FriendLobby invite to room '{room.Name}', but no UI listener is active yet. Will retry shortly."); + break; + } + + _pendingInRoomInviteRoomName = room.Name; + Debug.Log($"Detected pending FriendLobby invite to room '{room.Name}', requesting decision from UI."); + try + { + inviteReceivedHandler.Invoke(new InRoomInviteInfo(room.Name, leaderUserId, invitedUserId, targetGameType)); + } + catch (Exception ex) + { + _pendingInRoomInviteRoomName = string.Empty; + Debug.LogError($"TryAutoJoinInRoomInvite: invite UI callback failed for room '{room.Name}': {ex.Message}"); + } + break; + } } + public void OnLeftLobby() { LobbyOnLeftLobby?.Invoke(); } public void OnLobbyStatisticsUpdate(List lobbyStatistics) { LobbyOnLobbyStatisticsUpdate?.Invoke(); } public void OnFriendListUpdate(List friendList) { @@ -3578,338 +7878,625 @@ public void OnCreateRoomFailed(short returnCode, string message) } public void OnJoinRoomFailed(short returnCode, string message) { - Debug.LogError($"JoinRoomFailed {returnCode} {message}"); - // Signal to any waiting matchmaking coroutine that a join attempt failed - _joinRoomFailed = true; - LobbyOnJoinRoomFailed?.Invoke(returnCode, message); + Debug.LogError($"JoinRoomFailed {returnCode} {message} (joinAttemptId={_joinAttemptTracker.LastIssuedAttemptId})"); + // Correlate this failure to the most recent join attempt + try { _joinAttemptTracker.MarkJoinAttemptFailure(0, returnCode, message); } catch { } + + bool failedInviteAcceptJoin = !string.IsNullOrEmpty(_pendingAcceptedInRoomInviteRoomName); + string failedInviteRoomName = _pendingAcceptedInRoomInviteRoomName; + + bool isGameFull = false; + try + { + if (!string.IsNullOrEmpty(message) && message.ToLower().Contains("game full")) isGameFull = true; + } + catch (Exception ex) + { + Debug.LogWarning($"OnJoinRoomFailed: failed to inspect message: {ex.Message}"); + } + if (!isGameFull && returnCode == 32765) isGameFull = true; + + if (isGameFull && !failedInviteAcceptJoin) + { + _joinFailureAutoRequeueInFlight = true; + } + + try + { + LobbyOnJoinRoomFailed?.Invoke(returnCode, message); + + if (failedInviteAcceptJoin) + { + _pendingAcceptedInRoomInviteRoomName = string.Empty; + _pendingAcceptedInRoomInviteStartTime = -100f; + Debug.LogWarning($"JoinRoomFailed during accepted InRoom invite join. Room='{failedInviteRoomName}', code={returnCode}, msg={message}"); + OnInRoomInviteJoinFailed?.Invoke(failedInviteRoomName, returnCode, message); + return; + } + + // If the failure is a full-game error, loop back to queue/requeue flow + if (isGameFull) + { + GameType requeueGameType = _currentMatchmakingGameType; + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + requeueGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + } + catch (Exception ex) { Debug.LogWarning($"OnJoinRoomFailed: failed to read current room game type: {ex.Message}"); } + + try + { + Debug.Log($"JoinRoomFailed: game full, requeueing for {requeueGameType}"); + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to start LeaveAndAutoRequeue after JoinRoomFailed: {ex.Message}"); + } + } + } + catch (Exception ex) { Debug.LogWarning($"OnJoinRoomFailed: unexpected error: {ex.Message}"); } + finally + { + _joinFailureAutoRequeueInFlight = false; + } + } + public void OnJoinRandomFailed(short returnCode, string message) { LobbyOnJoinRandomFailed?.Invoke(returnCode, message); } + + public void OnEvent(EventData photonEvent) + { + if(photonEvent.Code != 103) Debug.Log($"Received PhotonEvent {photonEvent.Code}"); + + switch (photonEvent.Code) + { + case PhotonRealtimeClient.PhotonEvent.CancelGameStart: + { + Debug.Log("Received CancelGameStart"); + // Parse optional requeue instruction: [bool requeue, int gameType] + bool requeueInstruction = false; + GameType requeueGameType = GameType.Random2v2; + try + { + if (photonEvent.CustomData is object[] arr && arr.Length > 0) + { + if (arr[0] is bool b) requeueInstruction = b; + if (arr.Length > 1 && arr[1] is int gi) requeueGameType = (GameType)gi; + } + else if (photonEvent.CustomData is PhotonHashtable pht) + { + if (pht.ContainsKey("requeue")) requeueInstruction = (bool)pht["requeue"]; + if (pht.ContainsKey("gameType")) requeueGameType = (GameType)(int)pht["gameType"]; + } + } + catch { } + // Ensure any local start coroutine is stopped + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } + if (_startQuantumHolder != null) + { + StopCoroutine(_startQuantumHolder); + _startQuantumHolder = null; + } + + // Record cancel time so rejoins shortly after can trigger leader-led requeue + try { _lastStartCancelTime = Time.time; } catch { } + + // Clear any return-to-main flag; keep players in-room and restore pre-countdown UI. + _returnToMainMenuOnMatchmakingRejoin = false; + OnGameStartCancelled?.Invoke(); + // Also signal countdown listeners with a sentinel value so older UI code can cancel + // clear countdown active flag and notify listeners + _countdownActive = false; + _matchHasStartedInCurrentRoom = false; + OnGameCountdownUpdate?.Invoke(-1); + + try + { + StopMatchmakingCoroutines(); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to stop matchmaking coroutines: {ex.Message}"); + } + + // Non-master clients: either return to LobbyRoom or leave and requeue if instructed + if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + // If this is a Custom game, do not honour requeue instructions that force clients to leave. + bool isCustomRoom = false; + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + isCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; + } + } + catch { } + + if (requeueInstruction) + { + if (!isCustomRoom) + { + StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + } + else + { + Debug.Log("CancelGameStart: requeue requested but current room is Custom; staying in room."); + } + } + else + { + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); + } + } + + // Clear BattleID so cancelled start does not leave stale room state + try + { + if (CanMutateRoomPropertiesNow("CancelGameStart: clear BattleID", true) + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID)) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable { { PhotonBattleRoom.BattleID, "" } }); + } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to clear BattleID on CancelGameStart: {ex.Message}"); + } + + break; + } + case PhotonRealtimeClient.PhotonEvent.GameCountdown: + int countdown = (int)photonEvent.CustomData; + // Track countdown active state: >0 means active, <=0 or -1 means inactive/cancel + _countdownActive = countdown > 0; + if (countdown > 0) + { + _gamePlayedOut = false; + _lastCountdownStartTime = Time.time; + // Countdown started: reset auto-requeue attempts and stop any join watcher + _autoRequeueAttempts = 0; + try { if (_joinTimeoutWatcherHolder != null) { StopCoroutine(_joinTimeoutWatcherHolder); _joinTimeoutWatcherHolder = null; } } catch { } + } + OnGameCountdownUpdate?.Invoke(countdown); + break; + case PhotonRealtimeClient.PhotonEvent.StartGame: + // ByteArraySlice.Buffer may contain extra unused bytes beyond the actual data, + // so we must copy only the valid portion (Offset to Offset+Count) to avoid corrupt deserialization. + byte[] byteArray; + if (photonEvent.CustomData is byte[] directBytes) + { + byteArray = directBytes; + } + else if (photonEvent.CustomData is ByteArraySlice slice) + { + byteArray = new byte[slice.Count]; + System.Buffer.BlockCopy(slice.Buffer, slice.Offset, byteArray, 0, slice.Count); + } + else + { + Debug.LogError($"StartGame event received with unexpected data type: {photonEvent.CustomData?.GetType()}"); + break; + } + var startData = StartGameData.Deserialize(byteArray); + // Starting game clears countdown active flag + _countdownActive = false; + _gamePlayedOut = false; + _matchHasStartedInCurrentRoom = true; + + // Defensive check: if in matchmaking and any expected real player is missing, abort start. + if (PhotonRealtimeClient.InMatchmakingRoom) + { + bool missing = false; + foreach (string uid in startData.PlayerSlotUserIds) + { + if (string.IsNullOrEmpty(uid) || uid == "Bot") continue; + bool present = PhotonRealtimeClient.CurrentRoom?.Players?.Values?.Any(p => p.UserId == uid) ?? false; + if (!present) + { + missing = true; + Debug.LogWarning($"Received StartGame but player {uid} missing; aborting start."); + break; + } + } + + if (missing) + { + _matchHasStartedInCurrentRoom = false; + OnGameStartCancelled?.Invoke(); + + if (PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + { + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + } + + break; + } + } + + // Start the client-side StartQuantum coroutine and keep a holder so we can stop it if needed + if (_startQuantumHolder != null) + { + StopCoroutine(_startQuantumHolder); + _startQuantumHolder = null; + } + _startQuantumHolder = StartCoroutine(StartQuantum(startData)); + break; + case PhotonRealtimeClient.PhotonEvent.PlayerPositionChangeRequested: + int position = (int)photonEvent.CustomData; + Player player = PhotonRealtimeClient.CurrentRoom.GetPlayer(photonEvent.Sender); + if (player != null) + { + if (!_posChangeQueue.Contains(player.UserId)) StartCoroutine(SetPlayer(player, position)); + else Debug.LogError($"Player {photonEvent.Sender} pos change already queued."); + } + else Debug.LogError($"Player {photonEvent.Sender} not found in room"); + + QueueCustomBattleStartCheck(); + break; + + case PhotonRealtimeClient.PhotonEvent.RoomChangeRequested: + { + // Payload can be either a leaderUserId string, or an object[] { leaderUserId, expectedUsers[] } + string leaderUserId = string.Empty; + string[] expectedUsers = null; + string leaderRoomName = null; + try + { + // Local helper flattened here so all branches in this try can use it. + string[] FlattenExpected(object obj) + { + var list = new List(); + if (obj == null) return null; + try + { + if (obj is string ss) + { + if (!string.IsNullOrEmpty(ss)) list.Add(ss); + return list.ToArray(); + } + if (obj is string[] ssarr) + { + return ssarr.Where(x => !string.IsNullOrEmpty(x)).ToArray(); + } + if (obj is object[] oarr) + { + foreach (var o in oarr) + { + var sub = FlattenExpected(o); + if (sub != null && sub.Length > 0) list.AddRange(sub); + } + return list.Distinct().Where(x => !string.IsNullOrEmpty(x)).ToArray(); + } + if (obj is System.Collections.IEnumerable ie) + { + foreach (var o in ie) + { + var sub = FlattenExpected(o); + if (sub != null && sub.Length > 0) list.AddRange(sub); + } + return list.Distinct().Where(x => !string.IsNullOrEmpty(x)).ToArray(); + } + // Fallback: use ToString + string s = obj.ToString(); + if (!string.IsNullOrEmpty(s)) list.Add(s); + return list.Distinct().Where(x => !string.IsNullOrEmpty(x)).ToArray(); + } + catch { return null; } + } - // If the failure is a full-game error, loop back to queue/requeue flow - try - { - bool isGameFull = returnCode == ErrorCode.GameFull; - if (!isGameFull) - { + if (photonEvent.CustomData is object[] arr && arr.Length > 0) + { + leaderUserId = arr[0]?.ToString() ?? string.Empty; + + // Robustly handle multiple payload shapes for expected users. + // arr[1] may be a string[], an object[] of strings, a nested array, or even a single string when only one expected user. + object maybeExpected = arr.Length > 1 ? arr[1] : null; + + expectedUsers = FlattenExpected(maybeExpected); + + if (arr.Length > 2 && arr[2] is string rn) + { + // optional room name provided by leader + leaderRoomName = rn; + } + } + else if (photonEvent.CustomData is string s) + { + leaderUserId = s; + } + else if (photonEvent.CustomData is PhotonHashtable pht) + { + if (pht.ContainsKey("leader")) leaderUserId = pht["leader"].ToString(); + if (pht.ContainsKey("expectedUsers")) + { + expectedUsers = FlattenExpected(pht["expectedUsers"]); + } + } + } + catch { } + + string matchmakingLeaderId = string.Empty; + bool targetedByExpectedUsers = false; try { - if (!string.IsNullOrEmpty(message) && message.IndexOf("game full", StringComparison.OrdinalIgnoreCase) >= 0) + if (expectedUsers != null && expectedUsers.Length > 0) + { + string localId = PhotonRealtimeClient.LocalPlayer?.UserId; + targetedByExpectedUsers = !string.IsNullOrEmpty(localId) && expectedUsers.Contains(localId); + } + + if (!targetedByExpectedUsers) { - isGameFull = true; + Room currentRoom = PhotonRealtimeClient.CurrentRoom; + string localId = PhotonRealtimeClient.LocalPlayer?.UserId; + if (currentRoom != null + && currentRoom.CustomProperties != null + && currentRoom.GetCustomProperty(PhotonBattleRoom.PremadeModeKey, false) + && !string.IsNullOrEmpty(localId) + && !string.IsNullOrEmpty(leaderUserId)) + { + string premadeUserId1 = currentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, string.Empty); + string premadeUserId2 = currentRoom.GetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + targetedByExpectedUsers = (string.Equals(localId, premadeUserId1, StringComparison.Ordinal) && string.Equals(leaderUserId, premadeUserId2, StringComparison.Ordinal)) + || (string.Equals(localId, premadeUserId2, StringComparison.Ordinal) && string.Equals(leaderUserId, premadeUserId1, StringComparison.Ordinal)); + } } } - catch (Exception ex) + catch { } + + // If room is not a matchmaking room the person sending the event is the leader. + if (!PhotonRealtimeClient.InMatchmakingRoom) { - Debug.LogWarning($"OnJoinRoomFailed: failed to inspect message: {ex.Message}"); + if (!string.IsNullOrEmpty(leaderUserId)) + { + _matchHasStartedInCurrentRoom = false; + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + bool localIsLeader = !string.IsNullOrEmpty(localUserId) && localUserId == leaderUserId; + bool isQueueRoomForLeaderUpdate = false; + try + { + var curr = PhotonRealtimeClient.CurrentRoom; + if (curr != null && curr.CustomProperties != null && curr.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (curr.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) + { + isQueueRoomForLeaderUpdate = true; + } + } + catch { } + + bool shouldUpdateLeaderId = localIsLeader || (targetedByExpectedUsers && !isQueueRoomForLeaderUpdate); + if (shouldUpdateLeaderId) + { + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, leaderUserId); + try { OnRoomLeaderChanged?.Invoke(leaderUserId == PhotonRealtimeClient.LocalPlayer.UserId); } catch { } + matchmakingLeaderId = leaderUserId; + } + else if (isQueueRoomForLeaderUpdate && targetedByExpectedUsers) + { + Debug.Log("RoomChangeRequested: preserving local follower LeaderIdKey during queue-room handoff; using transient matchmaking leader only."); + } + } } - } - if (isGameFull) - { - GameType requeueGameType = _currentMatchmakingGameType; + if (string.IsNullOrEmpty(matchmakingLeaderId)) + { + matchmakingLeaderId = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.LeaderIdKey, string.Empty); + } + + Debug.Log($"RoomChangeRequested parsed: leaderUserId={leaderUserId}, matchmakingLeaderId={matchmakingLeaderId}, expectedUsersCount={(expectedUsers?.Length ?? 0)}, leaderRoomName={leaderRoomName}"); + + // Do not follow leader to another room in Custom game mode. + bool isCustomRoom = false; try { - if (TryGetRoomGameType(PhotonRealtimeClient.CurrentRoom, out GameType currentGameType)) + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) { - requeueGameType = currentGameType; + isCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; } } - catch (Exception ex) { Debug.LogWarning($"OnJoinRoomFailed: failed to read current room game type: {ex.Message}"); } + catch { } + // If expectedUsers is provided, only follow if local user is in the list + bool shouldFollow = true; try { - Debug.Log($"JoinRoomFailed: game full, requeueing for {requeueGameType}"); - StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + if (expectedUsers != null && expectedUsers.Length > 0) + { + shouldFollow = targetedByExpectedUsers; + } } - catch (Exception ex) + catch { } + + // If current room is a queue room and leaving is disabled for testing, do not follow leader + bool isQueueRoom = false; + try { - Debug.LogWarning($"Failed to start LeaveAndAutoRequeue after JoinRoomFailed: {ex.Message}"); + var curr = PhotonRealtimeClient.CurrentRoom; + if (curr != null && curr.CustomProperties != null && curr.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (curr.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool qb && qb)) + { + isQueueRoom = true; + } } - } - } - catch (Exception ex) { Debug.LogWarning($"OnJoinRoomFailed: unexpected error: {ex.Message}"); } - } - public void OnJoinRandomFailed(short returnCode, string message) { LobbyOnJoinRandomFailed?.Invoke(returnCode, message); } - - public void OnEvent(EventData photonEvent) - { - if (photonEvent.Code != 103) Debug.Log($"Received PhotonEvent {photonEvent.Code}"); - - switch (photonEvent.Code) - { - case PhotonRealtimeClient.PhotonEvent.CancelGameStart: - HandleCancelGameStartEvent(photonEvent); - break; - case PhotonRealtimeClient.PhotonEvent.GameCountdown: - HandleGameCountdownEvent(photonEvent); - break; - case PhotonRealtimeClient.PhotonEvent.StartGame: - HandleStartGameEvent(photonEvent); - break; - case PhotonRealtimeClient.PhotonEvent.PlayerPositionChangeRequested: - HandlePlayerPositionChangeRequestedEvent(photonEvent); - break; - case PhotonRealtimeClient.PhotonEvent.RoomChangeRequested: - HandleRoomChangeRequestedEvent(photonEvent); - break; - } - - LobbyOnEvent?.Invoke(); - } - - private void HandleCancelGameStartEvent(EventData photonEvent) - { - Debug.Log("Received CancelGameStart"); - - // Parse optional requeue instruction: [bool requeue, int gameType] - bool requeueInstruction = false; - GameType requeueGameType = GameType.Random2v2; - try - { - if (photonEvent.CustomData is object[] arr && arr.Length > 0) - { - if (arr[0] is bool b) requeueInstruction = b; - if (arr.Length > 1 && arr[1] is int gameTypeInt) requeueGameType = (GameType)gameTypeInt; - } - else if (photonEvent.CustomData is PhotonHashtable table) - { - if (table.ContainsKey("requeue")) requeueInstruction = (bool)table["requeue"]; - if (table.ContainsKey("gameType")) requeueGameType = (GameType)(int)table["gameType"]; - } - } - catch { } - - SafeStopCoroutine(ref _startGameHolder); - SafeStopCoroutine(ref _startQuantumHolder); + catch { } - try { _lastStartCancelTime = Time.time; } catch { } + bool leaderMatch = leaderUserId == matchmakingLeaderId; + bool hasExplicitLeaderRoom = !string.IsNullOrEmpty(leaderRoomName); + bool hasExpectedUsers = expectedUsers != null && expectedUsers.Length > 0; + bool alreadyInQueueFormedExpectedUserFlow = IsInQueueFormedExpectedUserMatchmakingFlow(); + bool isQueueLeaderHandoff = isQueueRoom + && hasExplicitLeaderRoom + && hasExpectedUsers + && !string.IsNullOrEmpty(leaderUserId) + && leaderRoomName.StartsWith("Queue_", StringComparison.Ordinal); + + if (isQueueLeaderHandoff) + { + _queuePendingLeaderUntil[leaderUserId] = Time.time + QueuePendingLeaderGraceSeconds; + int pendingExpectedAdded = 0; + try + { + HashSet presentQueueUsers = new( + PhotonRealtimeClient.CurrentRoom.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); - _returnToMainMenuOnMatchmakingRejoin = false; - OnGameStartCancelled?.Invoke(); - _countdownActive = false; - OnGameCountdownUpdate?.Invoke(-1); + foreach (string expectedUserId in expectedUsers) + { + if (string.IsNullOrEmpty(expectedUserId) || expectedUserId == leaderUserId) continue; + if (presentQueueUsers.Contains(expectedUserId)) continue; - try - { - StopMatchmakingCoroutines(); - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to stop matchmaking coroutines: {ex.Message}"); - } + _queuePendingExpectedUserUntil[expectedUserId] = Time.time + QueuePendingLeaderGraceSeconds; + pendingExpectedAdded++; + } + } + catch (Exception ex) + { + Debug.LogWarning($"RoomChangeRequested: failed to record pending expected queue users: {ex.Message}"); + } - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - bool isCustomRoom = false; - try - { - isCustomRoom = IsCustomRoom(PhotonRealtimeClient.CurrentRoom); - } - catch { } + Debug.Log($"RoomChangeRequested: recorded pending queue duo handoff for leader {leaderUserId} (expectedUsers={expectedUsers.Length}, grace={QueuePendingLeaderGraceSeconds}s)."); + if (pendingExpectedAdded > 0) + { + Debug.Log($"RoomChangeRequested: recorded {pendingExpectedAdded} unresolved expected queue users for pending duo handoff."); + } + } - if (requeueInstruction) - { - if (!isCustomRoom) + string[] followExpectedUsers = null; + if (hasExpectedUsers && shouldFollow) { - StartCoroutine(LeaveAndAutoRequeue(requeueGameType)); + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + followExpectedUsers = expectedUsers + .Where(uid => !string.IsNullOrEmpty(uid) && uid != localUserId) + .Distinct(StringComparer.Ordinal) + .ToArray(); } - else + bool leaderOnlyNoRoomFallback = !hasExplicitLeaderRoom && !hasExpectedUsers; + + bool alreadyInExplicitLeaderRoom = false; + if (hasExplicitLeaderRoom) { - Debug.Log("CancelGameStart: requeue requested but current room is Custom; staying in room."); + try + { + alreadyInExplicitLeaderRoom = PhotonRealtimeClient.InRoom + && PhotonRealtimeClient.CurrentRoom != null + && PhotonRealtimeClient.CurrentRoom.Name == leaderRoomName; + } + catch { } } - } - else - { - OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); - } - } - - try - { - if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID)) - { - PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable { { PhotonBattleRoom.BattleID, "" } }); - } - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to clear BattleID on CancelGameStart: {ex.Message}"); - } - } - - private void HandleGameCountdownEvent(EventData photonEvent) - { - int countdown = (int)photonEvent.CustomData; - _countdownActive = countdown > 0; - if (countdown > 0) - { - _gamePlayedOut = false; - _autoRequeueAttempts = 0; - try { SafeStopCoroutine(ref _joinTimeoutWatcherHolder); } catch { } - } - - OnGameCountdownUpdate?.Invoke(countdown); - } - - private void HandleStartGameEvent(EventData photonEvent) - { - // ByteArraySlice.Buffer may contain extra unused bytes beyond the actual data, - // so we must copy only the valid portion (Offset to Offset+Count) to avoid corrupt deserialization. - byte[] byteArray; - if (photonEvent.CustomData is byte[] directBytes) - { - byteArray = directBytes; - } - else if (photonEvent.CustomData is ByteArraySlice slice) - { - byteArray = new byte[slice.Count]; - System.Buffer.BlockCopy(slice.Buffer, slice.Offset, byteArray, 0, slice.Count); - } - else - { - Debug.LogError($"StartGame event received with unexpected data type: {photonEvent.CustomData?.GetType()}"); - return; - } - StartGameData startData = StartGameData.Deserialize(byteArray); - _countdownActive = false; - _gamePlayedOut = false; + Debug.Log($"RoomChangeRequested decision: isCustomRoom={isCustomRoom}, followHolderNull={_followLeaderHolder==null}, leaderMatch={leaderMatch}, shouldFollow={shouldFollow}, hasExplicitLeaderRoom={hasExplicitLeaderRoom}, hasExpectedUsers={hasExpectedUsers}"); - // Defensive check: if in matchmaking and any expected real player is missing, abort start. - if (IsMatchmakingRoom()) - { - bool missing = false; - foreach (string uid in startData.PlayerSlotUserIds) - { - if (string.IsNullOrEmpty(uid) || uid == "Bot") continue; - bool present = PhotonRealtimeClient.CurrentRoom?.Players?.Values?.Any(p => p.UserId == uid) ?? false; - if (!present) + if (!isCustomRoom && leaderOnlyNoRoomFallback && PhotonRealtimeClient.InMatchmakingRoom) { - missing = true; - Debug.LogWarning($"Received StartGame but player {uid} missing; aborting start."); + Debug.Log("RoomChangeRequested: ignoring leader-only no-room handoff while already in matchmaking room."); break; } - } - if (missing) - { - OnGameStartCancelled?.Invoke(); - - if (PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + if (!isCustomRoom && shouldFollow && hasExpectedUsers && !hasExplicitLeaderRoom && alreadyInQueueFormedExpectedUserFlow) { - SafeStopCoroutine(ref _matchmakingHolder); - _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + Debug.Log("RoomChangeRequested: ignoring targeted no-room handoff while already in queue-formed expected-user matchmaking flow."); + break; } - return; - } - } + // Targeted handoff without explicit room is queue pre-notify. + // Start follow flow only from queue rooms; if already in matchmaking room, + // treat it as stale duplicate and ignore to avoid leave cascades. + if (!isCustomRoom && shouldFollow && hasExpectedUsers && !hasExplicitLeaderRoom) + { + if (isQueueRoom) + { + if (_followLeaderHolder != null) + { + try { StopCoroutine(_followLeaderHolder); } catch { } + _followLeaderHolder = null; + Debug.Log("RoomChangeRequested: replacing stale follow coroutine for targeted queue pre-notify handoff."); + } - SafeStopCoroutine(ref _startQuantumHolder); - _startQuantumHolder = StartCoroutine(StartQuantum(startData)); - } + // Record pending leader handoff for targeted queue pre-notify (no explicit room name) + try + { + _queuePendingLeaderUntil[leaderUserId] = Time.time + QueuePendingLeaderGraceSeconds; + int pendingExpectedAdded = 0; + HashSet presentQueueUsers = new( + PhotonRealtimeClient.CurrentRoom.Players.Values + .Where(p => p != null && !string.IsNullOrEmpty(p.UserId) && p.UserId != "Bot") + .Select(p => p.UserId), + StringComparer.Ordinal); + + if (expectedUsers != null) + { + foreach (string expectedUserId in expectedUsers) + { + if (string.IsNullOrEmpty(expectedUserId) || expectedUserId == leaderUserId) continue; + if (presentQueueUsers.Contains(expectedUserId)) continue; - private void HandlePlayerPositionChangeRequestedEvent(EventData photonEvent) - { - int position = (int)photonEvent.CustomData; - Player player = PhotonRealtimeClient.CurrentRoom.GetPlayer(photonEvent.Sender); - if (player != null) - { - if (!_posChangeQueue.Contains(player.UserId)) StartCoroutine(SetPlayer(player, position)); - else Debug.LogError($"Player {photonEvent.Sender} pos change already queued."); - } - else - { - Debug.LogError($"Player {photonEvent.Sender} not found in room"); - } + _queuePendingExpectedUserUntil[expectedUserId] = Time.time + QueuePendingLeaderGraceSeconds; + pendingExpectedAdded++; + } + } - QueueCustomBattleStartCheck(); - } + Debug.Log($"RoomChangeRequested: recorded pending queue duo handoff for leader {leaderUserId} (expectedUsers={(expectedUsers?.Length ?? 0)}, grace={QueuePendingLeaderGraceSeconds}s)."); + if (pendingExpectedAdded > 0) + { + Debug.Log($"RoomChangeRequested: recorded {pendingExpectedAdded} unresolved expected queue users for pending duo handoff."); + } + } + catch (Exception ex) + { + Debug.LogWarning($"RoomChangeRequested: failed to record pending expected queue users for targeted pre-notify: {ex.Message}"); + } - private void HandleRoomChangeRequestedEvent(EventData photonEvent) - { - // Payload can be either a leaderUserId string, or an object[] { leaderUserId, expectedUsers[] } - string leaderUserId = string.Empty; - string[] expectedUsers = null; - string leaderRoomName = null; - try - { - if (photonEvent.CustomData is object[] payloadArray && payloadArray.Length > 0) - { - if (payloadArray[0] is string leaderId) leaderUserId = leaderId; - if (payloadArray.Length > 1 && payloadArray[1] is object[] expectedUsersArray) - { - expectedUsers = expectedUsersArray.Select(o => o?.ToString()).Where(x => !string.IsNullOrEmpty(x)).ToArray(); + Debug.Log("RoomChangeRequested: targeted queue pre-notify without explicit room name, starting immediate follow flow."); + _followLeaderHolder = StartCoroutine(FollowLeaderToNewRoom(leaderUserId, null, followExpectedUsers)); + } + else + { + Debug.Log("RoomChangeRequested: ignored targeted no-room handoff outside queue room (stale duplicate)."); + } } - else if (payloadArray.Length > 1 && payloadArray[1] is string[] expectedUsersStringArray) + else if (!isCustomRoom && shouldFollow && (hasExplicitLeaderRoom || leaderMatch)) { - expectedUsers = expectedUsersStringArray; - } + if (alreadyInExplicitLeaderRoom) + { + Debug.Log($"RoomChangeRequested: already in explicit leader room '{leaderRoomName}', ignoring duplicate handoff."); + break; + } - if (payloadArray.Length > 2 && payloadArray[2] is string roomName) - { - leaderRoomName = roomName; + // Explicit room handoff has highest priority; replace any stale follow coroutine. + if (hasExplicitLeaderRoom && _followLeaderHolder != null) + { + try { StopCoroutine(_followLeaderHolder); } catch { } + _followLeaderHolder = null; + } + + if (_followLeaderHolder == null) + { + if (hasExplicitLeaderRoom) _followLeaderHolder = StartCoroutine(FollowLeaderToNewRoom(leaderUserId, leaderRoomName, followExpectedUsers)); + else _followLeaderHolder = StartCoroutine(FollowLeaderToNewRoom(leaderUserId, null, followExpectedUsers)); + } } - } - else if (photonEvent.CustomData is string leaderId) - { - leaderUserId = leaderId; - } - else if (photonEvent.CustomData is PhotonHashtable pht) - { - if (pht.ContainsKey("leader")) leaderUserId = pht["leader"].ToString(); - if (pht.ContainsKey("expectedUsers") && pht["expectedUsers"] is object[] uo) + else if (isCustomRoom) { - expectedUsers = uo.Select(o => o?.ToString()).Where(x => !string.IsNullOrEmpty(x)).ToArray(); + Debug.Log("RoomChangeRequested ignored: current room is Custom mode."); } + break; } } - catch { } - - string matchmakingLeaderId = string.Empty; - if (!IsMatchmakingRoom()) - { - if (!string.IsNullOrEmpty(leaderUserId)) - { - PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, leaderUserId); - try { OnRoomLeaderChanged?.Invoke(leaderUserId == PhotonRealtimeClient.LocalPlayer.UserId); } catch { } - matchmakingLeaderId = leaderUserId; - } - } - - if (string.IsNullOrEmpty(matchmakingLeaderId)) - { - matchmakingLeaderId = PhotonRealtimeClient.LocalPlayer.GetCustomProperty(PhotonBattleRoom.LeaderIdKey, string.Empty); - } - - Debug.Log($"RoomChangeRequested parsed: leaderUserId={leaderUserId}, matchmakingLeaderId={matchmakingLeaderId}, expectedUsersCount={(expectedUsers?.Length ?? 0)}, leaderRoomName={leaderRoomName}"); - - bool isCustomRoom = false; - try - { - isCustomRoom = IsCustomRoom(PhotonRealtimeClient.CurrentRoom); - } - catch { } - - bool shouldFollow = true; - try - { - if (expectedUsers != null && expectedUsers.Length > 0) - { - string localId = PhotonRealtimeClient.LocalPlayer?.UserId; - shouldFollow = !string.IsNullOrEmpty(localId) && expectedUsers.Contains(localId); - } - } - catch { } - - Debug.Log($"RoomChangeRequested decision: isCustomRoom={isCustomRoom}, followHolderNull={_followLeaderHolder == null}, leaderMatch={leaderUserId == matchmakingLeaderId}, shouldFollow={shouldFollow}"); - if (!isCustomRoom && _followLeaderHolder == null && leaderUserId == matchmakingLeaderId && shouldFollow) - { - if (!string.IsNullOrEmpty(leaderRoomName)) _followLeaderHolder = StartCoroutine(FollowLeaderToNewRoom(leaderUserId, leaderRoomName)); - else _followLeaderHolder = StartCoroutine(FollowLeaderToNewRoom(leaderUserId)); - } - else if (isCustomRoom) - { - Debug.Log("RoomChangeRequested ignored: current room is Custom mode."); - } + LobbyOnEvent?.Invoke(); } public void OnConnected() { LobbyOnConnected?.Invoke(); } @@ -3929,166 +8516,131 @@ public void OnPlayerEnteredRoom(Player newPlayer) Room room = PhotonRealtimeClient.CurrentRoom; int playerCount = room.PlayerCount; int botCount = PhotonBattleRoom.GetBotCount(); - TryFormQueueMatch(room, "Queue"); - - if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - HandleMasterPlayerEnteredRoom(room, playerCount, botCount); - } - - if (playerCount + botCount <= room.MaxPlayers) LobbyOnPlayerEnteredRoom?.Invoke(new(newPlayer)); - - // If this is a queue room, stop further matchmaking/start processing here. - if (IsQueueRoomNoThrow(room)) return; - } - private void TryFormQueueMatch(Room room, string logPrefix) - { - // If this room is a queue room, let master form matches of 4 players. try { - if (!IsQueueRoom(room) || !PhotonRealtimeClient.LocalPlayer.IsMasterClient) - { - return; - } - - // Count only real players - var realPlayers = room.Players.Values.Where(p => p.UserId != null).ToList(); - if (realPlayers.Count < 4) + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey) + && (GameType)room.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.FriendLobby) { - return; - } - - // Select first 4 players to form a match - var selected = realPlayers.Take(4).Select(p => p.UserId).ToArray(); - Debug.Log($"{logPrefix}: forming match for users: {string.Join(",", selected)}"); - - int roomGameTypeInt = (int)GetRoomType(room); - string clanName = string.Empty; - if (room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.ClanNameKey)) clanName = room.CustomProperties[PhotonBattleRoom.ClanNameKey]?.ToString() ?? string.Empty; - int soulhomeRank = -1; - if (room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.SoulhomeRank)) soulhomeRank = (int)room.CustomProperties[PhotonBattleRoom.SoulhomeRank]; + string invitedUserId = room.GetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, string.Empty); + if (!string.IsNullOrEmpty(invitedUserId) && newPlayer != null && newPlayer.UserId == invitedUserId) + { + room.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + room.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, invitedUserId); + } - if (_formingMatchHolder == null) - { - _formingMatchHolder = StartCoroutine(FormMatchFromQueue(selected, roomGameTypeInt, clanName, soulhomeRank)); - } - else - { - Debug.Log($"{logPrefix}: already forming a match, skipping duplicate request."); + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient && room.PlayerCount >= room.MaxPlayers) + { + room.IsOpen = false; + } } } - catch (Exception ex) - { - Debug.LogWarning($"{logPrefix}: failed to enqueue match formation: {ex.Message}"); - } - } - - private void HandleMasterPlayerEnteredRoom(Room room, int playerCount, int botCount) - { - if (playerCount + botCount == room.MaxPlayers && room.IsOpen) PhotonRealtimeClient.CloseRoom(); - - QueueCustomBattleStartCheck(); - - // Ensure master continues matchmaking loop so countdowns can be restarted when new players join. - if (IsMatchmakingRoom() && _matchmakingHolder == null) - { - _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); - } - - // If a start was cancelled recently, trigger leader-led room change. - TryRaiseRoomChangeAfterRecentCancel(); - } + catch (Exception ex) { Debug.LogWarning($"OnPlayerEnteredRoom: failed to update FriendLobby premade state: {ex.Message}"); } - private void TryRaiseRoomChangeAfterRecentCancel() - { + // Queue match formation is centralized in QueueTimerCoroutine. try { - if (!PhotonRealtimeClient.LocalPlayer.IsMasterClient || !IsMatchmakingRoom() || Time.time - _lastStartCancelTime >= 15f) + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (bool)room.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) { - return; + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) + { + StartQueueTimer(); + } } - - bool isCustomRoom = false; - try + } + catch { } + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - isCustomRoom = IsCustomRoom(PhotonRealtimeClient.CurrentRoom); - } - catch { } + if (playerCount + botCount == room.MaxPlayers && room.IsOpen) PhotonRealtimeClient.CloseRoom(); - if (!isCustomRoom) - { - try + QueueCustomBattleStartCheck(); + + // Ensure master continues matchmaking loop so countdowns can be restarted when new players join + if (PhotonRealtimeClient.InMatchmakingRoom && _matchmakingHolder == null) { - PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, PhotonRealtimeClient.LocalPlayer.UserId); - OnRoomLeaderChanged?.Invoke(true); + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); } - catch { } - - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, - PhotonRealtimeClient.LocalPlayer.UserId, - new RaiseEventArgs { Receivers = ReceiverGroup.Others }, - SendOptions.SendReliable - ); + // If a start was cancelled recently trigger leader-led room change + try + { + if (PhotonRealtimeClient.LocalPlayer.IsMasterClient && PhotonRealtimeClient.InMatchmakingRoom && Time.time - _lastStartCancelTime < 15f) + { + bool isCustomRoom = false; + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + isCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; + } + } + catch { } - // prevent immediate repeated broadcasts - _lastStartCancelTime = -100f; + if (!isCustomRoom) + { + try { PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, PhotonRealtimeClient.LocalPlayer.UserId); OnRoomLeaderChanged?.Invoke(true); } catch { } + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, + PhotonRealtimeClient.LocalPlayer.UserId, + new RaiseEventArgs { Receivers = ReceiverGroup.Others }, + SendOptions.SendReliable + ); + // prevent immediate repeated broadcasts + _lastStartCancelTime = -100f; + } + } } + catch { } } - catch { } - } + if (playerCount + botCount <= room.MaxPlayers) LobbyOnPlayerEnteredRoom?.Invoke(new(newPlayer)); - private static bool IsQueueRoomNoThrow(Room room) - { + // If this is a queue room, stop further matchmaking/start processing here. try { - return IsQueueRoom(room); - } - catch - { - return false; + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (room.CustomProperties[PhotonBattleRoom.IsQueueKey] is bool b && b)) + { + return; + } } + catch { } } public void OnRoomPropertiesUpdate(PhotonHashtable propertiesThatChanged) { LobbyOnRoomPropertiesUpdate?.Invoke(new(propertiesThatChanged)); } public void OnPlayerPropertiesUpdate(Player targetPlayer, PhotonHashtable changedProps) { LobbyOnPlayerPropertiesUpdate?.Invoke(new(targetPlayer),new(changedProps)); } - public void OnMasterClientSwitched(Player newMasterClient) - { + public void OnMasterClientSwitched(Player newMasterClient) { LobbyOnMasterClientSwitched?.Invoke(new(newMasterClient)); - HandleMasterSwitchLocalCountdownCancel(); - HandleMasterSwitchRequeueRecovery(); - HandleMasterSwitchQueueTimer(); - ClearBattleIdOnMasterSwitch(); - HandleMatchmakingAfterMasterSwitch(newMasterClient); - HandleVerifyLoopAfterMasterSwitch(); - - // New master should also clean up any stale player position keys left in room properties - ClearStalePlayerPositionKeys(PhotonRealtimeClient.CurrentRoom, "OnMasterClientSwitched"); - } + bool currentRoomIsQueue = false; + try + { + Room room = PhotonRealtimeClient.CurrentRoom; + currentRoomIsQueue = room != null + && room.CustomProperties != null + && room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) + && room.GetCustomProperty(PhotonBattleRoom.IsQueueKey); + } + catch { } - private void HandleMasterSwitchLocalCountdownCancel() - { - SafeStopCoroutine(ref _startGameHolder); + // Cancel any in-progress countdown locally when master changes (previous master might have started it) + if (_startGameHolder != null) + { + StopCoroutine(_startGameHolder); + _startGameHolder = null; + } OnGameStartCancelled?.Invoke(); - } - private void HandleMasterSwitchRequeueRecovery() - { - // If the master left while a countdown was in progress (indicated by BattleID present), + // If the master left while a local countdown/start transition is in progress, // non-master clients may not have executed the OnPlayerLeftRoom requeue path because - // master switch can clear BattleID. Ensure clients still perform cancel+requeue here. + // the switch can race with room/property updates. Ensure clients still perform cancel+requeue here. try { - Room room = PhotonRealtimeClient.CurrentRoom; - bool wasStarting = false; - try { wasStarting = room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID) && !string.IsNullOrEmpty(room.GetCustomProperty(PhotonBattleRoom.BattleID)); } catch { } - if ((wasStarting || _countdownActive) && IsMatchmakingRoom() && !PhotonRealtimeClient.LocalPlayer.IsMasterClient) + var room = PhotonRealtimeClient.CurrentRoom; + bool wasStarting = !_matchHasStartedInCurrentRoom && IsGameStartTransitionActive(); + if (wasStarting && PhotonRealtimeClient.InMatchmakingRoom && !currentRoomIsQueue && !PhotonRealtimeClient.LocalPlayer.IsMasterClient) { // Mirror CancelGameStart handling with requeue=true for non-master clients _lastStartCancelTime = Time.time; - try { SafeStopCoroutine(ref _startGameHolder); } catch { } - try { SafeStopCoroutine(ref _startQuantumHolder); } catch { } + try { if (_startGameHolder != null) { StopCoroutine(_startGameHolder); _startGameHolder = null; } } catch { } + try { if (_startQuantumHolder != null) { StopCoroutine(_startQuantumHolder); _startQuantumHolder = null; } } catch { } try { StopMatchmakingCoroutines(); } catch { } OnGameStartCancelled?.Invoke(); @@ -4096,7 +8648,10 @@ private void HandleMasterSwitchRequeueRecovery() GameType roomGameType = GameType.Random2v2; try { - roomGameType = GetRoomType(room); + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + roomGameType = (GameType)room.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } } catch { } @@ -4110,22 +8665,24 @@ private void HandleMasterSwitchRequeueRecovery() try { OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.LobbyRoom); } catch { } } } - // If this client became the new master while a countdown was active, // take over leader-led requeue (create new matchmaking room) so players follow. - if ((wasStarting || _countdownActive) && IsMatchmakingRoom() && PhotonRealtimeClient.LocalPlayer.IsMasterClient) + if (wasStarting && PhotonRealtimeClient.InMatchmakingRoom && !currentRoomIsQueue && PhotonRealtimeClient.LocalPlayer.IsMasterClient) { _lastStartCancelTime = Time.time; - try { SafeStopCoroutine(ref _matchmakingHolder); } catch { } - try { SafeStopCoroutine(ref _startGameHolder); } catch { } - try { SafeStopCoroutine(ref _startQuantumHolder); } catch { } + try { if (_matchmakingHolder != null) { StopCoroutine(_matchmakingHolder); _matchmakingHolder = null; } } catch { } + try { if (_startGameHolder != null) { StopCoroutine(_startGameHolder); _startGameHolder = null; } } catch { } + try { if (_startQuantumHolder != null) { StopCoroutine(_startQuantumHolder); _startQuantumHolder = null; } } catch { } try { StopMatchmakingCoroutines(); } catch { } OnGameStartCancelled?.Invoke(); GameType roomGameType = GameType.Random2v2; try { - roomGameType = GetRoomType(room); + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + roomGameType = (GameType)room.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } } catch { } @@ -4151,17 +8708,14 @@ private void HandleMasterSwitchRequeueRecovery() } } catch { } - } - private void HandleMasterSwitchQueueTimer() - { // Queue timer handling: if we are now the master and inside a queue room, start the queue timer; otherwise stop it. try { if (PhotonRealtimeClient.LocalPlayer != null && PhotonRealtimeClient.LocalPlayer.IsMasterClient) { - Room room = PhotonRealtimeClient.CurrentRoom; - if (IsQueueRoom(room)) + var room = PhotonRealtimeClient.CurrentRoom; + if (room != null && room.CustomProperties != null && room.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) && (room.GetCustomProperty(PhotonBattleRoom.IsQueueKey))) { if (_verifyPositionsHolder == null) _verifyPositionsHolder = StartCoroutine(VerifyRoomPositionsLoop()); StartQueueTimer(); @@ -4173,14 +8727,12 @@ private void HandleMasterSwitchQueueTimer() } } catch { } - } - private void ClearBattleIdOnMasterSwitch() - { // Ensure any stale BattleID is cleared when master changes so new master does not see a hanging start try { - if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID)) + if (CanMutateRoomPropertiesNow("OnMasterClientSwitched: clear BattleID", true) + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.BattleID)) { PhotonRealtimeClient.CurrentRoom.SetCustomProperties(new PhotonHashtable { { PhotonBattleRoom.BattleID, "" } }); } @@ -4189,99 +8741,113 @@ private void ClearBattleIdOnMasterSwitch() { Debug.LogWarning($"Failed to clear BattleID on master switch: {ex.Message}"); } - } - private void HandleMatchmakingAfterMasterSwitch(Player newMasterClient) - { // If we are in a matchmaking room, new master should continue matchmaking; others stay and wait - if (!IsMatchmakingRoom()) - { - return; - } - - // Update local player's known leader id to the current master so returning/disconnected players don't reclaim leadership. - try - { - PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, newMasterClient.UserId); - try { OnRoomLeaderChanged?.Invoke(newMasterClient.UserId == PhotonRealtimeClient.LocalPlayer.UserId); } catch { } - } - catch (Exception ex) - { - Debug.LogWarning($"Failed to set leader id on master switch: {ex.Message}"); - } - - if (PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) - { - SafeStopCoroutine(ref _matchmakingHolder); - _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); - } - - // If this client became the new master, broadcast a RoomChangeRequested so clients follow to new room - try + if (PhotonRealtimeClient.InMatchmakingRoom && !currentRoomIsQueue) { - bool isCustomRoom = false; + bool isQueueRoom = false; try { - isCustomRoom = IsCustomRoom(PhotonRealtimeClient.CurrentRoom); + Room currentRoom = PhotonRealtimeClient.CurrentRoom; + isQueueRoom = currentRoom != null + && currentRoom.CustomProperties != null + && currentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.IsQueueKey) + && currentRoom.GetCustomProperty(PhotonBattleRoom.IsQueueKey); } catch { } - if (!isCustomRoom && PhotonRealtimeClient.LocalPlayer.IsMasterClient && PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) + // Update local player's known leader id to the current master so returning/disconnected players don't reclaim leadership. + try + { + if (!isQueueRoom) + { + PhotonRealtimeClient.LocalPlayer.SetCustomProperty(PhotonBattleRoom.LeaderIdKey, newMasterClient.UserId); + } + try { OnRoomLeaderChanged?.Invoke(newMasterClient.UserId == PhotonRealtimeClient.LocalPlayer.UserId); } catch { } + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to set leader id on master switch: {ex.Message}"); + } + + if (PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + { + if (_matchmakingHolder != null) + { + StopCoroutine(_matchmakingHolder); + _matchmakingHolder = null; + } + _matchmakingHolder = StartCoroutine(WaitForMatchmakingPlayers()); + } + + // If this client became the new master, broadcast a RoomChangeRequested so clients follow to new room + try { - // Only trigger leader-led requeue if a start was cancelled recently + bool isCustomRoom = false; try { - if (Time.time - _lastStartCancelTime < 15f) + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) { - GameType roomGameType = GameType.Random2v2; - try + isCustomRoom = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == GameType.Custom; + } + } + catch { } + + if (!isCustomRoom && PhotonRealtimeClient.LocalPlayer.IsMasterClient && PhotonRealtimeClient.Client != null && PhotonRealtimeClient.Client.IsConnectedAndReady && PhotonRealtimeClient.InRoom) + { + // Only trigger leader-led requeue if a start was cancelled recently + try + { + if (Time.time - _lastStartCancelTime < 15f) { - roomGameType = GetRoomType(PhotonRealtimeClient.CurrentRoom); - } - catch { } + GameType roomGameType = GameType.Random2v2; + try + { + if (PhotonRealtimeClient.CurrentRoom != null && PhotonRealtimeClient.CurrentRoom.CustomProperties != null && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.GameTypeKey)) + { + roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + } + catch { } - try { StartCoroutine(LeaveAndAutoRequeue(roomGameType)); } catch { } + try { StartCoroutine(LeaveAndAutoRequeue(roomGameType)); } catch { } - SafeRaiseEvent( - PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, - PhotonRealtimeClient.LocalPlayer.UserId, - new RaiseEventArgs { Receivers = ReceiverGroup.Others }, - SendOptions.SendReliable - ); - // prevent immediate repeated broadcasts - _lastStartCancelTime = -100f; + SafeRaiseEvent( + PhotonRealtimeClient.PhotonEvent.RoomChangeRequested, + PhotonRealtimeClient.LocalPlayer.UserId, + new RaiseEventArgs { Receivers = ReceiverGroup.Others }, + SendOptions.SendReliable + ); + // prevent immediate repeated broadcasts + _lastStartCancelTime = -100f; + } } + catch { } } - catch { } } - } - catch { } + catch { } - // If we deferred returning to the MainMenu because the master left, - // perform the UI return now that master switch has completed (for non-master clients). - try - { - if (_deferReturnToLobbyRoomOnMasterSwitch) + // If we deferred returning to the MainMenu because the master left, + // perform the UI return now that master switch has completed (for non-master clients). + try { - if (!PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient && PhotonRealtimeClient.InRoom) + if (_deferReturnToLobbyRoomOnMasterSwitch) { - _deferReturnToLobbyRoomOnMasterSwitch = false; - OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); - } - else - { - _deferReturnToLobbyRoomOnMasterSwitch = false; + if (!PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient && PhotonRealtimeClient.InRoom) + { + _deferReturnToLobbyRoomOnMasterSwitch = false; + OnLobbyWindowChangeRequest?.Invoke(LobbyWindowTarget.MainMenu); + } + else + { + _deferReturnToLobbyRoomOnMasterSwitch = false; + } } } + catch { _deferReturnToLobbyRoomOnMasterSwitch = false; } } - catch - { - _deferReturnToLobbyRoomOnMasterSwitch = false; - } - } - private void HandleVerifyLoopAfterMasterSwitch() - { // Start or stop verify loop depending on whether we are the new master try { @@ -4291,10 +8857,58 @@ private void HandleVerifyLoopAfterMasterSwitch() } else { - SafeStopCoroutine(ref _verifyPositionsHolder); + if (_verifyPositionsHolder != null) + { + StopCoroutine(_verifyPositionsHolder); + _verifyPositionsHolder = null; + } } } catch { } + + // New master should also clean up any stale player position keys left in room properties + try + { + var room = PhotonRealtimeClient.CurrentRoom; + if (room != null) + { + var existingUserIds = new HashSet(room.Players.Values.Select(p => p.UserId)); + string[] posKeys = { + PhotonBattleRoom.PlayerPositionKey1, + PhotonBattleRoom.PlayerPositionKey2, + PhotonBattleRoom.PlayerPositionKey3, + PhotonBattleRoom.PlayerPositionKey4 + }; + + if (CanMutateRoomPropertiesNow("OnMasterClientSwitched: clear stale positions", true)) + { + foreach (var key in posKeys) + { + string val = room.GetCustomProperty(key, ""); + if (string.IsNullOrEmpty(val)) continue; + if (val == "Bot") continue; + if (!existingUserIds.Contains(val)) + { + var emptyPosition = new LobbyPhotonHashtable(new Dictionary { { key, "" } }); + var expectedValue = new LobbyPhotonHashtable(new Dictionary { { key, val } }); + try + { + PhotonRealtimeClient.LobbyCurrentRoom.SetCustomProperties(emptyPosition, expectedValue); + Debug.Log($"Cleared stale position {key} (value {val}) on master switch."); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to clear stale position {key}: {ex.Message}"); + } + } + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"OnMasterClientSwitched: failed to clean stale positions: {ex.Message}"); + } } #endregion @@ -4367,15 +8981,38 @@ public class StartRaidTestEvent public class StartMatchmakingEvent { public readonly GameType SelectedGameType; + public readonly bool IsPremadeInRoom; - public StartMatchmakingEvent(GameType gameType) + public StartMatchmakingEvent(GameType gameType, bool isPremadeInRoom = false) { SelectedGameType = gameType; + IsPremadeInRoom = isPremadeInRoom; + } + + public override string ToString() + { + return $"{nameof(SelectedGameType)}: {SelectedGameType}, {nameof(IsPremadeInRoom)}: {IsPremadeInRoom}"; + } + } + + public class InRoomInviteInfo + { + public readonly string RoomName; + public readonly string LeaderUserId; + public readonly string InvitedUserId; + public readonly GameType TargetGameType; + + public InRoomInviteInfo(string roomName, string leaderUserId, string invitedUserId, GameType targetGameType) + { + RoomName = roomName; + LeaderUserId = leaderUserId; + InvitedUserId = invitedUserId; + TargetGameType = targetGameType; } public override string ToString() { - return $"{nameof(SelectedGameType)}: {SelectedGameType}"; + return $"{nameof(RoomName)}: {RoomName}, {nameof(LeaderUserId)}: {LeaderUserId}, {nameof(InvitedUserId)}: {InvitedUserId}, {nameof(TargetGameType)}: {TargetGameType}"; } } diff --git a/Assets/Altzone/Scripts/Photon/PhotonBattleRoom.cs b/Assets/Altzone/Scripts/Photon/PhotonBattleRoom.cs index 1cc5bd82d..76d0695ac 100644 --- a/Assets/Altzone/Scripts/Photon/PhotonBattleRoom.cs +++ b/Assets/Altzone/Scripts/Photon/PhotonBattleRoom.cs @@ -50,6 +50,20 @@ public class PhotonBattleRoom public const string ClanNameKey = "c"; public const string ClanOpponentNameKey = "c2"; public const string LeaderIdKey = "lid"; + public const string PremadeModeKey = "pm"; + public const string PremadeTargetGameTypeKey = "ptg"; + public const string PremadeLeaderUserIdKey = "plid"; + public const string PremadeInvitedUserIdKey = "piu"; + public const string PremadeInviteStateKey = "pis"; + public const string PremadeInviteTimestampKey = "pits"; + public const string PremadeUserId1Key = "pm1"; + public const string PremadeUserId2Key = "pm2"; + + public const int PremadeInviteStateNone = 0; + public const int PremadeInviteStatePending = 1; + public const int PremadeInviteStateAccepted = 2; + public const int PremadeInviteStateDeclined = 3; + public const int PremadeInviteStateExpired = 4; public static string PlayerPositionKey1 { get => PlayerPosition1.ToString(); } public static string PlayerPositionKey2 { get => PlayerPosition2.ToString(); } diff --git a/Assets/Altzone/Scripts/Photon/PhotonRealtimeClient.cs b/Assets/Altzone/Scripts/Photon/PhotonRealtimeClient.cs index 04aa036c3..d6d7baa6c 100644 --- a/Assets/Altzone/Scripts/Photon/PhotonRealtimeClient.cs +++ b/Assets/Altzone/Scripts/Photon/PhotonRealtimeClient.cs @@ -671,6 +671,25 @@ private static RoomOptions GetRoomOptions(GameType gameType, bool isMatchmaking List propertiesShowingToLobby = new() { PhotonBattleRoom.GameTypeKey, PhotonBattleRoom.IsMatchmakingKey }; + if (gameType == GameType.FriendLobby) + { + customRoomProperties.Add(PhotonBattleRoom.PremadeModeKey, true); + customRoomProperties.Add(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)GameType.Random2v2); + customRoomProperties.Add(PhotonBattleRoom.PremadeLeaderUserIdKey, LocalPlayer.UserId); + customRoomProperties.Add(PhotonBattleRoom.PremadeInvitedUserIdKey, ""); + customRoomProperties.Add(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); + customRoomProperties.Add(PhotonBattleRoom.PremadeInviteTimestampKey, 0L); + customRoomProperties.Add(PhotonBattleRoom.PremadeUserId1Key, LocalPlayer.UserId); + customRoomProperties.Add(PhotonBattleRoom.PremadeUserId2Key, ""); + + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeModeKey); + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeTargetGameTypeKey); + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeInvitedUserIdKey); + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeInviteStateKey); + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeInviteTimestampKey); + propertiesShowingToLobby.Add(PhotonBattleRoom.PremadeLeaderUserIdKey); + } + int maxPlayers; switch (gameType) @@ -690,6 +709,9 @@ private static RoomOptions GetRoomOptions(GameType gameType, bool isMatchmaking maxPlayers = 2; } break; + case GameType.FriendLobby: + maxPlayers = 2; + break; } if (maxPlayers == 4) { @@ -792,6 +814,94 @@ public static bool CreateCustomLobbyRoom(string roomName, string mapId, Emotion ); } + public static bool CreateInRoomPremadeLobbyRoom(string[] expectedUsers = null) + { + string roomName = $"FriendLobby_{LocalPlayer.UserId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + RoomOptions roomOptions = GetRoomOptions( + gameType: GameType.FriendLobby, + roomName: roomName + ); + + return CreateRoom( + roomName: roomName, + roomOptions: roomOptions, + expectedUsers: expectedUsers + ); + } + + public static bool SendPremadeInvite(string invitedUserId, GameType targetGameType = GameType.Random2v2) + { + if (string.IsNullOrEmpty(invitedUserId)) + { + Debug.LogWarning("SendPremadeInvite: invitedUserId is null or empty."); + return false; + } + + if (Client == null || !Client.IsConnectedAndReady) + { + Debug.LogWarning("SendPremadeInvite: Photon client is not connected or ready."); + return false; + } + + // If already in a room, set expected users and room props + if (InRoom && CurrentRoom != null) + { + if (!LocalLobbyPlayer.IsMasterClient) + { + Debug.LogWarning("SendPremadeInvite: only master client can send in-room invites."); + return false; + } + + try + { + LobbyCurrentRoom.ClearExpectedUsers(); + LobbyCurrentRoom.SetExpectedUsers(new[] { invitedUserId }); + } + catch (Exception ex) + { + Debug.LogWarning($"SendPremadeInvite: failed to set expected users: {ex.Message}"); + } + + string localUserId = LocalPlayer?.UserId ?? string.Empty; + + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, localUserId); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, invitedUserId); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStatePending); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteTimestampKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)targetGameType); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, localUserId); + CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + + return true; + } + + // Not in a room yet: create a premade friend lobby room with the invited user as expected user + string roomName = $"FriendLobby_{LocalPlayer?.UserId}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; + RoomOptions roomOptions = GetRoomOptions(gameType: GameType.FriendLobby, roomName: roomName); + + try + { + if (roomOptions.CustomRoomProperties == null) + { + roomOptions.CustomRoomProperties = new PhotonHashtable(); + } + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeInvitedUserIdKey] = invitedUserId; + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeInviteStateKey] = PhotonBattleRoom.PremadeInviteStatePending; + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeInviteTimestampKey] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeLeaderUserIdKey] = LocalPlayer?.UserId ?? string.Empty; + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeTargetGameTypeKey] = (int)targetGameType; + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeUserId1Key] = LocalPlayer?.UserId ?? string.Empty; + roomOptions.CustomRoomProperties[PhotonBattleRoom.PremadeUserId2Key] = string.Empty; + } + catch (Exception ex) + { + Debug.LogWarning($"SendPremadeInvite: failed to prepare room options: {ex.Message}"); + } + + return CreateRoom(roomName: roomName, roomOptions: roomOptions, expectedUsers: new[] { invitedUserId }); + } + public static bool CreateRoom(string roomName = "", RoomOptions roomOptions = null, TypedLobby typedLobby = null, string[] expectedUsers = null) { /*if (OfflineMode) @@ -889,8 +999,8 @@ public static bool JoinRandomOrCreateRandom2v2Room(string[] expectedUsers = null return Client.OpJoinRandomOrCreateRoom(joinRandomRoomArgs, enterRoomArgs); } - // Deterministic JoinOrCreate for matchmaking rooms to avoid duplicate queues when multiple clients race. - // Uses a fixed room name per gameType so the server will atomically join existing or create one. + // Server-side matchmaking room assignment using shared bucket properties. + // Room names are allocated by the server when creating new rooms. public static bool JoinOrCreateMatchmakingRoom(GameType gameType, string[] expectedUsers = null, string clanName = "", int soulhomeRank = -1) { if (Client.Server != ServerConnection.MasterServer || !Client.IsConnectedAndReady) @@ -899,30 +1009,19 @@ public static bool JoinOrCreateMatchmakingRoom(GameType gameType, string[] expec return false; } - // Deterministic room name reduces race: all clients attempt to join-or-create the same room. - string roomName = string.IsNullOrEmpty(clanName) ? $"Matchmaking_{gameType}" : $"Matchmaking_{gameType}_{clanName}"; + RoomOptions roomOptions = GetRoomOptions(gameType, true, "", Emotion.Blank, "", "", clanName, soulhomeRank); + EnterRoomArgs enterRoomArgs = GetEnterRoomArgs("", roomOptions, expectedUsers); - RoomOptions roomOptions = GetRoomOptions(gameType, true, "", Emotion.Blank, roomName, "", clanName, soulhomeRank); - EnterRoomArgs enterRoomArgs = GetEnterRoomArgs(roomName, roomOptions, expectedUsers); + JoinRandomRoomArgs joinRandomRoomArgs = new JoinRandomRoomArgs(); + var expectedProps = new PhotonHashtable{ { PhotonBattleRoom.GameTypeKey, gameType }, { PhotonBattleRoom.IsMatchmakingKey, true } }; + if (!string.IsNullOrEmpty(clanName)) expectedProps.Add(PhotonBattleRoom.ClanNameKey, clanName); + if (soulhomeRank >= 0) expectedProps.Add(PhotonBattleRoom.SoulhomeRank, soulhomeRank); + joinRandomRoomArgs.ExpectedCustomRoomProperties = expectedProps; + joinRandomRoomArgs.ExpectedMaxPlayers = roomOptions.MaxPlayers; + joinRandomRoomArgs.Lobby = enterRoomArgs.Lobby; + joinRandomRoomArgs.ExpectedUsers = expectedUsers; - // Use OpJoinOrCreateRoom if available on the Client API. This performs atomic server-side join-or-create. - try - { - return Client.OpJoinOrCreateRoom(enterRoomArgs); - } - catch (Exception ex) - { - Debug.LogWarning($"JoinOrCreateMatchmakingRoom fallback failed: {ex.Message}. Falling back to JoinRandomOrCreate."); - // Fallback to JoinRandomOrCreateRoom if OpJoinOrCreateRoom isn't available - JoinRandomRoomArgs joinRandomRoomArgs = new JoinRandomRoomArgs(); - var expectedProps = new PhotonHashtable{ { PhotonBattleRoom.GameTypeKey, gameType }, { PhotonBattleRoom.IsMatchmakingKey, true } }; - if (!string.IsNullOrEmpty(clanName)) expectedProps.Add(PhotonBattleRoom.ClanNameKey, clanName); - joinRandomRoomArgs.ExpectedCustomRoomProperties = expectedProps; - joinRandomRoomArgs.ExpectedMaxPlayers = roomOptions.MaxPlayers; - joinRandomRoomArgs.Lobby = enterRoomArgs.Lobby; - joinRandomRoomArgs.ExpectedUsers = expectedUsers; - return Client.OpJoinRandomOrCreateRoom(joinRandomRoomArgs, enterRoomArgs); - } + return Client.OpJoinRandomOrCreateRoom(joinRandomRoomArgs, enterRoomArgs); } // Join or create a persistent queue room that can hold many players waiting for matches. diff --git a/Assets/MenuUi/MenuUi.asmdef b/Assets/MenuUi/MenuUi.asmdef index 3443a1ffd..8788132a9 100644 --- a/Assets/MenuUi/MenuUi.asmdef +++ b/Assets/MenuUi/MenuUi.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "", "references": [ "GUID:7815ee0347c9e8f43bd8a6105ce3de40", + "GUID:831409e8f9d13b5479a3baef9822ad34", "GUID:8a58992e18163b845af14daeb9deb549", "GUID:75469ad4d38634e559750d17036d5f7c", "GUID:6055be8ebefd69e48b49212b09b47b2f", diff --git a/Assets/MenuUi/Prefabs/Friendlist, OnlinePlayers/OnlinePlayersPanelItem.prefab b/Assets/MenuUi/Prefabs/Friendlist, OnlinePlayers/OnlinePlayersPanelItem.prefab index 4fc19e648..6f678946e 100644 --- a/Assets/MenuUi/Prefabs/Friendlist, OnlinePlayers/OnlinePlayersPanelItem.prefab +++ b/Assets/MenuUi/Prefabs/Friendlist, OnlinePlayers/OnlinePlayersPanelItem.prefab @@ -371,6 +371,7 @@ MonoBehaviour: _declineFriendButton: {fileID: 221925500482121929} _addFriendButtonText: {fileID: 18945241310058985} _profileButton: {fileID: 6810360331478090544} + _inviteButton: {fileID: 9123456789012345682} --- !u!114 &5997357890131286892 MonoBehaviour: m_ObjectHideFlags: 0 @@ -1512,6 +1513,7 @@ RectTransform: - {fileID: 1291152434763294056} - {fileID: 5215710519443235911} - {fileID: 1638022984720524921} + - {fileID: 9123456789012345679} m_Father: {fileID: 5715535696324222795} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -1557,6 +1559,264 @@ MonoBehaviour: m_FillOrigin: 0 m_UseSpriteMesh: 0 m_PixelsPerUnitMultiplier: 1 +--- !u!1 &9123456789012345678 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 9123456789012345679} + - component: {fileID: 9123456789012345680} + - component: {fileID: 9123456789012345681} + - component: {fileID: 9123456789012345682} + m_Layer: 5 + m_Name: InviteButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &9123456789012345679 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345678} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 9123456789012345684} + m_Father: {fileID: 7433884823861233560} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.25, y: 0.05} + m_AnchorMax: {x: 0.45, y: 0.45} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &9123456789012345680 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345678} + m_CullTransparentMesh: 1 +--- !u!114 &9123456789012345681 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345678} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &9123456789012345682 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345678} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 9123456789012345681} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &9123456789012345683 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 9123456789012345684} + - component: {fileID: 9123456789012345685} + - component: {fileID: 9123456789012345686} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &9123456789012345684 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345683} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 9123456789012345679} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &9123456789012345685 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345683} + m_CullTransparentMesh: 1 +--- !u!114 &9123456789012345686 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9123456789012345683} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Kutsu + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 22.05 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 12 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} --- !u!1 &8335102251737258302 GameObject: m_ObjectHideFlags: 0 diff --git a/Assets/MenuUi/Prefabs/Lobby.meta b/Assets/MenuUi/Prefabs/Lobby.meta new file mode 100644 index 000000000..e060496f3 --- /dev/null +++ b/Assets/MenuUi/Prefabs/Lobby.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 1669e82fc1be5ea47b562321a4d39b11 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MenuUi/Prefabs/Lobby/InRoom.meta b/Assets/MenuUi/Prefabs/Lobby/InRoom.meta new file mode 100644 index 000000000..426431cfa --- /dev/null +++ b/Assets/MenuUi/Prefabs/Lobby/InRoom.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e9c8dfdd97f210f4fb8f942e6a03c429 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab b/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab new file mode 100644 index 000000000..cdf5abd39 --- /dev/null +++ b/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab @@ -0,0 +1,2499 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1300871504727383136 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6243485247068527248} + - component: {fileID: 8292185179536986267} + - component: {fileID: 6927941476471931558} + m_Layer: 5 + m_Name: Panel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &6243485247068527248 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1300871504727383136} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 3489897734204400162} + - {fileID: 4622649932985037272} + - {fileID: 8840567199651420083} + - {fileID: 3885297067285027425} + - {fileID: 8516799072215054270} + - {fileID: 8362020601074477031} + m_Father: {fileID: 5715535696324222795} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8292185179536986267 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1300871504727383136} + m_CullTransparentMesh: 1 +--- !u!114 &6927941476471931558 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1300871504727383136} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0, g: 0.17034149, b: 1, a: 0.29803923} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: eaa00bb62719824408bc897099de0571, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &1576785617772880524 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1017504813906036108} + - component: {fileID: 2708084954640028311} + - component: {fileID: 8626701643393592046} + - component: {fileID: 3512609700870063555} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1017504813906036108 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1576785617772880524} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1291152434763294056} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.05, y: 0} + m_AnchorMax: {x: 0.95, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &2708084954640028311 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1576785617772880524} + m_CullTransparentMesh: 1 +--- !u!114 &8626701643393592046 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1576785617772880524} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Katso profiilia + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 27 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 12 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &3512609700870063555 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1576785617772880524} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 220ef737e6ba13e48841e379d56ad79b, type: 3} + m_Name: + m_EditorClassIdentifier: + _finnishText: Katso profiilia + _englishText: View profile +--- !u!1 &3076611324160450174 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3885297067285027425} + - component: {fileID: 8401450835234611454} + - component: {fileID: 5560187034957675401} + m_Layer: 5 + m_Name: OnlineStatusIndicator + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &3885297067285027425 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3076611324160450174} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 6243485247068527248} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.85, y: 0} + m_AnchorMax: {x: 0.95, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8401450835234611454 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3076611324160450174} + m_CullTransparentMesh: 1 +--- !u!114 &5560187034957675401 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3076611324160450174} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19267406, g: 0.9339623, b: 0.13656995, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10913, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 0 + m_PreserveAspect: 1 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &3935509160329979117 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5715535696324222795} + - component: {fileID: 3731211414751999882} + - component: {fileID: 5997357890131286892} + m_Layer: 5 + m_Name: OnlinePlayersPanelItem + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5715535696324222795 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3935509160329979117} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 7433884823861233560} + - {fileID: 6243485247068527248} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: -5.02417, y: -139.54785} + m_SizeDelta: {x: -58.46997, y: 204.306} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!114 &3731211414751999882 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3935509160329979117} + m_Enabled: 0 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6c030cdb956184240a26e5c5bf6a7942, type: 3} + m_Name: + m_EditorClassIdentifier: + _topPanel: {fileID: 6243485247068527248} + _bottomPanel: {fileID: 7433884823861233560} + _avatarFaceLoader: {fileID: 2650290587671204662} + _onlineStatusIndicator: {fileID: 5560187034957675401} + _nameText: {fileID: 5823090110037391635} + _clanHeart: {fileID: 6571624357005112745} + _addfriendButton: {fileID: 9008562203616237217} + _removefriendButton: {fileID: 194267624776126907} + _acceptFriendButton: {fileID: 573951088741280508} + _declineFriendButton: {fileID: 221925500482121929} + _addFriendButtonText: {fileID: 18945241310058985} + _profileButton: {fileID: 6810360331478090544} +--- !u!114 &5997357890131286892 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 3935509160329979117} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 0} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &4009267373541308208 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4194195211609077414} + - component: {fileID: 1381274836708631751} + - component: {fileID: 6282096678898507424} + - component: {fileID: 8627609521625464074} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4194195211609077414 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4009267373541308208} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 5215710519443235911} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.05, y: 0} + m_AnchorMax: {x: 0.95, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1381274836708631751 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4009267373541308208} + m_CullTransparentMesh: 1 +--- !u!114 &6282096678898507424 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4009267373541308208} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Poista kavereista + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 22.05 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 12 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &8627609521625464074 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4009267373541308208} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 220ef737e6ba13e48841e379d56ad79b, type: 3} + m_Name: + m_EditorClassIdentifier: + _finnishText: Poista kavereista + _englishText: Remove Friend +--- !u!1 &4063941065324564134 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5215710519443235911} + - component: {fileID: 5973491883220046332} + - component: {fileID: 5714184660145128862} + - component: {fileID: 194267624776126907} + m_Layer: 5 + m_Name: RemoveFriendButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5215710519443235911 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4063941065324564134} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4194195211609077414} + m_Father: {fileID: 7433884823861233560} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.775, y: 0.05} + m_AnchorMax: {x: 0.975, y: 0.45} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &5973491883220046332 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4063941065324564134} + m_CullTransparentMesh: 1 +--- !u!114 &5714184660145128862 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4063941065324564134} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &194267624776126907 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4063941065324564134} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 5714184660145128862} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &4837339114878357771 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1214049881076027653} + - component: {fileID: 8338963487334465897} + - component: {fileID: 8095732588530502290} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1214049881076027653 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4837339114878357771} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8516799072215054270} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8338963487334465897 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4837339114878357771} + m_CullTransparentMesh: 1 +--- !u!114 &8095732588530502290 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 4837339114878357771} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: "Hylk\xE4\xE4" + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 20 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!1 &6050363312076749630 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8362020601074477031} + - component: {fileID: 2394955967588672209} + - component: {fileID: 6816586748864237667} + - component: {fileID: 573951088741280508} + m_Layer: 5 + m_Name: AcceptFriendButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &8362020601074477031 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6050363312076749630} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 201942486347965360} + m_Father: {fileID: 6243485247068527248} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.65, y: 0} + m_AnchorMax: {x: 0.8, y: 1} + m_AnchoredPosition: {x: 0, y: -0.00012207031} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &2394955967588672209 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6050363312076749630} + m_CullTransparentMesh: 1 +--- !u!114 &6816586748864237667 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6050363312076749630} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.011712074, g: 1, b: 0, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &573951088741280508 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6050363312076749630} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 6816586748864237667} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &6832703221580773677 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4622649932985037272} + - component: {fileID: 4141090442323590779} + - component: {fileID: 5823090110037391635} + m_Layer: 5 + m_Name: NameText + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4622649932985037272 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6832703221580773677} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 0.82499, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 6243485247068527248} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.15, y: 0} + m_AnchorMax: {x: 0.7, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: -10, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4141090442323590779 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6832703221580773677} + m_CullTransparentMesh: 1 +--- !u!114 &5823090110037391635 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6832703221580773677} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Kaverin nimi + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 50 + m_fontSizeBase: 36 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 50 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: -3.718628, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!1 &6864839820369477615 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1442144847420188575} + - component: {fileID: 5949699682807850325} + - component: {fileID: 18945241310058985} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1442144847420188575 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6864839820369477615} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1.0125, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 1638022984720524921} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &5949699682807850325 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6864839820369477615} + m_CullTransparentMesh: 1 +--- !u!114 &18945241310058985 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6864839820369477615} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: "Lis\xE4\xE4 kaveriksi" + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 20 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!1 &7009106795627293045 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8516799072215054270} + - component: {fileID: 1262146103241926645} + - component: {fileID: 7014232929437497596} + - component: {fileID: 221925500482121929} + m_Layer: 5 + m_Name: DeclineFriendButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &8516799072215054270 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7009106795627293045} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1214049881076027653} + m_Father: {fileID: 6243485247068527248} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.8, y: 0} + m_AnchorMax: {x: 0.95, y: 1} + m_AnchoredPosition: {x: 0, y: -0.00012207031} + m_SizeDelta: {x: -0.000061035156, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1262146103241926645 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7009106795627293045} + m_CullTransparentMesh: 1 +--- !u!114 &7014232929437497596 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7009106795627293045} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0, b: 0, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &221925500482121929 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7009106795627293045} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 7014232929437497596} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &7278059677700821779 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1291152434763294056} + - component: {fileID: 1402003381635778771} + - component: {fileID: 660975737554513030} + - component: {fileID: 6810360331478090544} + - component: {fileID: 986945324364258732} + m_Layer: 5 + m_Name: PlayerProfileButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1291152434763294056 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7278059677700821779} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1017504813906036108} + m_Father: {fileID: 7433884823861233560} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.025, y: 0.05} + m_AnchorMax: {x: 0.225, y: 0.45} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1402003381635778771 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7278059677700821779} + m_CullTransparentMesh: 1 +--- !u!114 &660975737554513030 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7278059677700821779} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &6810360331478090544 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7278059677700821779} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 660975737554513030} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &986945324364258732 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7278059677700821779} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 337dca1768bd6a44a9fe3bfb30afa666, type: 3} + m_Name: + m_EditorClassIdentifier: + _naviTarget: {fileID: 11400000, guid: e159432f6bd8572419fea7a32165e31b, type: 2} + _useNonDefaultWindow: 0 + _targetWindow: 0 + _isCurrentPopOutWindow: 0 +--- !u!1 &7788641511057208300 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7433884823861233560} + - component: {fileID: 4154373385801558558} + - component: {fileID: 5882243127057643294} + m_Layer: 5 + m_Name: LowerPanel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &7433884823861233560 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7788641511057208300} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1291152434763294056} + - {fileID: 5215710519443235911} + - {fileID: 1638022984720524921} + m_Father: {fileID: 5715535696324222795} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4154373385801558558 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7788641511057208300} + m_CullTransparentMesh: 1 +--- !u!114 &5882243127057643294 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7788641511057208300} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0, g: 0.617, b: 0.9960785, a: 0.29803923} + m_RaycastTarget: 0 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: eaa00bb62719824408bc897099de0571, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &8335102251737258302 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1638022984720524921} + - component: {fileID: 8224075641934588883} + - component: {fileID: 6674142208463253511} + - component: {fileID: 9008562203616237217} + m_Layer: 5 + m_Name: AddFriendButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &1638022984720524921 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8335102251737258302} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1442144847420188575} + m_Father: {fileID: 7433884823861233560} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.775, y: 0.05} + m_AnchorMax: {x: 0.975, y: 0.45} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &8224075641934588883 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8335102251737258302} + m_CullTransparentMesh: 1 +--- !u!114 &6674142208463253511 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8335102251737258302} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &9008562203616237217 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8335102251737258302} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 6674142208463253511} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &9178286625869719438 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 201942486347965360} + - component: {fileID: 1338137980711866924} + - component: {fileID: 3558636603848781148} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &201942486347965360 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9178286625869719438} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8362020601074477031} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1338137980711866924 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9178286625869719438} + m_CullTransparentMesh: 1 +--- !u!114 &3558636603848781148 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 9178286625869719438} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: "Hyv\xE4ksy" + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 20 + m_fontSizeBase: 20 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!1001 &905341657561742215 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 6243485247068527248} + m_Modifications: + - target: {fileID: 422640454044224970, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 422640454044224970, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 422640454044224970, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 422640454044224970, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1157164680827611820, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 1157164680827611820, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 1157164680827611820, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1157164680827611820, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2499646538924253020, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 2499646538924253020, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 2499646538924253020, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2499646538924253020, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3126123353691013904, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 3126123353691013904, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 3126123353691013904, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3126123353691013904, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_Pivot.x + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_Pivot.y + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.x + value: 0.15 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalRotation.w + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalRotation.x + value: -0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalRotation.y + value: -0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalRotation.z + value: -0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5154675011300758231, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 5154675011300758231, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 5154675011300758231, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5154675011300758231, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6696904714036861434, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_Name + value: CharacterHeadImage + objectReference: {fileID: 0} + - target: {fileID: 6696904714036861434, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_IsActive + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 7648882653449573367, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMax.y + value: 0.9 + objectReference: {fileID: 0} + - target: {fileID: 7648882653449573367, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchorMin.y + value: -0.1 + objectReference: {fileID: 0} + - target: {fileID: 7648882653449573367, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7648882653449573367, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 242eb41c9b6db9841a5c6610aa625c54, type: 3} +--- !u!114 &2650290587671204662 stripped +MonoBehaviour: + m_CorrespondingSourceObject: {fileID: 2907023324437588145, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + m_PrefabInstance: {fileID: 905341657561742215} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 39ec419aaf903c747b6c6e5ac7dd4bbd, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!224 &3489897734204400162 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 4395214235301648805, guid: 242eb41c9b6db9841a5c6610aa625c54, + type: 3} + m_PrefabInstance: {fileID: 905341657561742215} + m_PrefabAsset: {fileID: 0} +--- !u!1001 &6547914104798814800 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 6243485247068527248} + m_Modifications: + - target: {fileID: 91970901561838323, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 138983387084199929, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: _setOwnClanHeart + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 319371060048814892, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 470375666449288133, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 501610058746512236, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 851326381426900438, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 959904786580144925, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 974914128738299870, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1227168792284614422, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1296916841443853834, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1325263059449995020, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1376401861853490447, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1519986797003222848, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1621433151065102908, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1653114280352332272, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 1913256147025420911, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2002009846852152940, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2237239805142557775, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_Pivot.x + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_Pivot.y + value: 0.5 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMax.x + value: 0.85 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMax.y + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMin.x + value: 0.7 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMin.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalPosition.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalRotation.w + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalRotation.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalRotation.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalRotation.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchoredPosition.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchoredPosition.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalEulerAnglesHint.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalEulerAnglesHint.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_LocalEulerAnglesHint.z + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2487156833588508019, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2549159752423439018, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2772760674639381124, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 2990173483288100572, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3157597938240693584, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMax.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3157597938240693584, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_AnchorMax.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3157597938240693584, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.x + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3157597938240693584, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3205477658382480987, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3519174174243801943, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 3601513182057961562, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4035549320030009021, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4245234531808657854, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4576776222570107925, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 4678709096312057958, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5248029060837262431, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5323122090189538256, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5365629912341225553, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5397163918508318507, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5490945982148460650, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_Name + value: ClanHeart + objectReference: {fileID: 0} + - target: {fileID: 5490945982148460650, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_IsActive + value: 1 + objectReference: {fileID: 0} + - target: {fileID: 5656563543629351072, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5659936938558701495, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5691878597105258290, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 5981255314838583054, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6073072499891600026, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6165021406224357010, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6253357168936260193, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6510601437633983029, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6632921287898695582, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 6870568549543007334, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7110521646190318245, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7111774231632246173, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7556721369445466621, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 7895804332560210890, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 8095826248076067992, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 8440141586240382234, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 8820635703629614574, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 8829047857185701650, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + - target: {fileID: 9095221933186176013, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + propertyPath: m_SizeDelta.y + value: 0 + objectReference: {fileID: 0} + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 71a38dbc9d18bd44f9de8793036dbead, type: 3} +--- !u!114 &6571624357005112745 stripped +MonoBehaviour: + m_CorrespondingSourceObject: {fileID: 138983387084199929, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + m_PrefabInstance: {fileID: 6547914104798814800} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d55e7369e2c8f004d9fb5c5f55f7ee10, type: 3} + m_Name: + m_EditorClassIdentifier: +--- !u!224 &8840567199651420083 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 2337047324238083555, guid: 71a38dbc9d18bd44f9de8793036dbead, + type: 3} + m_PrefabInstance: {fileID: 6547914104798814800} + m_PrefabAsset: {fileID: 0} diff --git a/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab.meta b/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab.meta new file mode 100644 index 000000000..e1974c7b9 --- /dev/null +++ b/Assets/MenuUi/Prefabs/Lobby/InRoom/InviteSelectorRow.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7f8c3d0f53a84a23b89f59c2a4d1e6b7 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MenuUi/Prefabs/Panels/UIOverlayPanel.prefab b/Assets/MenuUi/Prefabs/Panels/UIOverlayPanel.prefab index dd8926e95..9599f0b87 100644 --- a/Assets/MenuUi/Prefabs/Panels/UIOverlayPanel.prefab +++ b/Assets/MenuUi/Prefabs/Panels/UIOverlayPanel.prefab @@ -845,6 +845,7 @@ RectTransform: - {fileID: 3647519061533906455} - {fileID: 8633110126328995404} - {fileID: 1177530891263587228} + - {fileID: 888120001223344551} m_Father: {fileID: 8476621660179101015} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} @@ -4393,6 +4394,25 @@ RectTransform: type: 3} m_PrefabInstance: {fileID: 1627773713561907960} m_PrefabAsset: {fileID: 0} +--- !u!1001 &888120001223344550 +PrefabInstance: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_Modification: + serializedVersion: 3 + m_TransformParent: {fileID: 5704505538415407603} + m_Modifications: [] + m_RemovedComponents: [] + m_RemovedGameObjects: [] + m_AddedGameObjects: [] + m_AddedComponents: [] + m_SourcePrefab: {fileID: 100100000, guid: 21472cc085cd46439b268f7b2469d1ac, type: 3} +--- !u!224 &888120001223344551 stripped +RectTransform: + m_CorrespondingSourceObject: {fileID: 3909196248017474539, guid: 21472cc085cd46439b268f7b2469d1ac, + type: 3} + m_PrefabInstance: {fileID: 888120001223344550} + m_PrefabAsset: {fileID: 0} --- !u!1001 &1649264663230407380 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/MenuUi/Prefabs/UI Components/Battle Popup.prefab b/Assets/MenuUi/Prefabs/UI Components/Battle Popup.prefab index 0bd86e833..72571cd97 100644 --- a/Assets/MenuUi/Prefabs/UI Components/Battle Popup.prefab +++ b/Assets/MenuUi/Prefabs/UI Components/Battle Popup.prefab @@ -782,7 +782,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 48 m_fontSizeBase: 42 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -1113,7 +1113,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 45.7 m_fontSizeBase: 36 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -1269,7 +1269,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 49 m_fontSizeBase: 55.15 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -1622,14 +1622,14 @@ Canvas: m_PlaneDistance: 100 m_PixelPerfect: 0 m_ReceivesEvents: 1 - m_OverrideSorting: 1 + m_OverrideSorting: 0 m_OverridePixelPerfect: 0 m_SortingBucketNormalizedSize: 0 m_VertexColorAlwaysGammaSpace: 0 m_AdditionalShaderChannelsFlag: 25 m_UpdateRectTransformForStandalone: 0 m_SortingLayerID: 0 - m_SortingOrder: 220 + m_SortingOrder: 0 m_TargetDisplay: 0 --- !u!114 &2471567399234579102 MonoBehaviour: @@ -1701,19 +1701,1046 @@ MonoBehaviour: m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 820259465500924750} + m_GameObject: {fileID: 820259465500924750} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 6ec8f5993408d6f4a86d53e6811366c1, type: 3} + m_Name: + m_EditorClassIdentifier: + _searchPanel: {fileID: 1752140370002469978} + _roomSwitcher: {fileID: 4116104174498300769} + _createRoomCustom: {fileID: 2108616739130861843} + _createRoomFromMainMenuButton: {fileID: 2483906150829253786} + _passwordPopup: {fileID: 2803323736018011014} + _creatingRoomText: {fileID: 8493655741309318595} +--- !u!1 &833237438110384610 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5930569245492670130} + - component: {fileID: 2369995787527946291} + - component: {fileID: 1504166851438060575} + - component: {fileID: 1291016132344631619} + - component: {fileID: 6926488886697078667} + m_Layer: 5 + m_Name: PasswordPopup + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5930569245492670130 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 833237438110384610} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 987534261164539157} + - {fileID: 1507871623682239209} + - {fileID: 8442712825648987967} + - {fileID: 4252688780623151084} + m_Father: {fileID: 4060773305982483674} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.15, y: 0.4050038} + m_AnchorMax: {x: 0.85, y: 0.64} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &2369995787527946291 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 833237438110384610} + m_CullTransparentMesh: 1 +--- !u!114 &1504166851438060575 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 833237438110384610} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0.6201109, b: 0.3443396, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &1291016132344631619 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 833237438110384610} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3} + m_Name: + m_EditorClassIdentifier: + m_EffectColor: {r: 0, g: 0, b: 0, a: 0.5} + m_EffectDistance: {x: 0, y: -10} + m_UseGraphicAlpha: 1 +--- !u!114 &6926488886697078667 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 833237438110384610} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 0 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 0} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!1 &901000000000000000 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000001} + - component: {fileID: 901000000000000002} + - component: {fileID: 901000000000000003} + - component: {fileID: 901000000000000004} + m_Layer: 5 + m_Name: InviteSelectorPanel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &901000000000000001 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000000} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 901000000000000011} + m_Father: {fileID: 8151943761461288942} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000002 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000000} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000003 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0, g: 0, b: 0, a: 0.62} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &901000000000000004 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000000} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4ed3e5740e17a5e499d4d546a3a1ed6a, type: 3} + m_Name: + m_EditorClassIdentifier: + _root: {fileID: 901000000000000000} + _contentRoot: {fileID: 901000000000000051} + _closeButton: {fileID: 901000000000000074} + _titleText: {fileID: 901000000000000123} + _emptyText: {fileID: 901000000000000133} + _overlayImage: {fileID: 901000000000000003} + _cardImage: {fileID: 901000000000000013} + _scrollBackgroundImage: {fileID: 901000000000000033} + _closeButtonImage: {fileID: 901000000000000073} + _closeButtonText: {fileID: 0} + _onlinePlayersRowPrefab: {fileID: 3935509160329979117, guid: 7f8c3d0f53a84a23b89f59c2a4d1e6b7, + type: 3} + _fallbackOverlayColor: {r: 0, g: 0, b: 0, a: 0.62} + _fallbackCardColor: {r: 1, g: 1, b: 1, a: 0.98} + _fallbackScrollColor: {r: 0.95, g: 0.95, b: 0.95, a: 0.95} + _fallbackButtonColor: {r: 0.841, g: 0.635, b: 0.973, a: 1} + _fallbackTextColor: {r: 0.196, g: 0.196, b: 0.196, a: 1} + _fallbackRowFontSize: 24 +--- !u!1 &901000000000000010 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000011} + - component: {fileID: 901000000000000012} + - component: {fileID: 901000000000000013} + - component: {fileID: 901000000000000014} + m_Layer: 5 + m_Name: Card + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000011 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000010} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 901000000000000121} + - {fileID: 901000000000000031} + - {fileID: 901000000000000071} + m_Father: {fileID: 901000000000000001} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.08, y: 0.08} + m_AnchorMax: {x: 0.92, y: 0.92} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000012 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000010} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000013 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000010} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 0.97} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 776df73bddbed9848b7968a02085a428, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &901000000000000014 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000010} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Padding: + m_Left: 24 + m_Right: 24 + m_Top: 22 + m_Bottom: 22 + m_ChildAlignment: 1 + m_Spacing: 14 + m_ChildForceExpandWidth: 1 + m_ChildForceExpandHeight: 0 + m_ChildControlWidth: 1 + m_ChildControlHeight: 1 + m_ChildScaleWidth: 0 + m_ChildScaleHeight: 0 + m_ReverseArrangement: 0 +--- !u!1 &901000000000000030 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000031} + - component: {fileID: 901000000000000032} + - component: {fileID: 901000000000000033} + - component: {fileID: 901000000000000034} + - component: {fileID: 901000000000000035} + m_Layer: 5 + m_Name: ScrollView + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000031 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000030} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 901000000000000041} + m_Father: {fileID: 901000000000000011} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000032 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000030} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000033 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000030} + m_Enabled: 0 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 0.9} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 776df73bddbed9848b7968a02085a428, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &901000000000000034 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000030} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1aa08ab6e0800fa44ae55d278d1423e3, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Content: {fileID: 901000000000000051} + m_Horizontal: 0 + m_Vertical: 1 + m_MovementType: 2 + m_Elasticity: 0.1 + m_Inertia: 1 + m_DecelerationRate: 0.135 + m_ScrollSensitivity: 28 + m_Viewport: {fileID: 901000000000000041} + m_HorizontalScrollbar: {fileID: 0} + m_VerticalScrollbar: {fileID: 0} + m_HorizontalScrollbarVisibility: 2 + m_VerticalScrollbarVisibility: 2 + m_HorizontalScrollbarSpacing: -3 + m_VerticalScrollbarSpacing: -3 + m_OnValueChanged: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &901000000000000035 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000030} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3} + m_Name: + m_EditorClassIdentifier: + m_IgnoreLayout: 0 + m_MinWidth: -1 + m_MinHeight: 260 + m_PreferredWidth: -1 + m_PreferredHeight: -1 + m_FlexibleWidth: -1 + m_FlexibleHeight: 1 + m_LayoutPriority: 1 +--- !u!1 &901000000000000040 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000041} + - component: {fileID: 901000000000000042} + - component: {fileID: 901000000000000043} + - component: {fileID: 901000000000000044} + m_Layer: 5 + m_Name: Viewport + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000041 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000040} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 901000000000000051} + - {fileID: 901000000000000131} + m_Father: {fileID: 901000000000000031} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000042 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000040} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000043 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000040} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 0.03} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &901000000000000044 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000040} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 31a19414c41e5ae4aae2af33fee712f6, type: 3} + m_Name: + m_EditorClassIdentifier: + m_ShowMaskGraphic: 0 +--- !u!1 &901000000000000050 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000051} + - component: {fileID: 901000000000000052} + - component: {fileID: 901000000000000053} + m_Layer: 5 + m_Name: Content + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000051 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000050} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 901000000000000041} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 1} +--- !u!114 &901000000000000052 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000050} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Padding: + m_Left: 8 + m_Right: 8 + m_Top: 8 + m_Bottom: 8 + m_ChildAlignment: 0 + m_Spacing: 10 + m_ChildForceExpandWidth: 1 + m_ChildForceExpandHeight: 0 + m_ChildControlWidth: 1 + m_ChildControlHeight: 1 + m_ChildScaleWidth: 0 + m_ChildScaleHeight: 0 + m_ReverseArrangement: 0 +--- !u!114 &901000000000000053 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000050} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 3245ec927659c4140ac4f8d17403cc18, type: 3} + m_Name: + m_EditorClassIdentifier: + m_HorizontalFit: 0 + m_VerticalFit: 2 +--- !u!1 &901000000000000070 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000071} + - component: {fileID: 901000000000000072} + - component: {fileID: 901000000000000073} + - component: {fileID: 901000000000000074} + - component: {fileID: 901000000000000075} + m_Layer: 5 + m_Name: CloseButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000071 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000070} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 901000000000000081} + m_Father: {fileID: 901000000000000011} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000072 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000070} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000073 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000070} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.8411652, g: 0.63529414, b: 0.972549, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 47f3804936cb29747bf9619ef5303fec, type: 3} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &901000000000000074 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000070} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 901000000000000073} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &901000000000000075 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000070} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3} + m_Name: + m_EditorClassIdentifier: + m_IgnoreLayout: 0 + m_MinWidth: -1 + m_MinHeight: -1 + m_PreferredWidth: -1 + m_PreferredHeight: 70 + m_FlexibleWidth: -1 + m_FlexibleHeight: -1 + m_LayoutPriority: 1 +--- !u!1 &901000000000000080 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000081} + - component: {fileID: 901000000000000082} + - component: {fileID: 901000000000000083} + m_Layer: 5 + m_Name: Label + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000081 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000080} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 901000000000000071} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000082 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000080} + m_CullTransparentMesh: 0 +--- !u!114 &901000000000000083 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000080} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_FontData: + m_Font: {fileID: 12800000, guid: 6a96980bd413de443bb4f5cba114a5a1, type: 3} + m_FontSize: 28 + m_FontStyle: 0 + m_BestFit: 1 + m_MinSize: 4 + m_MaxSize: 60 + m_Alignment: 4 + m_AlignByGeometry: 0 + m_RichText: 1 + m_HorizontalOverflow: 0 + m_VerticalOverflow: 0 + m_LineSpacing: 1 + m_Text: Peruuta +--- !u!1 &901000000000000120 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 901000000000000121} + - component: {fileID: 901000000000000122} + - component: {fileID: 901000000000000123} + - component: {fileID: 901000000000000124} + m_Layer: 5 + m_Name: Title + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &901000000000000121 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000120} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 901000000000000011} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &901000000000000122 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000120} + m_CullTransparentMesh: 1 +--- !u!114 &901000000000000123 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000120} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Valitse kutsuttava online-pelaaja + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281413937 + m_fontColor: {r: 0.19215688, g: 0.19215688, b: 0.19215688, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 32 + m_fontSizeBase: 32 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 60 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 6, y: 0, z: 6, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &901000000000000124 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 901000000000000120} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 6ec8f5993408d6f4a86d53e6811366c1, type: 3} + m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3} m_Name: m_EditorClassIdentifier: - _searchPanel: {fileID: 1752140370002469978} - _roomSwitcher: {fileID: 4116104174498300769} - _createRoomCustom: {fileID: 2108616739130861843} - _createRoomFromMainMenuButton: {fileID: 2483906150829253786} - _passwordPopup: {fileID: 2803323736018011014} - _creatingRoomText: {fileID: 8493655741309318595} ---- !u!1 &833237438110384610 + m_IgnoreLayout: 0 + m_MinWidth: -1 + m_MinHeight: -1 + m_PreferredWidth: -1 + m_PreferredHeight: 72 + m_FlexibleWidth: 0 + m_FlexibleHeight: 0 + m_LayoutPriority: 1 +--- !u!1 &901000000000000130 GameObject: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -1721,138 +2748,135 @@ GameObject: m_PrefabAsset: {fileID: 0} serializedVersion: 6 m_Component: - - component: {fileID: 5930569245492670130} - - component: {fileID: 2369995787527946291} - - component: {fileID: 1504166851438060575} - - component: {fileID: 1291016132344631619} - - component: {fileID: 6926488886697078667} + - component: {fileID: 901000000000000131} + - component: {fileID: 901000000000000132} + - component: {fileID: 901000000000000133} m_Layer: 5 - m_Name: PasswordPopup + m_Name: EmptyText m_TagString: Untagged m_Icon: {fileID: 0} m_NavMeshLayer: 0 m_StaticEditorFlags: 0 m_IsActive: 1 ---- !u!224 &5930569245492670130 +--- !u!224 &901000000000000131 RectTransform: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 833237438110384610} + m_GameObject: {fileID: 901000000000000130} m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} m_LocalPosition: {x: 0, y: 0, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 - m_Children: - - {fileID: 987534261164539157} - - {fileID: 1507871623682239209} - - {fileID: 8442712825648987967} - - {fileID: 4252688780623151084} - m_Father: {fileID: 4060773305982483674} + m_Children: [] + m_Father: {fileID: 901000000000000041} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0.15, y: 0.4050038} - m_AnchorMax: {x: 0.85, y: 0.64} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 0, y: 0} + m_SizeDelta: {x: -24, y: -24} m_Pivot: {x: 0.5, y: 0.5} ---- !u!222 &2369995787527946291 +--- !u!222 &901000000000000132 CanvasRenderer: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 833237438110384610} + m_GameObject: {fileID: 901000000000000130} m_CullTransparentMesh: 1 ---- !u!114 &1504166851438060575 +--- !u!114 &901000000000000133 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 833237438110384610} + m_GameObject: {fileID: 901000000000000130} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} m_Name: m_EditorClassIdentifier: m_Material: {fileID: 0} - m_Color: {r: 1, g: 0.6201109, b: 0.3443396, a: 1} - m_RaycastTarget: 1 + m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_RaycastTarget: 0 m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} m_Maskable: 1 m_OnCullStateChanged: m_PersistentCalls: m_Calls: [] - m_Sprite: {fileID: 0} - m_Type: 0 - m_PreserveAspect: 0 - m_FillCenter: 1 - m_FillMethod: 4 - m_FillAmount: 1 - m_FillClockwise: 1 - m_FillOrigin: 0 - m_UseSpriteMesh: 0 - m_PixelsPerUnitMultiplier: 1 ---- !u!114 &1291016132344631619 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 833237438110384610} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: cfabb0440166ab443bba8876756fdfa9, type: 3} - m_Name: - m_EditorClassIdentifier: - m_EffectColor: {r: 0, g: 0, b: 0, a: 0.5} - m_EffectDistance: {x: 0, y: -10} - m_UseGraphicAlpha: 1 ---- !u!114 &6926488886697078667 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 833237438110384610} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} - m_Name: - m_EditorClassIdentifier: - m_Navigation: - m_Mode: 3 - m_WrapAround: 0 - m_SelectOnUp: {fileID: 0} - m_SelectOnDown: {fileID: 0} - m_SelectOnLeft: {fileID: 0} - m_SelectOnRight: {fileID: 0} - m_Transition: 0 - m_Colors: - m_NormalColor: {r: 1, g: 1, b: 1, a: 1} - m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} - m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} - m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} - m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} - m_ColorMultiplier: 1 - m_FadeDuration: 0.1 - m_SpriteState: - m_HighlightedSprite: {fileID: 0} - m_PressedSprite: {fileID: 0} - m_SelectedSprite: {fileID: 0} - m_DisabledSprite: {fileID: 0} - m_AnimationTriggers: - m_NormalTrigger: Normal - m_HighlightedTrigger: Highlighted - m_PressedTrigger: Pressed - m_SelectedTrigger: Selected - m_DisabledTrigger: Disabled - m_Interactable: 1 - m_TargetGraphic: {fileID: 0} - m_OnClick: - m_PersistentCalls: - m_Calls: [] + m_text: Ei kutsuttavia online-pelaajia. + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281413937 + m_fontColor: {r: 0.19215688, g: 0.19215688, b: 0.19215688, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 28 + m_fontSizeBase: 28 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 16 + m_fontSizeMax: 40 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 10, y: 6, z: 10, w: 6} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} --- !u!1 &924863700751620412 GameObject: m_ObjectHideFlags: 0 @@ -1891,10 +2915,11 @@ RectTransform: - {fileID: 1855071695233303273} - {fileID: 6132178349188500596} - {fileID: 2967702573071307971} + - {fileID: 901000000000000001} m_Father: {fileID: 7093675246889126709} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 0} - m_AnchorMax: {x: 1, y: 0.9} + m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 0, y: 0} m_Pivot: {x: 0.5, y: 0.5} @@ -1921,7 +2946,7 @@ MonoBehaviour: _toggleBotPlayerP2: {fileID: 0} _toggleBotPlayerP3: {fileID: 0} _toggleBotPlayerP4: {fileID: 0} - _buttonStartPlay: {fileID: 0} + _buttonStartPlay: {fileID: 116578133472790575} _buttonRaidTest: {fileID: 0} _nameP1: {fileID: 0} _nameP2: {fileID: 0} @@ -1968,6 +2993,9 @@ MonoBehaviour: _roomSwitcher: {fileID: 4116104174498300769} _noticeText: {fileID: 8001834333424065541} _sendInviteToFriendText: {fileID: 6755532399491747094} + _premadeTargetModeDropdown: {fileID: 0} + _inviteOnlinePlayerButton: {fileID: 6233693051039505318} + _inviteSelectorPanel: {fileID: 901000000000000004} --- !u!1 &927223887747304151 GameObject: m_ObjectHideFlags: 0 @@ -2473,8 +3501,8 @@ RectTransform: m_Children: [] m_Father: {fileID: 8151943761461288942} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} - m_AnchorMin: {x: 0, y: 0.95} - m_AnchorMax: {x: 1, y: 1} + m_AnchorMin: {x: 0, y: 0.8} + m_AnchorMax: {x: 1, y: 0.9} m_AnchoredPosition: {x: 0, y: 0} m_SizeDelta: {x: 0, y: 0} m_Pivot: {x: 0.5, y: 0.5} @@ -3492,7 +4520,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 30.55 + m_fontSize: 72 m_fontSizeBase: 36 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -3949,7 +4977,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 48 m_fontSizeBase: 42 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -4518,7 +5546,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 48 m_fontSizeBase: 42 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -4669,7 +5697,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 41.65 + m_fontSize: 72 m_fontSizeBase: 36 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -5411,6 +6439,9 @@ MonoBehaviour: _roomSwitcher: {fileID: 4116104174498300769} _noticeText: {fileID: 0} _sendInviteToFriendText: {fileID: 0} + _premadeTargetModeDropdown: {fileID: 0} + _inviteOnlinePlayerButton: {fileID: 0} + _inviteSelectorPanel: {fileID: 0} --- !u!1 &2927705854601579417 GameObject: m_ObjectHideFlags: 0 @@ -7049,7 +8080,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 49.2 m_fontSizeBase: 55.15 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -7290,7 +8321,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 30.55 + m_fontSize: 72 m_fontSizeBase: 36 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -9566,7 +10597,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 49.2 m_fontSizeBase: 55.15 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -9718,7 +10749,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 48 m_fontSizeBase: 42 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -11902,7 +12933,7 @@ MonoBehaviour: m_faceColor: serializedVersion: 2 rgba: 4294967295 - m_fontSize: 18 + m_fontSize: 48.25 m_fontSizeBase: 55.15 m_fontWeight: 400 m_enableAutoSizing: 1 @@ -14600,7 +15631,7 @@ PrefabInstance: - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_SizeDelta.x - value: 192.92514 + value: 560.1052 objectReference: {fileID: 0} - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -14617,7 +15648,7 @@ PrefabInstance: - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_fontSize - value: 18.3 + value: 48 objectReference: {fileID: 0} - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -15143,7 +16174,7 @@ PrefabInstance: - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_SizeDelta.x - value: 192.92514 + value: 560.1052 objectReference: {fileID: 0} - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -15160,7 +16191,7 @@ PrefabInstance: - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_fontSize - value: 18.3 + value: 48 objectReference: {fileID: 0} - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -15793,7 +16824,7 @@ PrefabInstance: - target: {fileID: 2245342582527431817, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_fontSize - value: 18 + value: 44.25 objectReference: {fileID: 0} - target: {fileID: 3612572125838640521, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -15828,12 +16859,12 @@ PrefabInstance: - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_SizeDelta.x - value: 32.604004 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8463783555492997815, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -16363,7 +17394,7 @@ PrefabInstance: - target: {fileID: 2245342582527431817, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_fontSize - value: 18 + value: 44.6 objectReference: {fileID: 0} - target: {fileID: 3612572125838640521, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -16388,12 +17419,12 @@ PrefabInstance: - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_SizeDelta.x - value: 21.518639 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8463783555492997815, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -17295,7 +18326,7 @@ PrefabInstance: - target: {fileID: 2245342582527431817, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_fontSize - value: 18 + value: 44.6 objectReference: {fileID: 0} - target: {fileID: 3612572125838640521, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -17320,12 +18351,12 @@ PrefabInstance: - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_SizeDelta.x - value: 21.518639 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8463783555492997815, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -17476,7 +18507,7 @@ PrefabInstance: - target: {fileID: 2245342582527431817, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_fontSize - value: 18 + value: 44.6 objectReference: {fileID: 0} - target: {fileID: 3612572125838640521, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -17501,12 +18532,12 @@ PrefabInstance: - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_SizeDelta.x - value: 21.518639 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8463783555492997815, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -17977,7 +19008,7 @@ PrefabInstance: - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_SizeDelta.x - value: 192.92514 + value: 560.1052 objectReference: {fileID: 0} - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -17992,7 +19023,7 @@ PrefabInstance: - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_fontSize - value: 18.3 + value: 48 objectReference: {fileID: 0} - target: {fileID: 8532152085635170614, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -18225,6 +19256,11 @@ PrefabInstance: propertyPath: m_AnchoredPosition.y value: -95.7096 objectReference: {fileID: 0} + - target: {fileID: 6082567443366930256, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, + type: 3} + propertyPath: _isInRoom + value: 1 + objectReference: {fileID: 0} - target: {fileID: 6691270648693797485, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_AnchorMax.y @@ -18744,7 +19780,7 @@ PrefabInstance: - target: {fileID: 4028375515925020952, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} propertyPath: m_fontSize - value: 22.3 + value: 40 objectReference: {fileID: 0} - target: {fileID: 4028375515925020952, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} @@ -19509,6 +20545,16 @@ PrefabInstance: propertyPath: m_AnchoredPosition.y value: 0 objectReference: {fileID: 0} + - target: {fileID: 7663954824446020484, guid: 3db9db4cd4ab4f84b96799395497bcfa, + type: 3} + propertyPath: _englishText + value: Leave Room + objectReference: {fileID: 0} + - target: {fileID: 7663954824446020484, guid: 3db9db4cd4ab4f84b96799395497bcfa, + type: 3} + propertyPath: _finnishText + value: "Poistu \nhuoneesta" + objectReference: {fileID: 0} - target: {fileID: 7811438372343880993, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} propertyPath: m_Type @@ -19806,7 +20852,7 @@ PrefabInstance: - target: {fileID: 2245342582527431817, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_fontSize - value: 18 + value: 44.6 objectReference: {fileID: 0} - target: {fileID: 3612572125838640521, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -19831,12 +20877,12 @@ PrefabInstance: - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_AnchorMax.y - value: 0 + value: 1 objectReference: {fileID: 0} - target: {fileID: 7483590805758421809, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} propertyPath: m_SizeDelta.x - value: 21.518639 + value: 0 objectReference: {fileID: 0} - target: {fileID: 8463783555492997815, guid: 1335c77fb45904842a7783f1ba479a62, type: 3} @@ -19882,7 +20928,7 @@ PrefabInstance: - target: {fileID: 4028375515925020952, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} propertyPath: m_fontSize - value: 18 + value: 40 objectReference: {fileID: 0} - target: {fileID: 4028375515925020952, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} @@ -20546,6 +21592,16 @@ PrefabInstance: propertyPath: m_AnchoredPosition.x value: 0 objectReference: {fileID: 0} + - target: {fileID: 7663954824446020484, guid: 3db9db4cd4ab4f84b96799395497bcfa, + type: 3} + propertyPath: _englishText + value: Start + objectReference: {fileID: 0} + - target: {fileID: 7663954824446020484, guid: 3db9db4cd4ab4f84b96799395497bcfa, + type: 3} + propertyPath: _finnishText + value: Aloita + objectReference: {fileID: 0} - target: {fileID: 7811438372343880993, guid: 3db9db4cd4ab4f84b96799395497bcfa, type: 3} propertyPath: m_Type @@ -21496,7 +22552,7 @@ PrefabInstance: - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_SizeDelta.x - value: 192.92514 + value: 560.1052 objectReference: {fileID: 0} - target: {fileID: 7797741711845899225, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -21511,7 +22567,7 @@ PrefabInstance: - target: {fileID: 8163461842882552144, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_fontSize - value: 18.3 + value: 48 objectReference: {fileID: 0} - target: {fileID: 8532152085635170614, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} @@ -21729,6 +22785,11 @@ PrefabInstance: propertyPath: m_AnchoredPosition.y value: -95.7096 objectReference: {fileID: 0} + - target: {fileID: 6082567443366930256, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, + type: 3} + propertyPath: _isInRoom + value: 1 + objectReference: {fileID: 0} - target: {fileID: 6691270648693797485, guid: 8e246b0a47c7e6b489fcb7b2eeb10763, type: 3} propertyPath: m_AnchorMax.y diff --git a/Assets/MenuUi/Resources/GameTypeReference.asset b/Assets/MenuUi/Resources/GameTypeReference.asset index 8afa815d5..4dac1431d 100644 --- a/Assets/MenuUi/Resources/GameTypeReference.asset +++ b/Assets/MenuUi/Resources/GameTypeReference.asset @@ -15,13 +15,22 @@ MonoBehaviour: _info: - Icon: {fileID: -581953326, guid: 3eeac8954e19b2f48a903a13b480ec55, type: 3} Banner: {fileID: 21300000, guid: 0f29848d83e6e3949a191807264d256a, type: 3} - Background: {fileID: 21300000, guid: 7b26f6cf383fe4d4285394b6b795d20f, type: 3} + Background: {fileID: 21300000, guid: 7b26f6cf383fe4d4285394b6b795d20f, type: 3} gameType: 1 Enabled: 1 FinnishName: "Ker\xE4ily 2v2" FinnishDescription: "Pelaa satunnaisten pelaajien kanssa satunnaistiimeiss\xE4" EnglishName: Collector 2v2 EnglishDescription: Play with random players and teams + - Icon: {fileID: -581953326, guid: 3eeac8954e19b2f48a903a13b480ec55, type: 3} + Banner: {fileID: 21300000, guid: 0f29848d83e6e3949a191807264d256a, type: 3} + Background: {fileID: 21300000, guid: 7b26f6cf383fe4d4285394b6b795d20f, type: 3} + gameType: 3 + Enabled: 1 + FinnishName: Duo 2v2 + FinnishDescription: Pelaa duon kanssa + EnglishName: Duo 2v2 + EnglishDescription: Play with a premade duo - Icon: {fileID: 1390362308, guid: 2c1ef5c1e2da228448095c34d1370feb, type: 3} Banner: {fileID: 21300000, guid: 9438445216aac314daf2381661325c49, type: 3} Background: {fileID: 21300000, guid: 2affc5251b93b4c4f86625bbd69e31e3, type: 3} diff --git a/Assets/MenuUi/Resources/InviteDecisionPanel.prefab b/Assets/MenuUi/Resources/InviteDecisionPanel.prefab new file mode 100644 index 000000000..bebf9226e --- /dev/null +++ b/Assets/MenuUi/Resources/InviteDecisionPanel.prefab @@ -0,0 +1,966 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1975582657999255549 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1314412372844923886} + - component: {fileID: 398518435663336821} + - component: {fileID: 2139956585484295374} + - component: {fileID: 802595462673599422} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1314412372844923886 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1975582657999255549} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 5146851720205949467} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.1, y: 0.1} + m_AnchorMax: {x: 0.9, y: 0.9} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &398518435663336821 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1975582657999255549} + m_CullTransparentMesh: 1 +--- !u!114 &2139956585484295374 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1975582657999255549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Liity + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281413937 + m_fontColor: {r: 0.19215688, g: 0.19215688, b: 0.19215688, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 45.8 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &802595462673599422 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1975582657999255549} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 220ef737e6ba13e48841e379d56ad79b, type: 3} + m_Name: + m_EditorClassIdentifier: + _finnishText: Liity + _englishText: Join +--- !u!1 &2138903436155762796 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3909196248017474539} + - component: {fileID: 4139031153675824652} + - component: {fileID: 3915831388473070548} + m_Layer: 5 + m_Name: InviteDecisionPanel + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &3909196248017474539 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2138903436155762796} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 8517968017114738386} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4139031153675824652 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2138903436155762796} + m_CullTransparentMesh: 1 +--- !u!114 &3915831388473070548 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2138903436155762796} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 1d9c775637884d7a8a3e9f4eeaf376f9, type: 3} + m_Name: + m_EditorClassIdentifier: + _popup: {fileID: 7706476986623892885} + _rejectButton: {fileID: 2589222831719803575} + _acceptButton: {fileID: 1005031969331987872} + _messageText: {fileID: 2864667504755610613} + _rejectButtonText: {fileID: 661011559652948227} + _acceptButtonText: {fileID: 2139956585484295374} + _defaultMessage: "Sinut kutsuttiin huoneeseen. Liityt\xE4\xE4nk\xF6?" + _defaultDeclineText: "Hylk\xE4\xE4" + _defaultAcceptText: Liity +--- !u!1 &2956264022003729281 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2427020280311030317} + - component: {fileID: 1842480168047400331} + - component: {fileID: 2864667504755610613} + - component: {fileID: 7981972590442812495} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &2427020280311030317 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2956264022003729281} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 344869134700871957} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.1, y: 0.4} + m_AnchorMax: {x: 0.9, y: 0.9} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &1842480168047400331 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2956264022003729281} + m_CullTransparentMesh: 1 +--- !u!114 &2864667504755610613 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2956264022003729281} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: "Sinut kutsuttiin huoneeseen. Liityt\xE4\xE4nk\xF6?" + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 42.75 + m_fontSizeBase: 48 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 48 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &7981972590442812495 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2956264022003729281} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 220ef737e6ba13e48841e379d56ad79b, type: 3} + m_Name: + m_EditorClassIdentifier: + _finnishText: "Sinut kutsuttiin huoneeseen. Liityt\xE4\xE4nk\xF6?" + _englishText: You have been invited to a room. Join? +--- !u!1 &5779282171730594791 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 5146851720205949467} + - component: {fileID: 4587718236259447372} + - component: {fileID: 8377236895389338371} + - component: {fileID: 1005031969331987872} + - component: {fileID: 3796417596236718709} + m_Layer: 5 + m_Name: AcceptButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &5146851720205949467 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5779282171730594791} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1314412372844923886} + m_Father: {fileID: 344869134700871957} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.55, y: 0.2} + m_AnchorMax: {x: 0.85, y: 0.2} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 103.68001} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4587718236259447372 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5779282171730594791} + m_CullTransparentMesh: 1 +--- !u!114 &8377236895389338371 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5779282171730594791} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0.1462264, b: 0.1462264, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &1005031969331987872 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5779282171730594791} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 8377236895389338371} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &3796417596236718709 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5779282171730594791} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 86710e43de46f6f4bac7c8e50813a599, type: 3} + m_Name: + m_EditorClassIdentifier: + m_AspectMode: 1 + m_AspectRatio: 2.5 +--- !u!1 &6681462213237707818 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 344869134700871957} + - component: {fileID: 966922201092353245} + - component: {fileID: 4345241651748636663} + - component: {fileID: 5701180948673388135} + m_Layer: 5 + m_Name: Image + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &344869134700871957 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6681462213237707818} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2427020280311030317} + - {fileID: 7064126630016018865} + - {fileID: 5146851720205949467} + m_Father: {fileID: 8517968017114738386} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.1, y: 0.5} + m_AnchorMax: {x: 0.9, y: 0.5} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 576} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &966922201092353245 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6681462213237707818} + m_CullTransparentMesh: 1 +--- !u!114 &4345241651748636663 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6681462213237707818} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.19223924, g: 0.26528567, b: 0.3018868, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 21300000, guid: 7a057d2e8f87d9145bcea87d66268b50, type: 3} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &5701180948673388135 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6681462213237707818} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 86710e43de46f6f4bac7c8e50813a599, type: 3} + m_Name: + m_EditorClassIdentifier: + m_AspectMode: 1 + m_AspectRatio: 1.5 +--- !u!1 &7706476986623892885 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8517968017114738386} + - component: {fileID: 4794758951583287868} + - component: {fileID: 6070586354270729784} + m_Layer: 5 + m_Name: Blocker + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 0 +--- !u!224 &8517968017114738386 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7706476986623892885} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 344869134700871957} + m_Father: {fileID: 3909196248017474539} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 1, y: 1} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4794758951583287868 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7706476986623892885} + m_CullTransparentMesh: 1 +--- !u!114 &6070586354270729784 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7706476986623892885} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0, g: 0, b: 0, a: 0.392} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10907, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &7780434279961174456 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7064126630016018865} + - component: {fileID: 4042545883980374730} + - component: {fileID: 1044697738140997009} + - component: {fileID: 2589222831719803575} + - component: {fileID: 750114685933657428} + m_Layer: 5 + m_Name: RejectButton + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &7064126630016018865 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7780434279961174456} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 2949104592673960180} + m_Father: {fileID: 344869134700871957} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.15, y: 0.2} + m_AnchorMax: {x: 0.45, y: 0.2} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 103.67999} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4042545883980374730 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7780434279961174456} + m_CullTransparentMesh: 1 +--- !u!114 &1044697738140997009 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7780434279961174456} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 0.13679248, g: 0.86327857, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0} + m_Type: 1 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!114 &2589222831719803575 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7780434279961174456} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Navigation: + m_Mode: 3 + m_WrapAround: 0 + m_SelectOnUp: {fileID: 0} + m_SelectOnDown: {fileID: 0} + m_SelectOnLeft: {fileID: 0} + m_SelectOnRight: {fileID: 0} + m_Transition: 1 + m_Colors: + m_NormalColor: {r: 1, g: 1, b: 1, a: 1} + m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1} + m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1} + m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608} + m_ColorMultiplier: 1 + m_FadeDuration: 0.1 + m_SpriteState: + m_HighlightedSprite: {fileID: 0} + m_PressedSprite: {fileID: 0} + m_SelectedSprite: {fileID: 0} + m_DisabledSprite: {fileID: 0} + m_AnimationTriggers: + m_NormalTrigger: Normal + m_HighlightedTrigger: Highlighted + m_PressedTrigger: Pressed + m_SelectedTrigger: Selected + m_DisabledTrigger: Disabled + m_Interactable: 1 + m_TargetGraphic: {fileID: 1044697738140997009} + m_OnClick: + m_PersistentCalls: + m_Calls: [] +--- !u!114 &750114685933657428 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 7780434279961174456} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 86710e43de46f6f4bac7c8e50813a599, type: 3} + m_Name: + m_EditorClassIdentifier: + m_AspectMode: 1 + m_AspectRatio: 2.5 +--- !u!1 &8573024165001618612 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 2949104592673960180} + - component: {fileID: 4111480193382309282} + - component: {fileID: 661011559652948227} + - component: {fileID: 3098847835943497535} + m_Layer: 5 + m_Name: Text (TMP) + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &2949104592673960180 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8573024165001618612} + m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 7064126630016018865} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.1, y: 0.1} + m_AnchorMax: {x: 0.9, y: 0.9} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &4111480193382309282 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8573024165001618612} + m_CullTransparentMesh: 1 +--- !u!114 &661011559652948227 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8573024165001618612} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: "Hylk\xE4\xE4" + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 35c39ce28c397b848b84f7de2f26c5ac, type: 2} + m_sharedMaterial: {fileID: 2109022488393823743, guid: 35c39ce28c397b848b84f7de2f26c5ac, + type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4281479730 + m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 36.9 + m_fontSizeBase: 24 + m_fontWeight: 400 + m_enableAutoSizing: 1 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 2 + m_VerticalAlignment: 512 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 0 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!114 &3098847835943497535 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 8573024165001618612} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 220ef737e6ba13e48841e379d56ad79b, type: 3} + m_Name: + m_EditorClassIdentifier: + _finnishText: "Hylk\xE4\xE4" + _englishText: Decline diff --git a/Assets/MenuUi/Resources/InviteDecisionPanel.prefab.meta b/Assets/MenuUi/Resources/InviteDecisionPanel.prefab.meta new file mode 100644 index 000000000..685fa86fa --- /dev/null +++ b/Assets/MenuUi/Resources/InviteDecisionPanel.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 21472cc085cd46439b268f7b2469d1ac +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MenuUi/Scripts/Lobby/BattlePopupPanelManager.cs b/Assets/MenuUi/Scripts/Lobby/BattlePopupPanelManager.cs index b9c77b81d..dd071914d 100644 --- a/Assets/MenuUi/Scripts/Lobby/BattlePopupPanelManager.cs +++ b/Assets/MenuUi/Scripts/Lobby/BattlePopupPanelManager.cs @@ -1,4 +1,6 @@ using Altzone.Scripts.Lobby; +using Altzone.Scripts; +using Altzone.Scripts.Battle.Photon; using MenuUi.Scripts.Lobby; using MenuUi.Scripts.Lobby.CreateRoom; using MenuUi.Scripts.Signals; @@ -17,6 +19,7 @@ public class BattlePopupPanelManager : MonoBehaviour [SerializeField] private GameObject _custom2v2WaitingRoom; [SerializeField] private GameObject _clanAndRandom2v2WaitingRoom; [SerializeField] private MatchmakingPanel _matchmakingPanel; + private Coroutine _delayedMatchCheckHolder; private void OnEnable() { @@ -34,20 +37,77 @@ public void SwitchRoom(GameType gameType) { ClosePanels(); + // If we're already in a matchmaking or queue room, prefer showing the matchmaking panel + bool inMatchmakingOrQueue = false; + try + { + if (PhotonRealtimeClient.InMatchmakingRoom) inMatchmakingOrQueue = true; + var curr = PhotonRealtimeClient.LobbyCurrentRoom; + if (curr != null && curr.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) inMatchmakingOrQueue = true; + } + catch { } + + bool isLeader = PhotonRealtimeClient.LocalLobbyPlayer != null && PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient; + + string currRoomName = ""; + try + { + var c = PhotonRealtimeClient.LobbyCurrentRoom; + if (c != null) currRoomName = c.Name ?? ""; + } + catch { } + + Debug.Log($"BattlePopupPanelManager.SwitchRoom: gameType={gameType}, inMatchmakingOrQueue={inMatchmakingOrQueue}, currRoom={currRoomName}, isLeader={isLeader}"); + switch (gameType) { case GameType.Custom: SwitchCustomRoom(CustomGameMode.TwoVersusTwo); break; + case GameType.FriendLobby: case GameType.Clan2v2: - _clanAndRandom2v2WaitingRoom.SetActive(true); - break; case GameType.Random2v2: - _clanAndRandom2v2WaitingRoom.SetActive(true); + if (inMatchmakingOrQueue) + { + SwitchToMatchmakingPanel(isLeader); + } + else + { + _clanAndRandom2v2WaitingRoom.SetActive(true); + // Start a short delayed check to catch race where matchmaking join finishes shortly after popup opens + try + { + if (_delayedMatchCheckHolder != null) { StopCoroutine(_delayedMatchCheckHolder); _delayedMatchCheckHolder = null; } + _delayedMatchCheckHolder = StartCoroutine(DelayedMatchCheckCoroutine(isLeader)); + Debug.Log("BattlePopupPanelManager.SwitchRoom: started delayed match check coroutine"); + } + catch { } + } break; } } + private System.Collections.IEnumerator DelayedMatchCheckCoroutine(bool isLeader) + { + yield return new WaitForSeconds(0.15f); + bool inMatchmakingOrQueue = false; + try + { + if (PhotonRealtimeClient.InMatchmakingRoom) inMatchmakingOrQueue = true; + var curr = PhotonRealtimeClient.LobbyCurrentRoom; + if (curr != null && curr.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) inMatchmakingOrQueue = true; + } + catch { } + + if (inMatchmakingOrQueue) + { + Debug.Log($"BattlePopupPanelManager.DelayedMatchCheckCoroutine: switching to matchmaking panel, isLeader={isLeader}"); + SwitchToMatchmakingPanel(isLeader); + } + + _delayedMatchCheckHolder = null; + } + public void OpenCustomRoomSettings() { ClosePanels(); @@ -69,6 +129,7 @@ private void SwitchCustomRoom(CustomGameMode mode) public void SwitchToMatchmakingPanel(bool isLeader) { + Debug.Log($"BattlePopupPanelManager.SwitchToMatchmakingPanel: isLeader={isLeader}"); ClosePanels(); _matchmakingPanel.SetCancelButton(isLeader); _matchmakingPanel.gameObject.SetActive(true); diff --git a/Assets/MenuUi/Scripts/Lobby/InLobby/InLobbyController.cs b/Assets/MenuUi/Scripts/Lobby/InLobby/InLobbyController.cs index b13983ddc..dee98ea13 100644 --- a/Assets/MenuUi/Scripts/Lobby/InLobby/InLobbyController.cs +++ b/Assets/MenuUi/Scripts/Lobby/InLobby/InLobbyController.cs @@ -2,12 +2,15 @@ using System.Collections; using Altzone.Scripts; using Altzone.Scripts.Config; +using Altzone.Scripts.Model.Poco.Game; using Altzone.Scripts.Model.Poco.Player; using Altzone.Scripts.Lobby; using UnityEngine; using UnityEngine.SceneManagement; using MenuUi.Scripts.Signals; using Altzone.Scripts.Battle.Photon; +using MenuUi.Scripts.Window; +using PopupSignalBus = MenuUI.Scripts.SignalBus; namespace MenuUi.Scripts.Signals { @@ -56,11 +59,23 @@ public class InLobbyController : AltMonoBehaviour private Coroutine _creatingRoomCoroutineHolder = null; public static GameType SelectedGameType { get; private set; } + public static GameType SelectedPremadeTargetGameType { get; private set; } = GameType.Random2v2; + + public static void SetPremadeTargetGameType(GameType gameType) + { + if (gameType == GameType.Random2v2 || gameType == GameType.Clan2v2) + { + SelectedPremadeTargetGameType = gameType; + } + } private void Awake() { SignalBus.OnBattlePopupRequested += OpenWindow; SignalBus.OnCloseBattlePopupRequested += CloseWindow; + LobbyManager.OnMatchmakingStopped += OnMatchmakingStopped; + LobbyManager.OnInRoomInviteReceived += OnInRoomInviteReceived; + LobbyManager.OnInRoomInviteJoinFailed += OnInRoomInviteJoinFailed; // Register runtime popup reference for other components to find (safe to set here because serialized field is available in Awake) PopupContentsInstance = _popupContents; OnPopupContentsInstanceAssigned?.Invoke(PopupContentsInstance); @@ -71,6 +86,9 @@ private void OnDestroy() { SignalBus.OnBattlePopupRequested -= OpenWindow; SignalBus.OnCloseBattlePopupRequested -= CloseWindow; + LobbyManager.OnMatchmakingStopped -= OnMatchmakingStopped; + LobbyManager.OnInRoomInviteReceived -= OnInRoomInviteReceived; + LobbyManager.OnInRoomInviteJoinFailed -= OnInRoomInviteJoinFailed; if (PopupContentsInstance == _popupContents) { PopupContentsInstance = null; @@ -206,7 +224,19 @@ private void OpenWindow(GameType gameType) } catch { } - if ((PhotonRealtimeClient.InMatchmakingRoom || inQueueRoom) && gameType == SelectedGameType) + bool currentRoomGameTypeMatches = false; + try + { + var currRoom = PhotonRealtimeClient.LobbyCurrentRoom; + if (currRoom != null) + { + var gt = currRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + currentRoomGameTypeMatches = gt == (int)gameType; + } + } + catch { } + + if ((PhotonRealtimeClient.InMatchmakingRoom || inQueueRoom) && currentRoomGameTypeMatches) { _roomSwitcher.SwitchToMatchmakingPanel(PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient); return; @@ -214,7 +244,7 @@ private void OpenWindow(GameType gameType) else if (PhotonRealtimeClient.InRoom) // If we are in a room { // Checking if the game type changed, if it didn't we don't want to do anything but if it did we leave the room - if (gameType == SelectedGameType) + if (currentRoomGameTypeMatches) { return; } @@ -226,6 +256,37 @@ private void OpenWindow(GameType gameType) } } break; + case GameType.FriendLobby: + bool currentFriendRoomMatches = false; + try + { + var currRoom = PhotonRealtimeClient.LobbyCurrentRoom; + if (currRoom != null) + { + var gt = currRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + currentFriendRoomMatches = gt == (int)gameType; + } + } + catch { } + + if (PhotonRealtimeClient.InMatchmakingRoom && currentFriendRoomMatches) + { + _roomSwitcher.SwitchToMatchmakingPanel(PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient); + return; + } + + if (PhotonRealtimeClient.InRoom) + { + if (currentFriendRoomMatches) + { + _roomSwitcher.SwitchRoom(GameType.FriendLobby); + return; + } + + LobbyManager.Instance.StopMatchmakingCoroutines(); + PhotonRealtimeClient.LeaveRoom(); + } + break; default: return; } @@ -248,6 +309,12 @@ public void CloseWindow() _popupContents.SetActive(false); } + private void OnMatchmakingStopped() + { + // Any matchmaking stop should close the battle popup to avoid stale queue UI. + CloseWindow(); + } + private void RefreshTopInfo() { try @@ -285,5 +352,88 @@ private void QuickGameButtonOnClick() { Debug.Log($"{PhotonRealtimeClient.LobbyNetworkClientState}"); } + + private void OnInRoomInviteReceived(LobbyManager.InRoomInviteInfo inviteInfo) + { + if (inviteInfo == null || string.IsNullOrEmpty(inviteInfo.RoomName)) return; + + string inviterName = ResolveOnlinePlayerName(inviteInfo.LeaderUserId); + string targetMode = inviteInfo.TargetGameType == GameType.Clan2v2 ? "Clan 2v2" : "Random 2v2"; + string message = $"{inviterName} kutsui sinut Friend Lobby -huoneeseen. Haettava pelimuoto: {targetMode}. Liitytaanko huoneeseen?"; + + bool popupShown = InviteDecisionPopupHandler.RequestInviteDecisionPrompt( + message, + "Liity", + "Hylkää", + accepted => + { + if (LobbyManager.Instance == null) return; + if (accepted) + { + OpenBattlePopupForInviteAccept(); + LobbyManager.Instance.AcceptInRoomInvite(inviteInfo.RoomName); + } + else LobbyManager.Instance.DeclineInRoomInvite(inviteInfo.RoomName); + }); + + if (!popupShown) + { + Debug.LogWarning("OnInRoomInviteReceived: decision popup unavailable, declining invite to fail closed."); + LobbyManager.Instance?.DeclineInRoomInvite(inviteInfo.RoomName); + PopupSignalBus.OnChangePopupInfoSignal("Friend Lobby -kutsu saatu, mutta vahvistusikkunaa ei voitu avata. Kutsu hylättiin turvallisuussyista."); + } + } + + private void OpenBattlePopupForInviteAccept() + { + SelectedGameType = GameType.FriendLobby; + + if (_popupContents != null && !_popupContents.activeSelf) + { + _popupContents.SetActive(true); + } + + RefreshTopInfo(); + _roomSwitcher?.SwitchRoom(GameType.FriendLobby); + } + + private string ResolveOnlinePlayerName(string userId) + { + if (string.IsNullOrEmpty(userId)) return "Pelaaja"; + + try + { + var onlinePlayers = ServerManager.Instance?.OnlinePlayers; + if (onlinePlayers != null) + { + foreach (ServerOnlinePlayer player in onlinePlayers) + { + if (player == null || player._id != userId) continue; + if (!string.IsNullOrWhiteSpace(player.name)) return player.name; + break; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"ResolveOnlinePlayerName failed: {ex.Message}"); + } + + return userId; + } + + private void OnInRoomInviteJoinFailed(string roomName, short returnCode, string message) + { + bool isRoomFull = returnCode == 32765 + || (!string.IsNullOrEmpty(message) && message.ToLowerInvariant().Contains("game full")); + + string popupMessage = isRoomFull + ? "Friend Lobby -kutsuun liittyminen epaonnistui: huone on taynna tai kutsu ei ole enaa voimassa." + : "Friend Lobby -kutsuun liittyminen epaonnistui. Yrita uudelleen, jos kutsu on yha voimassa."; + + PopupSignalBus.OnChangePopupInfoSignal(popupMessage); + // Close the battle popup since join failed and we're not in the FriendLobby room + try { CloseWindow(); } catch (Exception ex) { Debug.LogWarning($"OnInRoomInviteJoinFailed: failed to close popup: {ex.Message}"); } + } } } diff --git a/Assets/MenuUi/Scripts/Lobby/InLobby/LobbyRoomListingController.cs b/Assets/MenuUi/Scripts/Lobby/InLobby/LobbyRoomListingController.cs index 92c6b826b..d7ce6f4e8 100644 --- a/Assets/MenuUi/Scripts/Lobby/InLobby/LobbyRoomListingController.cs +++ b/Assets/MenuUi/Scripts/Lobby/InLobby/LobbyRoomListingController.cs @@ -37,6 +37,7 @@ public class LobbyRoomListingController : AltMonoBehaviour private JoinIntent _pendingJoinIntent = JoinIntent.None; private GameType _pendingQueueGameType = GameType.Random2v2; private Coroutine _queueRejoinHolder; + private bool _createRoomRequestInFlight; private enum JoinIntent { @@ -92,6 +93,7 @@ public void OnDisable() StopCoroutine(_queueRejoinHolder); _queueRejoinHolder = null; } + _createRoomRequestInFlight = false; _pendingJoinIntent = JoinIntent.None; } @@ -111,6 +113,15 @@ private void OnDestroy() /// public IEnumerator StartCreatingRoom(GameType gameType, Action callback) { + if (_createRoomRequestInFlight) + { + Debug.Log("StartCreatingRoom: room creation already in flight, ignoring duplicate request."); + yield break; + } + + _createRoomRequestInFlight = true; + try + { // Do not show the creating-room text if the client is in a matchmaking or queue room bool isMatchmakingOrQueue = PhotonRealtimeClient.InMatchmakingRoom; try @@ -133,6 +144,9 @@ public IEnumerator StartCreatingRoom(GameType gameType, Action callback) { switch (gameType) { + case GameType.FriendLobby: + PhotonRealtimeClient.CreateInRoomPremadeLobbyRoom(); + break; case GameType.Clan2v2: CreateClan2v2Room(); break; @@ -148,7 +162,12 @@ public IEnumerator StartCreatingRoom(GameType gameType, Action callback) } } while (!roomCreated); - callback(); + callback?.Invoke(); + } + finally + { + _createRoomRequestInFlight = false; + } } private void CreateCustomRoom() @@ -263,6 +282,14 @@ public void OnJoinedRoomFailed(short returnCode, string message) if (ShouldRejoinQueueAfterJoinFailed(returnCode, message, out GameType queueGameType)) { + if (LobbyManager.Instance != null && LobbyManager.Instance.IsJoinFailureAutoRequeueInFlight) + { + if (creatingTextActive) _creatingRoomText.SetActive(false); + _pendingJoinIntent = JoinIntent.None; + Debug.Log("OnJoinedRoomFailed: skipping queue rejoin because LobbyManager already started recovery."); + return; + } + if (creatingTextActive) _creatingRoomText.SetActive(false); _pendingJoinIntent = JoinIntent.None; StartQueueRejoin(queueGameType); diff --git a/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomController.cs b/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomController.cs index 43c7c1923..8c4efec93 100644 --- a/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomController.cs +++ b/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomController.cs @@ -30,6 +30,15 @@ public class InRoomController : MonoBehaviour [SerializeField] private BattlePopupPanelManager _roomSwitcher; [SerializeField] private TMP_Text _noticeText; [SerializeField] private TMP_Text _sendInviteToFriendText; + + [SerializeField] private Button _inviteOnlinePlayerButton; + [SerializeField] private InRoomInviteSelectorPanel _inviteSelectorPanel; + + private Coroutine _inviteLifecycleHolder; + private const float InviteLifecycleTickSeconds = 1f; + private const long InviteExpirationSeconds = 60; + private Coroutine _customRoomTimeoutHolder; + private const float CustomRoomTimeoutSeconds = 60f; private void Awake() { @@ -37,16 +46,28 @@ private void Awake() //buttons[1].onClick.AddListener(SetPlayerAsSpectator); _startGameButton.onClick.AddListener(StartPlaying); _backButton.onClick.AddListener(GoBack); + // premade target-mode selector removed until prefab wiring is fixed + if (_inviteOnlinePlayerButton != null) _inviteOnlinePlayerButton.onClick.AddListener(OnInviteOnlinePlayerButtonPressed); //buttons[3].onClick.AddListener(StartRaidTest); } private void OnEnable() { + if (_startGameButton != null) _startGameButton.interactable = true; + if (_inviteOnlinePlayerButton != null) _inviteOnlinePlayerButton.interactable = InLobbyController.SelectedGameType == GameType.FriendLobby; + switch (InLobbyController.SelectedGameType) { case GameType.Custom: if (_title != null) StartCoroutine(SetRoomTitle()); if (_conflictText != null) StartCoroutine(CycleConflicts()); + StartCustomRoomTimeoutMonitoring(); + break; + case GameType.FriendLobby: + if (_title != null) _title.text = "Friend Lobby"; + if (_noticeText != null) _noticeText.text = "Kutsu yksi online-pelaaja ja valitse haettava 2v2 pelimuoto."; + if (_sendInviteToFriendText != null) _sendInviteToFriendText.text = "Kutsu online-pelaaja"; + EnsureInviteSelectorPanel(); break; case GameType.Random2v2: //if (_title != null) _title.text = "Keräily 2v2"; @@ -61,14 +82,40 @@ private void OnEnable() if (_sendInviteToFriendText != null) _sendInviteToFriendText.text = "Lähetä kutsu yhdelle klaanin jäsenelle"; break; } + + if (InLobbyController.SelectedGameType == GameType.FriendLobby) + { + StartInviteLifecycleMonitoring(); + } + else + { + StopInviteLifecycleMonitoring(); + } + + if (InLobbyController.SelectedGameType != GameType.Custom) + { + StopCustomRoomTimeoutMonitoring(); + } } private void OnDestroy() { + // premade target-mode selector removed until prefab wiring is fixed + if (_inviteOnlinePlayerButton != null) _inviteOnlinePlayerButton.onClick.RemoveListener(OnInviteOnlinePlayerButtonPressed); + if (_inviteSelectorPanel != null) _inviteSelectorPanel.HideSilently(); + StopInviteLifecycleMonitoring(); + StopCustomRoomTimeoutMonitoring(); _startGameButton.onClick.RemoveAllListeners(); _backButton.onClick.RemoveAllListeners(); } + private void OnDisable() + { + if (_inviteSelectorPanel != null) _inviteSelectorPanel.HideSilently(); + StopInviteLifecycleMonitoring(); + StopCustomRoomTimeoutMonitoring(); + } + private void SetPlayerAsGuest() { Debug.Log($"setPlayerAsGuest {PhotonLobbyRoom.PlayerPositionGuest}"); @@ -83,6 +130,11 @@ private void SetPlayerAsSpectator() private void StartPlaying() { + void RestoreStartButton() + { + if (_startGameButton != null) _startGameButton.interactable = true; + } + //if (!PhotonLobbyRoom.IsValidAllSelectedCharacters()) //{ // SignalBus.OnChangePopupInfoSignal("Kaikkien pelaajien pitää ensin valita 3 puolustushahmoa."); @@ -96,6 +148,50 @@ private void StartPlaying() this.Publish(new LobbyManager.StartPlayingEvent()); break; + case GameType.FriendLobby: + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + RestoreStartButton(); + return; + } + + if (!PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + { + PopupSignalBus.OnChangePopupInfoSignal("Vain huoneen johtaja voi aloittaa matchmakingin."); + RestoreStartButton(); + return; + } + + if (PhotonLobbyRoom.CountRealPlayers() != PhotonRealtimeClient.LobbyCurrentRoom.MaxPlayers) + { + PopupSignalBus.OnChangePopupInfoSignal($"Huoneessa pitää olla {PhotonRealtimeClient.LobbyCurrentRoom.MaxPlayers} pelaajaa."); + RestoreStartButton(); + return; + } + + GameType targetGameType = InLobbyController.SelectedPremadeTargetGameType; + if (targetGameType != GameType.Random2v2 && targetGameType != GameType.Clan2v2) + { + targetGameType = GameType.Random2v2; + } + + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId ?? string.Empty; + string teammateUserId = string.Empty; + foreach (var player in PhotonRealtimeClient.CurrentRoom.Players.Values) + { + if (player == null || player.UserId == localUserId) continue; + teammateUserId = player.UserId; + break; + } + + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)targetGameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, teammateUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + this.Publish(new LobbyManager.StartMatchmakingEvent(targetGameType, true)); + break; + case GameType.Clan2v2: if (PhotonLobbyRoom.CountRealPlayers() == PhotonRealtimeClient.LobbyCurrentRoom.MaxPlayers) { @@ -106,6 +202,7 @@ private void StartPlaying() if (curr != null && curr.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) { Debug.Log("StartPlaying suppressed: current room is a queue room (Clan2v2)."); + RestoreStartButton(); return; } } @@ -115,6 +212,7 @@ private void StartPlaying() else { PopupSignalBus.OnChangePopupInfoSignal($"Huoneessa pitää olla {PhotonRealtimeClient.LobbyCurrentRoom.MaxPlayers} pelaajaa."); + RestoreStartButton(); } break; case GameType.Random2v2: @@ -125,6 +223,7 @@ private void StartPlaying() if (curr != null && curr.GetCustomProperty(PhotonBattleRoom.IsQueueKey)) { Debug.Log("StartPlaying suppressed: current room is a queue room (Random2v2)."); + RestoreStartButton(); return; } } @@ -133,6 +232,443 @@ private void StartPlaying() break; } } + + // Premade target selector UI path temporarily removed. + + private void OnInviteOnlinePlayerButtonPressed() + { + if (InLobbyController.SelectedGameType != GameType.FriendLobby) return; + StartCoroutine(InviteOnlinePlayerRoutine()); + } + + private IEnumerator InviteOnlinePlayerRoutine() + { + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) yield break; + + if (!PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + { + PopupSignalBus.OnChangePopupInfoSignal("Vain huoneen johtaja voi lähettää kutsun."); + yield break; + } + + List candidates = null; + yield return StartCoroutine(GetInviteCandidatesRoutine(result => candidates = result)); + + if (candidates == null || candidates.Count == 0) + { + PopupSignalBus.OnChangePopupInfoSignal("Ei sopivia online-pelaajia kutsuttavaksi."); + yield break; + } + + EnsureInviteSelectorPanel(); + if (_inviteSelectorPanel == null) + { + PopupSignalBus.OnChangePopupInfoSignal("Kutsulistaa ei voitu avata. Yrita uudelleen."); + yield break; + } + + SetInviteButtonInteractable(false); + _inviteSelectorPanel.Show( + candidates, + selectedPlayer => + { + SetInviteButtonInteractable(true); + SendInviteToOnlinePlayer(selectedPlayer); + }, + () => + { + SetInviteButtonInteractable(true); + PopupSignalBus.OnChangePopupInfoSignal("Kutsun lähetys peruttu."); + }); + } + + private IEnumerator GetInviteCandidatesRoutine(Action> callback) + { + List onlinePlayers = null; + if (ServerManager.Instance != null) + { + List fetchedPlayers = null; + yield return StartCoroutine(ServerManager.Instance.GetOnlinePlayersFromServer(players => fetchedPlayers = players)); + + // Always prefer a fresh server snapshot when opening the invite list. + onlinePlayers = fetchedPlayers ?? ServerManager.Instance.OnlinePlayers; + } + + callback?.Invoke(FilterInviteCandidates(onlinePlayers)); + } + + private List FilterInviteCandidates(List onlinePlayers) + { + List candidates = new(); + if (onlinePlayers == null || onlinePlayers.Count == 0) return candidates; + + string localUserId = GetLocalUserId(); + foreach (ServerOnlinePlayer onlinePlayer in onlinePlayers) + { + if (onlinePlayer == null || string.IsNullOrEmpty(onlinePlayer._id)) continue; + if (onlinePlayer._id == localUserId) continue; + if (IsPlayerAlreadyInCurrentRoom(onlinePlayer._id)) continue; + candidates.Add(onlinePlayer); + } + + return candidates; + } + + private void SendInviteToOnlinePlayer(ServerOnlinePlayer onlinePlayer) + { + if (onlinePlayer == null || string.IsNullOrEmpty(onlinePlayer._id)) + { + PopupSignalBus.OnChangePopupInfoSignal("Virheellinen kutsuttava pelaaja."); + return; + } + + if (!TrySendInviteToUserId(onlinePlayer._id)) + { + return; + } + + PopupSignalBus.OnChangePopupInfoSignal($"Kutsu lähetetty pelaajalle {GetOnlinePlayerDisplayName(onlinePlayer)}."); + } + + private bool TrySendInviteToUserId(string invitedUserId) + { + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + return false; + } + + if (!PhotonRealtimeClient.LocalLobbyPlayer.IsMasterClient) + { + PopupSignalBus.OnChangePopupInfoSignal("Vain huoneen johtaja voi lähettää kutsun."); + return false; + } + + if (string.IsNullOrEmpty(invitedUserId)) + { + PopupSignalBus.OnChangePopupInfoSignal("Sopivaa kutsuttavaa online-pelaajaa ei löytynyt."); + return false; + } + + string localUserId = GetLocalUserId(); + if (string.IsNullOrEmpty(localUserId) || invitedUserId == localUserId) return false; + if (IsPlayerAlreadyInCurrentRoom(invitedUserId)) + { + PopupSignalBus.OnChangePopupInfoSignal("Valittu pelaaja on jo huoneessa."); + return false; + } + + int inviteState = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); + string currentInvitedUserId = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, string.Empty); + if (inviteState == PhotonBattleRoom.PremadeInviteStatePending && currentInvitedUserId == invitedUserId) + { + PopupSignalBus.OnChangePopupInfoSignal("Kutsu on jo lähetetty tälle pelaajalle."); + return false; + } + + try + { + PhotonRealtimeClient.LobbyCurrentRoom.ClearExpectedUsers(); + PhotonRealtimeClient.LobbyCurrentRoom.SetExpectedUsers(new[] { invitedUserId }); + } + catch (Exception ex) + { + Debug.LogWarning($"TrySendInviteToUserId: failed to set expected users: {ex.Message}"); + } + + ApplyPendingPremadeInviteSelection(invitedUserId, localUserId, InLobbyController.SelectedPremadeTargetGameType); + + return true; + } + + private void ApplyPendingPremadeInviteSelection(string invitedUserId, string localUserId, GameType targetGameType) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeModeKey, true); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeLeaderUserIdKey, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, invitedUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStatePending); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteTimestampKey, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeTargetGameTypeKey, (int)targetGameType); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId1Key, localUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + } + + private void MarkPremadeInviteAccepted(string invitedUserId) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateAccepted); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, invitedUserId); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteTimestampKey, 0L); + + if (PhotonRealtimeClient.CurrentRoom.PlayerCount >= PhotonRealtimeClient.CurrentRoom.MaxPlayers) + { + PhotonRealtimeClient.CurrentRoom.IsOpen = false; + } + } + + private void ExpirePremadeInvite() + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateExpired); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, string.Empty); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeUserId2Key, string.Empty); + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteTimestampKey, 0L); + + try { PhotonRealtimeClient.LobbyCurrentRoom.ClearExpectedUsers(); } + catch (Exception ex) { Debug.LogWarning($"InviteLifecycleRoutine: failed to clear expected users on expiry: {ex.Message}"); } + } + + private void SetInviteButtonInteractable(bool interactable) + { + if (_inviteOnlinePlayerButton != null) + { + _inviteOnlinePlayerButton.interactable = interactable; + } + } + + private string GetLocalUserId() + { + string localUserId = PhotonRealtimeClient.LocalPlayer?.UserId; + if (string.IsNullOrEmpty(localUserId) && ServerManager.Instance?.Player != null) + { + localUserId = ServerManager.Instance.Player._id; + } + + return localUserId; + } + + private bool IsPlayerAlreadyInCurrentRoom(string userId) + { + if (string.IsNullOrEmpty(userId) || PhotonRealtimeClient.CurrentRoom == null) return false; + + foreach (var player in PhotonRealtimeClient.CurrentRoom.Players.Values) + { + if (player == null || string.IsNullOrEmpty(player.UserId)) continue; + if (player.UserId == userId) return true; + } + + return false; + } + + private static string GetOnlinePlayerDisplayName(ServerOnlinePlayer onlinePlayer) + { + if (onlinePlayer == null) return "Tuntematon"; + if (!string.IsNullOrWhiteSpace(onlinePlayer.name)) return onlinePlayer.name; + return string.IsNullOrEmpty(onlinePlayer._id) ? "Tuntematon" : onlinePlayer._id; + } + + private void StartInviteLifecycleMonitoring() + { + if (_inviteLifecycleHolder != null) + { + return; + } + + _inviteLifecycleHolder = StartCoroutine(InviteLifecycleRoutine()); + } + + private void StopInviteLifecycleMonitoring() + { + if (_inviteLifecycleHolder == null) + { + return; + } + + StopCoroutine(_inviteLifecycleHolder); + _inviteLifecycleHolder = null; + } + + private void StartCustomRoomTimeoutMonitoring() + { + if (_customRoomTimeoutHolder != null) + { + return; + } + + _customRoomTimeoutHolder = StartCoroutine(CustomRoomTimeoutRoutine()); + } + + private void StopCustomRoomTimeoutMonitoring() + { + if (_customRoomTimeoutHolder == null) + { + return; + } + + StopCoroutine(_customRoomTimeoutHolder); + _customRoomTimeoutHolder = null; + } + + private IEnumerator CustomRoomTimeoutRoutine() + { + try + { + yield return new WaitUntil(() => PhotonRealtimeClient.InRoom || InLobbyController.SelectedGameType != GameType.Custom); + + if (InLobbyController.SelectedGameType != GameType.Custom || !PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + yield break; + } + + bool isCustomRoom = false; + try + { + isCustomRoom = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == (int)GameType.Custom; + } + catch { } + + if (!isCustomRoom) + { + yield break; + } + + yield return new WaitForSecondsRealtime(CustomRoomTimeoutSeconds); + + if (InLobbyController.SelectedGameType != GameType.Custom || !PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + yield break; + } + + if (_startGameButton != null && !_startGameButton.interactable) + { + yield break; + } + + try + { + isCustomRoom = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey) == (int)GameType.Custom; + } + catch + { + isCustomRoom = false; + } + + if (!isCustomRoom) + { + yield break; + } + + Debug.Log($"Custom room timeout reached after {CustomRoomTimeoutSeconds}s, leaving room."); + GoBack(); + } + finally + { + _customRoomTimeoutHolder = null; + } + } + + private IEnumerator InviteLifecycleRoutine() + { + WaitForSecondsRealtime delay = new(InviteLifecycleTickSeconds); + + while (true) + { + if (!PhotonRealtimeClient.InRoom || PhotonRealtimeClient.CurrentRoom == null) + { + yield return delay; + continue; + } + + var localLobbyPlayer = PhotonRealtimeClient.LocalLobbyPlayer; + if (localLobbyPlayer == null || !localLobbyPlayer.IsMasterClient) + { + yield return delay; + continue; + } + + GameType roomGameType = GameType.FriendLobby; + bool failedToReadRoomGameType = false; + try + { + roomGameType = (GameType)PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.GameTypeKey); + } + catch + { + failedToReadRoomGameType = true; + } + + if (failedToReadRoomGameType) + { + yield return delay; + continue; + } + + if (roomGameType != GameType.FriendLobby) + { + yield return delay; + continue; + } + + int inviteState = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeInviteStateKey, PhotonBattleRoom.PremadeInviteStateNone); + if (inviteState != PhotonBattleRoom.PremadeInviteStatePending) + { + yield return delay; + continue; + } + + string invitedUserId = PhotonRealtimeClient.CurrentRoom.GetCustomProperty(PhotonBattleRoom.PremadeInvitedUserIdKey, string.Empty); + if (string.IsNullOrEmpty(invitedUserId)) + { + yield return delay; + continue; + } + + if (IsPlayerAlreadyInCurrentRoom(invitedUserId)) + { + MarkPremadeInviteAccepted(invitedUserId); + yield return delay; + continue; + } + + long inviteTimestampMilliseconds = 0; + try + { + if (PhotonRealtimeClient.CurrentRoom.CustomProperties != null + && PhotonRealtimeClient.CurrentRoom.CustomProperties.ContainsKey(PhotonBattleRoom.PremadeInviteTimestampKey)) + { + inviteTimestampMilliseconds = Convert.ToInt64(PhotonRealtimeClient.CurrentRoom.CustomProperties[PhotonBattleRoom.PremadeInviteTimestampKey]); + } + } + catch + { + inviteTimestampMilliseconds = 0; + } + + long nowMilliseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (inviteTimestampMilliseconds <= 0) + { + PhotonRealtimeClient.CurrentRoom.SetCustomProperty(PhotonBattleRoom.PremadeInviteTimestampKey, nowMilliseconds); + yield return delay; + continue; + } + + if (nowMilliseconds - inviteTimestampMilliseconds >= InviteExpirationSeconds * 1000L) + { + ExpirePremadeInvite(); + yield return delay; + continue; + } + + yield return delay; + } + } + + private void EnsureInviteSelectorPanel() + { + if (_inviteSelectorPanel != null) + { + return; + } + + _inviteSelectorPanel = GetComponentInChildren(true); + if (_inviteSelectorPanel == null) + { + Debug.LogWarning("InRoomController: InRoomInviteSelectorPanel prefab instance is missing from Battle Popup hierarchy."); + if (_inviteOnlinePlayerButton != null) + { + _inviteOnlinePlayerButton.interactable = false; + } + return; + } + } + private void GoBack() { Debug.Log($"leavingRoom"); diff --git a/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomInviteSelectorPanel.cs b/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomInviteSelectorPanel.cs new file mode 100644 index 000000000..a7791a2ca --- /dev/null +++ b/Assets/MenuUi/Scripts/Lobby/InRoom/InRoomInviteSelectorPanel.cs @@ -0,0 +1,405 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Prg.Scripts.Common; +using TMPro; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +namespace MenuUi.Scripts.Lobby.InRoom +{ + /// + /// Dedicated panel for selecting which online player to invite into a Friend Lobby premade room. + /// Assigned from prefab. + /// + public class InRoomInviteSelectorPanel : MonoBehaviour + { + [SerializeField] private GameObject _root; + [SerializeField] private RectTransform _contentRoot; + [SerializeField] private Button _closeButton; + [SerializeField] private TMP_Text _titleText; + [SerializeField] private TMP_Text _emptyText; + [SerializeField] private Image _overlayImage; + [SerializeField] private Image _cardImage; + [SerializeField] private Image _scrollBackgroundImage; + [SerializeField] private Image _closeButtonImage; + [SerializeField] private TMP_Text _closeButtonText; + + [Header("Fallback Style")] + [SerializeField] private Color _fallbackOverlayColor = new(0f, 0f, 0f, 0.62f); + [SerializeField] private Color _fallbackCardColor = new(1f, 1f, 1f, 0.98f); + [SerializeField] private Color _fallbackScrollColor = new(0.95f, 0.95f, 0.95f, 0.95f); + [SerializeField] private Color _fallbackButtonColor = new(0.841f, 0.635f, 0.973f, 1f); + [SerializeField] private Color _fallbackTextColor = new(0.196f, 0.196f, 0.196f, 1f); + [SerializeField] private float _fallbackRowFontSize = 24f; + + private readonly List _spawnedRows = new(); + private Action _onSelected; + private Action _onCancelled; + + private Sprite _rowSprite; + private Material _rowMaterial; + private Image.Type _rowImageType = Image.Type.Simple; + private Color _rowColor; + private ColorBlock _rowColorBlock; + private TMP_FontAsset _rowFontAsset; + private Color _rowTextColor; + private float _rowFontSize; + private bool _rowStyleInitialized; + private bool _closing; + + public bool IsVisible => _root != null && _root.activeSelf; + + private void Awake() + { + if (_root == null) + { + _root = gameObject; + } + + WireUi(); + EnsureFallbackStyleInitialized(); + } + + private void OnDestroy() + { + if (_closeButton != null) + { + _closeButton.onClick.RemoveListener(OnClosePressed); + } + } + + private void LateUpdate() + { + if (!IsVisible) + { + _closing = false; + return; + } + + if (ClickStateHandler.GetClickState() is ClickState.Start) + { + if (!IsPointerOnSelectorCard()) + { + _closing = true; + } + } + + if (ClickStateHandler.GetClickState() is ClickState.End && _closing) + { + if (!IsPointerOnSelectorCard()) + { + Hide(true); + } + _closing = false; + } + } + + public void Show(List players, Action onSelected, Action onCancelled = null) + { + if (_root == null) + { + _root = gameObject; + } + + // Show can be called before Awake when panel starts inactive in prefab. + WireUi(); + EnsureFallbackStyleInitialized(); + + if (_root == null || _contentRoot == null) + { + Debug.LogWarning("InRoomInviteSelectorPanel: missing UI references."); + return; + } + + _onSelected = onSelected; + _onCancelled = onCancelled; + + if (_titleText != null) + { + _titleText.text = "Valitse kutsuttava online-pelaaja"; + } + + BuildPlayerRows(players); + _root.SetActive(true); + } + + public void ConfigureVisualStyle(Button styleSourceButton) + { + if (styleSourceButton == null) + { + return; + } + + Image sourceImage = styleSourceButton.targetGraphic as Image; + TMP_Text sourceText = styleSourceButton.GetComponentInChildren(true); + + if (sourceImage != null) + { + _rowSprite = sourceImage.sprite; + _rowMaterial = sourceImage.material; + _rowImageType = sourceImage.sprite != null ? sourceImage.type : Image.Type.Simple; + _rowColor = sourceImage.color; + } + + _rowColorBlock = styleSourceButton.colors; + + if (sourceText != null) + { + _rowTextColor = sourceText.color; + _rowFontSize = Mathf.Max(18f, sourceText.fontSize); + if (sourceText.font != null) + { + _rowFontAsset = sourceText.font; + } + } + + _rowStyleInitialized = true; + } + + public void Hide(bool invokeCancel) + { + if (_root == null) + { + return; + } + + bool wasVisible = _root.activeSelf; + _root.SetActive(false); + ClearRows(); + + Action onCancelled = _onCancelled; + _onSelected = null; + _onCancelled = null; + + if (invokeCancel && wasVisible) + { + onCancelled?.Invoke(); + } + } + + public void HideSilently() + { + Hide(false); + } + + private void WireUi() + { + if (_closeButton == null) + { + return; + } + + if (_closeButtonImage == null) + { + _closeButtonImage = _closeButton.targetGraphic as Image; + } + + if (_closeButtonText == null) + { + _closeButtonText = _closeButton.GetComponentInChildren(true); + } + + _closeButton.onClick.RemoveListener(OnClosePressed); + _closeButton.onClick.AddListener(OnClosePressed); + } + + private void OnClosePressed() + { + Hide(true); + } + + private void OnPlayerPressed(ServerOnlinePlayer player) + { + Action onSelected = _onSelected; + Hide(false); + onSelected?.Invoke(player); + } + + private void BuildPlayerRows(List players) + { + ClearRows(); + + int candidateCount = players?.Count ?? 0; + if (_emptyText != null) + { + _emptyText.gameObject.SetActive(candidateCount == 0); + _emptyText.text = "Ei kutsuttavia online-pelaajia."; + } + + if (candidateCount == 0) + { + return; + } + + foreach (ServerOnlinePlayer player in players + .Where(player => player != null) + .OrderBy(GetDisplayName, StringComparer.OrdinalIgnoreCase)) + { + GameObject row = CreateRowObject(_contentRoot, player); + Button button = row.GetComponent