// 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")
// Top-bar row of buff badges for the wave currently spawning. Rebuilt only when the
// underlying set changes — see waveBuffSignature.
private VisualElement waveBuffRow;
// Cheap change-detector for the buff row. RefreshTopBar runs every frame, and rebuilding
// a handful of VisualElements at 60 Hz would churn the UI system for no reason. Encodes
// the slot plus the option ids on it, so it changes when the wave advances OR when a vote
// lands on the slot the players are about to face again.
private int waveBuffSignature = int.MinValue;
private readonly System.Collections.Generic.List waveBuffScratch
= new System.Collections.Generic.List();
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