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

510 lines
22 KiB
C#

// Assets/_Project/Scripts/UI/RaceSelectionOverlay.cs
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
using TD.Core;
using TD.Gameplay;
namespace TD.UI
{
/// <summary>
/// Lobby-scene overlay that lets the local player pick their race.
/// Shows a 4x4 grid of race icons + a detail panel that pops up to the
/// right when an icon is clicked. Reads other players' picks from
/// <see cref="PlayerMatchState.AllPlayers"/> each frame to grey out
/// races that have already been taken.
/// </summary>
/// <remarks>
/// <para><b>Wiring.</b> Attach this component to the same GameObject as
/// <c>LobbyController</c>. The controller calls <see cref="Initialize"/>
/// once with its root <see cref="VisualElement"/>, and <see cref="Show"/>
/// when the "Select Race" button is clicked.</para>
///
/// <para><b>Why same UIDocument.</b> The overlay's elements are inserted as
/// absolute-positioned children of the lobby root — that way one UIDocument
/// + one PanelSettings serves both the lobby and the overlay, and z-order is
/// natural (overlay last → on top).</para>
///
/// <para><b>Visual states per grid cell.</b>
/// <list type="bullet">
/// <item><b>Free</b> — race not picked by anyone. Icon in color, clickable.</item>
/// <item><b>Taken by another player</b> — greyed out, clickable for viewing
/// the detail panel but confirm button is disabled. Player slot
/// number overlaid in color so others know who claimed it.</item>
/// <item><b>Taken by local player</b> — bordered highlight, clickable.
/// Confirm button enabled (no-op if already selected, but lets the
/// player re-confirm). Local slot number overlaid.</item>
/// <item><b>Unregistered slot</b> — placeholder "Coming Soon", not
/// clickable. No RaceDefinition asset exists for this RaceId yet.</item>
/// </list></para>
/// </remarks>
public class RaceSelectionOverlay : MonoBehaviour
{
// ----- UI state ---------------------------------------------------
private VisualElement overlayRoot;
private VisualElement gridContainer;
private VisualElement detailPanel;
private Label detailHeader;
private VisualElement detailIcon;
private Label detailBuilderName;
private Label detailBuilderDesc;
private Label detailLore;
private Button detailConfirmButton;
private Label detailUnavailableNote;
// Currently-viewed race in the detail panel. RaceId.None means no
// detail is open and the panel shows a hint instead.
private RaceId viewedRace = RaceId.None;
// Re-built each Update from PlayerMatchState data. Maps each taken
// RaceId to the slot of the player that picked it.
private readonly Dictionary<RaceId, PlayerSlot> picksByRace = new Dictionary<RaceId, PlayerSlot>();
// Snapshot of pick-state from the last grid rebuild. RefreshGrid skips
// rebuilding when this matches the current frame — same click-eating
// problem as the lobby's player list. Cleared on Show() so the grid
// always rebuilds when the overlay reopens.
private string lastGridSignature = string.Empty;
private readonly StringBuilder signatureBuffer = new StringBuilder();
// ----- Public API -------------------------------------------------
public bool IsVisible =>
overlayRoot != null && overlayRoot.style.display.value == DisplayStyle.Flex;
/// <summary>
/// Called by LobbyController once to attach the overlay's UI to the
/// lobby's UIDocument root. Builds the elements (hidden) and is then
/// reused across Show / Hide cycles.
/// </summary>
public void Initialize(VisualElement lobbyRoot)
{
if (lobbyRoot == null)
{
Debug.LogError("[RaceSelectionOverlay] Initialize received null lobby root.");
return;
}
BuildUI(lobbyRoot);
Hide();
}
/// <summary>
/// Reveals the overlay, refreshes the grid against current picks, and
/// auto-opens the detail panel for the local player's currently-picked
/// race (if any) so they can change it directly.
/// </summary>
public void Show()
{
if (overlayRoot == null) return;
overlayRoot.style.display = DisplayStyle.Flex;
// If the local player already has a race, seed the detail panel with
// it. Otherwise leave the panel showing the placeholder hint.
var local = PlayerMatchState.Local;
viewedRace = (local != null) ? local.RaceSelection : RaceId.None;
// Force a fresh grid build on each open so visuals reflect any
// picks that happened while the overlay was closed.
lastGridSignature = string.Empty;
RefreshGrid();
RefreshDetail();
}
public void Hide()
{
if (overlayRoot == null) return;
overlayRoot.style.display = DisplayStyle.None;
viewedRace = RaceId.None;
}
// ----- Lifecycle --------------------------------------------------
private void Update()
{
if (!IsVisible) return;
// Esc closes the overlay. Read directly via Input System — we want
// this even when nothing is focused.
var kb = Keyboard.current;
if (kb != null && kb.escapeKey.wasPressedThisFrame)
{
Hide();
return;
}
// Refresh against current picks every frame — cheap (max 16 cells,
// max 9 players) and means other-player changes show up immediately.
RefreshGrid();
RefreshDetail();
}
// ----- UI construction --------------------------------------------
private void BuildUI(VisualElement lobbyRoot)
{
// Root overlay — fills the screen with a dark backdrop.
overlayRoot = new VisualElement();
overlayRoot.pickingMode = PickingMode.Position;
overlayRoot.style.position = Position.Absolute;
overlayRoot.style.left = 0;
overlayRoot.style.right = 0;
overlayRoot.style.top = 0;
overlayRoot.style.bottom = 0;
overlayRoot.style.backgroundColor = new Color(0f, 0f, 0f, 0.85f);
overlayRoot.style.alignItems = Align.Center;
overlayRoot.style.justifyContent = Justify.Center;
lobbyRoot.Add(overlayRoot);
// Title + close button in a top bar.
var topBar = new VisualElement();
topBar.style.position = Position.Absolute;
topBar.style.top = 20;
topBar.style.left = 40;
topBar.style.right = 40;
topBar.style.flexDirection = FlexDirection.Row;
topBar.style.justifyContent = Justify.SpaceBetween;
topBar.style.alignItems = Align.Center;
overlayRoot.Add(topBar);
var title = new Label("Select Race");
title.style.fontSize = 32;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
topBar.Add(title);
var closeBtn = new Button(Hide) { text = "X" };
closeBtn.style.width = 44;
closeBtn.style.height = 44;
closeBtn.style.fontSize = 20;
topBar.Add(closeBtn);
// Main content row: grid on the left, detail panel on the right.
var content = new VisualElement();
content.style.flexDirection = FlexDirection.Row;
content.style.alignItems = Align.Stretch;
content.style.marginTop = 40;
overlayRoot.Add(content);
// Grid container — 4x4 of cells. Uses wrap to flow rows.
gridContainer = new VisualElement();
gridContainer.style.flexDirection = FlexDirection.Row;
gridContainer.style.flexWrap = Wrap.Wrap;
gridContainer.style.width = 560; // 4 cells * (120 + 8 margin) = 512 + slack
gridContainer.style.marginRight = 32;
content.Add(gridContainer);
// Detail panel — built once, populated in RefreshDetail.
detailPanel = new VisualElement();
detailPanel.style.width = 380;
detailPanel.style.minHeight = 520;
detailPanel.style.paddingTop = 20;
detailPanel.style.paddingBottom = 20;
detailPanel.style.paddingLeft = 20;
detailPanel.style.paddingRight = 20;
detailPanel.style.backgroundColor = new Color(0.10f, 0.10f, 0.14f, 0.95f);
detailPanel.style.borderTopWidth = detailPanel.style.borderBottomWidth =
detailPanel.style.borderLeftWidth = detailPanel.style.borderRightWidth = 2;
var detailBorder = new Color(0.4f, 0.4f, 0.5f);
detailPanel.style.borderTopColor = detailPanel.style.borderBottomColor =
detailPanel.style.borderLeftColor = detailPanel.style.borderRightColor = detailBorder;
detailPanel.style.flexDirection = FlexDirection.Column;
content.Add(detailPanel);
detailHeader = new Label("Pick a race");
detailHeader.style.fontSize = 22;
detailHeader.style.color = Color.white;
detailHeader.style.unityFontStyleAndWeight = FontStyle.Bold;
detailHeader.style.marginBottom = 12;
detailPanel.Add(detailHeader);
detailIcon = new VisualElement();
detailIcon.style.width = 200;
detailIcon.style.height = 200;
detailIcon.style.alignSelf = Align.Center;
detailIcon.style.backgroundColor = new Color(0.18f, 0.18f, 0.22f);
detailIcon.style.marginBottom = 12;
detailPanel.Add(detailIcon);
detailBuilderName = new Label();
detailBuilderName.style.fontSize = 16;
detailBuilderName.style.color = new Color(0.95f, 0.85f, 0.5f);
detailBuilderName.style.unityFontStyleAndWeight = FontStyle.Bold;
detailPanel.Add(detailBuilderName);
detailBuilderDesc = new Label();
detailBuilderDesc.style.fontSize = 13;
detailBuilderDesc.style.color = new Color(0.85f, 0.85f, 0.85f);
detailBuilderDesc.style.marginBottom = 10;
detailBuilderDesc.style.whiteSpace = WhiteSpace.Normal;
detailPanel.Add(detailBuilderDesc);
detailLore = new Label();
detailLore.style.fontSize = 12;
detailLore.style.color = new Color(0.75f, 0.75f, 0.75f);
detailLore.style.marginBottom = 16;
detailLore.style.whiteSpace = WhiteSpace.Normal;
detailLore.style.flexGrow = 1;
detailPanel.Add(detailLore);
// Notice shown when the viewed race is taken by another player.
detailUnavailableNote = new Label();
detailUnavailableNote.style.color = new Color(1f, 0.5f, 0.3f);
detailUnavailableNote.style.fontSize = 12;
detailUnavailableNote.style.marginBottom = 8;
detailUnavailableNote.style.whiteSpace = WhiteSpace.Normal;
detailPanel.Add(detailUnavailableNote);
detailConfirmButton = new Button(OnConfirmClicked) { text = "Confirm Selection" };
detailConfirmButton.style.height = 40;
detailConfirmButton.style.fontSize = 16;
detailPanel.Add(detailConfirmButton);
}
// ----- Per-frame refresh ------------------------------------------
// Walks every PlayerMatchState and records which RaceId each non-None
// pick belongs to. Used by both the grid (grey out taken) and the
// detail panel (gate the confirm button).
private void RebuildPickIndex()
{
picksByRace.Clear();
foreach (var pms in PlayerMatchState.AllPlayers)
{
if (pms.RaceSelection == RaceId.None) continue;
picksByRace[pms.RaceSelection] = pms.Slot;
}
}
private void RefreshGrid()
{
if (gridContainer == null) return;
if (RaceRegistry.Instance == null) return;
RebuildPickIndex();
// Skip rebuild when nothing changed — keeps the cell event handlers
// alive across frames so clicks aren't eaten mid-press.
PlayerSlot localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
string signature = ComputeGridSignature(localSlot);
if (signature == lastGridSignature) return;
lastGridSignature = signature;
gridContainer.Clear();
foreach (var (id, def) in RaceRegistry.Instance.AllSlots())
{
gridContainer.Add(BuildCell(id, def, localSlot));
}
}
// Compact signature of every input the grid cells depend on: the
// current pick map (RaceId → PlayerSlot) plus the local player's slot
// (which affects "is this mine" highlighting on each cell).
private string ComputeGridSignature(PlayerSlot localSlot)
{
signatureBuffer.Clear();
signatureBuffer.Append((int)localSlot);
signatureBuffer.Append('|');
foreach (var kvp in picksByRace.OrderBy(p => (int)p.Key))
{
signatureBuffer.Append((int)kvp.Key);
signatureBuffer.Append(':');
signatureBuffer.Append((int)kvp.Value);
signatureBuffer.Append(';');
}
return signatureBuffer.ToString();
}
private VisualElement BuildCell(RaceId id, RaceDefinition def, PlayerSlot localSlot)
{
// Outer cell with fixed size for consistent grid layout.
var cell = new VisualElement();
cell.style.width = 120;
cell.style.height = 120;
cell.style.marginTop = cell.style.marginBottom =
cell.style.marginLeft = cell.style.marginRight = 4;
cell.style.borderTopWidth = cell.style.borderBottomWidth =
cell.style.borderLeftWidth = cell.style.borderRightWidth = 2;
bool isLocked = (def == null);
bool isPicked = picksByRace.TryGetValue(id, out var picker);
bool isMine = isPicked && picker == localSlot;
bool isTakenByOther = isPicked && !isMine;
// Border color signals state at a glance.
Color borderColor = isMine
? new Color(0.3f, 0.85f, 0.3f) // green for "mine"
: isTakenByOther
? new Color(0.6f, 0.2f, 0.2f) // red-ish for taken
: isLocked
? new Color(0.3f, 0.3f, 0.3f) // dim for placeholder
: new Color(0.5f, 0.5f, 0.6f); // neutral for free
cell.style.borderTopColor = cell.style.borderBottomColor =
cell.style.borderLeftColor = cell.style.borderRightColor = borderColor;
cell.style.backgroundColor = new Color(0.10f, 0.10f, 0.14f);
// Icon area (or placeholder text for locked slots).
var iconHolder = new VisualElement();
iconHolder.pickingMode = PickingMode.Ignore;
iconHolder.style.flexGrow = 1;
iconHolder.style.alignItems = Align.Center;
iconHolder.style.justifyContent = Justify.Center;
iconHolder.style.unityBackgroundImageTintColor = isTakenByOther
? new Color(0.4f, 0.4f, 0.4f) // greyed out
: Color.white;
if (def != null && def.Icon != null)
iconHolder.style.backgroundImage = new StyleBackground(def.Icon);
else if (isLocked)
{
var placeholder = new Label("?");
placeholder.style.fontSize = 36;
placeholder.style.color = new Color(0.4f, 0.4f, 0.4f);
iconHolder.Add(placeholder);
}
cell.Add(iconHolder);
// Race name strip at the bottom — visible even when icon is missing.
var nameLabel = new Label(def != null ? def.DisplayName : "Coming Soon");
nameLabel.pickingMode = PickingMode.Ignore;
nameLabel.style.unityTextAlign = TextAnchor.MiddleCenter;
nameLabel.style.fontSize = 11;
nameLabel.style.color = isLocked ? new Color(0.45f, 0.45f, 0.45f) : Color.white;
nameLabel.style.paddingTop = 2;
nameLabel.style.paddingBottom = 2;
nameLabel.style.backgroundColor = new Color(0f, 0f, 0f, 0.55f);
cell.Add(nameLabel);
// Picker badge — top-right corner, only when taken. Shows the slot
// number of whoever picked it.
if (isPicked)
{
var badge = new Label($"P{(int)picker}");
badge.pickingMode = PickingMode.Ignore;
badge.style.position = Position.Absolute;
badge.style.top = 4;
badge.style.right = 4;
badge.style.fontSize = 16;
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
badge.style.color = isMine
? new Color(0.3f, 0.85f, 0.3f)
: new Color(1f, 0.7f, 0.2f);
badge.style.paddingLeft = 4;
badge.style.paddingRight = 4;
badge.style.backgroundColor = new Color(0f, 0f, 0f, 0.7f);
cell.Add(badge);
}
// Click handler — only enabled for non-locked cells. Locked cells
// still get the visual treatment but no click response.
if (!isLocked)
{
cell.RegisterCallback<ClickEvent>(_ => OnCellClicked(id));
}
return cell;
}
// ----- Detail panel -----------------------------------------------
private void OnCellClicked(RaceId id)
{
viewedRace = id;
RefreshDetail();
}
private void RefreshDetail()
{
if (detailPanel == null) return;
var registry = RaceRegistry.Instance;
if (registry == null) return;
var def = registry.Get(viewedRace);
if (def == null)
{
// Empty / locked / no selection — show placeholder hint.
detailHeader.text = "Pick a race";
detailBuilderName.text = string.Empty;
detailBuilderDesc.text = string.Empty;
detailLore.text = "Click a race icon to see details.";
detailIcon.style.backgroundImage = null;
detailUnavailableNote.text = string.Empty;
detailConfirmButton.SetEnabled(false);
detailConfirmButton.text = "Confirm Selection";
return;
}
detailHeader.text = def.DisplayName;
detailBuilderName.text = string.IsNullOrEmpty(def.BuilderName)
? "Builder: (unnamed)"
: $"Builder: {def.BuilderName}";
detailBuilderDesc.text = def.BuilderDescription ?? string.Empty;
detailLore.text = def.LoreText ?? string.Empty;
detailIcon.style.backgroundImage = def.Icon != null
? new StyleBackground(def.Icon)
: null;
// Is this race available to the local player?
bool isPicked = picksByRace.TryGetValue(viewedRace, out var picker);
PlayerSlot localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
bool takenByOther = isPicked && picker != localSlot;
bool takenByMe = isPicked && picker == localSlot;
if (takenByOther)
{
detailUnavailableNote.text = $"Already selected by P{(int)picker}.";
detailConfirmButton.SetEnabled(false);
detailConfirmButton.text = "Unavailable";
}
else if (takenByMe)
{
detailUnavailableNote.text = "Your current selection.";
detailConfirmButton.SetEnabled(true);
detailConfirmButton.text = "Confirm Selection";
}
else
{
detailUnavailableNote.text = string.Empty;
detailConfirmButton.SetEnabled(true);
detailConfirmButton.text = "Confirm Selection";
}
}
private void OnConfirmClicked()
{
var local = PlayerMatchState.Local;
if (local == null)
{
Debug.LogWarning("[RaceSelectionOverlay] No local PlayerMatchState — cannot submit race.");
return;
}
if (viewedRace == RaceId.None) return;
// Re-check exclusivity right before submitting — between detail open
// and confirm click, another player may have grabbed it.
if (picksByRace.TryGetValue(viewedRace, out var picker) && picker != local.Slot)
{
Debug.Log($"[RaceSelectionOverlay] Race already taken by P{(int)picker}. " +
"Refreshing detail.");
RefreshDetail();
return;
}
local.SubmitRaceRpc(viewedRace);
// Per the spec: confirming clears the detail panel but keeps the
// overlay open. The grid will update to show the new picked state
// on the next Update tick.
viewedRace = RaceId.None;
RefreshDetail();
}
}
}