Comitting lobby code without testing.
This commit is contained in:
parent
66f84652dc
commit
60fa58b07f
14 changed files with 1207 additions and 37 deletions
270
Assets/_Project/Scripts/UI/LobbyController.cs
Normal file
270
Assets/_Project/Scripts/UI/LobbyController.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue