532 lines
23 KiB
C#
532 lines
23 KiB
C#
// 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();
|
||
}
|
||
}
|
||
}
|