// Assets/_Project/Scripts/UI/HUDController.cs using System.Collections; using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using UnityEngine.InputSystem; using UnityEngine.SceneManagement; using UnityEngine.UIElements; using TD.Core; using TD.Gameplay; using TD.Gameplay.BuilderSpells; using TD.Gameplay.Draft; using TD.Towers; using TD.UI.Minimap; namespace TD.UI { /// /// Drives the in-match HUD. Requires a UIDocument on the same GameObject. /// Wires gold display, tower command grid, tooltip, rejection messages, /// and minimap RenderTexture to their UI Toolkit counterparts. /// [RequireComponent(typeof(UIDocument))] public class HUDController : MonoBehaviour { public static HUDController Instance { get; private set; } // ----- Inspector -------------------------------------------------- [Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField] private TowerPlacementController placementController; [Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")] [SerializeField] private TowerPaintController paintController; [Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField] private TowerPlacementManager placementManager; [Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " + "and drag-to-pan.")] [SerializeField] private CameraController cameraController; [Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f; [Tooltip("Maximum visible height of the chat feed in pixels. Content past this " + "height is clipped — older messages scroll off the top of the visible area " + "but stay in history (scroll up while chat is open to view).")] [SerializeField] private float chatMaxHeight = 280f; [Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " + "(int.MaxValue) — every message sent during a match stays scrollable. " + "Lower the value if a long match ever shows DOM perf issues; this field " + "is the safety valve, not a normal-play limit.")] [SerializeField] private int chatMaxMessages = int.MaxValue; [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f); [Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")] [SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f); // ----- Cached UI element references ------------------------------- private Label goldLabel; private Label waveLabel; private Label livesLabel; private Label nextWaveLabel; // prep countdown ("next: 0:12") private Label leakedLabel; // local player's origin-leak count ("leaked: 3") private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") private VisualElement playerListContainer; // right-panel scoreboard rows private Label portraitName; private Label levelLabel; private VisualElement statLines; private VisualElement commandGrid; private VisualElement actionFrame; // hidden via display:none when no actions are available private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection private Button tabBuild; private Button tabPaint; private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections private VisualElement buildProgressFill; // width driven each frame from progress private Label buildProgressPercent; private Label ttTitle; private Label ttDesc; private Label ttStats; private Label ttCost; private Label rejectionLabel; private VisualElement portraitFrame; // Enemy-info sub-panel — built programmatically and inserted into stat-lines // whenever an Enemy is selected. Cached so we can update HP each frame // without rebuilding the elements. private VisualElement enemyHealthBar; private VisualElement enemyHealthFill; private Label enemyHealthText; // Match-end overlay — built once on Start and toggled on Phase changes. private VisualElement matchEndOverlay; // Buff menu overlay — toggled by the B key via ToggleBuffMenu(). private VisualElement buffMenuOverlay; private VisualElement buffMenuContent; private Label matchEndTitle; // Draft overlay (roguelike between-waves draft). Non-modal: the card row floats at // top-center so the player can keep building during prep. The "Buy Roll" button is // shown when no draft is pending so a paid roll can be bought any time. private VisualElement draftPanel; private VisualElement draftCardRow; private Button draftBuyButton; private bool draftSubscribed; private PlayerDraft subscribedDraft; // Spell hotbar (bottom-ui Section 6). Frame is display:none while the local player // has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown // state changes continuously and has no change event, so it's polled every Update. private VisualElement spellHotbarFrame; private VisualElement spellHotbar; private bool spellLoadoutSubscribed; private PlayerSpellLoadout subscribedSpellLoadout; private readonly List spellSlotUis = new List(); private readonly struct SpellSlotUi { public readonly VisualElement Cell; public readonly Label CooldownLabel; public SpellSlotUi(VisualElement cell, Label cooldownLabel) { Cell = cell; CooldownLabel = cooldownLabel; } } // Chat panel (bottom-left, above portrait) — programmatic. The container // holds both the scrollable feed and the input. Highlight + scroll // interactivity are toggled on the container when typing. private VisualElement chatContainer; private ScrollView chatFeed; private TextField chatInput; private bool chatInputOpen; // Frame on which the chat input was opened or closed. Enter on that frame // and the next one is ignored to prevent the open/close-triggering keypress // from also being consumed by the input or the open-toggle. Without this, // pressing Enter to open chat would immediately submit an empty message. private int chatToggleSuppressFrame = -1; // Set true whenever the chat input or any other text field on the HUD has // keyboard focus. Camera, builder input, and hotkey handlers all gate on // this to keep typing from driving gameplay. public static bool IsTextInputActive { get; private set; } // ----- State ------------------------------------------------------ // Which command-grid tab is active for a Builder selection. Build = tower buttons // (default), Paint = color swatches. Reset to Build whenever a non-builder is // selected so reselecting a builder always starts on Build. private enum CommandTab { Build, Paint } private CommandTab activeTab = CommandTab.Build; private Coroutine rejectionFadeCoroutine; private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null private bool uiInitialized; private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged private bool matchStateSubscribed; // true once OnPhaseChanged is hooked private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance private MinimapView minimapView; private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us // ----- Hotkeys ---------------------------------------------------- // // Per-slot hotkey layout matching the WC3 / Wintermaul Reforged convention. // Slot index 0..14 corresponds to row-major position in the 5×3 action grid. // To rebind, edit this array. (Per-tower hotkeys could live on TowerDefinition // later; for now the position-based layout is enough and predictable.) private static readonly Key[] HotkeyLayout = { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.A, Key.S, Key.D, Key.F, Key.G, Key.Z, Key.X, Key.C, Key.V, Key.B, }; // Active hotkey bindings — rebuilt on every selection change inside // PopulateGridForSelection. HandleHotkeys reads this every Update. private readonly List hotkeyBindings = new List(); private readonly struct HotkeyBinding { public readonly Key Key; public readonly VisualElement Button; // for enabledSelf gating public readonly System.Action Action; public HotkeyBinding(Key k, VisualElement b, System.Action a) { Key = k; Button = b; Action = a; } } // ----- Static hit-test probe -------------------------------------- // Set when InitializeUI succeeds; cleared on OnDestroy. Non-UI systems (camera, // input handlers) can query IsPointerOverInteractiveHud without taking a direct // reference to HUDController. private static IPanel s_hudPanel; /// /// True if falls over an interactive (non-ignore) /// HUD element. Non-UI systems that consume mouse input (camera scroll-zoom, edge-pan) /// should gate their handling on this so a cursor over the minimap, command grid, or /// any other interactive HUD region doesn't drive both the HUD and the world at once. /// /// /// Convention: uses Unity Input System screen coords /// (origin bottom-left, y up). Returns false before the HUD has initialized; safe to /// call from any system at any time. /// public static bool IsPointerOverInteractiveHud(Vector2 screenMousePos) { if (s_hudPanel == null) return false; // Coord convention rabbit hole: // - Screen mouse position: origin bottom-left, y up (Unity Input System). // - UI Toolkit panel coords: origin top-left, y down. // // RuntimePanelUtils.ScreenToPanel converts the SCALE (e.g., reference resolution // vs. actual resolution) but does NOT flip Y. We flip manually using the visual // tree's height so the result works regardless of PanelSettings scale mode. // // Subtle: visualTree.worldBound height may be 0 for one frame on the very first // layout pass. The caller (CameraController) checks the result against "is over // interactive HUD"; a one-frame false positive (camera zooms when it shouldn't) // is harmless and self-corrects the next frame. Vector2 scaled = RuntimePanelUtils.ScreenToPanel(s_hudPanel, screenMousePos); float panelHeight = s_hudPanel.visualTree.worldBound.height; Vector2 panelPos = new Vector2(scaled.x, panelHeight - scaled.y); // panel.Pick returns null when the topmost element under the point has // PickingMode.Ignore (or there's no element there at all). Non-null means an // interactive HUD element is under the cursor. return s_hudPanel.Pick(panelPos) != null; } // ----- Lifecycle -------------------------------------------------- private void Start() { // UIDocument creates its panel in OnEnable, which runs after all // Awake() calls. Accessing rootVisualElement in Awake() is too early. // Start() is safe because all OnEnable() calls have completed by then. InitializeUI(); TryReadyPlacementManager(); // Subscription fallback: if OnEnable couldn't subscribe (SelectionState.Awake // hadn't run yet), Start() is guaranteed to be after all Awake calls in the // scene. Retry here. Without this fallback, the HUD silently misses every // selection event for the rest of the session. TrySubscribeSelection(); // Seed portrait/grid in case the builder already auto-selected before Start. HandleSelectionChanged(SelectionState.Instance?.SelectedObject); TrySubscribeMatchState(); } private void TrySubscribeMatchState() { if (matchStateSubscribed) return; if (MatchState.Instance == null) return; MatchState.Instance.OnPhaseChanged += HandlePhaseChanged; matchStateSubscribed = true; } // Hook the local player's deck so the command grid rebuilds live when a draft // grant lands while the builder is already selected. The local deck may not // exist for the first few frames (spawn race) — retried each Update until it does. private void TrySubscribeDeck() { var deck = PlayerTowerDeck.Local; if (deck == null) return; deck.OnDeckChanged += HandleDeckChanged; subscribedDeck = deck; deckSubscribed = true; // The deck may have populated before we subscribed — rebuild now so the // grid reflects the current contents. PopulateGridForSelection(SelectionState.Instance?.SelectedObject); } private void HandleDeckChanged() { PopulateGridForSelection(SelectionState.Instance?.SelectedObject); } // Hook the local player's draft so the overlay rebuilds when options are offered, // picked, or rerolled. Retried each Update until the local PlayerDraft exists. private void TrySubscribeDraft() { var draft = PlayerDraft.Local; if (draft == null) return; draft.OnDraftChanged += HandleDraftChanged; subscribedDraft = draft; draftSubscribed = true; RebuildDraftCards(); } private void HandleDraftChanged() { RebuildDraftCards(); } // Hook the local player's spell loadout so the hotbar rebuilds when a new spell is // granted. Retried each Update until the local PlayerSpellLoadout exists. private void TrySubscribeSpellLoadout() { var loadout = PlayerSpellLoadout.Local; if (loadout == null) return; loadout.OnLoadoutChanged += HandleSpellLoadoutChanged; subscribedSpellLoadout = loadout; spellLoadoutSubscribed = true; RebuildSpellHotbar(); } private void HandleSpellLoadoutChanged() { RebuildSpellHotbar(); } // ----- Draft overlay ---------------------------------------------- // Non-modal draft UI: a card row floats at top-center while a draft is active, and // a "Buy Roll" button shows when no draft is pending. The host panel ignores picks // so empty space clicks through to the world — the player keeps building during prep. private void BuildDraftOverlay(VisualElement root) { draftPanel = new VisualElement(); draftPanel.style.position = Position.Absolute; draftPanel.style.left = 0; draftPanel.style.right = 0; draftPanel.style.top = 70; // below the top bar draftPanel.style.alignItems = Align.Center; draftPanel.pickingMode = PickingMode.Ignore; draftPanel.style.display = DisplayStyle.None; var box = new VisualElement(); box.style.flexDirection = FlexDirection.Column; box.style.alignItems = Align.Center; box.pickingMode = PickingMode.Ignore; draftCardRow = new VisualElement(); draftCardRow.style.flexDirection = FlexDirection.Row; draftCardRow.pickingMode = PickingMode.Ignore; box.Add(draftCardRow); draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc()) { text = "Buy Roll" }; draftBuyButton.style.marginTop = 6; box.Add(draftBuyButton); draftPanel.Add(box); root.Add(draftPanel); UpdateDraftVisibility(); } // Rebuilds the option cards from the local player's current offer. Called on // OnDraftChanged (offered / picked / rerolled). private void RebuildDraftCards() { if (draftCardRow == null) return; draftCardRow.Clear(); var draft = PlayerDraft.Local; var pool = DraftPool.Instance; if (draft != null && pool != null) { for (int i = 0; i < draft.OptionCount; i++) { int id = draft.GetOptionId(i); var option = pool.Get(id); if (option == null) continue; draftCardRow.Add(CreateDraftCard(option, id, draft)); } } UpdateDraftVisibility(); } private VisualElement CreateDraftCard(DraftOption option, int optionId, PlayerDraft draft) { var card = new VisualElement(); card.style.width = 150; card.style.marginLeft = card.style.marginRight = 4; card.style.paddingTop = card.style.paddingBottom = 10; card.style.paddingLeft = card.style.paddingRight = 10; card.style.backgroundColor = new Color(0.10f, 0.10f, 0.13f, 0.96f); card.style.borderTopWidth = card.style.borderBottomWidth = card.style.borderLeftWidth = card.style.borderRightWidth = 2; var border = new Color(0.4f, 0.4f, 0.45f); card.style.borderTopColor = card.style.borderBottomColor = card.style.borderLeftColor = card.style.borderRightColor = border; card.style.alignItems = Align.Center; var title = new Label(option.DisplayName); title.style.fontSize = 14; title.style.color = Color.white; title.style.unityFontStyleAndWeight = FontStyle.Bold; title.style.whiteSpace = WhiteSpace.Normal; title.style.unityTextAlign = TextAnchor.MiddleCenter; title.style.marginBottom = 6; card.Add(title); if (option.Icon != null) { var img = new Image { sprite = option.Icon }; img.style.width = 48; img.style.height = 48; img.style.marginBottom = 6; card.Add(img); } var desc = new Label(option.Description ?? ""); desc.style.fontSize = 11; desc.style.color = new Color(0.82f, 0.82f, 0.82f); desc.style.whiteSpace = WhiteSpace.Normal; desc.style.unityTextAlign = TextAnchor.MiddleCenter; desc.style.marginBottom = 8; card.Add(desc); var pick = new Button(() => draft.RequestPickRpc(optionId)) { text = "Pick" }; card.Add(pick); return card; } // Per-frame: toggles the card row vs. the buy button and keeps the buy button's // label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame // refresh); only allocates a string while the buy button is shown. private void UpdateDraftVisibility() { if (draftPanel == null) return; var draft = PlayerDraft.Local; var service = DraftService.Instance; var gold = PlayerGoldManager.Local; bool hasDraft = draft != null && draft.HasActiveDraft; bool canShowBuy = draft != null && service != null && !hasDraft; if (draftCardRow != null) draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None; if (draftBuyButton != null) { draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None; if (canShowBuy) { int cost = service.RerollCost; draftBuyButton.text = $"Buy Roll ({cost}g)"; draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost); } } draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None; } // ----- Spell hotbar ------------------------------------------------- // Rebuilds the hotbar cells from the local player's current loadout. Called on // OnLoadoutChanged (a new spell granted) — spells are append-only, so this is rare, // not a per-frame concern. private void RebuildSpellHotbar() { if (spellHotbar == null) return; spellHotbar.Clear(); spellSlotUis.Clear(); var loadout = PlayerSpellLoadout.Local; int slotCount = loadout?.SlotCount ?? 0; if (spellHotbarFrame != null) spellHotbarFrame.style.display = slotCount > 0 ? DisplayStyle.Flex : DisplayStyle.None; if (loadout == null) return; var layout = SpellHotkeys.Layout; for (int i = 0; i < slotCount; i++) { var kind = loadout.GetKind(i); if (kind == null) continue; var definition = BuilderSpellPool.Instance?.Get(kind.Value); var cell = new VisualElement(); cell.AddToClassList("spell-slot"); var icon = new VisualElement(); icon.AddToClassList("spell-slot-icon"); icon.pickingMode = PickingMode.Ignore; if (definition?.Icon != null) icon.style.backgroundImage = new StyleBackground(definition.Icon); cell.Add(icon); if (i < layout.Length) { var hkLabel = new Label(SpellKeyToDisplay(layout[i])); hkLabel.AddToClassList("spell-slot-hotkey"); hkLabel.pickingMode = PickingMode.Ignore; cell.Add(hkLabel); } var cooldownLabel = new Label(""); cooldownLabel.AddToClassList("spell-slot-cooldown-label"); cooldownLabel.pickingMode = PickingMode.Ignore; cell.Add(cooldownLabel); cell.RegisterCallback(_ => ShowSpellTooltip(definition)); cell.RegisterCallback(_ => ClearTooltip()); spellHotbar.Add(cell); spellSlotUis.Add(new SpellSlotUi(cell, cooldownLabel)); } } // Per-frame: dims each slot while on cooldown and shows the remaining whole seconds. // Cooldown has no change event (it advances continuously with server time), so this // has to be polled — cheap at hotbar scale (at most MaxSpellSlots cells). private void UpdateSpellCooldowns() { if (spellSlotUis.Count == 0) return; var loadout = PlayerSpellLoadout.Local; if (loadout == null) return; for (int i = 0; i < spellSlotUis.Count; i++) { var ui = spellSlotUis[i]; bool onCooldown = loadout.IsSlotOnCooldown(i); ui.Cell.EnableInClassList("on-cooldown", onCooldown); ui.CooldownLabel.text = onCooldown ? $"{Mathf.CeilToInt(loadout.GetCooldownRemaining(i))}" : ""; } } // Renders a spell hotkey as its display glyph. SpellHotkeys.Layout uses Key.DigitN, // whose ToString() is "DigitN" — strip the prefix so the badge shows "1", not "Digit1". private static string SpellKeyToDisplay(Key key) { string s = key.ToString(); return s.StartsWith("Digit") ? s.Substring("Digit".Length) : s; } // Lightweight tooltip for spell slots — reuses the tooltip box (title + desc + cooldown). private void ShowSpellTooltip(BuilderSpellDefinition def) { if (ttTitle == null || def == null) return; ttTitle.text = def.DisplayName; ttDesc.text = def.Description ?? ""; ttStats.text = $"Cooldown: {def.Cooldown:0.#}s"; ttCost.text = ""; } private void InitializeUI() { var doc = GetComponent(); if (doc == null) { Debug.LogError("[HUDController] No UIDocument component found."); return; } var root = doc.rootVisualElement; if (root == null) { Debug.LogError("[HUDController] rootVisualElement is null. " + "Check that Panel Settings and Source Asset are assigned."); return; } // Cache element references — log a warning for any that are missing // so UXML/USS mismatches surface immediately. goldLabel = Require