UnityTowerDefense/Assets/_Project/Scripts/UI/LobbyController.cs
2026-05-21 23:36:19 -07:00

532 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
{
/// <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 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<UIDocument>();
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<RaceSelectionOverlay>();
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<ClickEvent>(_ => OnMapCardClicked(capturedIndex));
// Standard hover affordance to advertise interactivity.
card.RegisterCallback<MouseEnterEvent>(_ =>
{
if (!isSelected)
card.style.backgroundColor = new Color(0.17f, 0.20f, 0.28f);
});
card.RegisterCallback<MouseLeaveEvent>(_ =>
{
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<PlayerMatchState> 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();
}
}
}