// 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.Gameplay.EnemyUpgrades; using TD.Gameplay.Waves; 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("Optional icon for the top-right menu button. Leave empty to use the " + "procedurally-drawn gear (no art asset required).")] [SerializeField] private Sprite menuButtonIcon; [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; 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 bool draftSubscribed; private PlayerDraft subscribedDraft; // Enemy-buff vote overlay. Shares the draft overlay's position and card styling, but the // cards are shared rather than per-player and carry live voter badges. private VisualElement votePanel; private VisualElement voteCardRow; private Label voteTitle; private bool voteSubscribed; private WaveVote subscribedVote; // Boss health bar. Shown only while a boss is alive; polled per frame like lives/gold // rather than event-driven, since HP changes continuously anyway. private VisualElement bossBarPanel; private VisualElement bossHealthFill; private Label bossHealthText; private Label bossNameLabel; // Reused when painting voter badges so a rebuild-per-ballot doesn't allocate a list per // card. The HUD is single-threaded, so one shared buffer is safe. private readonly System.Collections.Generic.List voteVoterScratch = new System.Collections.Generic.List(); // Reused when building a selected tower's upgrade buttons. private readonly System.Collections.Generic.List<(TowerDefinition Def, int TypeId)> upgradeOptionScratch = new System.Collections.Generic.List<(TowerDefinition, int)>(); // Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies // its reserved width, so the centered command bar never shifts) 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; // The loadout the hotbar is currently showing — the SELECTED builder's, or null when // no builder is selected (hotbar hidden). Cooldown polling reads this. private PlayerSpellLoadout displayedSpellLoadout; 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. Setter is internal (not // private) so other standalone text-input surfaces — e.g. TD.Dev.DebugConsole, // which is not part of HUDController — can contribute to the same flag // instead of every gameplay script needing to know about every input widget. public static bool IsTextInputActive { get; internal set; } /// /// True while a modal UI surface (currently the gear menu) owns the screen. Gameplay /// systems should treat this exactly like — see /// , which is the flag they actually gate on. /// public static bool IsModalUiOpen { get; private set; } /// /// True when any UI surface is consuming keyboard/mouse input this frame: a focused /// text field OR an open modal menu. Gameplay input handlers (camera, selection, /// hotkeys, Escape-to-cancel) gate on this so typing or a menu never doubles as a /// gameplay command. /// public static bool IsUiCapturingInput => IsTextInputActive || IsModalUiOpen; // Frame until which chat's "Enter opens chat" behavior is suppressed. Other // standalone text-input surfaces (e.g. TD.Dev.DebugConsole) set this when their // own Enter-driven submit/close already consumed the keypress, so the same // Enter press doesn't also get interpreted as "open chat" this frame. Without // this, submitting a debug console command with Enter would immediately pop // chat open too, since both systems poll the raw key independently. internal static int SuppressChatOpenUntilFrame = -1; // ----- 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 bool upgradesSubscribed; // true once we've hooked the local PlayerTowerUpgrades private PlayerTowerUpgrades subscribedUpgrades; private MinimapView minimapView; private GameMenuView gameMenu; // gear button + modal menu overlay 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); } // Same idea for unlocked upgrade nodes: a draft pick can grant an upgrade while the // player already has the target tower selected, and the new branch should appear in the // action grid without needing a reselect. private void TrySubscribeUpgrades() { var upgrades = PlayerTowerUpgrades.Local; if (upgrades == null) return; upgrades.OnUpgradesChanged += HandleDeckChanged; subscribedUpgrades = upgrades; upgradesSubscribed = true; 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 the local player has an // unresolved offer. The host panel ignores picks so empty space clicks through to the // world — the player can still pan and inspect their maze while deciding. 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); // The "Buy Roll" button lived here. Removed with the extra-draft shop for the 2.0 MVP // — see PlayerDraft's commented-out RequestBuyRerollRpc for why the backend was kept. 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); var iconSprite = option.ResolveIcon(); if (iconSprite != null) { var img = new Image { sprite = iconSprite }; 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; } // Shows the draft row only while the local player has an unresolved offer. With the // extra-draft shop gone there is nothing else in this panel, so it hides entirely once // the player has picked — which also gives them a clear "I'm done, waiting on others" // signal during the barrier. private void UpdateDraftVisibility() { if (draftPanel == null) return; var draft = PlayerDraft.Local; bool hasDraft = draft != null && draft.HasActiveDraft; if (draftCardRow != null) draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None; draftPanel.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None; } // ----- Enemy-buff vote overlay -------------------------------------- // Shared vote UI. Same anchor and card language as the draft row (they never show at the // same time — the inter-wave stages are strictly sequential), but every peer sees the same // three cards, and each card carries badges for the players currently voting for it. private void BuildVoteOverlay(VisualElement root) { votePanel = new VisualElement(); votePanel.style.position = Position.Absolute; votePanel.style.left = 0; votePanel.style.right = 0; votePanel.style.top = 70; // below the top bar, same as the draft row votePanel.style.alignItems = Align.Center; votePanel.pickingMode = PickingMode.Ignore; votePanel.style.display = DisplayStyle.None; var box = new VisualElement(); box.style.flexDirection = FlexDirection.Column; box.style.alignItems = Align.Center; box.pickingMode = PickingMode.Ignore; voteTitle = new Label("Choose how this wave comes back"); voteTitle.style.fontSize = 15; voteTitle.style.color = new Color(0.95f, 0.72f, 0.55f); voteTitle.style.unityFontStyleAndWeight = FontStyle.Bold; voteTitle.style.marginBottom = 6; box.Add(voteTitle); voteCardRow = new VisualElement(); voteCardRow.style.flexDirection = FlexDirection.Row; voteCardRow.pickingMode = PickingMode.Ignore; box.Add(voteCardRow); votePanel.Add(box); root.Add(votePanel); UpdateVoteVisibility(); } // Hook the shared vote so the panel rebuilds whenever a ballot lands. Retried each Update // until the scene's WaveVote has network-spawned. private void TrySubscribeVote() { var vote = WaveVote.Instance; if (vote == null) return; vote.OnVoteChanged += HandleVoteChanged; subscribedVote = vote; voteSubscribed = true; RebuildVoteCards(); } private void HandleVoteChanged() { RebuildVoteCards(); } // Rebuilt on every ballot change, not just when the offered set changes — the voter // badges are the point of the panel, so they have to track votes as they land. private void RebuildVoteCards() { if (voteCardRow == null) return; voteCardRow.Clear(); var vote = WaveVote.Instance; var pool = EnemyUpgradePool.Instance; if (vote != null && pool != null && vote.IsOpen) { for (int i = 0; i < vote.OptionCount; i++) { int id = vote.GetOptionId(i); var option = pool.Get(id); if (option == null) continue; voteCardRow.Add(CreateVoteCard(option, id, vote)); } } UpdateVoteVisibility(); } private VisualElement CreateVoteCard(EnemyUpgradeOption option, int optionId, WaveVote vote) { var localSlot = PlayerMatchState.Local != null ? PlayerMatchState.Local.Slot : PlayerSlot.None; bool isLocalChoice = localSlot != PlayerSlot.None && vote.GetBallot(localSlot) == optionId; var card = new VisualElement(); card.style.width = 160; 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.13f, 0.09f, 0.09f, 0.96f); card.style.borderTopWidth = card.style.borderBottomWidth = card.style.borderLeftWidth = card.style.borderRightWidth = 2; // The local player's current choice gets a bright border so they can tell at a glance // which one is theirs among everyone else's badges. var border = isLocalChoice ? new Color(0.95f, 0.72f, 0.35f) : new Color(0.45f, 0.30f, 0.30f); 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); var iconSprite = option.ResolveIcon(); if (iconSprite != null) { var img = new Image { sprite = iconSprite }; 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); // Live voter badges — one per player currently voting for this card, in their own // player color. This row is why the panel rebuilds on every ballot change. var badgeRow = new VisualElement(); badgeRow.style.flexDirection = FlexDirection.Row; badgeRow.style.justifyContent = Justify.Center; badgeRow.style.minHeight = 24; badgeRow.style.marginBottom = 6; voteVoterScratch.Clear(); vote.GetVotersFor(optionId, voteVoterScratch); foreach (var voter in voteVoterScratch) badgeRow.Add(CreateVoterBadge(voter)); card.Add(badgeRow); var pick = new Button(() => WaveVote.Instance?.RequestVoteRpc(optionId)) { text = isLocalChoice ? "Voted" : "Vote" }; card.Add(pick); return card; } // Small filled circle carrying the player's slot number, in their canonical player color. // Same visual vocabulary as the race-selection overlay's picker badges, so players read it // as "who" without a legend. private VisualElement CreateVoterBadge(PlayerSlot slot) { var badge = new Label(((int)slot).ToString()); badge.style.width = 20; badge.style.height = 20; badge.style.marginLeft = badge.style.marginRight = 2; badge.style.backgroundColor = PlayerColors.Get(slot); badge.style.color = Color.black; badge.style.fontSize = 11; badge.style.unityFontStyleAndWeight = FontStyle.Bold; badge.style.unityTextAlign = TextAnchor.MiddleCenter; badge.style.borderTopLeftRadius = badge.style.borderTopRightRadius = badge.style.borderBottomLeftRadius = badge.style.borderBottomRightRadius = 10; return badge; } private void UpdateVoteVisibility() { if (votePanel == null) return; var vote = WaveVote.Instance; bool open = vote != null && vote.IsOpen && vote.OptionCount > 0; votePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None; } // ----- Boss bar ----------------------------------------------------- // Prominent health bar for the phase boss. Hidden whenever no boss is alive, so it costs // nothing outside a boss encounter. private void BuildBossBar(VisualElement root) { bossBarPanel = new VisualElement(); bossBarPanel.style.position = Position.Absolute; bossBarPanel.style.left = 0; bossBarPanel.style.right = 0; bossBarPanel.style.top = 70; bossBarPanel.style.alignItems = Align.Center; bossBarPanel.pickingMode = PickingMode.Ignore; bossBarPanel.style.display = DisplayStyle.None; var box = new VisualElement(); box.style.width = 440; box.style.alignItems = Align.Center; box.pickingMode = PickingMode.Ignore; bossNameLabel = new Label("BOSS"); bossNameLabel.style.fontSize = 18; bossNameLabel.style.color = new Color(0.95f, 0.45f, 0.4f); bossNameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; bossNameLabel.style.marginBottom = 3; box.Add(bossNameLabel); var track = new VisualElement(); track.style.width = 440; track.style.height = 18; track.style.backgroundColor = new Color(0.08f, 0.06f, 0.06f, 0.95f); track.style.borderTopWidth = track.style.borderBottomWidth = track.style.borderLeftWidth = track.style.borderRightWidth = 2; var trackBorder = new Color(0.5f, 0.25f, 0.22f); track.style.borderTopColor = track.style.borderBottomColor = track.style.borderLeftColor = track.style.borderRightColor = trackBorder; track.pickingMode = PickingMode.Ignore; bossHealthFill = new VisualElement(); bossHealthFill.style.height = Length.Percent(100); bossHealthFill.style.width = Length.Percent(100); bossHealthFill.style.backgroundColor = new Color(0.75f, 0.18f, 0.16f); bossHealthFill.pickingMode = PickingMode.Ignore; track.Add(bossHealthFill); bossHealthText = new Label(""); bossHealthText.style.position = Position.Absolute; bossHealthText.style.left = 0; bossHealthText.style.right = 0; bossHealthText.style.top = 0; bossHealthText.style.bottom = 0; bossHealthText.style.fontSize = 12; bossHealthText.style.color = Color.white; bossHealthText.style.unityTextAlign = TextAnchor.MiddleCenter; bossHealthText.pickingMode = PickingMode.Ignore; track.Add(bossHealthText); box.Add(track); bossBarPanel.Add(box); root.Add(bossBarPanel); } // Polled from the per-frame refresh, like lives and gold. Picks the boss in the local // player's own zone when there is one — the boss encounter spawns one per zone, and the // one actually walking through your maze is the one you need to watch. private void RefreshBossBar() { if (bossBarPanel == null) return; var boss = ResolveDisplayedBoss(); if (boss == null) { bossBarPanel.style.display = DisplayStyle.None; return; } bossBarPanel.style.display = DisplayStyle.Flex; float max = Mathf.Max(1f, boss.MaxHp); float pct = Mathf.Clamp01(boss.CurrentHp / max); bossHealthFill.style.width = Length.Percent(pct * 100f); bossHealthText.text = $"{Mathf.CeilToInt(boss.CurrentHp)} / {Mathf.CeilToInt(max)}"; bossNameLabel.text = boss.DisplayName; } private EnemyHealth ResolveDisplayedBoss() { var bosses = EnemyHealth.ActiveBosses; if (bosses.Count == 0) return null; var localSlot = PlayerMatchState.Local != null ? PlayerMatchState.Local.Slot : PlayerSlot.None; EnemyHealth fallback = null; for (int i = 0; i < bosses.Count; i++) { var boss = bosses[i]; if (boss == null || boss.IsDead) continue; fallback ??= boss; if (localSlot != PlayerSlot.None) { var movement = boss.GetComponent(); if (movement != null && movement.OriginZone == localSlot) return boss; } } return fallback; } // ----- Spell hotbar ------------------------------------------------- // Rebuilds the hotbar cells for the SELECTED builder's loadout. Called on selection // change and on OnLoadoutChanged (a new spell granted). Spells are append-only, so // this is rare, not a per-frame concern. // // The hotbar follows selection: it shows the selected builder's spells and is removed // entirely when no builder is selected (spells belong to the builder). While a builder // IS selected, the frame keeps its reserved width (visibility toggle, not display) so // granting a spell mid-selection doesn't reflow/shift the centered command bar. private void RebuildSpellHotbar() { if (spellHotbar == null) return; spellHotbar.Clear(); spellSlotUis.Clear(); var loadout = GetSelectedBuilderLoadout(); displayedSpellLoadout = loadout; if (spellHotbarFrame != null) { if (loadout == null) { // No builder selected → remove the slot (the command grid is hidden too). spellHotbarFrame.style.display = DisplayStyle.None; return; } // Builder selected → keep the reserved slot in layout; toggle only visibility so // a mid-selection spell grant doesn't jump the command bar. spellHotbarFrame.style.display = DisplayStyle.Flex; spellHotbarFrame.style.visibility = loadout.SlotCount > 0 ? Visibility.Visible : Visibility.Hidden; } if (loadout == null) return; int slotCount = loadout.SlotCount; 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)); } } // The loadout the spell hotbar should display: the currently-selected builder's // (resolved via its owner, so it shows whichever builder is selected — normally the // local player's), or null when the selection isn't a builder → hotbar hidden. private PlayerSpellLoadout GetSelectedBuilderLoadout() { var selected = SelectionState.Instance?.SelectedObject; if (selected is Builder builder) return PlayerSpellLoadout.GetForClient(builder.OwnerClientId); return null; } // 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 = displayedSpellLoadout; 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