Comitting lobby code without testing.
This commit is contained in:
parent
66f84652dc
commit
60fa58b07f
14 changed files with 1207 additions and 37 deletions
165
Assets/_Project/Scripts/Gameplay/LobbyService.cs
Normal file
165
Assets/_Project/Scripts/Gameplay/LobbyService.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Assets/_Project/Scripts/Gameplay/LobbyService.cs
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using TD.Core;
|
||||
using TD.Net;
|
||||
|
||||
namespace TD.Gameplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Scene singleton that owns lobby-wide actions: Start Match (host),
|
||||
/// Return to Lobby (after a match), and the "host disconnected" broadcast.
|
||||
/// Lives in the <c>Lobby</c> scene with a NetworkObject; spawns when the
|
||||
/// scene loads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>What lives here vs. on PlayerMatchState.</b> Per-player state
|
||||
/// (race, ready, slot, display name) lives on <see cref="PlayerMatchState"/>
|
||||
/// because it carries across scene transitions with the player connection.
|
||||
/// Lobby-wide ACTIONS (Start Match, Return to Lobby) live here because they
|
||||
/// are scoped to the lobby scene and don't need to persist.</para>
|
||||
///
|
||||
/// <para><b>Start Match flow.</b>
|
||||
/// <list type="number">
|
||||
/// <item>Host clicks Start in the lobby UI.</item>
|
||||
/// <item>UI calls <see cref="RequestStartMatchRpc"/>; server validates:
|
||||
/// every PlayerMatchState has a race and IsReady.</item>
|
||||
/// <item>Server calls <c>NetworkBootstrap.LoadSceneAsHost(SceneNames.Match)</c>.
|
||||
/// NGO replicates the scene load to every client.</item>
|
||||
/// <item>Match scene loads; <see cref="MatchState"/> spawns there; gameplay
|
||||
/// begins as usual.</item>
|
||||
/// </list></para>
|
||||
///
|
||||
/// <para><b>Return to Lobby flow.</b> Triggered by the match-end overlay's
|
||||
/// "Retry" button. Server calls <see cref="RequestReturnToLobbyRpc"/>;
|
||||
/// resets <see cref="PlayerMatchState.IsReady"/> on every player; loads
|
||||
/// the Lobby scene. Race picks are preserved so players don't have to
|
||||
/// re-select.</para>
|
||||
///
|
||||
/// <para><b>Host-leaves behavior (Option A).</b> When the host quits, NGO
|
||||
/// shuts down the connection for every client. Clients detect the disconnect
|
||||
/// in <see cref="MainMenuRedirectOnDisconnect"/> (subscribed to
|
||||
/// <c>NetworkManager.OnClientDisconnectCallback</c> on the local client)
|
||||
/// and load the MainMenu scene locally. See roadmap §1.7-Future Steam Lobby
|
||||
/// Migration for the Option C upgrade (lobby persists, new host elected)
|
||||
/// once Steam SDK is in.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(NetworkObject))]
|
||||
public class LobbyService : NetworkBehaviour
|
||||
{
|
||||
// ----- Singleton --------------------------------------------------
|
||||
|
||||
public static LobbyService Instance { get; private set; }
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
public override void OnNetworkSpawn()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Debug.LogError("[LobbyService] Duplicate instance detected. " +
|
||||
"Only one LobbyService should exist per scene.");
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
|
||||
// Entering the lobby always starts everyone un-ready. Race picks
|
||||
// are preserved from the previous lobby visit, but ready state
|
||||
// resets so each match requires explicit re-readying.
|
||||
if (IsServer)
|
||||
ResetAllReady();
|
||||
}
|
||||
|
||||
public override void OnNetworkDespawn()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
}
|
||||
|
||||
// ----- Public RPC API (client → server) --------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Host-only request to begin the match. Validates that every connected
|
||||
/// player has picked a race and is ready, then transitions every peer
|
||||
/// to the Match scene via NGO scene management.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||
public void RequestStartMatchRpc(RpcParams rpcParams = default)
|
||||
{
|
||||
// Only the host's local client may start a match. We compare the
|
||||
// sender's clientId to the server's clientId (which == the host's
|
||||
// local clientId in host mode).
|
||||
if (rpcParams.Receive.SenderClientId != NetworkManager.Singleton.LocalClientId)
|
||||
{
|
||||
Debug.LogWarning("[LobbyService] Non-host client attempted to start the match. Ignored.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AreAllPlayersReady(out string reason))
|
||||
{
|
||||
Debug.Log($"[LobbyService] Cannot start match: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
NetworkBootstrap.LoadSceneAsHost(SceneNames.Match);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anyone can request a return to the lobby (typically the host clicks
|
||||
/// "Retry" after a match). Server-side: resets ready state on every
|
||||
/// player and triggers the scene transition. Race picks are preserved.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)]
|
||||
public void RequestReturnToLobbyRpc()
|
||||
{
|
||||
// We don't gate this on host-only currently — any client retrying is
|
||||
// semantically equivalent (the host needs to be willing to keep
|
||||
// running). When dedicated server / Steam migration lands this
|
||||
// becomes host-only.
|
||||
ResetAllReady();
|
||||
NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby);
|
||||
}
|
||||
|
||||
// ----- Public queries --------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// True if every connected player has picked a race AND marked themselves
|
||||
/// ready. Returns the reason as <paramref name="reason"/> when false
|
||||
/// (for debug logs / future UI tooltips on the disabled Start button).
|
||||
/// </summary>
|
||||
public static bool AreAllPlayersReady(out string reason)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
{
|
||||
count++;
|
||||
if (pms.RaceSelection == RaceId.None)
|
||||
{
|
||||
reason = $"Slot {pms.Slot} hasn't picked a race.";
|
||||
return false;
|
||||
}
|
||||
if (!pms.IsReady)
|
||||
{
|
||||
reason = $"Slot {pms.Slot} is not ready.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
reason = "No players connected.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ----- Server helpers --------------------------------------------
|
||||
|
||||
private static void ResetAllReady()
|
||||
{
|
||||
foreach (var pms in PlayerMatchState.AllPlayers)
|
||||
pms.SetReady(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta
Normal file
2
Assets/_Project/Scripts/Gameplay/LobbyService.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 46ab03868ea4bd541bd6be3446c2bd3d
|
||||
|
|
@ -89,30 +89,91 @@ namespace TD.Gameplay
|
|||
writePerm: NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
// STUBBED — written by the race-pick system (Phase 1.8). None until a race is chosen.
|
||||
[Tooltip("STUBBED — not written until the race-pick system lands (Phase 1.8).")]
|
||||
// Race chosen by this player. Written by the lobby system in response to
|
||||
// SetRaceRpc; carries over scene transitions because PlayerMatchState is
|
||||
// the player prefab (owned by the connection, persists across scenes).
|
||||
private readonly NetworkVariable<RaceId> raceSelection = new NetworkVariable<RaceId>(
|
||||
value: RaceId.None,
|
||||
readPerm: NetworkVariableReadPermission.Everyone,
|
||||
writePerm: NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
// Lobby ready-state. Cleared back to false on scene transitions out of
|
||||
// the lobby (the new lobby session starts everyone un-ready).
|
||||
private readonly NetworkVariable<bool> isReady = new NetworkVariable<bool>(
|
||||
value: false,
|
||||
readPerm: NetworkVariableReadPermission.Everyone,
|
||||
writePerm: NetworkVariableWritePermission.Server
|
||||
);
|
||||
|
||||
/// <summary>This player's assigned slot in the match. Authoritative once spawned.</summary>
|
||||
public PlayerSlot Slot => slot.Value;
|
||||
|
||||
/// <summary>Display name. Stub until a lobby/name system provides it.</summary>
|
||||
public string DisplayName => displayName.Value.ToString();
|
||||
|
||||
/// <summary>STUBBED. Race chosen by this player. <see cref="RaceId.None"/> until Phase 1.8.</summary>
|
||||
/// <summary>Race chosen by this player. <see cref="RaceId.None"/> until selected in the lobby.</summary>
|
||||
public RaceId RaceSelection => raceSelection.Value;
|
||||
|
||||
/// <summary>STUBBED. Sets this player's race selection. Called by the race-pick system (Phase 1.8).</summary>
|
||||
/// <summary>True if this player has marked themselves ready in the lobby.</summary>
|
||||
public bool IsReady => isReady.Value;
|
||||
|
||||
/// <summary>Server-only. Sets this player's race selection.</summary>
|
||||
public void SetRaceSelection(RaceId race)
|
||||
{
|
||||
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetRaceSelection called on a client — ignored."); return; }
|
||||
raceSelection.Value = race;
|
||||
}
|
||||
|
||||
/// <summary>Server-only. Sets this player's ready state.</summary>
|
||||
public void SetReady(bool ready)
|
||||
{
|
||||
if (!IsServer) { Debug.LogWarning("[PlayerMatchState] SetReady called on a client — ignored."); return; }
|
||||
isReady.Value = ready;
|
||||
}
|
||||
|
||||
// ----- Client → Server lobby RPCs --------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Client → server: submit a race selection. Owner-only — players can
|
||||
/// only change their own race. Validates that the caller is the owner
|
||||
/// of this PlayerMatchState; ignored otherwise.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
public void SubmitRaceRpc(RaceId race)
|
||||
{
|
||||
// Reset ready state when race changes — players shouldn't be "ready"
|
||||
// with a stale pick.
|
||||
if (raceSelection.Value != race)
|
||||
{
|
||||
raceSelection.Value = race;
|
||||
isReady.Value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client → server: submit ready state. Owner-only. A player can only
|
||||
/// ready up if they've picked a race; the lobby UI gates this already
|
||||
/// but the server enforces it as a safety net.
|
||||
/// </summary>
|
||||
[Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
|
||||
public void SubmitReadyRpc(bool ready)
|
||||
{
|
||||
if (ready && raceSelection.Value == RaceId.None)
|
||||
{
|
||||
Debug.Log($"[PlayerMatchState] Slot {Slot} tried to ready up without a race. Ignored.");
|
||||
return;
|
||||
}
|
||||
isReady.Value = ready;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-side helper: enumerate every currently-spawned PlayerMatchState.
|
||||
/// Used by <c>LobbyService</c> to evaluate "are all players ready" and
|
||||
/// by <c>LobbyController</c> on every peer to render the player list.
|
||||
/// </summary>
|
||||
public static IEnumerable<PlayerMatchState> AllPlayers => s_byClientId.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only. Fires on the server immediately after the slot is assigned in
|
||||
/// <see cref="OnNetworkSpawn"/>. Sibling components (e.g. PlayerBuilderSpawner)
|
||||
|
|
|
|||
8
Assets/_Project/Scripts/Net.meta
Normal file
8
Assets/_Project/Scripts/Net.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 6bc7137210913b24da6d85326de66ed5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
190
Assets/_Project/Scripts/Net/NetworkBootstrap.cs
Normal file
190
Assets/_Project/Scripts/Net/NetworkBootstrap.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta
Normal file
2
Assets/_Project/Scripts/Net/NetworkBootstrap.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 0f83a5afd3f38414da451d1250c92be4
|
||||
136
Assets/_Project/Scripts/Net/SessionFlow.cs
Normal file
136
Assets/_Project/Scripts/Net/SessionFlow.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/Net/SessionFlow.cs.meta
Normal file
2
Assets/_Project/Scripts/Net/SessionFlow.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 474003b8e462dcc479e313f3d4f1cf12
|
||||
|
|
@ -1322,12 +1322,25 @@ namespace TD.UI
|
|||
matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
panel.Add(matchEndTitle);
|
||||
|
||||
// Action row — Retry (back to lobby with everyone who retried)
|
||||
// and Return to Main Menu (this player only disconnects).
|
||||
var actionRow = new VisualElement();
|
||||
actionRow.style.flexDirection = FlexDirection.Row;
|
||||
actionRow.style.marginTop = 8;
|
||||
panel.Add(actionRow);
|
||||
|
||||
var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
|
||||
retryBtn.style.minWidth = 120;
|
||||
retryBtn.style.minWidth = 140;
|
||||
retryBtn.style.height = 36;
|
||||
retryBtn.style.fontSize = 16;
|
||||
retryBtn.style.marginTop = 8;
|
||||
panel.Add(retryBtn);
|
||||
retryBtn.style.marginRight = 12;
|
||||
actionRow.Add(retryBtn);
|
||||
|
||||
var menuBtn = new Button(OnReturnToMainMenuClicked) { text = "Return to Main Menu" };
|
||||
menuBtn.style.minWidth = 200;
|
||||
menuBtn.style.height = 36;
|
||||
menuBtn.style.fontSize = 16;
|
||||
actionRow.Add(menuBtn);
|
||||
|
||||
matchEndOverlay.Add(panel);
|
||||
root.Add(matchEndOverlay);
|
||||
|
|
@ -1368,41 +1381,34 @@ namespace TD.UI
|
|||
// sceneLoaded callback survives the scene reload (HUDController dies),
|
||||
// re-arms StartHost once the fresh scene has finished loading, and
|
||||
// unsubscribes itself.
|
||||
// Retry: take everyone back to the Lobby scene via LobbyService. The
|
||||
// lobby preserves race picks, clears ready state. Anyone who clicked
|
||||
// Return to Main Menu instead has already disconnected — they don't
|
||||
// come along.
|
||||
private void OnRetryClicked()
|
||||
{
|
||||
var svc = LobbyService.Instance;
|
||||
if (svc != null)
|
||||
{
|
||||
svc.RequestReturnToLobbyRpc();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: LobbyService isn't spawned (e.g. testing the gameplay
|
||||
// scene standalone without the lobby flow). Hard-reload the scene.
|
||||
Debug.LogWarning("[HUDController] LobbyService not found — falling back to scene reload.");
|
||||
var nm = NetworkManager.Singleton;
|
||||
if (nm == null)
|
||||
{
|
||||
Debug.LogWarning("[HUDController] Retry clicked but NetworkManager is null.");
|
||||
return;
|
||||
}
|
||||
if (!nm.IsServer)
|
||||
{
|
||||
Debug.LogWarning("[HUDController] Retry only works on the host. " +
|
||||
"Clients should ask the host to retry.");
|
||||
return;
|
||||
}
|
||||
|
||||
Scene active = SceneManager.GetActiveScene();
|
||||
s_pendingHostRestartBuildIndex = active.buildIndex;
|
||||
SceneManager.sceneLoaded += OnSceneLoadedForRetry;
|
||||
|
||||
nm.Shutdown();
|
||||
SceneManager.LoadScene(active.buildIndex);
|
||||
if (nm != null && nm.IsServer && nm.SceneManager != null)
|
||||
nm.SceneManager.LoadScene(SceneManager.GetActiveScene().name, LoadSceneMode.Single);
|
||||
}
|
||||
|
||||
private static int s_pendingHostRestartBuildIndex = -1;
|
||||
|
||||
private static void OnSceneLoadedForRetry(Scene loaded, LoadSceneMode mode)
|
||||
// Return to Main Menu: disconnect only this player. SessionFlow's
|
||||
// OnClientDisconnect handler routes us back to MainMenu locally. Other
|
||||
// peers remain in the match (until the host quits, at which point
|
||||
// SessionFlow on each remaining client routes them out too).
|
||||
private void OnReturnToMainMenuClicked()
|
||||
{
|
||||
if (loaded.buildIndex != s_pendingHostRestartBuildIndex) return;
|
||||
|
||||
SceneManager.sceneLoaded -= OnSceneLoadedForRetry;
|
||||
s_pendingHostRestartBuildIndex = -1;
|
||||
|
||||
var nm = NetworkManager.Singleton;
|
||||
if (nm != null) nm.StartHost();
|
||||
else Debug.LogWarning("[HUDController] Retry: no NetworkManager in reloaded scene.");
|
||||
TD.Net.NetworkBootstrap.Disconnect();
|
||||
}
|
||||
|
||||
// ----- Helpers ----------------------------------------------------
|
||||
|
|
|
|||
270
Assets/_Project/Scripts/UI/LobbyController.cs
Normal file
270
Assets/_Project/Scripts/UI/LobbyController.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// Assets/_Project/Scripts/UI/LobbyController.cs
|
||||
using System.Linq;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using TD.Core;
|
||||
using TD.Gameplay;
|
||||
using TD.Net;
|
||||
|
||||
namespace TD.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the lobby UI. Requires a <see cref="UIDocument"/> on the same
|
||||
/// GameObject. Programmatic UI for v1 — UXML can be added later if needed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>What's displayed.</b> A player list with one row per connected
|
||||
/// player showing slot, display name, race pick, and ready ✓. The local
|
||||
/// player's row has interactive controls: a race dropdown (currently a
|
||||
/// placeholder with just <see cref="RaceId.None"/> until Phase 1.8 fills
|
||||
/// the enum) and a Ready toggle button. The host sees a Start Match
|
||||
/// button at the bottom, enabled only when every player is ready.</para>
|
||||
///
|
||||
/// <para><b>Polling vs reactive.</b> The list is rebuilt every Update from
|
||||
/// <see cref="PlayerMatchState.AllPlayers"/>. PlayerMatchState's
|
||||
/// <c>NetworkVariable</c>s expose <c>OnValueChanged</c> events we could
|
||||
/// hook for reactive updates, but polling is simpler at lobby scale
|
||||
/// (max 9 players × a handful of fields each) and avoids tracking
|
||||
/// subscription lifecycles across spawn/despawn.</para>
|
||||
///
|
||||
/// <para><b>Race picker placeholder.</b> The <see cref="RaceId"/> enum
|
||||
/// currently only has <c>None</c>. The picker shows a single "Test Race"
|
||||
/// button that calls <c>SubmitRaceRpc(RaceId.None)</c> — temporary, so
|
||||
/// ready-up flow can be exercised end-to-end before Phase 1.8 lands real
|
||||
/// races. Replace with the actual race-selection UI when
|
||||
/// <see cref="RaceDefinition"/> assets exist.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(UIDocument))]
|
||||
public class LobbyController : MonoBehaviour
|
||||
{
|
||||
// ----- Cached UI elements -----------------------------------------
|
||||
|
||||
private VisualElement playerListContainer;
|
||||
private Button startMatchButton;
|
||||
private Button leaveButton;
|
||||
private Label statusLabel;
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
{
|
||||
var doc = GetComponent<UIDocument>();
|
||||
var root = doc?.rootVisualElement;
|
||||
if (root == null)
|
||||
{
|
||||
Debug.LogError("[LobbyController] rootVisualElement is null. " +
|
||||
"Check the UIDocument's Panel Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
BuildUI(root);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (playerListContainer == null) return;
|
||||
RefreshPlayerList();
|
||||
RefreshStartButton();
|
||||
}
|
||||
|
||||
// ----- UI construction --------------------------------------------
|
||||
|
||||
private void BuildUI(VisualElement root)
|
||||
{
|
||||
root.style.flexDirection = FlexDirection.Column;
|
||||
root.style.alignItems = Align.Center;
|
||||
root.style.justifyContent = Justify.FlexStart;
|
||||
root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f);
|
||||
root.style.width = Length.Percent(100);
|
||||
root.style.height = Length.Percent(100);
|
||||
root.style.paddingTop = 40;
|
||||
|
||||
// Title.
|
||||
var title = new Label("Lobby");
|
||||
title.style.fontSize = 36;
|
||||
title.style.color = Color.white;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.marginBottom = 24;
|
||||
root.Add(title);
|
||||
|
||||
// Player list container (rebuilt every Update from AllPlayers).
|
||||
playerListContainer = new VisualElement();
|
||||
playerListContainer.style.flexDirection = FlexDirection.Column;
|
||||
playerListContainer.style.minWidth = 520;
|
||||
playerListContainer.style.paddingTop = 12;
|
||||
playerListContainer.style.paddingBottom = 12;
|
||||
playerListContainer.style.paddingLeft = 16;
|
||||
playerListContainer.style.paddingRight = 16;
|
||||
playerListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
|
||||
playerListContainer.style.marginBottom = 24;
|
||||
root.Add(playerListContainer);
|
||||
|
||||
// Action row: Start Match (host) + Leave (everyone).
|
||||
var actionRow = new VisualElement();
|
||||
actionRow.style.flexDirection = FlexDirection.Row;
|
||||
actionRow.style.marginTop = 8;
|
||||
root.Add(actionRow);
|
||||
|
||||
startMatchButton = new Button(OnStartMatchClicked) { text = "Start Match" };
|
||||
startMatchButton.style.minWidth = 200;
|
||||
startMatchButton.style.height = 44;
|
||||
startMatchButton.style.fontSize = 18;
|
||||
startMatchButton.style.marginRight = 16;
|
||||
startMatchButton.SetEnabled(false);
|
||||
actionRow.Add(startMatchButton);
|
||||
|
||||
leaveButton = new Button(OnLeaveClicked) { text = "Leave Lobby" };
|
||||
leaveButton.style.minWidth = 200;
|
||||
leaveButton.style.height = 44;
|
||||
leaveButton.style.fontSize = 18;
|
||||
actionRow.Add(leaveButton);
|
||||
|
||||
statusLabel = new Label(string.Empty);
|
||||
statusLabel.style.color = new Color(1f, 0.6f, 0.3f);
|
||||
statusLabel.style.marginTop = 16;
|
||||
statusLabel.style.minHeight = 20;
|
||||
root.Add(statusLabel);
|
||||
}
|
||||
|
||||
// ----- Per-frame refresh ------------------------------------------
|
||||
|
||||
private void RefreshPlayerList()
|
||||
{
|
||||
playerListContainer.Clear();
|
||||
|
||||
ulong localId = NetworkManager.Singleton != null
|
||||
? NetworkManager.Singleton.LocalClientId
|
||||
: ulong.MaxValue;
|
||||
|
||||
// Sort by slot for stable ordering. AllPlayers is keyed by clientId
|
||||
// which may not be slot-ordered.
|
||||
var players = PlayerMatchState.AllPlayers.OrderBy(p => (int)p.Slot).ToList();
|
||||
|
||||
foreach (var pms in players)
|
||||
{
|
||||
bool isLocal = pms.OwnerClientId == localId;
|
||||
playerListContainer.Add(BuildPlayerRow(pms, isLocal));
|
||||
}
|
||||
|
||||
if (players.Count == 0)
|
||||
playerListContainer.Add(new Label("(no players connected)") { style = { color = Color.gray } });
|
||||
}
|
||||
|
||||
private VisualElement BuildPlayerRow(PlayerMatchState pms, bool isLocal)
|
||||
{
|
||||
var row = new VisualElement();
|
||||
row.style.flexDirection = FlexDirection.Row;
|
||||
row.style.alignItems = Align.Center;
|
||||
row.style.paddingTop = 6;
|
||||
row.style.paddingBottom = 6;
|
||||
row.style.paddingLeft = 8;
|
||||
row.style.paddingRight = 8;
|
||||
row.style.marginBottom = 4;
|
||||
row.style.backgroundColor = isLocal
|
||||
? new Color(0.18f, 0.22f, 0.30f)
|
||||
: new Color(0.10f, 0.10f, 0.12f);
|
||||
|
||||
// Slot label.
|
||||
var slotLabel = new Label($"P{(int)pms.Slot}");
|
||||
slotLabel.style.minWidth = 40;
|
||||
slotLabel.style.color = Color.white;
|
||||
slotLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
row.Add(slotLabel);
|
||||
|
||||
// Display name.
|
||||
var nameLabel = new Label(string.IsNullOrEmpty(pms.DisplayName)
|
||||
? $"Player {(int)pms.Slot}"
|
||||
: pms.DisplayName);
|
||||
nameLabel.style.minWidth = 140;
|
||||
nameLabel.style.color = Color.white;
|
||||
row.Add(nameLabel);
|
||||
|
||||
// Race indicator.
|
||||
var raceLabel = new Label($"Race: {pms.RaceSelection}");
|
||||
raceLabel.style.minWidth = 160;
|
||||
raceLabel.style.color = pms.RaceSelection == RaceId.None
|
||||
? new Color(0.7f, 0.5f, 0.5f)
|
||||
: new Color(0.85f, 0.85f, 0.85f);
|
||||
row.Add(raceLabel);
|
||||
|
||||
// Ready indicator.
|
||||
var readyLabel = new Label(pms.IsReady ? "READY ✓" : "not ready");
|
||||
readyLabel.style.minWidth = 100;
|
||||
readyLabel.style.color = pms.IsReady
|
||||
? new Color(0.3f, 0.85f, 0.3f)
|
||||
: new Color(0.6f, 0.6f, 0.6f);
|
||||
readyLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
row.Add(readyLabel);
|
||||
|
||||
// Local-only controls.
|
||||
if (isLocal)
|
||||
{
|
||||
// PLACEHOLDER race picker — the RaceId enum only has None right
|
||||
// now (Phase 1.8 will fill it). For now the single button submits
|
||||
// RaceId.None, which keeps the ready-up gate effectively a no-op
|
||||
// (the server requires RaceSelection != None to allow ready). To
|
||||
// exercise the flow end-to-end before races exist, comment out
|
||||
// the gate in PlayerMatchState.SubmitReadyRpc.
|
||||
//
|
||||
// TODO (Phase 1.8): replace with a dropdown of RaceDefinition
|
||||
// assets discovered at runtime, each option calling
|
||||
// pms.SubmitRaceRpc(definition.Id).
|
||||
var pickRaceBtn = new Button(() => pms.SubmitRaceRpc(RaceId.None))
|
||||
{
|
||||
text = "Pick Race (stub)"
|
||||
};
|
||||
pickRaceBtn.style.minWidth = 130;
|
||||
pickRaceBtn.style.height = 28;
|
||||
pickRaceBtn.style.marginLeft = 12;
|
||||
row.Add(pickRaceBtn);
|
||||
|
||||
var readyBtn = new Button(() => pms.SubmitReadyRpc(!pms.IsReady))
|
||||
{
|
||||
text = pms.IsReady ? "Unready" : "Ready"
|
||||
};
|
||||
readyBtn.style.minWidth = 100;
|
||||
readyBtn.style.height = 28;
|
||||
readyBtn.style.marginLeft = 8;
|
||||
row.Add(readyBtn);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private void RefreshStartButton()
|
||||
{
|
||||
var nm = NetworkManager.Singleton;
|
||||
bool isHost = nm != null && nm.IsHost;
|
||||
|
||||
startMatchButton.style.display = isHost ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (!isHost) return;
|
||||
|
||||
bool canStart = LobbyService.AreAllPlayersReady(out string reason);
|
||||
startMatchButton.SetEnabled(canStart);
|
||||
statusLabel.text = canStart ? string.Empty : reason;
|
||||
}
|
||||
|
||||
// ----- Button handlers --------------------------------------------
|
||||
|
||||
private void OnStartMatchClicked()
|
||||
{
|
||||
var svc = LobbyService.Instance;
|
||||
if (svc == null)
|
||||
{
|
||||
Debug.LogError("[LobbyController] LobbyService.Instance is null. " +
|
||||
"Scene setup is incomplete.");
|
||||
return;
|
||||
}
|
||||
svc.RequestStartMatchRpc();
|
||||
}
|
||||
|
||||
private void OnLeaveClicked()
|
||||
{
|
||||
// Disconnect drops our connection. SessionFlow will route the
|
||||
// local peer back to the MainMenu scene when the disconnect
|
||||
// callback fires.
|
||||
NetworkBootstrap.Disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/UI/LobbyController.cs.meta
Normal file
2
Assets/_Project/Scripts/UI/LobbyController.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8c40427d598ba944c82f3790429c5532
|
||||
233
Assets/_Project/Scripts/UI/MainMenuController.cs
Normal file
233
Assets/_Project/Scripts/UI/MainMenuController.cs
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Assets/_Project/Scripts/UI/MainMenuController.cs
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using TD.Net;
|
||||
|
||||
namespace TD.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the main menu UI. Requires a <see cref="UIDocument"/> on the same
|
||||
/// GameObject. Builds the UI programmatically — no UXML needed for v1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Buttons.</b>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Host</b> — calls <see cref="NetworkBootstrap.StartHost"/> on
|
||||
/// the default port, then NGO scene-loads the Lobby. Clients that
|
||||
/// join later get pulled into whatever scene the server is in.</item>
|
||||
/// <item><b>Join</b> — reveals IP + port fields, then calls
|
||||
/// <see cref="NetworkBootstrap.StartClient"/>. The server will pull
|
||||
/// the client into the current networked scene (Lobby or Match)
|
||||
/// once the connection completes.</item>
|
||||
/// <item><b>Quit</b> — Application.Quit (no-op in the editor).</item>
|
||||
/// </list></para>
|
||||
///
|
||||
/// <para><b>Future Steam integration.</b> A lobby-browser panel will be
|
||||
/// added here. The Host button will create a Steam lobby instead of a
|
||||
/// direct-IP host; Join becomes "browse public lobbies" + "accept friend
|
||||
/// invite". See Project_Roadmap.md §1.7-Future Steam Lobby Migration.</para>
|
||||
/// </remarks>
|
||||
[RequireComponent(typeof(UIDocument))]
|
||||
public class MainMenuController : MonoBehaviour
|
||||
{
|
||||
// ----- Inspector --------------------------------------------------
|
||||
|
||||
[Tooltip("Default port shown in the Join field and used by the Host button.")]
|
||||
[SerializeField] private ushort defaultPort = NetworkBootstrap.DefaultPort;
|
||||
|
||||
[Tooltip("Default address pre-filled in the Join field for solo testing.")]
|
||||
[SerializeField] private string defaultJoinAddress = NetworkBootstrap.DefaultConnectAddress;
|
||||
|
||||
// ----- Cached UI elements -----------------------------------------
|
||||
|
||||
private Button hostButton;
|
||||
private Button joinButton;
|
||||
private Button quitButton;
|
||||
private VisualElement joinPanel;
|
||||
private TextField joinAddressField;
|
||||
private TextField joinPortField;
|
||||
private Button joinConfirmButton;
|
||||
private Button joinCancelButton;
|
||||
private Label statusLabel;
|
||||
|
||||
// ----- Lifecycle --------------------------------------------------
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// UIDocument creates its panel in OnEnable, which runs after Awake.
|
||||
// Start is the safe time to access rootVisualElement.
|
||||
var doc = GetComponent<UIDocument>();
|
||||
var root = doc?.rootVisualElement;
|
||||
if (root == null)
|
||||
{
|
||||
Debug.LogError("[MainMenuController] rootVisualElement is null. " +
|
||||
"Check the UIDocument's Panel Settings.");
|
||||
return;
|
||||
}
|
||||
|
||||
BuildUI(root);
|
||||
}
|
||||
|
||||
// ----- UI construction --------------------------------------------
|
||||
|
||||
private void BuildUI(VisualElement root)
|
||||
{
|
||||
// Centered vertical stack on a dark background.
|
||||
root.style.flexDirection = FlexDirection.Column;
|
||||
root.style.justifyContent = Justify.Center;
|
||||
root.style.alignItems = Align.Center;
|
||||
root.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f, 1f);
|
||||
// The UIDocument root needs explicit width/height to fill the screen.
|
||||
root.style.width = Length.Percent(100);
|
||||
root.style.height = Length.Percent(100);
|
||||
|
||||
// Title.
|
||||
var title = new Label("Unity Tower Defense");
|
||||
title.style.fontSize = 48;
|
||||
title.style.color = Color.white;
|
||||
title.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
title.style.marginBottom = 60;
|
||||
root.Add(title);
|
||||
|
||||
// Primary button column.
|
||||
var buttonColumn = new VisualElement();
|
||||
buttonColumn.style.flexDirection = FlexDirection.Column;
|
||||
buttonColumn.style.alignItems = Align.Center;
|
||||
root.Add(buttonColumn);
|
||||
|
||||
hostButton = MakeMenuButton("Host Game", OnHostClicked);
|
||||
joinButton = MakeMenuButton("Join Game", OnJoinClicked);
|
||||
quitButton = MakeMenuButton("Quit", OnQuitClicked);
|
||||
buttonColumn.Add(hostButton);
|
||||
buttonColumn.Add(joinButton);
|
||||
buttonColumn.Add(quitButton);
|
||||
|
||||
// Join sub-panel — hidden until Join is clicked. Holds the IP+port
|
||||
// fields and the Connect / Cancel buttons.
|
||||
joinPanel = new VisualElement();
|
||||
joinPanel.style.flexDirection = FlexDirection.Column;
|
||||
joinPanel.style.alignItems = Align.Center;
|
||||
joinPanel.style.marginTop = 24;
|
||||
joinPanel.style.paddingTop = 16;
|
||||
joinPanel.style.paddingBottom = 16;
|
||||
joinPanel.style.paddingLeft = 24;
|
||||
joinPanel.style.paddingRight = 24;
|
||||
joinPanel.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f);
|
||||
joinPanel.style.display = DisplayStyle.None;
|
||||
root.Add(joinPanel);
|
||||
|
||||
joinAddressField = new TextField("Host address");
|
||||
joinAddressField.value = defaultJoinAddress;
|
||||
joinAddressField.style.width = 280;
|
||||
joinAddressField.style.color = Color.white;
|
||||
joinPanel.Add(joinAddressField);
|
||||
|
||||
joinPortField = new TextField("Port");
|
||||
joinPortField.value = defaultPort.ToString();
|
||||
joinPortField.style.width = 280;
|
||||
joinPortField.style.marginTop = 8;
|
||||
joinPortField.style.color = Color.white;
|
||||
joinPanel.Add(joinPortField);
|
||||
|
||||
var joinButtons = new VisualElement();
|
||||
joinButtons.style.flexDirection = FlexDirection.Row;
|
||||
joinButtons.style.marginTop = 12;
|
||||
joinPanel.Add(joinButtons);
|
||||
|
||||
joinConfirmButton = new Button(OnJoinConfirmClicked) { text = "Connect" };
|
||||
joinConfirmButton.style.minWidth = 120;
|
||||
joinConfirmButton.style.height = 32;
|
||||
joinConfirmButton.style.marginRight = 8;
|
||||
joinButtons.Add(joinConfirmButton);
|
||||
|
||||
joinCancelButton = new Button(OnJoinCancelClicked) { text = "Cancel" };
|
||||
joinCancelButton.style.minWidth = 120;
|
||||
joinCancelButton.style.height = 32;
|
||||
joinButtons.Add(joinCancelButton);
|
||||
|
||||
// Status line at the bottom for connection feedback.
|
||||
statusLabel = new Label(string.Empty);
|
||||
statusLabel.style.color = new Color(1f, 0.6f, 0.3f);
|
||||
statusLabel.style.marginTop = 32;
|
||||
statusLabel.style.minHeight = 20;
|
||||
root.Add(statusLabel);
|
||||
}
|
||||
|
||||
private static Button MakeMenuButton(string text, System.Action onClick)
|
||||
{
|
||||
var btn = new Button(() => onClick?.Invoke()) { text = text };
|
||||
btn.style.minWidth = 240;
|
||||
btn.style.height = 48;
|
||||
btn.style.fontSize = 20;
|
||||
btn.style.marginBottom = 8;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ----- Button handlers --------------------------------------------
|
||||
|
||||
private void OnHostClicked()
|
||||
{
|
||||
statusLabel.text = "Starting host…";
|
||||
if (!NetworkBootstrap.StartHost(defaultPort))
|
||||
{
|
||||
statusLabel.text = "Failed to start host. Check the console.";
|
||||
return;
|
||||
}
|
||||
|
||||
// After StartHost the local peer is the server + client. Trigger
|
||||
// the networked scene load to take everyone to the Lobby. NGO
|
||||
// replicates this to any future-joining clients automatically.
|
||||
NetworkBootstrap.LoadSceneAsHost(SceneNames.Lobby);
|
||||
}
|
||||
|
||||
private void OnJoinClicked()
|
||||
{
|
||||
// Toggle the join sub-panel.
|
||||
joinPanel.style.display = joinPanel.style.display == DisplayStyle.None
|
||||
? DisplayStyle.Flex
|
||||
: DisplayStyle.None;
|
||||
}
|
||||
|
||||
private void OnJoinConfirmClicked()
|
||||
{
|
||||
string address = string.IsNullOrWhiteSpace(joinAddressField.value)
|
||||
? defaultJoinAddress
|
||||
: joinAddressField.value.Trim();
|
||||
|
||||
ushort port = defaultPort;
|
||||
if (!string.IsNullOrWhiteSpace(joinPortField.value)
|
||||
&& ushort.TryParse(joinPortField.value.Trim(), out var parsed))
|
||||
{
|
||||
port = parsed;
|
||||
}
|
||||
|
||||
statusLabel.text = $"Connecting to {address}:{port}…";
|
||||
if (!NetworkBootstrap.StartClient(address, port))
|
||||
{
|
||||
statusLabel.text = "Failed to start client. Check the console.";
|
||||
return;
|
||||
}
|
||||
|
||||
// The server's NGO SceneManager will pull this client into whatever
|
||||
// scene the server is in (Lobby or Match) once the connection
|
||||
// completes. SessionFlow handles disconnect recovery.
|
||||
joinPanel.style.display = DisplayStyle.None;
|
||||
}
|
||||
|
||||
private void OnJoinCancelClicked()
|
||||
{
|
||||
joinPanel.style.display = DisplayStyle.None;
|
||||
statusLabel.text = string.Empty;
|
||||
}
|
||||
|
||||
private void OnQuitClicked()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.EditorApplication.isPlaying = false;
|
||||
#else
|
||||
Application.Quit();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/_Project/Scripts/UI/MainMenuController.cs.meta
Normal file
2
Assets/_Project/Scripts/UI/MainMenuController.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 01199add5a12f4a4bb9a94d1e44fbb4d
|
||||
Loading…
Add table
Add a link
Reference in a new issue