// Assets/_Project/Scripts/UI/LobbyController.cs using System.Linq; using System.Text; using Unity.Netcode; using UnityEngine; using UnityEngine.UIElements; using TD.Core; using TD.Gameplay; using TD.Levels; using TD.Net; namespace TD.UI { /// /// Drives the lobby UI. Requires a on the same /// GameObject. Programmatic UI for v1 — UXML can be added later if needed. /// /// /// What's displayed. 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 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. /// /// Polling vs reactive. The list is rebuilt every Update from /// . PlayerMatchState's /// NetworkVariables expose OnValueChanged 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. /// /// Race picker placeholder. The enum /// currently only has None. The picker shows a single "Test Race" /// button that calls SubmitRaceRpc(RaceId.None) — 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 /// assets exist. /// [RequireComponent(typeof(UIDocument))] public class LobbyController : MonoBehaviour { // ----- Cached UI elements ----------------------------------------- private VisualElement mapListContainer; private VisualElement playerListContainer; private Button startMatchButton; private Button leaveButton; private Label statusLabel; // Signature snapshot for the map list, mirroring the player-list pattern. // Components: registry count, selected index, current player count, host flag. // Without this, the cards rebuild every frame and the Clickable manipulator // loses presses (same root cause as the player-list signature). private string lastMapListSignature = string.Empty; // ----- Race selection overlay ------------------------------------ [Tooltip("Sibling RaceSelectionOverlay component that owns the race-pick UI. " + "Auto-located on the same GameObject if not assigned.")] [SerializeField] private RaceSelectionOverlay raceOverlay; // Snapshot of player-list state from the last rebuild. RefreshPlayerList // skips rebuilding when this matches the current frame's signature — // critical because rebuilding every frame destroys the per-row buttons // mid-click, and UI Toolkit's Clickable manipulator needs the same // element instance to receive PointerDown AND PointerUp for the action // to fire. Pre-fix, clicks on Select Race / Ready / Unready were silently // lost because the button was destroyed between press and release. private string lastPlayerListSignature = string.Empty; private readonly StringBuilder signatureBuffer = new StringBuilder(); // ----- Lifecycle -------------------------------------------------- private void Start() { var doc = GetComponent(); var root = doc?.rootVisualElement; if (root == null) { Debug.LogError("[LobbyController] rootVisualElement is null. " + "Check the UIDocument's Panel Settings."); return; } BuildUI(root); // Initialize the race overlay with our root so it can install its // UI elements on top of the lobby. Hidden by default. if (raceOverlay == null) raceOverlay = GetComponent(); if (raceOverlay != null) raceOverlay.Initialize(root); else Debug.LogWarning("[LobbyController] No RaceSelectionOverlay component found — " + "race-picker button will be disabled. Add one to this GameObject."); } private void Update() { if (playerListContainer == null) return; RefreshMapList(); 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); // Map selection panel — host clicks to change, everyone sees the current pick. // Horizontal row of cards rebuilt by RefreshMapList when its signature changes. var mapPanelLabel = new Label("Map"); mapPanelLabel.style.fontSize = 18; mapPanelLabel.style.color = new Color(0.85f, 0.85f, 0.85f); mapPanelLabel.style.unityFontStyleAndWeight = FontStyle.Bold; mapPanelLabel.style.marginBottom = 6; root.Add(mapPanelLabel); mapListContainer = new VisualElement(); mapListContainer.style.flexDirection = FlexDirection.Row; mapListContainer.style.justifyContent = Justify.Center; mapListContainer.style.flexWrap = Wrap.Wrap; mapListContainer.style.minWidth = 520; mapListContainer.style.paddingTop = 8; mapListContainer.style.paddingBottom = 8; mapListContainer.style.paddingLeft = 8; mapListContainer.style.paddingRight = 8; mapListContainer.style.backgroundColor = new Color(0f, 0f, 0f, 0.4f); mapListContainer.style.marginBottom = 24; root.Add(mapListContainer); // 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 RefreshMapList() { // Inputs that determine the rendered state of the map row. Anything not in this // signature won't trigger a rebuild — match what BuildMapCard reads. var registry = MapRegistry.Instance; var svc = LobbyService.Instance; int registryCount = registry != null ? registry.Count : 0; int selectedIndex = svc != null ? svc.SelectedMapIndex : -1; int playerCount = LobbyService.CountConnectedPlayers(); bool isHost = NetworkManager.Singleton != null && NetworkManager.Singleton.IsHost; string signature = $"{registryCount}:{selectedIndex}:{playerCount}:{(isHost ? 'H' : 'C')}"; if (signature == lastMapListSignature) return; lastMapListSignature = signature; mapListContainer.Clear(); if (registry == null || registryCount == 0) { var emptyLabel = new Label("(no maps available — MapRegistry missing)"); emptyLabel.style.color = new Color(0.7f, 0.5f, 0.5f); emptyLabel.style.unityTextAlign = TextAnchor.MiddleCenter; mapListContainer.Add(emptyLabel); return; } for (int i = 0; i < registryCount; i++) { var map = registry.Get(i); if (map == null) continue; bool isSelected = i == selectedIndex; bool fitsLobby = MapRegistry.IsSelectableFor(map, playerCount); bool clickable = isHost && fitsLobby; mapListContainer.Add(BuildMapCard(map, i, isSelected, fitsLobby, clickable, playerCount)); } } private VisualElement BuildMapCard(LevelData map, int index, bool isSelected, bool fitsLobby, bool clickable, int playerCount) { var card = new VisualElement(); card.style.width = 180; card.style.height = 220; card.style.marginRight = 8; card.style.marginLeft = 8; card.style.paddingTop = 8; card.style.paddingBottom = 8; card.style.paddingLeft = 8; card.style.paddingRight = 8; card.style.alignItems = Align.Center; card.style.justifyContent = Justify.FlexStart; // Background reflects selectability: highlighted when selected, dim when oversized. Color background = isSelected ? new Color(0.20f, 0.32f, 0.55f) : new Color(0.12f, 0.12f, 0.16f); if (!fitsLobby) background.a *= 0.7f; card.style.backgroundColor = background; // Border to make the selection visually unambiguous. float borderWidth = isSelected ? 3f : 1f; Color borderColor = isSelected ? new Color(0.45f, 0.70f, 1f) : new Color(0.25f, 0.25f, 0.30f); card.style.borderTopWidth = borderWidth; card.style.borderBottomWidth = borderWidth; card.style.borderLeftWidth = borderWidth; card.style.borderRightWidth = borderWidth; card.style.borderTopColor = borderColor; card.style.borderBottomColor = borderColor; card.style.borderLeftColor = borderColor; card.style.borderRightColor = borderColor; // Thumbnail (top of card). Falls back to a neutral placeholder if MapThumbnail isn't set. var thumb = new VisualElement(); thumb.style.width = 140; thumb.style.height = 105; thumb.style.marginBottom = 6; thumb.style.backgroundColor = new Color(0.05f, 0.05f, 0.08f); if (map.MapThumbnail != null) { thumb.style.backgroundImage = new StyleBackground(map.MapThumbnail); } // Dim the thumbnail when the map doesn't fit the lobby — reinforces the disabled look. if (!fitsLobby) { thumb.style.opacity = 0.45f; } card.Add(thumb); // Map name. var nameLabel = new Label(map.MapName); nameLabel.style.color = fitsLobby ? Color.white : new Color(0.7f, 0.6f, 0.6f); nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; nameLabel.style.fontSize = 14; nameLabel.style.unityTextAlign = TextAnchor.MiddleCenter; card.Add(nameLabel); // Player count label — tells the user the capacity, and why the card is greyed out // when oversized. Live player count is shown alongside the cap so it's obvious why. string countText = fitsLobby ? $"Up to {map.PlayerCount} players" : $"Up to {map.PlayerCount} — needs ≤ {map.PlayerCount} (lobby has {playerCount})"; var countLabel = new Label(countText); countLabel.style.color = fitsLobby ? new Color(0.75f, 0.75f, 0.75f) : new Color(0.85f, 0.55f, 0.45f); countLabel.style.fontSize = 11; countLabel.style.unityTextAlign = TextAnchor.MiddleCenter; countLabel.style.marginTop = 2; countLabel.style.whiteSpace = WhiteSpace.Normal; card.Add(countLabel); // Make the whole card clickable for host (when the map fits the lobby). Non-hosts // see the cards but can't change selection. Capture `index` in a local so the // closure doesn't bind to the loop variable. if (clickable) { int capturedIndex = index; card.RegisterCallback(_ => OnMapCardClicked(capturedIndex)); // Standard hover affordance to advertise interactivity. card.RegisterCallback(_ => { if (!isSelected) card.style.backgroundColor = new Color(0.17f, 0.20f, 0.28f); }); card.RegisterCallback(_ => { if (!isSelected) card.style.backgroundColor = new Color(0.12f, 0.12f, 0.16f); }); } return card; } private void OnMapCardClicked(int index) { var svc = LobbyService.Instance; if (svc == null) { Debug.LogError("[LobbyController] LobbyService.Instance is null — cannot change map."); return; } // Don't bother round-tripping if we're already on this map. if (svc.SelectedMapIndex == index) return; svc.RequestSelectMapRpc(index); } private void RefreshPlayerList() { // 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(); // Skip rebuild when the player-relevant state hasn't changed. // Without this guard the per-row buttons are destroyed every frame, // which loses clicks (see lastPlayerListSignature comment above). string signature = ComputePlayerListSignature(players); if (signature == lastPlayerListSignature) return; lastPlayerListSignature = signature; playerListContainer.Clear(); ulong localId = NetworkManager.Singleton != null ? NetworkManager.Singleton.LocalClientId : ulong.MaxValue; 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 } }); } // Compact signature of every field the player rows depend on. When this // changes we rebuild; when it's identical we leave the existing rows // (and their button event handlers) intact for click handling. private string ComputePlayerListSignature(System.Collections.Generic.List players) { signatureBuffer.Clear(); foreach (var pms in players) { signatureBuffer.Append(pms.OwnerClientId); signatureBuffer.Append(':'); signatureBuffer.Append((int)pms.Slot); signatureBuffer.Append(':'); signatureBuffer.Append((int)pms.RaceSelection); signatureBuffer.Append(':'); signatureBuffer.Append(pms.IsReady ? '1' : '0'); signatureBuffer.Append(';'); } return signatureBuffer.ToString(); } 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) { // Race selection — opens the overlay that lets the player browse // the 4x4 race grid and pick one. The overlay handles submission // (PlayerMatchState.SubmitRaceRpc) directly, so we just open it. var pickRaceBtn = new Button(OpenRaceOverlay) { text = "Select Race" }; pickRaceBtn.style.minWidth = 130; pickRaceBtn.style.height = 28; pickRaceBtn.style.marginLeft = 12; pickRaceBtn.SetEnabled(raceOverlay != null); 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; // Two gates: readiness (existing) and map selectability (new). Show whichever // failure is preventing the start so the host knows what to fix. Readiness // is checked first because it's the more common blocker. bool readyOk = LobbyService.AreAllPlayersReady(out string readyReason); string reason = readyOk ? string.Empty : readyReason; bool mapOk = true; if (readyOk) { var svc = LobbyService.Instance; var selected = svc != null ? svc.SelectedMap : null; int playerCount = LobbyService.CountConnectedPlayers(); if (selected == null) { mapOk = false; reason = "No map selected."; } else if (!MapRegistry.IsSelectableFor(selected, playerCount)) { mapOk = false; reason = $"'{selected.MapName}' supports up to {selected.PlayerCount} " + $"players; lobby has {playerCount}."; } } bool canStart = readyOk && mapOk; startMatchButton.SetEnabled(canStart); statusLabel.text = canStart ? string.Empty : reason; } // ----- Button handlers -------------------------------------------- private void OpenRaceOverlay() { if (raceOverlay == null) { Debug.LogWarning("[LobbyController] Race overlay not assigned."); return; } raceOverlay.Show(); } 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(); } } }