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,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);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 46ab03868ea4bd541bd6be3446c2bd3d

View file

@ -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)

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6bc7137210913b24da6d85326de66ed5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View file

@ -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 ----------------------------------------------------

View 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();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c40427d598ba944c82f3790429c5532

View 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
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01199add5a12f4a4bb9a94d1e44fbb4d