1327 lines
48 KiB
C#
Raw Normal View History

2025-09-19 19:43:49 +05:00

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
{
/// <summary>
/// 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.
/// </summary>
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<PlayerRef, PlayerData> _players = new();
private PlayerRef _currentHost;
private readonly Dictionary<int, int> _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<SessionInfo> _lastSessionList = new();
private PlayerData _pendingLocalData;
private readonly HashSet<PlayerRef> _matchAcks = new();
private readonly HashSet<PlayerRef> _sceneReadyAcks = new();
private enum LobbyMsg : byte { PlayerInfo = 1, MatchStart = 2, MatchStartAck = 3, SceneReady = 4 }
private int _reliableSeq = 0;
//PVP
public event Action<int, int> 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)
/// <summary>Creates a new shared-mode session and becomes the host player.</summary>
/// <summary>
/// Creates a new Shared-mode session and becomes host player.
/// Scene is resolved by name using <see cref="GetBuildIndexBySceneName"/>.
/// </summary>
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<NetworkSceneManagerDefault>(),
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}");
}
/// <summary>Joins an existing session by its six-digit code.</summary>
public async void JoinRoom(string code, PlayerData data, Action onJoined, Action<string> onFailed)
{
EnsureRunnerClosed();
PrepareRunner();
_pendingLocalData = data;
_currentSession = code;
var result = await _runner.StartGame(new StartGameArgs
{
GameMode = GameMode.Shared,
SessionName = code,
SceneManager = gameObject.AddComponent<NetworkSceneManagerDefault>(),
ObjectProvider = _provider
});
if (result.Ok)
{
onJoined?.Invoke();
Debug.Log($"Joined room {code}");
}
else
{
Debug.LogError($"Join failed: {result.ShutdownReason}");
onFailed?.Invoke(result.ShutdownReason.ToString());
}
}
/// <summary>
/// Tries to find an open session running the same map; if none is found, creates a new one.
/// </summary>
public async void AutoJoinRoom(PlayerData data, Action<string> onJoined, Action<string> 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<PvpSync>();
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<string> onJoinedOrCreated,
Action<string> 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<NetworkSceneManagerDefault>(),
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<NetworkSceneManagerDefault>(),
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<NetworkSceneManagerDefault>(),
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);
}
}
/// <summary>Leaves the current session and clears local data.</summary>
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<byte> 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<SessionInfo> list)
{
_lastSessionList = list;
_searchDone.Set();
}
public void OnSceneLoadStart(NetworkRunner r)
{
if (_waitingGameplayScene)
LoadingManager.Singleton?.Open(1.2f);
}
/// <summary>
/// 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.
/// </summary>
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<string, object> 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<NetworkRunner>();
_provider = runnerObj.AddComponent<PoolObjectProvider>();
_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<NetworkObject>(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<NetworkObject>();
if (!no) return false;
return !no.IsValid;
}
private void PromoteToHost(NetworkRunner runner)
{
Debug.Log("<color=cyan>[Lobby] This client is the new Shared-Mode Master.</color>");
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<SessionInfo> FindAnyAvailableSession()
{
var go = new GameObject("FusionQueryRunner");
var queryRunner = go.AddComponent<NetworkRunner>();
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<SessionInfo> FindAvailablePvpSession(string modeKey, int capacity)
{
GameObject go = null;
try
{
go = new GameObject("FusionQueryRunner_PVP");
var queryRunner = go.AddComponent<NetworkRunner>();
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<CharacterEntity>();
if (!ce) continue;
byte team = (byte)(players.IndexOf(p) % teamCount);
ce.RPC_AssignTeam(team);
}
if (PvpSync.Instance)
PvpSync.Instance.MarkTeamsAssignedAndReady();
}
/// <summary>
/// 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.
/// </summary>
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<SessionInfo> FindAvailableSession(string mapName)
{
var queryRunner = gameObject.AddComponent<NetworkRunner>();
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<char> 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);
}
}
/// <summary>
/// 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.
/// </summary>
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<CharacterEntity>();
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<CharacterEntity>();
if (ceStage)
{
ceStage.CharacterAttackComponent?.StopAutoAttack();
ceStage.CharacterControllerComponent?.StopMovement(false);
}
}
_runner.SetPlayerObject(_runner.LocalPlayer, netObj);
if (netObj.HasInputAuthority)
{
var ce = netObj.GetComponent<CharacterEntity>();
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<CharacterEntity>();
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<CharacterEntity>();
return ce != null;
}
#endregion
/// <summary>
/// Ends the current network session, shuts down the runner and cleans up.
/// </summary>
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<NetworkRunner>(FindObjectsInactive.Include, FindObjectsSortMode.None))
#else
foreach (var r in UnityEngine.Object.FindObjectsOfType<NetworkRunner>(true))
#endif
{
try { if (r.IsRunning) await r.Shutdown(); } catch { }
Destroy(r.gameObject);
}
KillIfAlive<GameplaySync>();
KillIfAlive<PvpSync>();
KillIfAlive<SharedModeMasterClientTracker>();
#if UNITY_6000_0_OR_NEWER
foreach (var p in UnityEngine.Object.FindObjectsByType<PvpSync>(FindObjectsInactive.Include, FindObjectsSortMode.None))
#else
foreach (var p in UnityEngine.Object.FindObjectsOfType<PvpSync>(true))
#endif
if (p) Destroy(p.gameObject);
#if UNITY_6000_0_OR_NEWER
foreach (var g in UnityEngine.Object.FindObjectsByType<GameplaySync>(FindObjectsInactive.Include, FindObjectsSortMode.None))
#else
foreach (var g in UnityEngine.Object.FindObjectsOfType<GameplaySync>(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<T>() 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<string> 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
}
/// <summary>Lightweight struct holding public player data.</summary>
[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;
}
}
}