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