// 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