// 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.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; // 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 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; } 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