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

270 lines
11 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 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();
}
}
}