Files
2026-06-22 16:18:34 +02:00

407 lines
15 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
[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
/// <summary>
/// Global access point used by the menu UI to launch a session.
/// </summary>
public static NetworkSessionManager Instance { get; private set; }
/// <summary>
/// The gameplay scene name (single source of truth, read by anything that needs it).
/// </summary>
public string GameSceneName => gameSceneName;
/// <summary>
/// Cached FishNet manager resolved from the active scene.
/// </summary>
private NetworkManager networkManager;
/// <summary>
/// True while we are waiting for the server to come up before loading the game scene.
/// </summary>
private bool pendingSceneLoad;
/// <summary>
/// Round-robin cursor over the available spawn points.
/// </summary>
private int spawnIndex;
/// <summary>
/// Connections already given a player this session (guards against double spawns).
/// </summary>
private readonly HashSet<int> spawnedConnections = new HashSet<int>();
#endregion
#region Unity Lifecycle
/// <summary>
/// Establishes the singleton; the GameObject persists via the NetworkManager itself.
/// </summary>
private void Awake()
{
if (Instance != null && Instance != this)
{
// Only drop this extra component, never the shared NetworkManager GameObject.
Destroy(this);
return;
}
Instance = this;
}
/// <summary>
/// Resolves the NetworkManager and subscribes to transport + scene-presence events.
/// </summary>
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;
}
/// <summary>
/// Unsubscribes — mirrors OnEnable exactly.
/// </summary>
private void OnDisable()
{
if (networkManager == null) return;
networkManager.ServerManager.OnServerConnectionState -= HandleServerState;
networkManager.ServerManager.OnRemoteConnectionState -= HandleRemoteState;
networkManager.ClientManager.OnClientConnectionState -= HandleClientState;
networkManager.SceneManager.OnClientPresenceChangeEnd -= HandleClientPresenceEnd;
}
/// <summary>
/// Clears the singleton reference.
/// </summary>
private void OnDestroy()
{
if (Instance == this) Instance = null;
}
#endregion
#region Public API
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Joins an existing session by its code (the host SteamID64). Used later for friends.
/// </summary>
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();
}
/// <summary>
/// Tears down the current session (client first, then server) and returns offline.
/// </summary>
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
/// <summary>
/// Server transport changed state — on Started we publish the code and load the scene.
/// </summary>
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();
}
}
/// <summary>
/// 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.
/// </summary>
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();
}
}
/// <summary>
/// A *remote* client connected to or disconnected from our server; updates the count.
/// </summary>
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
/// <summary>
/// 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.
/// </summary>
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}.");
}
/// <summary>
/// Picks the next spawn point (round-robin) from the given loaded scene, falling back to origin.
/// </summary>
private void ResolveSpawnPoint(Scene scene, out Vector3 position, out Quaternion rotation)
{
List<PlayerSpawnPoint> points = new List<PlayerSpawnPoint>();
foreach (GameObject root in scene.GetRootGameObjects())
points.AddRange(root.GetComponentsInChildren<PlayerSpawnPoint>(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
/// <summary>
/// Loads the gameplay scene as a global networked scene, replacing the menu.
/// </summary>
private void LoadGameSceneNetworked()
{
Log($"Loading scene '{gameSceneName}' over the network…");
SceneLoadData sld = new SceneLoadData(gameSceneName)
{
ReplaceScenes = ReplaceOption.All
};
networkManager.SceneManager.LoadGlobalScenes(sld);
}
/// <summary>
/// Returns the short, shareable session code derived from the local player's SteamID64.
/// </summary>
private string GetLocalSteamCode()
{
if (!SteamManager.Initialized)
return "(Steam non initialisé)";
return SessionCode.FromSteamId(SteamUser.GetSteamID().m_SteamID);
}
/// <summary>
/// Verifies the NetworkManager and Steam are both ready before any network action.
/// </summary>
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;
}
/// <summary>
/// Prefixed console log gated by the verbose toggle.
/// </summary>
private void Log(string message)
{
if (verboseLogging)
Debug.Log($"[NetworkSession] {message}", this);
}
#endregion
}
}