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

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