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"; [Header("Player")] [Tooltip("The networked player prefab (NetworkObject + PlayerNetworkController).")] [SerializeField] private NetworkObject playerPrefab; [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; /// /// 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; /// /// 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; } /// /// 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; } /// /// 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…"); 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); } #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) { string code = GetLocalSteamCode(); PlayerEvents.RaiseSessionStarted(code); Log($"✅ Session créée ! Code à partager = {code}"); if (pendingSceneLoad) { pendingSceneLoad = false; LoadGameSceneNetworked(); } } 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(); } } /// /// 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 /// /// 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 } }