// 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 { /// /// 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 /// each frame to grey out /// races that have already been taken. /// /// /// Wiring. Attach this component to the same GameObject as /// LobbyController. The controller calls /// once with its root , and /// when the "Select Race" button is clicked. /// /// Why same UIDocument. 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). /// /// Visual states per grid cell. /// /// Free — race not picked by anyone. Icon in color, clickable. /// Taken by another player — 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. /// Taken by local player — bordered highlight, clickable. /// Confirm button enabled (no-op if already selected, but lets the /// player re-confirm). Local slot number overlaid. /// Unregistered slot — placeholder "Coming Soon", not /// clickable. No RaceDefinition asset exists for this RaceId yet. /// /// 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 picksByRace = new Dictionary(); // 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; /// /// 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. /// public void Initialize(VisualElement lobbyRoot) { if (lobbyRoot == null) { Debug.LogError("[RaceSelectionOverlay] Initialize received null lobby root."); return; } BuildUI(lobbyRoot); Hide(); } /// /// 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. /// 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(_ => 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(); } } }