using System.Collections; using System.Collections.Generic; using FishNet; using FishNet.Connection; using FishNet.Managing; using FishNet.Managing.Scened; using FishNet.Object; using FishNet.Transporting; using Steamworks; using UnityEngine; using UnityEngine.SceneManagement; using FishyTransport = FishySteamworks.FishySteamworks; using Ashwild.Player; namespace Ashwild.Network { /// /// The single custom coordinator for the networked game session (FishNet + FishySteamworks over /// Steam). It is the ONE place that knows the game scene name, and it owns the whole flow: /// host/join/stop, generating the shareable code, loading the game scene over the network, and /// spawning each player into that scene once it is actually loaded. Every transport step is /// mirrored onto the PlayerEvents bus. Place this on the persistent FishNet NetworkManager object. /// [DisallowMultipleComponent] public class NetworkSessionManager : MonoBehaviour { #region Serialized Fields [Header("Scene")] [Tooltip("The single source of truth for the gameplay scene loaded over the network.")] [SerializeField] private string gameSceneName = "TestScene"; [Tooltip("Max seconds to wait for the loading curtain to fully fade in before loading the game " + "scene anyway. Safety net so a missing/disabled LoadingScreen can never soft-lock the host.")] [SerializeField] private float curtainWaitTimeout = 3f; [Header("Player")] [Tooltip("The networked player prefab (NetworkObject + PlayerNetworkController).")] [SerializeField] private NetworkObject playerPrefab; [Tooltip("Hard cap on total players in a room (host included). The transport is set to accept " + "maxPlayers - 1 remote clients, and the Steam invite lobby mirrors this limit.")] [SerializeField] private int maxPlayers = 6; [Header("Debug")] [Tooltip("Logs every host/join/connection/spawn step to the console.")] [SerializeField] private bool verboseLogging = true; #endregion #region State /// /// Global access point used by the menu UI to launch a session. /// public static NetworkSessionManager Instance { get; private set; } /// /// The gameplay scene name (single source of truth, read by anything that needs it). /// public string GameSceneName => gameSceneName; /// /// Hard cap on total players per room (host included). Single source of truth for the /// player limit — the Steam invite lobby reads this to mirror the cap. /// public int MaxPlayers => maxPlayers; /// /// Cached FishNet manager resolved from the active scene. /// private NetworkManager networkManager; /// /// True while we are waiting for the server to come up before loading the game scene. /// private bool pendingSceneLoad; /// /// Set once the loading curtain has fully faded in; gates the heavy game scene load so the /// activation freeze happens behind an opaque curtain. Reset at the start of every host launch. /// private bool curtainShown; /// /// Round-robin cursor over the available spawn points. /// private int spawnIndex; /// /// Connections already given a player this session (guards against double spawns). /// private readonly HashSet spawnedConnections = new HashSet(); #endregion #region Unity Lifecycle /// /// Establishes the singleton; the GameObject persists via the NetworkManager itself. /// private void Awake() { if (Instance != null && Instance != this) { // Only drop this extra component, never the shared NetworkManager GameObject. Destroy(this); return; } Instance = this; } /// /// Resolves the NetworkManager and subscribes to transport + scene-presence events. /// private void OnEnable() { if (networkManager == null) networkManager = InstanceFinder.NetworkManager; if (networkManager == null) { Debug.LogError("[NetworkSession] No NetworkManager found — add this component to the NetworkManager GameObject.", this); return; } networkManager.ServerManager.OnServerConnectionState += HandleServerState; networkManager.ServerManager.OnRemoteConnectionState += HandleRemoteState; networkManager.ClientManager.OnClientConnectionState += HandleClientState; networkManager.SceneManager.OnClientPresenceChangeEnd += HandleClientPresenceEnd; PlayerEvents.LoadingCurtainShown += HandleLoadingCurtainShown; } /// /// Unsubscribes — mirrors OnEnable exactly. /// private void OnDisable() { if (networkManager == null) return; networkManager.ServerManager.OnServerConnectionState -= HandleServerState; networkManager.ServerManager.OnRemoteConnectionState -= HandleRemoteState; networkManager.ClientManager.OnClientConnectionState -= HandleClientState; networkManager.SceneManager.OnClientPresenceChangeEnd -= HandleClientPresenceEnd; PlayerEvents.LoadingCurtainShown -= HandleLoadingCurtainShown; } /// /// Clears the singleton reference. /// private void OnDestroy() { if (Instance == this) Instance = null; } #endregion #region Public API /// /// Starts hosting (server + local client) over Steam, then loads the game scene /// across the network once the server is up. This is what a save-slot click triggers. /// public void HostSession() { if (!EnsureReady()) return; if (networkManager.ServerManager.Started) { Debug.LogWarning("[NetworkSession] Already hosting — ignoring HostSession().", this); return; } PlayerEvents.RaiseSessionStarting(); Log("Starting host (server + client) over Steam…"); curtainShown = false; pendingSceneLoad = true; bool serverOk = networkManager.ServerManager.StartConnection(); bool clientOk = networkManager.ClientManager.StartConnection(); if (!serverOk || !clientOk) { pendingSceneLoad = false; string reason = "Impossible de démarrer l'hôte (server/client). Steam est-il lancé ?"; PlayerEvents.RaiseSessionError(reason); Debug.LogError($"[NetworkSession] {reason} serverOk={serverOk}, clientOk={clientOk}", this); } } /// /// Joins an existing session by its code (the host SteamID64). Used later for friends. /// public void JoinSession(string code) { if (!EnsureReady()) return; if (string.IsNullOrWhiteSpace(code)) { PlayerEvents.RaiseSessionError("Code de session vide."); Debug.LogError("[NetworkSession] JoinSession called with an empty code.", this); return; } code = code.Trim(); if (!SessionCode.TryToSteamId(code, out ulong hostSteamId)) { PlayerEvents.RaiseSessionError("Code de session invalide."); Debug.LogError($"[NetworkSession] JoinSession received a malformed code '{code}'.", this); return; } PlayerEvents.RaiseSessionJoining(code); Log($"Joining session with code {code} (SteamID {hostSteamId})…"); FishyTransport fishy = networkManager.TransportManager.Transport as FishyTransport; if (fishy == null) { PlayerEvents.RaiseSessionError("Le transport actif n'est pas FishySteamworks."); Debug.LogError("[NetworkSession] Active transport is not FishySteamworks — cannot join via Steam.", this); return; } fishy.SetClientAddress(hostSteamId.ToString()); networkManager.ClientManager.StartConnection(); } /// /// Tears down the current session (client first, then server) and returns offline. /// public void StopSession() { if (networkManager == null) return; Log("Stopping session…"); if (networkManager.ClientManager.Started) networkManager.ClientManager.StopConnection(); if (networkManager.ServerManager.Started) networkManager.ServerManager.StopConnection(true); } /// /// Leaves the current session and returns to the menu scene behind the loading curtain. Mirrors /// the host launch: it raises the curtain, waits for it to fully fade in (so the menu-load freeze /// is hidden), tears the session down, loads the menu, then signals MenuReady to drop the curtain. /// Orchestrated here — not on the per-scene GameUIManager — because this object persists across /// the scene swap while the caller is destroyed by the load. /// public void ReturnToMenu(string menuSceneName) { if (string.IsNullOrWhiteSpace(menuSceneName)) { Debug.LogError("[NetworkSession] ReturnToMenu called with no menu scene name.", this); return; } PlayerEvents.RaiseReturningToMenu(); curtainShown = false; StartCoroutine(ReturnToMenuRoutine(menuSceneName)); } #endregion #region Connection Handlers /// /// Server transport changed state — on Started we publish the code and load the scene. /// private void HandleServerState(ServerConnectionStateArgs args) { Log($"Server state → {args.ConnectionState}"); if (args.ConnectionState == LocalConnectionState.Started) { ApplyPlayerCap(); string code = GetLocalSteamCode(); PlayerEvents.RaiseSessionStarted(code); Log($"✅ Session créée ! Code à partager = {code}"); if (pendingSceneLoad) { pendingSceneLoad = false; StartCoroutine(LoadGameSceneWhenCurtainShown()); } } else if (args.ConnectionState == LocalConnectionState.Stopped) { Log("Server stopped."); spawnedConnections.Clear(); PlayerEvents.RaiseSessionStopped(); } } /// /// Local client transport changed state. For a pure client (join) this drives the /// joined/stopped bus events; for a host the server handler already covers it. /// private void HandleClientState(ClientConnectionStateArgs args) { Log($"Client state → {args.ConnectionState}"); bool isHost = networkManager.ServerManager.Started; if (isHost) return; if (args.ConnectionState == LocalConnectionState.Started) { PlayerEvents.RaiseSessionJoined(); Log("✅ Connecté à l'hôte."); } else if (args.ConnectionState == LocalConnectionState.Stopped) { PlayerEvents.RaiseSessionStopped(); } } /// /// The loading curtain finished fading in — release the gate so the deferred game scene /// load can proceed (see ). /// private void HandleLoadingCurtainShown() => curtainShown = true; /// /// A *remote* client connected to or disconnected from our server; updates the count. /// private void HandleRemoteState(NetworkConnection conn, RemoteConnectionStateArgs args) { if (args.ConnectionState == RemoteConnectionState.Started) Log($"➡️ Client connecté (id {conn.ClientId})."); else if (args.ConnectionState == RemoteConnectionState.Stopped) Log($"⬅️ Client déconnecté (id {conn.ClientId})."); // Exclude the host's own client from the "other players" count. int others = Mathf.Max(0, networkManager.ServerManager.Clients.Count - 1); PlayerEvents.RaiseRemotePlayerCountChanged(others); } #endregion #region Player Spawning /// /// Spawns the player once its connection has been added to the (loaded) game scene. /// Using scene presence — not OnClientLoadedStartScenes — guarantees the scene and its /// PlayerSpawnPoints exist, so players never spawn at the origin / under the map. /// private void HandleClientPresenceEnd(ClientPresenceChangeEventArgs args) { if (!args.Added) return; if (args.Scene.name != gameSceneName) return; NetworkConnection conn = args.Connection; if (conn == null || !conn.IsActive) return; if (spawnedConnections.Contains(conn.ClientId)) return; if (playerPrefab == null) { Debug.LogError("[NetworkSession] No player prefab assigned — cannot spawn.", this); return; } ResolveSpawnPoint(args.Scene, out Vector3 position, out Quaternion rotation); NetworkObject nob = Instantiate(playerPrefab, position, rotation); networkManager.ServerManager.Spawn(nob.gameObject, conn, args.Scene); spawnedConnections.Add(conn.ClientId); Log($"🧍 Player spawned for connection id {conn.ClientId} in '{args.Scene.name}' at {position}."); } /// /// Picks the next spawn point (round-robin) from the given loaded scene, falling back to origin. /// private void ResolveSpawnPoint(Scene scene, out Vector3 position, out Quaternion rotation) { List points = new List(); foreach (GameObject root in scene.GetRootGameObjects()) points.AddRange(root.GetComponentsInChildren(true)); if (points.Count > 0) { PlayerSpawnPoint point = points[spawnIndex % points.Count]; spawnIndex++; position = point.transform.position; rotation = point.transform.rotation; return; } position = Vector3.zero; rotation = Quaternion.identity; Debug.LogWarning($"[NetworkSession] No PlayerSpawnPoint in '{scene.name}' — spawning at origin.", this); } #endregion #region Internal Helpers /// /// Defers the heavy networked scene load until the loading curtain has fully faded in /// (PlayerEvents.LoadingCurtainShown), so the unavoidable activation freeze of a big scene /// happens behind an already-opaque curtain instead of stalling the frame the curtain was /// meant to appear on. Falls back to loading anyway after so /// a missing or disabled LoadingScreen can never soft-lock the host. Runs on unscaled time. /// private IEnumerator LoadGameSceneWhenCurtainShown() { float start = Time.unscaledTime; while (!curtainShown && Time.unscaledTime - start < curtainWaitTimeout) yield return null; if (!curtainShown) Log($"Loading curtain not reported within {curtainWaitTimeout}s — loading scene anyway."); LoadGameSceneNetworked(); } /// /// Waits for the curtain to fully fade in (or the timeout), tears the session down, loads the /// menu scene locally (Single — the persistent NetworkManager survives), then raises MenuReady /// so the curtain drops once the menu is up. Runs on the persistent NetworkManager object so it /// outlives the game scene being unloaded. Uses unscaled time. /// private IEnumerator ReturnToMenuRoutine(string menuSceneName) { float start = Time.unscaledTime; while (!curtainShown && Time.unscaledTime - start < curtainWaitTimeout) yield return null; StopSession(); AsyncOperation op = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(menuSceneName, LoadSceneMode.Single); if (op != null) { while (!op.isDone) yield return null; } else { Debug.LogError($"[NetworkSession] Could not load menu scene '{menuSceneName}' — is it in Build Settings?", this); } PlayerEvents.RaiseMenuReady(); } /// /// Caps the transport at - 1 remote clients. Called once the server has /// Started, not before: FishySteamworks re-applies its own serialized limit to the socket inside /// StartConnection, so an earlier call would be clobbered. The host runs a local client that never /// crosses the Steam socket, so the remote limit is one below the total headcount; the transport /// then refuses the surplus connection outright. /// private void ApplyPlayerCap() { int remoteCap = Mathf.Max(0, maxPlayers - 1); networkManager.TransportManager.Transport.SetMaximumClients(remoteCap); Log($"Player cap set to {maxPlayers} (transport accepts {remoteCap} remote clients)."); } /// /// Loads the gameplay scene as a global networked scene, replacing the menu. /// private void LoadGameSceneNetworked() { Log($"Loading scene '{gameSceneName}' over the network…"); SceneLoadData sld = new SceneLoadData(gameSceneName) { ReplaceScenes = ReplaceOption.All }; networkManager.SceneManager.LoadGlobalScenes(sld); } /// /// Returns the short, shareable session code derived from the local player's SteamID64. /// private string GetLocalSteamCode() { if (!SteamManager.Initialized) return "(Steam non initialisé)"; return SessionCode.FromSteamId(SteamUser.GetSteamID().m_SteamID); } /// /// Verifies the NetworkManager and Steam are both ready before any network action. /// private bool EnsureReady() { if (networkManager == null) networkManager = InstanceFinder.NetworkManager; if (networkManager == null) { PlayerEvents.RaiseSessionError("NetworkManager introuvable."); Debug.LogError("[NetworkSession] NetworkManager is null — is it present in the scene?", this); return false; } if (!SteamManager.Initialized) { PlayerEvents.RaiseSessionError("Steam n'est pas initialisé (lance Steam)."); Debug.LogError("[NetworkSession] Steam is not initialized — is Steam running and steam_appid.txt present?", this); return false; } return true; } /// /// Prefixed console log gated by the verbose toggle. /// private void Log(string message) { if (verboseLogging) Debug.Log($"[NetworkSession] {message}", this); } #endregion } }