using BulletHellTemplate.PVP; using Cysharp.Threading.Tasks; #if FUSION2 using Fusion; using Fusion.Sockets; #endif using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.SceneManagement; namespace BulletHellTemplate { /// /// Manages lobby flow (create, join-by-code, auto-join) using Photon Fusion 2 in Shared mode. /// Stores public player data and exposes it to the UI layer. /// public class FusionLobbyManager : MonoBehaviour #if FUSION2 , INetworkRunnerCallbacks #endif { /* ------------------------------------------------------------------ */ #region Inspector [Header("Lobby Settings")] public GameObject masterClientTrackerPrefab; [Header("Coop")] public GameObject gameSyncPrefab; [Tooltip("Maximum simultaneous players per session.")] public int defaultMaxPlayers = 3; [Header("PVP")] public GameObject pvpSyncPrefab; [Header("PVP Debug")] public bool pvpDebugStartSolo = false; #endregion public static FusionLobbyManager Instance { get; private set; } /* ------------------------------------------------------------------ */ #if FUSION2 #region Runtime fields private NetworkRunner _runner; public NetworkRunner Runner => _runner; private PoolObjectProvider _provider; private readonly Dictionary _players = new(); private PlayerRef _currentHost; private readonly Dictionary _ref2idx = new(); private string _currentSession; private string _selectedMap; private bool _isHostCached = false; private bool _waitingGameplayScene; private SceneRef _waitingRef; private bool _matchStarted = false; private int _matchBuildIndex = -1; private float _startGuardUntil = 0f; // Helper objects for async session search private readonly AutoResetEvent _searchDone = new(false); private List _lastSessionList = new(); private PlayerData _pendingLocalData; private readonly HashSet _matchAcks = new(); private readonly HashSet _sceneReadyAcks = new(); private enum LobbyMsg : byte { PlayerInfo = 1, MatchStart = 2, MatchStartAck = 3, SceneReady = 4 } private int _reliableSeq = 0; //PVP public event Action OnPlayerCountChanged; // (count, capacity) public int CurrentPlayerCount { get { if (_runner == null || !_runner.IsRunning) return 0; return _runner.ActivePlayers.Count(); } } public int CurrentMaxPlayers { get; private set; } public string CurrentPvpModeKey { get; private set; } private bool isPvpGame; #endregion /* ------------------------------------------------------------------ */ #region Mono-life-cycle private void Awake() { if (Instance == null) { Instance = this; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } } #endregion /* ------------------------------------------------------------------ */ #region Public API (Create / Join / AutoJoin / Leave) /// Creates a new shared-mode session and becomes the host player. /// /// Creates a new Shared-mode session and becomes host player. /// Scene is resolved by name using . /// public async void CreateRoom(string mapName, PlayerData _playerData, Action onJoined) { EnsureRunnerClosed(); PrepareRunner(); _pendingLocalData = _playerData; _currentSession = GenerateCode(); _selectedMap = mapName; int buildIdx = GetBuildIndexBySceneName(mapName); if (buildIdx < 0) { Debug.LogError($"Scene '{mapName}' is not in Build Settings!"); return; } SceneRef lobbyRef = SceneRef.FromIndex(SceneManager.GetActiveScene().buildIndex); var result = await _runner.StartGame(new StartGameArgs { GameMode = GameMode.Shared, SessionName = _currentSession, Scene = lobbyRef, PlayerCount = defaultMaxPlayers, SceneManager = gameObject.AddComponent(), SessionProperties = new() { { "map", (SessionProperty)mapName } }, ObjectProvider = _provider }); if (result.Ok) { _pendingLocalData = _playerData; if (result.Ok) { if (_runner.IsSharedModeMasterClient && masterClientTrackerPrefab) { await _runner.SpawnAsync(masterClientTrackerPrefab); } onJoined?.Invoke(); Debug.Log($"Room {_currentSession} created"); } else { Debug.LogError($"StartGame failed: {result.ShutdownReason}"); } onJoined?.Invoke(); Debug.Log($"Room {_currentSession} created on map {mapName}"); } else Debug.LogError($"StartGame failed: {result.ShutdownReason}"); } /// Joins an existing session by its six-digit code. public async void JoinRoom(string code, PlayerData data, Action onJoined, Action onFailed) { EnsureRunnerClosed(); PrepareRunner(); _pendingLocalData = data; _currentSession = code; var result = await _runner.StartGame(new StartGameArgs { GameMode = GameMode.Shared, SessionName = code, SceneManager = gameObject.AddComponent(), ObjectProvider = _provider }); if (result.Ok) { onJoined?.Invoke(); Debug.Log($"Joined room {code}"); } else { Debug.LogError($"Join failed: {result.ShutdownReason}"); onFailed?.Invoke(result.ShutdownReason.ToString()); } } /// /// Tries to find an open session running the same map; if none is found, creates a new one. /// public async void AutoJoinRoom(PlayerData data, Action onJoined, Action onFailed) { EnsureRunnerClosed(); PrepareRunner(); var any = await FindAnyAvailableSession(); if (any == null || !any.IsValid) { onFailed?.Invoke("NO_ROOMS"); return; } JoinRoom(any.Name, data, onJoined: () => onJoined?.Invoke(any.Name), onFailed: reason => onFailed?.Invoke(reason)); } public void StartMatch() { StartMatchAsync().Forget(); } private async UniTask StartMatchAsync() { if (_runner == null || !_runner.IsSceneAuthority) return; isPvpGame = !string.IsNullOrEmpty(CurrentPvpModeKey); var now = Time.realtimeSinceStartup; if (now < _startGuardUntil) await UniTask.Delay(TimeSpan.FromSeconds(_startGuardUntil - now)); int buildIdx = GetBuildIndexBySceneName(_selectedMap); if (buildIdx < 0) { Debug.LogError("[Match] Map not found in Build Settings."); return; } await WaitForAllPlayerInfos(1500); _matchStarted = true; _matchBuildIndex = buildIdx; byte[] msg = EncodeMatchStart(buildIdx); foreach (var p in _runner.ActivePlayers) if (p != _runner.LocalPlayer) SendBytesToPlayer(p, msg); LoadingManager.Singleton?.Open(0.2f); await WaitForMatchAcks(2000); _waitingGameplayScene = true; _waitingRef = SceneRef.FromIndex(buildIdx); var op = _runner.LoadScene(_waitingRef, LoadSceneMode.Single); await op; if (gameSyncPrefab && !GameplaySync.Instance) await _runner.SpawnAsync(gameSyncPrefab); if (!string.IsNullOrEmpty(CurrentPvpModeKey) && pvpSyncPrefab && _runner.IsSharedModeMasterClient) { var pvpNobj = await _runner.SpawnAsync(pvpSyncPrefab); var pvp = pvpNobj.GetComponent(); if (pvp) { var mode = GameInstance.Singleton.pvpModes.FirstOrDefault(m => m && m.GetModeKey() == CurrentPvpModeKey); if (mode) pvp.InitializeFrom(mode); } } if (GameplaySync.Instance && GameplaySync.Instance.HasStateAuthority) { GameplaySync.Instance.TimerSecs = GameplayManager.Singleton.GetSurvivalTime(); GameplaySync.Instance.MatchStarted = true; } _matchAcks.Clear(); } private async UniTask WaitForMatchAcks(int timeoutMs) { float end = Time.realtimeSinceStartup + timeoutMs / 1000f; while (Time.realtimeSinceStartup < end) { bool allAck = _runner.ActivePlayers .Where(p => p != _runner.LocalPlayer) .All(p => _matchAcks.Contains(p)); if (allAck) break; await UniTask.Yield(PlayerLoopTiming.Update); } } public async void StartPvpMatchmaking( PvpModeData mode, PlayerData data, Action onJoinedOrCreated, Action onFailed) { EnsureRunnerClosed(); if (!PrepareRunner()) { onFailed?.Invoke("CHAR_PREFAB_INVALID"); return; } // ----- Guards NRE ----- if (mode == null) { onFailed?.Invoke("MODE_NULL"); return; } if (string.IsNullOrWhiteSpace(mode.sceneName)) { onFailed?.Invoke("SCENE_INVALID"); return; } int buildIdx = GetBuildIndexBySceneName(mode.sceneName); if (buildIdx < 0) { onFailed?.Invoke("SCENE_NOT_IN_BUILD"); return; } int cap = mode.GetMaxPlayers(); if (cap <= 0) { onFailed?.Invoke("CAPACITY_INVALID"); return; } if (_runner == null) { onFailed?.Invoke("RUNNER_NULL"); return; } try { isPvpGame = true; CurrentPvpModeKey = mode.GetModeKey() ?? string.Empty; CurrentMaxPlayers = cap; _selectedMap = mode.sceneName; _pendingLocalData = data; var session = await FindAvailablePvpSession(CurrentPvpModeKey, CurrentMaxPlayers); if (session != null && session.IsValid) { var result = await _runner.StartGame(new StartGameArgs { GameMode = GameMode.Shared, SessionName = session.Name, SceneManager = gameObject.AddComponent(), ObjectProvider = _provider }); if (!result.Ok) { Debug.LogError($"[PVP] Join Failed: {result.ShutdownReason}"); onFailed?.Invoke(result.ShutdownReason.ToString()); return; } _currentSession = session.Name; onJoinedOrCreated?.Invoke(_currentSession); Debug.Log($"[PVP] Enter on queue {session.Name} ({CurrentPvpModeKey})"); } else { var queueName = $"PVP-{CurrentPvpModeKey}-{CurrentMaxPlayers}-{_selectedMap}"; var lobbyRef = SceneRef.FromIndex(UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex); var create = await _runner.StartGame(new StartGameArgs { GameMode = GameMode.Shared, SessionName = queueName, Scene = lobbyRef, PlayerCount = CurrentMaxPlayers, SceneManager = gameObject.AddComponent(), ObjectProvider = _provider, SessionProperties = new() { { "pvp", (SessionProperty)1 }, { "mode", (SessionProperty)CurrentPvpModeKey }, { "cap", (SessionProperty)CurrentMaxPlayers }, { "map", (SessionProperty)_selectedMap } } }); if (!create.Ok) { var join = await _runner.StartGame(new StartGameArgs { GameMode = GameMode.Shared, SessionName = queueName, SceneManager = gameObject.AddComponent(), ObjectProvider = _provider }); if (!join.Ok) { Debug.LogError($"[PVP] Create/Join Failed: {join.ShutdownReason}"); onFailed?.Invoke(join.ShutdownReason.ToString()); return; } } _currentSession = queueName; if (_runner.IsSharedModeMasterClient && masterClientTrackerPrefab) await _runner.SpawnAsync(masterClientTrackerPrefab); onJoinedOrCreated?.Invoke(_currentSession); Debug.Log($"[PVP] Queue {queueName} ({CurrentPvpModeKey}) cap={CurrentMaxPlayers}"); } int safeCount = CurrentPlayerCount; OnPlayerCountChanged?.Invoke(safeCount, CurrentMaxPlayers); AutoStartWhenFullAsync().Forget(); } catch (Exception ex) { Debug.LogError($"[PVP] StartPvpMatchmaking exception: {ex}"); onFailed?.Invoke(ex.Message); } } private async UniTaskVoid AutoStartWhenFullAsync() { await UniTask.WaitUntil(() => _runner != null); float queuedAt = Time.realtimeSinceStartup; const float grace = 3.0f; while (_runner && _runner.IsRunning) { int count = _runner.ActivePlayers.Count(); OnPlayerCountChanged?.Invoke(count, CurrentMaxPlayers); if (_runner.IsSharedModeMasterClient && !string.IsNullOrEmpty(CurrentPvpModeKey) && !_matchStarted) { if (count >= CurrentMaxPlayers) { await WaitForAllPlayerInfos(2000); Debug.Log($"[PVP] AutoStart (full). players={count}/{CurrentMaxPlayers}"); StartMatch(); break; } if (pvpDebugStartSolo && (Time.realtimeSinceStartup - queuedAt) > grace && count >= 1) { await WaitForAllPlayerInfos(2000); Debug.Log($"[PVP] AutoStart (solo DEV). players={count}/{CurrentMaxPlayers}"); StartMatch(); break; } } await UniTask.Delay(250); } } /// Leaves the current session and clears local data. public void LeaveRoom() { ForceLeaveAndResetAsync(destroyManager: false).Forget(); } #endregion /* ------------------------------------------------------------------ */ #region INetworkRunnerCallbacks (only essential implementations) public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { bool isLocal = player == runner.LocalPlayer; bool isHost = runner.IsSharedModeMasterClient; if (!_players.ContainsKey(player)) _players[player] = isLocal ? _pendingLocalData : default; if (isLocal && !isHost) SendBytesToHost(EncodePlayerInfo(player, _pendingLocalData)); if (isLocal && isHost) { UILobby.Instance?.SetLeader(); UILobby.Instance?.RefreshSlots(); } _currentHost = isHost ? player : _currentHost; _isHostCached = isHost; if (isHost) { _startGuardUntil = Time.realtimeSinceStartup + 2f; UILobby.Instance?.TriggerStartCooldown(1.5f); if (_matchStarted && _matchBuildIndex >= 0) { SendBytesToPlayer(player, EncodeMatchStart(_matchBuildIndex)); } } int safeCount = CurrentPlayerCount; OnPlayerCountChanged?.Invoke(safeCount, CurrentMaxPlayers); } public void OnPlayerLeft(NetworkRunner runner, PlayerRef player) { _players.Remove(player); UILobby.Instance?.RefreshSlots(); bool becameHost = runner.IsSharedModeMasterClient && !_isHostCached; _isHostCached = runner.IsSharedModeMasterClient; if (becameHost) PromoteToHost(runner); if (runner.IsSharedModeMasterClient) BroadcastFullSnapshot(); int safeCount = CurrentPlayerCount; OnPlayerCountChanged?.Invoke(safeCount, CurrentMaxPlayers); } public void OnReliableDataReceived(NetworkRunner runner, PlayerRef sender, ReliableKey k, ArraySegment seg) { using var br = new BinaryReader(new MemoryStream(seg.Array!, seg.Offset, seg.Count)); var tag = (LobbyMsg)br.ReadByte(); if (tag == LobbyMsg.PlayerInfo) { var (who, data) = DecodePlayerInfo(br); bool iAmHost = runner.IsSharedModeMasterClient; if (iAmHost) { _players[who] = data; BroadcastFullSnapshot(); } else { _players[who] = data; } UILobby.Instance?.RefreshSlots(); } else if (tag == LobbyMsg.MatchStart) { int buildIdx = br.ReadInt32(); _waitingGameplayScene = true; _waitingRef = SceneRef.FromIndex(buildIdx); Debug.Log($"[PVP][Client] MatchStart recv. waitingGameplayScene=TRUE expected={_waitingRef.AsIndex}"); LoadingManager.Singleton?.Open(0.2f); SendBytesToHost(EncodeMatchStartAck()); return; } else if (tag == LobbyMsg.MatchStartAck) { if (runner.IsSharedModeMasterClient) _matchAcks.Add(sender); return; } else if (tag == LobbyMsg.SceneReady) { if (runner.IsSharedModeMasterClient) _sceneReadyAcks.Add(sender); return; } } public void OnSessionListUpdated(NetworkRunner r, List list) { _lastSessionList = list; _searchDone.Set(); } public void OnSceneLoadStart(NetworkRunner r) { if (_waitingGameplayScene) LoadingManager.Singleton?.Open(1.2f); } /// /// Called by Fusion when a new scene has finished loading on this client. /// It initializes the PvP session, spawns the local character for both host and clients, /// waits for all players to signal readiness, assigns teams and enables PvP rules. /// public async void OnSceneLoadDone(NetworkRunner runner) { var activeBuildIndex = SceneManager.GetActiveScene().buildIndex; var expectedIndex = _waitingRef.IsValid ? _waitingRef.AsIndex : -1; bool isGameplayScene = (_waitingGameplayScene || (expectedIndex >= 0 && activeBuildIndex == expectedIndex)); if (!isGameplayScene) { if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading) LoadingManager.Singleton.Close(); return; } _waitingGameplayScene = false; _waitingRef = default; GameplayManager.Singleton?.SetPvpSession(isPvpGame); if (runner.IsSharedModeMasterClient) { try { await SpawnCharacterAtStartPositionAsync(stagingSpawn: true); await FastLocalPlaceAndCloseLoading(2.0f); AssignTeamsAndReadyAsync().Forget(); PlaceAllByRpcAsync().Forget(); } catch (Exception e) { Debug.LogError($"[SceneDone][Host] Exception: {e}"); } finally { await UniTask.Delay(2000); if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading) LoadingManager.Singleton.Close(); } return; } // Client try { await SpawnCharacterAtStartPositionAsync(stagingSpawn: true); SendBytesToHost(EncodeSceneReady()); await FastLocalPlaceAndCloseLoading(2.0f); } catch (Exception e) { Debug.LogError($"[Spawn][Client] Exception: {e}"); } finally { await UniTask.Delay(2000); if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading) LoadingManager.Singleton.Close(); } } // Unused callbacks kept empty to satisfy the interface public void OnInput(NetworkRunner r, NetworkInput i) { } public void OnInputMissing(NetworkRunner r, PlayerRef p, NetworkInput i) { } public void OnShutdown(NetworkRunner r, ShutdownReason s) { } void INetworkRunnerCallbacks.OnConnectedToServer(NetworkRunner r) { } void INetworkRunnerCallbacks.OnDisconnectedFromServer(NetworkRunner r, NetDisconnectReason rsn) { } public void OnConnectRequest(NetworkRunner r, NetworkRunnerCallbackArgs.ConnectRequest req, byte[] token) { } public void OnConnectFailed(NetworkRunner r, NetAddress addr, NetConnectFailedReason rsn) { } public void OnUserSimulationMessage(NetworkRunner r, SimulationMessagePtr msg) { } public void OnCustomAuthenticationResponse(NetworkRunner r, Dictionary data) { } public void OnHostMigration(NetworkRunner r, HostMigrationToken t) { } public void OnReliableDataProgress(NetworkRunner r, PlayerRef p, ReliableKey k, float progress) { } public void OnObjectEnterAOI(NetworkRunner r, NetworkObject o, PlayerRef p) { } public void OnObjectExitAOI(NetworkRunner r, NetworkObject o, PlayerRef p) { } #endregion /* ------------------------------------------------------------------ */ #region Private helpers private bool PrepareRunner() { if (_runner == null) { var runnerObj = new GameObject("NetworkRunner"); runnerObj.transform.SetPositionAndRotation(transform.position, Quaternion.identity); _runner = runnerObj.AddComponent(); _provider = runnerObj.AddComponent(); _runner.AddCallbacks(this); _players.Clear(); _ref2idx.Clear(); _isHostCached = false; } var charPrefab = GameInstance.Singleton?.characterEntity?.gameObject; if (!charPrefab) { Debug.LogError("[Fusion] Character prefab not configured in GameInstance."); return false; } if (!charPrefab.TryGetComponent(out _)) { Debug.LogError($"[Fusion] Prefab '{charPrefab.name}' needs a NetworkObject."); return false; } return true; } private static bool IsValidNetworkPrefabAsset(GameObject go) { if (!go) return false; var no = go.GetComponent(); if (!no) return false; return !no.IsValid; } private void PromoteToHost(NetworkRunner runner) { Debug.Log("[Lobby] This client is the new Shared-Mode Master."); if (masterClientTrackerPrefab && IsValidNetworkPrefabAsset(masterClientTrackerPrefab)) { if (SharedModeMasterClientTracker.GetSharedModeMasterClientPlayerRef() == null) { try { runner.Spawn(masterClientTrackerPrefab); } catch (Exception e) { Debug.LogError($"[Lobby] Failed to spawn masterClientTrackerPrefab: {e}"); } } } else { Debug.LogWarning("[Lobby] masterClientTrackerPrefab is null."); } BroadcastFullSnapshot(); UILobby.Instance?.SetLeader(); UILobby.Instance?.RefreshSlots(); } private async Task FindAnyAvailableSession() { var go = new GameObject("FusionQueryRunner"); var queryRunner = go.AddComponent(); queryRunner.AddCallbacks(this); _searchDone.Reset(); await queryRunner.JoinSessionLobby(SessionLobby.Shared); await Task.Run(() => _searchDone.WaitOne()); var session = _lastSessionList.FirstOrDefault(s => s.PlayerCount < defaultMaxPlayers); Destroy(go); return session.IsValid ? session : (SessionInfo)null; } private void EnsureRunnerClosed() { if (_runner != null) { _runner.Shutdown(); Destroy(_runner.gameObject); _runner = null; } } private async Task FindAvailablePvpSession(string modeKey, int capacity) { GameObject go = null; try { go = new GameObject("FusionQueryRunner_PVP"); var queryRunner = go.AddComponent(); queryRunner.AddCallbacks(this); _searchDone.Reset(); await queryRunner.JoinSessionLobby(SessionLobby.Shared); await Task.Run(() => _searchDone.WaitOne()); if (_lastSessionList == null || _lastSessionList.Count == 0) return default; foreach (var s in _lastSessionList) { if (!s.IsValid) continue; if (s.PlayerCount >= s.MaxPlayers) continue; var props = s.Properties; if (props == null || props.Count == 0) continue; if (!props.TryGetValue("pvp", out var p)) continue; if ((int)(SessionProperty)p != 1) continue; bool sameMode = props.TryGetValue("mode", out var m) && ((string)(SessionProperty)m) == modeKey; bool sameCap = props.TryGetValue("cap", out var c) && ((int)(SessionProperty)c) == capacity; if (sameMode && sameCap) return s; } return default; } finally { if (go) Destroy(go); } } public byte ComputeTeamFor(PlayerRef pref) { // Use declared team count if available; if <=1, switch to FFA/BR (one team per player) int teamCount = 2; if (PvpSync.Instance != null && PvpSync.Instance.Object != null && PvpSync.Instance.Object.IsValid) teamCount = Mathf.Max(1, PvpSync.Instance.TeamCount); var ordered = _runner.ActivePlayers.OrderBy(p => p.RawEncoded).ToList(); int idx = Mathf.Max(0, ordered.IndexOf(pref)); if (teamCount <= 1) teamCount = ordered.Count; // FFA/BR: each player is its own team return (byte)(idx % teamCount); } private async UniTask AssignTeamsAndReadyAsync() { await UniTask.WaitUntil(() => PvpSync.Instance == null || (PvpSync.Instance.Object != null && PvpSync.Instance.Object.IsValid)); var players = _runner.ActivePlayers.OrderBy(p => p.RawEncoded).ToList(); int teamCount = 2; if (PvpSync.Instance != null && PvpSync.Instance.Object && PvpSync.Instance.Object.IsValid) teamCount = Mathf.Max(1, PvpSync.Instance.TeamCount); if (teamCount <= 1) teamCount = players.Count; // FFA/BR foreach (var p in players) { NetworkObject obj = null; float tEnd = Time.realtimeSinceStartup + 1.5f; while (Time.realtimeSinceStartup < tEnd) { if (_runner.TryGetPlayerObject(p, out obj) && obj) break; await UniTask.Yield(PlayerLoopTiming.Update); } if (!obj) continue; var ce = obj.GetComponent(); if (!ce) continue; byte team = (byte)(players.IndexOf(p) % teamCount); ce.RPC_AssignTeam(team); } if (PvpSync.Instance) PvpSync.Instance.MarkTeamsAssignedAndReady(); } /// /// Finds the build index of a scene by its file name (without extension), /// no matter which folder it is located in. Returns -1 if not found. /// private static int GetBuildIndexBySceneName(string sceneName) { int sceneCount = SceneManager.sceneCountInBuildSettings; for (int i = 0; i < sceneCount; ++i) { string path = SceneUtility.GetScenePathByBuildIndex(i); string file = Path.GetFileNameWithoutExtension(path); if (file.Equals(sceneName, StringComparison.OrdinalIgnoreCase)) return i; } return -1; } private ReliableKey NextKey() => ReliableKey.FromInts(++_reliableSeq); /* Sends bytes to a specific peer (host or client). */ private void SendBytesToPlayer(PlayerRef player, byte[] bytes) { _runner.SendReliableDataToPlayer(player, NextKey(), bytes); } private void SendBytesToHost(byte[] bytes) { // Robust way: use the tracker object if it already exists. PlayerRef? hostMaybe = SharedModeMasterClientTracker .GetSharedModeMasterClientPlayerRef(); PlayerRef host = hostMaybe ?? // tracker spawned? _runner.ActivePlayers // fallback: lowest id .OrderBy(p => p.RawEncoded) .First(); // Defensive check – avoids sending to ourselves when we *are* the host. if (host == _runner.LocalPlayer) return; _runner.SendReliableDataToPlayer(host, NextKey(), bytes); } private async Task FindAvailableSession(string mapName) { var queryRunner = gameObject.AddComponent(); queryRunner.AddCallbacks(this); await queryRunner.JoinSessionLobby(SessionLobby.Shared); await Task.Run(() => _searchDone.WaitOne()); foreach (var s in _lastSessionList) { if (s.Properties.TryGetValue("map", out var p) && (string)p == mapName && s.PlayerCount < defaultMaxPlayers) return s; } Destroy(queryRunner.gameObject); return null; } private async UniTask WaitForAllPlayerInfos(int timeoutMs) { if (_runner == null) return; float end = Time.realtimeSinceStartup + (timeoutMs / 1000f); while (Time.realtimeSinceStartup < end) { bool allOk = true; foreach (var p in _runner.ActivePlayers) { if (!_players.TryGetValue(p, out var d) || string.IsNullOrEmpty(d.playerName)) { allOk = false; break; } } if (allOk) break; await UniTask.Yield(PlayerLoopTiming.Update); } } private static string GenerateCode() { const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789"; var rand = new System.Random(); Span buf = stackalloc char[6]; for (int i = 0; i < buf.Length; ++i) buf[i] = chars[rand.Next(chars.Length)]; return new string(buf); } #region Lobby-messages ----------------------------------- private static byte[] EncodePlayerInfo(PlayerRef who, in PlayerData d) { using var ms = new MemoryStream(128); using var bw = new BinaryWriter(ms); bw.Write((byte)LobbyMsg.PlayerInfo); bw.Write(who.RawEncoded); bw.Write(d.playerName ?? ""); bw.Write(d.playerFrame ?? ""); bw.Write(d.playerIcon ?? ""); bw.Write(d.characterId); bw.Write(d.characterSkin); bw.Write(d.characterMastery); return ms.ToArray(); } private static (PlayerRef, PlayerData) DecodePlayerInfo(BinaryReader br) { int raw = br.ReadInt32(); var pref = PlayerRef.FromEncoded(raw); var data = new PlayerData( br.ReadString(), br.ReadString(), br.ReadString(), br.ReadInt32(), br.ReadInt32(), br.ReadInt32()); return (pref, data); } private static byte[] EncodeMatchStart(int buildIndex) { using var ms = new MemoryStream(16); using var bw = new BinaryWriter(ms); bw.Write((byte)LobbyMsg.MatchStart); bw.Write(buildIndex); return ms.ToArray(); } private static byte[] EncodeMatchStartAck() { using var ms = new MemoryStream(8); using var bw = new BinaryWriter(ms); bw.Write((byte)LobbyMsg.MatchStartAck); return ms.ToArray(); } private static byte[] EncodeSceneReady() { using var ms = new MemoryStream(8); using var bw = new BinaryWriter(ms); bw.Write((byte)LobbyMsg.SceneReady); return ms.ToArray(); } private void BroadcastFullSnapshot() { foreach (var kv in _players) { byte[] msg = EncodePlayerInfo(kv.Key, kv.Value); foreach (var p in _runner.ActivePlayers) if (p != _runner.LocalPlayer) SendBytesToPlayer(p, msg); } } /// /// Instantiates the local player's character at the appropriate spawn location. /// In PvP modes it uses the team spawn positions defined in GameplayManager; in coop it defaults to the origin. /// The method waits until the GameplayManager is available instead of skipping the spawn. /// public async Task SpawnCharacterAtStartPositionAsync(bool stagingSpawn = false) { if (_runner == null) { Debug.LogError("[Spawn] NetworkRunner not initialized."); return; } if (_runner.TryGetPlayerObject(_runner.LocalPlayer, out _)) return; GameObject prefab = GameInstance.Singleton.characterEntity?.gameObject; if (prefab == null) { Debug.LogError("[Spawn] Character prefab null in GameInstance."); return; } byte cid = (byte)_pendingLocalData.characterId; byte skin = (byte)_pendingLocalData.characterSkin; string nick = _pendingLocalData.playerName; byte team = ComputeTeamFor(_runner.LocalPlayer); GameplayManager gm = GameplayManager.Singleton; float elapsed = 0f; while (gm == null && elapsed < 5f) { await UniTask.Yield(PlayerLoopTiming.Update); elapsed += Time.deltaTime; gm = GameplayManager.Singleton; } Vector3 spawnPos = Vector3.zero; Quaternion spawnRot = Quaternion.identity; if (!stagingSpawn && gm != null && gm.IsPvp) { spawnPos = gm.GetTeamSpawnPosition(team); spawnRot = gm.GetTeamSpawnRotation(team); } NetworkObject netObj = await _runner.SpawnAsync( prefab, spawnPos, spawnRot, _runner.LocalPlayer, (runner, obj) => { var ce = obj.GetComponent(); ce.SetInitialNetworkData(cid, skin, nick, team); }); if (netObj == null) { Debug.LogError("[Spawn] SpawnAsync returned null!"); return; } // If staging, disable attack until placement RPC arrives if (stagingSpawn) { var ceStage = netObj.GetComponent(); if (ceStage) { ceStage.CharacterAttackComponent?.StopAutoAttack(); ceStage.CharacterControllerComponent?.StopMovement(false); } } _runner.SetPlayerObject(_runner.LocalPlayer, netObj); if (netObj.HasInputAuthority) { var ce = netObj.GetComponent(); if (ce != null) { var data = GameInstance.Singleton.GetCharacterDataById(cid); ce.SetCharacterData(data, skin); } if (UIGameplay.Singleton == null) { var hud = UnityEngine.Object.Instantiate(GameInstance.Singleton.GetUIGameplayForPlatform()); if (hud.minimapImage) hud.minimapImage.sprite = hud.unlockedSprite; } } } //dispatch placement to each owner (InputAuthority) so Shared mode respects authority. private async UniTask PlaceAllByRpcAsync() { if (_runner == null) return; var gm = GameplayManager.Singleton; if (!gm) return; var players = _runner.ActivePlayers.OrderBy(p => p.RawEncoded).ToList(); int teamCount = 2; if (PvpSync.Instance != null && PvpSync.Instance.Object && PvpSync.Instance.Object.IsValid) teamCount = Mathf.Max(1, PvpSync.Instance.TeamCount); if (teamCount <= 1) teamCount = players.Count; // FFA/BR foreach (var p in players) { if (!_runner.TryGetPlayerObject(p, out var obj) || !obj) continue; var ce = obj.GetComponent(); if (!ce) continue; byte team = (byte)(players.IndexOf(p) % teamCount); ce.RPC_RequestPlaceAtTeamSpawn(team); } await UniTask.Yield(PlayerLoopTiming.Update); } private async UniTask FastLocalPlaceAndCloseLoading(double maxWaitSeconds) { var deadline = Time.realtimeSinceStartup + (float)maxWaitSeconds; while (Time.realtimeSinceStartup < deadline) { if (TryGetLocalCharacterEntity(out var ce) && GameplayManager.Singleton != null) { byte team = ComputeTeamFor(_runner.LocalPlayer); ce.RPC_RequestPlaceAtTeamSpawn(team); float end = Time.realtimeSinceStartup + 2f; while (Time.realtimeSinceStartup < end) { if (LoadingManager.Singleton && !LoadingManager.Singleton.isLoading) return; await UniTask.Yield(PlayerLoopTiming.Update); } if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading) LoadingManager.Singleton.Close(); return; } await UniTask.Yield(PlayerLoopTiming.Update); } // Fallback: close loading even if we couldn't place (avoid stuck screens) if (LoadingManager.Singleton && LoadingManager.Singleton.isLoading) LoadingManager.Singleton.Close(); } private bool TryGetLocalCharacterEntity(out CharacterEntity ce) { ce = null; if (_runner == null) return false; if (!_runner.TryGetPlayerObject(_runner.LocalPlayer, out var obj) || !obj) return false; ce = obj.GetComponent(); return ce != null; } #endregion /// /// Ends the current network session, shuts down the runner and cleans up. /// public void EndGameSession() { ForceLeaveAndResetAsync(destroyManager: false).Forget(); } private async UniTaskVoid ForceLeaveAndResetAsync(bool destroyManager = false) { try { _matchAcks.Clear(); _sceneReadyAcks.Clear(); if (_runner != null) { try { if (_runner.IsRunning) await _runner.Shutdown(); } catch { /* error shutdown */ } if (_runner) { _runner.RemoveCallbacks(this); Destroy(_runner.gameObject); } _runner = null; } #if UNITY_6000_0_OR_NEWER foreach (var r in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) #else foreach (var r in UnityEngine.Object.FindObjectsOfType(true)) #endif { try { if (r.IsRunning) await r.Shutdown(); } catch { } Destroy(r.gameObject); } KillIfAlive(); KillIfAlive(); KillIfAlive(); #if UNITY_6000_0_OR_NEWER foreach (var p in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) #else foreach (var p in UnityEngine.Object.FindObjectsOfType(true)) #endif if (p) Destroy(p.gameObject); #if UNITY_6000_0_OR_NEWER foreach (var g in UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)) #else foreach (var g in UnityEngine.Object.FindObjectsOfType(true)) #endif if (g) Destroy(g.gameObject); ResetLocalState(); if (destroyManager) { if (Instance == this) Instance = null; Destroy(gameObject); } } catch (Exception e) { Debug.LogError($"[FusionCleanup] Cleanup Exception: {e}"); } } private static void KillIfAlive() where T : Component { var instField = typeof(T).GetProperty("Instance", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); if (instField != null) { var inst = instField.GetValue(null) as Component; if (inst && inst.gameObject) UnityEngine.Object.Destroy(inst.gameObject); } } private void ResetLocalState() { _players.Clear(); _ref2idx.Clear(); _currentSession = null; _selectedMap = null; _waitingGameplayScene = false; _waitingRef = default; _matchStarted = false; _matchBuildIndex = -1; _isHostCached = false; _currentHost = default; CurrentMaxPlayers = 0; CurrentPvpModeKey = null; isPvpGame = false; OnPlayerCountChanged = null; _pendingLocalData = default; _reliableSeq = 0; _searchDone.Reset(); } #endregion /* ------------------------------------------------------------------ */ #region Public getters public (PlayerRef, PlayerData)[] OrderedPlayers() => _players.OrderBy(kv => kv.Key.RawEncoded).Select(kv => (kv.Key, kv.Value)).ToArray(); public IReadOnlyList ConnectedNicknames => _players.Values.Select(p => p.playerName).ToList(); public string CurrentSessionCode => _currentSession; public string SelectedMap => _selectedMap; static int Id(PlayerRef p) => p.RawEncoded; #endregion #endif } /// Lightweight struct holding public player data. [Serializable] public struct PlayerData { public string playerName; public string playerFrame; public string playerIcon; public int characterId; public int characterSkin; public int characterMastery; public PlayerData(string _nickname, string _frame, string _icon, int _character, int _skin, int _mastery) { playerName = _nickname; playerFrame = _frame; playerIcon = _icon; characterId = _character; characterSkin = _skin; characterMastery = _mastery; } } }