407 lines
15 KiB
C#
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
|
|
}
|
|
}
|