Comitting lobby code without testing.

This commit is contained in:
Matt F 2026-05-15 14:30:15 -07:00
parent 66f84652dc
commit 60fa58b07f
14 changed files with 1207 additions and 37 deletions

View file

@ -0,0 +1,190 @@
// Assets/_Project/Scripts/Net/NetworkBootstrap.cs
using System;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using UnityEngine;
namespace TD.Net
{
/// <summary>
/// Static facade for starting a host, joining a host, and disconnecting.
/// Wraps NGO's <see cref="NetworkManager"/> and <see cref="UnityTransport"/>
/// so the rest of the codebase (lobby UI, main menu UI) doesn't talk to
/// NGO directly — and so the transport implementation can be swapped
/// without touching lobby code.
/// </summary>
/// <remarks>
/// <para><b>Why a static facade.</b> Today this is a thin wrapper over
/// <see cref="UnityTransport"/>. When Steam integration lands
/// (see <c>Project_Roadmap.md</c> §1.7-Future Steam Lobby Migration), this
/// class becomes the seam: it grows into an <c>IConnectionProvider</c>
/// abstraction with two implementations (<c>DirectIpConnectionProvider</c>
/// for LAN / DRM-free distribution, <c>SteamConnectionProvider</c> for
/// Steam friend-invite + lobby-browser flows). The lobby UI never changes;
/// only this class does.</para>
///
/// <para><b>Default port.</b> NGO's NetworkConfig holds the canonical
/// transport settings (port, listen address). <see cref="DefaultPort"/>
/// is just the value we use when the user doesn't override it in the UI.</para>
///
/// <para><b>Scene management.</b> NGO's <c>SceneManager.LoadScene</c> is
/// called by the host AFTER <see cref="StartHost"/> succeeds — see
/// <see cref="LoadSceneAsHost"/>. The lobby and match scene names are
/// canonicalized in <see cref="SceneNames"/>.</para>
/// </remarks>
public static class NetworkBootstrap
{
// ----- Configuration ---------------------------------------------
/// <summary>Default port used by Host / Join when the UI doesn't override.</summary>
public const ushort DefaultPort = 7777;
/// <summary>Default listen address for the host. 0.0.0.0 listens on all interfaces.</summary>
public const string DefaultListenAddress = "0.0.0.0";
/// <summary>Default address clients use when no IP is entered (loopback for solo testing).</summary>
public const string DefaultConnectAddress = "127.0.0.1";
// ----- Public API ------------------------------------------------
/// <summary>True if a host or client connection is currently active.</summary>
public static bool IsConnected =>
NetworkManager.Singleton != null
&& (NetworkManager.Singleton.IsHost
|| NetworkManager.Singleton.IsServer
|| NetworkManager.Singleton.IsClient);
/// <summary>True if the local peer is the host (server + client in one process).</summary>
public static bool IsHost =>
NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost;
/// <summary>
/// Starts a host on <paramref name="listenAddress"/>:<paramref name="port"/>.
/// Returns true on success. Logs and returns false if NetworkManager
/// is missing or already running.
/// </summary>
public static bool StartHost(ushort port = DefaultPort, string listenAddress = DefaultListenAddress)
{
var nm = NetworkManager.Singleton;
if (nm == null)
{
Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " +
"Make sure a NetworkManager exists in the active scene.");
return false;
}
if (IsConnected)
{
Debug.LogWarning("[NetworkBootstrap] StartHost called while already connected. Ignored.");
return false;
}
ConfigureTransport(listenAddress, port);
bool ok = nm.StartHost();
if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartHost() returned false.");
return ok;
}
/// <summary>
/// Starts a client and connects to <paramref name="address"/>:<paramref name="port"/>.
/// Returns true on success. Logs and returns false if NetworkManager
/// is missing or already running.
/// </summary>
public static bool StartClient(string address, ushort port = DefaultPort)
{
var nm = NetworkManager.Singleton;
if (nm == null)
{
Debug.LogError("[NetworkBootstrap] NetworkManager.Singleton is null. " +
"Make sure a NetworkManager exists in the active scene.");
return false;
}
if (IsConnected)
{
Debug.LogWarning("[NetworkBootstrap] StartClient called while already connected. Ignored.");
return false;
}
if (string.IsNullOrWhiteSpace(address))
address = DefaultConnectAddress;
ConfigureTransport(address, port);
bool ok = nm.StartClient();
if (!ok) Debug.LogError("[NetworkBootstrap] NetworkManager.StartClient() returned false.");
return ok;
}
/// <summary>
/// Shuts down the current host or client connection. Safe to call when
/// already disconnected.
/// </summary>
public static void Disconnect()
{
var nm = NetworkManager.Singleton;
if (nm == null) return;
if (!IsConnected) return;
nm.Shutdown();
}
/// <summary>
/// Server-only helper that transitions every peer to <paramref name="sceneName"/>
/// via NGO's networked scene manager. Logs and no-ops if not the server,
/// if NGO scene management is disabled, or if the scene name is invalid.
/// </summary>
public static void LoadSceneAsHost(string sceneName)
{
var nm = NetworkManager.Singleton;
if (nm == null) { Debug.LogError("[NetworkBootstrap] NetworkManager null."); return; }
if (!nm.IsServer)
{
Debug.LogWarning($"[NetworkBootstrap] LoadSceneAsHost('{sceneName}') called on a client. Ignored.");
return;
}
if (nm.SceneManager == null)
{
Debug.LogError("[NetworkBootstrap] NetworkManager.SceneManager is null — " +
"Enable Scene Management on the NetworkManager.");
return;
}
nm.SceneManager.LoadScene(sceneName, UnityEngine.SceneManagement.LoadSceneMode.Single);
}
// ----- Internals --------------------------------------------------
// Writes connection data into the UnityTransport component attached to
// the NetworkManager. Doesn't allocate; reuses the transport instance
// that's already in the scene.
//
// FUTURE STEAM MIGRATION: when SteamConnectionProvider lands, this
// method's body is replaced (or split) to configure the appropriate
// transport. The public API above stays as-is so call sites in the
// lobby UI don't change.
private static void ConfigureTransport(string address, ushort port)
{
var nm = NetworkManager.Singleton;
var transport = nm.NetworkConfig?.NetworkTransport as UnityTransport;
if (transport == null)
{
Debug.LogError("[NetworkBootstrap] NetworkManager's transport is not UnityTransport. " +
"If you switched transports (e.g. Steam), update NetworkBootstrap " +
"to configure the new one.");
return;
}
transport.SetConnectionData(address, port, DefaultListenAddress);
}
}
/// <summary>
/// Canonical scene names used by scene transitions.
/// Centralized so renames or additions touch one place.
/// </summary>
public static class SceneNames
{
public const string MainMenu = "MainMenu";
public const string Lobby = "Lobby";
/// <summary>The gameplay scene (currently "Main" — the original prototype scene).</summary>
public const string Match = "Main";
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f83a5afd3f38414da451d1250c92be4

View file

@ -0,0 +1,136 @@
// Assets/_Project/Scripts/Net/SessionFlow.cs
using Unity.Netcode;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace TD.Net
{
/// <summary>
/// Bootstrap script — drop one on a GameObject in the MainMenu scene so it
/// runs once when the app boots. Subscribes to NGO disconnect callbacks
/// and routes the local peer back to the MainMenu scene whenever its
/// connection ends (host left, server kicked it, transport error, etc.).
/// </summary>
/// <remarks>
/// <para><b>Why this exists.</b> Per Option A (see Project_Roadmap.md §1.7),
/// host disconnect tears down the session for everyone. Each client needs
/// to recover gracefully and return to its own main menu. Without this,
/// a client would be stuck in the Lobby or Match scene with no working
/// network connection after the host quits.</para>
///
/// <para><b>Lifecycle.</b> The NetworkManager itself is marked
/// DontDestroyOnLoad by NGO once it spawns. This script is also marked
/// DontDestroyOnLoad so it survives scene transitions and its subscription
/// stays alive across MainMenu → Lobby → Match → back.</para>
///
/// <para><b>Server side.</b> When the host (server) shuts down, the
/// <c>OnServerStopped</c> callback fires on the host too. For the host
/// that's fine — they've initiated the shutdown deliberately (e.g., from
/// the "Return to Main Menu" button) and being sent to MainMenu is what
/// they wanted.</para>
/// </remarks>
public class SessionFlow : MonoBehaviour
{
// ----- Singleton --------------------------------------------------
public static SessionFlow Instance { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
private void Start()
{
HookCallbacks();
}
private void OnDestroy()
{
UnhookCallbacks();
if (Instance == this) Instance = null;
}
// ----- NGO callback wiring ---------------------------------------
private bool callbacksHooked;
private void HookCallbacks()
{
var nm = NetworkManager.Singleton;
if (nm == null)
{
// NetworkManager may not be alive yet on the very first frame
// depending on script execution order — retry next frame.
Invoke(nameof(HookCallbacks), 0.1f);
return;
}
if (callbacksHooked) return;
nm.OnClientDisconnectCallback += HandleClientDisconnect;
nm.OnServerStopped += HandleServerStopped;
callbacksHooked = true;
}
private void UnhookCallbacks()
{
if (!callbacksHooked) return;
var nm = NetworkManager.Singleton;
if (nm != null)
{
nm.OnClientDisconnectCallback -= HandleClientDisconnect;
nm.OnServerStopped -= HandleServerStopped;
}
callbacksHooked = false;
}
// ----- Disconnect handlers ---------------------------------------
// Fires when ANY client disconnects on the server, and when the LOCAL
// client gets disconnected on a client. We only care about the latter:
// when the local peer loses its connection (host left, transport error,
// explicit Shutdown call), route back to the main menu.
private void HandleClientDisconnect(ulong clientId)
{
var nm = NetworkManager.Singleton;
if (nm == null) return;
// On the server, this fires when other clients disconnect — ignore
// those, the server keeps running. We only act when the LOCAL
// client's connection ends.
if (clientId != nm.LocalClientId) return;
ReturnToMainMenu();
}
// Fires on the server when the server shuts down (including the host).
// The host's own client also gets HandleClientDisconnect — but
// OnServerStopped is the canonical "server is gone" signal and is
// safer to act on for the host path.
private void HandleServerStopped(bool isHost)
{
ReturnToMainMenu();
}
// ----- Scene transition ------------------------------------------
// Returns the local peer to the MainMenu scene if they're not already
// there. Uses Unity's local SceneManager (NOT NGO's networked scene
// manager) because by the time this fires the network connection is
// already gone.
private static void ReturnToMainMenu()
{
if (SceneManager.GetActiveScene().name == SceneNames.MainMenu)
return;
Debug.Log("[SessionFlow] Connection ended — returning to main menu.");
SceneManager.LoadScene(SceneNames.MainMenu);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 474003b8e462dcc479e313f3d4f1cf12