UnityTowerDefense/Assets/_Project/Scripts/UI/LobbyController.cs

325 lines
14 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.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;
// ----- 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;
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()
{
// 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;
bool canStart = LobbyService.AreAllPlayersReady(out string reason);
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();
}
}
}