// 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
{
// ----- Inspector --------------------------------------------------
[Header("Scene References")]
[Tooltip("The local client's TowerPlacementController.")]
[SerializeField] private TowerPlacementController placementController;
[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;
// ----- Cached UI element references -------------------------------
private Label goldLabel;
private Label waveLabel;
private Label livesLabel;
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 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;
// ----- State ------------------------------------------------------
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(root, "gold-label");
waveLabel = Require(root, "wave-label");
livesLabel = Require(root, "lives-label");
portraitName = Require(root, "portrait-name");
levelLabel = Require(root, "level-label");
statLines = Require(root, "stat-lines");
commandGrid = Require(root, "command-grid");
actionFrame = Require(root, "action-frame");
buildProgressContainer = Require(root, "build-progress");
buildProgressFill = Require(root, "build-progress-fill");
buildProgressPercent = Require(root, "build-progress-percent");
ttTitle = Require(root, "tt-title");
ttDesc = Require(root, "tt-desc");
ttStats = Require(root, "tt-stats");
ttCost = Require(root, "tt-cost");
rejectionLabel = Require(root, "rejection-label");
// Map area and its transparent ancestors must not consume pointer
// events so clicks reach the 3D scene underneath. The bottom-ui is now
// a transparent strip too — the *individual frames* are the opaque
// interactive surfaces, so the empty margins on either side click
// through to the world.
SetPickIgnore(root, "hud-root");
SetPickIgnore(root, "main-area");
SetPickIgnore(root, "map-area");
SetPickIgnore(root, "bottom-ui");
if (rejectionLabel != null)
rejectionLabel.pickingMode = PickingMode.Ignore;
// Minimap. The MinimapView owns the two sub-elements (terrain + entity overlay)
// and drives them; we just hand it the host container and the camera controller.
// Bake is deferred until LevelLoader is ready — view tries each frame in Tick().
var minimapContainer = root.Q("minimap");
if (minimapContainer != null)
{
if (cameraController == null)
Debug.LogWarning("[HUDController] cameraController not assigned. " +
"Click-to-jump and drag-to-pan on the minimap will be disabled.");
minimapView = new MinimapView(minimapContainer, cameraController);
}
// Portrait click → center camera on current selection (WC3 convention).
portraitFrame = root.Q("portrait-frame");
if (portraitFrame != null)
portraitFrame.RegisterCallback(OnPortraitClicked);
// Build the match-end overlay (Victory / Defeat + Retry). Hidden until
// MatchState.OnPhaseChanged fires Victory or Defeat.
BuildMatchEndOverlay(root);
// Publish the panel so non-UI systems can query "is pointer over the HUD".
// Stored on `myPanel` too so OnDestroy only clears the static if it still
// points at this instance (defensive against re-creation overlap).
myPanel = root.panel;
s_hudPanel = myPanel;
uiInitialized = true;
}
private void OnEnable()
{
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
TrySubscribeSelection();
}
private void OnDisable()
{
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
if (selectionSubscribed && SelectionState.Instance != null)
{
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
selectionSubscribed = false;
}
if (matchStateSubscribed && MatchState.Instance != null)
{
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
matchStateSubscribed = false;
}
}
private void TrySubscribeSelection()
{
if (selectionSubscribed) return;
if (SelectionState.Instance == null) return;
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
selectionSubscribed = true;
}
private void Update()
{
if (!uiInitialized) return;
if (!placementManagerReady)
TryReadyPlacementManager();
if (!matchStateSubscribed)
TrySubscribeMatchState();
RefreshGoldDisplay();
RefreshMatchStateDisplays();
UpdateBuildProgressIfShown();
UpdateEnemyInfoIfShown();
HandleHotkeys();
minimapView?.Tick();
}
///
/// While a BuildSiteVisual is selected, refresh the progress bar's fill
/// width and the percent label. Runs every frame so the bar tracks server
/// time as construction advances. Cheap enough not to throttle (one width
/// assignment, one string allocation per frame while selected).
///
private void UpdateBuildProgressIfShown()
{
if (buildProgressContainer == null) return;
var sel = SelectionState.Instance?.SelectedObject;
// Use Unity's overloaded equality via cast — a destroyed BuildSiteVisual
// still passes `is` but blows up on member access. Match the same
// backstop pattern SelectionVisualizer uses.
if (sel is BuildSiteVisual bsv && (UnityEngine.Object)bsv != null)
{
float progress = bsv.ComputeProgressNormalized();
if (buildProgressFill != null)
{
// Width is a percent of the parent bar background; multiplying
// by 100 keeps the StyleLength in percent units.
buildProgressFill.style.width =
new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
}
if (buildProgressPercent != null)
{
buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
}
}
}
///
/// Reads raw keyboard state via the New Input System and fires the matching
/// action for any bound hotkey pressed this frame. Mirrors the disabled-button
/// behaviour: a SetEnabled(false) button is skipped (so Upgrade/Sell don't
/// trigger from the keyboard while their backing systems are still stubbed).
///
private void HandleHotkeys()
{
var kb = Keyboard.current;
if (kb == null) return;
// Iterate by index — foreach over a struct list would copy each entry.
for (int i = 0; i < hotkeyBindings.Count; i++)
{
var binding = hotkeyBindings[i];
if (!kb[binding.Key].wasPressedThisFrame) continue;
if (binding.Button == null || !binding.Button.enabledSelf) continue;
binding.Action?.Invoke();
}
}
private void OnDestroy()
{
minimapView?.Dispose();
minimapView = null;
if (s_hudPanel != null && s_hudPanel == myPanel)
s_hudPanel = null;
myPanel = null;
}
// ----- Gold display -----------------------------------------------
private void RefreshGoldDisplay()
{
if (goldLabel == null) return;
var gm = PlayerGoldManager.Local;
goldLabel.text = gm != null ? $"{gm.CurrentGold:N0} g" : "-- g";
}
// ----- Match-state display ----------------------------------------
// Polled each frame from Update. MatchState exposes Lives and CurrentWave
// as plain int properties backed by NetworkVariables; polling avoids
// having to add OnLivesChanged / OnCurrentWaveChanged events to MatchState
// for every consumer that wants to display them. The cost is one int read
// and a string allocation per frame — negligible at HUD scale.
private void RefreshMatchStateDisplays()
{
var ms = MatchState.Instance;
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
if (waveLabel != null)
{
int total = WaveManager.Instance?.TotalWaves ?? 0;
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
? $"Wave {ms.CurrentWave} / {total}"
: "Wave --";
}
}
// ----- Command grid -----------------------------------------------
private const int GRID_COLS = 5;
private const int GRID_ROWS = 3;
private const int GRID_MAX = GRID_COLS * GRID_ROWS;
// First-time check that TowerPlacementManager exists. Once ready, populates the
// grid for the current selection. Subsequent populates flow through
// HandleSelectionChanged → PopulateGridForSelection.
private void TryReadyPlacementManager()
{
if (placementManager == null)
placementManager = TowerPlacementManager.Instance;
if (placementManager == null) return; // not spawned yet — retry next frame
placementManagerReady = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
///
/// Rebuilds the command grid based on the current selection AND toggles the
/// action frame visibility. Builder → tower-build buttons in the early slots.
/// Tower → Upgrade in slot 0, Sell in the last slot. Anything else (Enemy,
/// null) → action frame hidden entirely (display:none).
///
private void PopulateGridForSelection(ISelectable selection)
{
if (commandGrid == null) return;
if (!placementManagerReady) return; // deferred to TryReadyPlacementManager
// Selection changed — invalidate the previous frame's hotkey bindings
// before creating new ones. Without this, stale buttons from a previous
// selection would keep responding to their hotkey.
hotkeyBindings.Clear();
// Decide whether any actions exist for this selection. The action frame
// is hidden entirely when there are none — matches the WC3-style UX.
bool hasActions = selection is Builder
|| selection is TowerInstance
|| selection is BuildSiteVisual;
if (actionFrame != null)
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
commandGrid.Clear();
if (!hasActions) return; // grid stays empty; frame is hidden anyway
// Build the 15-cell action layout for the active selection kind.
var cells = new VisualElement[GRID_MAX];
if (selection is Builder)
{
int i = 0;
foreach (var (def, typeId) in placementManager.GetAvailableDefinitions())
{
if (i >= GRID_MAX) break;
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++;
}
}
else if (selection is TowerInstance tower)
{
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
}
else if (selection is BuildSiteVisual bsv)
{
// Cancel is the only action available on an in-progress build.
// Placed at top-left (Q) — primary slot for a single-action menu.
cells[0] = CreateCancelButton(bsv, HotkeyLayout[0]);
}
// Remaining cells become empty slots so the 5×3 grid layout is preserved.
for (int i = 0; i < GRID_MAX; i++)
{
if (cells[i] == null) cells[i] = CreateEmptySlot();
}
for (int row = 0; row < GRID_ROWS; row++)
{
var rowEl = new VisualElement();
rowEl.AddToClassList("cmd-row");
for (int col = 0; col < GRID_COLS; col++)
rowEl.Add(cells[row * GRID_COLS + col]);
commandGrid.Add(rowEl);
}
}
// Tower button: clicking begins placement; hovering drives the Tool Tip.
private VisualElement CreateTowerButton(TowerDefinition def, int typeId, Key hotkey)
{
var btn = CreateActionButton(
costText: $"{def.GoldCost}g",
hotkey: hotkey,
onClick: () =>
{
if (placementController != null)
placementController.BeginPlacement(def, typeId);
else
Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
});
btn.RegisterCallback(_ => ShowTooltip(def));
btn.RegisterCallback(_ => ClearTooltip());
return btn;
}
private static VisualElement CreateEmptySlot()
{
var slot = new VisualElement();
slot.AddToClassList("cmd-btn");
slot.AddToClassList("empty-slot");
slot.pickingMode = PickingMode.Ignore;
return slot;
}
///
/// Builds the standard action button: a Button containing a centered icon
/// placeholder, plus optional hotkey badge (top-left) and cost badge
/// (bottom-left). Both badges are pure visual overlays — click events still
/// reach the Button. The hotkey, when non-None, is registered in
/// so Update can fire
/// on keypress (gated on the button's enabledSelf ).
///
private Button CreateActionButton(string costText, Key hotkey, System.Action onClick)
{
var btn = new Button(() => onClick?.Invoke());
btn.AddToClassList("cmd-btn");
// Icon — added FIRST so it sits underneath the absolute-positioned badges.
var iconPlaceholder = new VisualElement();
iconPlaceholder.AddToClassList("cmd-icon-placeholder");
iconPlaceholder.pickingMode = PickingMode.Ignore;
btn.Add(iconPlaceholder);
// Hotkey badge — top-left.
if (hotkey != Key.None)
{
var hkLabel = new Label(KeyToDisplay(hotkey));
hkLabel.AddToClassList("cmd-hotkey");
hkLabel.pickingMode = PickingMode.Ignore;
btn.Add(hkLabel);
hotkeyBindings.Add(new HotkeyBinding(hotkey, btn, onClick));
}
// Cost badge — bottom-left. Omitted when no cost is meaningful (e.g., Upgrade
// before the upgrade system has a tier-cost lookup).
if (!string.IsNullOrEmpty(costText))
{
var costLabel = new Label(costText);
costLabel.AddToClassList("cmd-cost");
costLabel.pickingMode = PickingMode.Ignore;
btn.Add(costLabel);
}
return btn;
}
// Upgrade and Sell — visuals + hotkeys wired; click is a no-op because the
// upgrade/sell systems aren't built yet. Buttons are SetEnabled(false) so the
// hotkey handler also skips them (it gates on enabledSelf).
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
{
var btn = CreateActionButton(
costText: "", // tier cost unknown until upgrade system lands
hotkey: hotkey,
onClick: () => { /* TODO: upgrade flow */ });
btn.SetEnabled(false);
return btn;
}
private VisualElement CreateSellButton(TowerInstance tower, Key hotkey)
{
int sellValue = tower.Definition != null
? Mathf.RoundToInt(tower.Definition.GoldCost * 0.7f)
: 0;
var btn = CreateActionButton(
costText: sellValue > 0 ? $"+{sellValue}g" : "",
hotkey: hotkey,
onClick: () => { /* TODO: sell flow */ });
btn.SetEnabled(false);
return btn;
}
// Cancel action for an in-progress build. Fires the owner-only RPC; the
// server cancels the matching job (or, for shelved sites, refunds + despawns
// directly), full gold is refunded, the BuildSiteVisual is despawned, and
// OnSelectionChanged fires with null — HUD/visualizer/ring all clear
// automatically.
private VisualElement CreateCancelButton(BuildSiteVisual bsv, Key hotkey)
{
int refund = bsv.GoldSpent;
return CreateActionButton(
costText: refund > 0 ? $"+{refund}g" : "",
hotkey: hotkey,
onClick: () => bsv.RequestCancelRpc());
}
// Renders a Key as a single-character badge ("Q", "1", etc.). Letter and number
// keys produce their own glyph via ToString; if we ever bind non-letter keys
// (e.g., F1 or Space), extend this with a mapping table.
private static string KeyToDisplay(Key key) => key.ToString();
// ----- Portrait / selection context ----------------------------------
///
/// Called by the selection system when a unit is selected or deselected.
/// Pass null or empty to clear (nothing selected).
///
public void SetSelectedUnitName(string unitName)
{
if (portraitName == null) return;
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
}
private void HandleSelectionChanged(ISelectable selection)
{
// Sections 2/3/4 (portrait, info, tooltip) stay visible regardless;
// their *contents* update based on selection. Section 5 (action menu)
// hides via PopulateGridForSelection when there are no actions.
PopulateInfoPanel(selection);
PopulateGridForSelection(selection);
}
///
/// Drives the portrait name (section 3 header), level (section 2 footer),
/// contextual stat lines (section 3 body), and the build-progress sub-view
/// (section 3 — shown only when a BuildSiteVisual is selected).
///
private void PopulateInfoPanel(ISelectable selection)
{
// Name
SetSelectedUnitName(selection?.DisplayName);
// Level — placeholder until upgrade system lands.
// Builder → "Lv. 1" (per design; may carry experience later)
// Tower → "Lv. 1" (will reflect tower upgrade tier when implemented)
// Nothing → blank
if (levelLabel != null)
levelLabel.text = selection != null ? "Lv. 1" : "";
// Build-progress block visibility: shown only for BuildSiteVisual.
// Stat lines remain underneath and just stay empty for that case.
bool isBuildSite = selection is BuildSiteVisual;
if (buildProgressContainer != null)
buildProgressContainer.style.display =
isBuildSite ? DisplayStyle.Flex : DisplayStyle.None;
// Stat lines — clear and rebuild based on selection kind.
// The enemy-info cached references (health bar + label) are invalidated
// by Clear() and re-cached if a new Enemy gets selected below.
enemyHealthBar = null;
enemyHealthFill = null;
enemyHealthText = null;
if (statLines != null)
{
statLines.Clear();
if (selection is Builder builder)
{
AddStatLine($"Build range: {builder.BuildRange:0.0}");
}
else if (selection is TowerInstance tower)
{
var def = tower.Definition;
if (def != null)
{
if (def.Damage > 0) AddStatLine($"Damage: {def.Damage}");
if (def.Range > 0) AddStatLine($"Range: {def.Range:0.0}");
if (def.FireRate > 0) AddStatLine($"Fire rate: {def.FireRate:0.0}/s");
if (def.SlowFactor < 1f)
AddStatLine($"Slow: {(1f - def.SlowFactor) * 100f:0}%");
}
}
else if (selection is EnemyHealth enemy)
{
BuildEnemyInfo(enemy);
}
// BuildSiteVisual: no stat lines — progress bar conveys the state.
}
}
// ----- Enemy info -------------------------------------------------
///
/// Builds the enemy info stat block (HP bar + speed + bounty) inside the
/// stat-lines container. The bar and label references are cached so
/// can drive them every frame as the
/// enemy takes damage.
///
private void BuildEnemyInfo(EnemyHealth enemy)
{
var def = enemy.Definition;
// ----- Health bar (X / Y) -----
// A thin horizontal bar with a green fill and a centered "X / Y" label
// on top. Styled with inline-style so we don't need to add USS classes
// — keeps this self-contained.
var hpRow = new VisualElement();
hpRow.style.flexDirection = FlexDirection.Row;
hpRow.style.alignItems = Align.Center;
hpRow.style.marginTop = 4;
hpRow.style.marginBottom = 4;
var hpLabel = new Label("HP");
hpLabel.style.minWidth = 28;
hpLabel.style.color = new Color(0.85f, 0.85f, 0.85f);
hpRow.Add(hpLabel);
// Background + fill share a parent so the fill can be styled as a
// percentage width inside the background.
var hpBackground = new VisualElement();
hpBackground.style.flexGrow = 1;
hpBackground.style.height = 14;
hpBackground.style.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 0.85f);
hpBackground.style.borderTopWidth = hpBackground.style.borderBottomWidth =
hpBackground.style.borderLeftWidth = hpBackground.style.borderRightWidth = 1;
var borderColor = new Color(0.3f, 0.3f, 0.3f);
hpBackground.style.borderTopColor = hpBackground.style.borderBottomColor =
hpBackground.style.borderLeftColor = hpBackground.style.borderRightColor = borderColor;
var hpFill = new VisualElement();
hpFill.style.height = Length.Percent(100);
hpFill.style.width = Length.Percent(100);
hpFill.style.backgroundColor = new Color(0.3f, 0.85f, 0.3f);
hpFill.pickingMode = PickingMode.Ignore;
hpBackground.Add(hpFill);
// Overlay "X / Y" label, centered above the bar.
var hpText = new Label();
hpText.pickingMode = PickingMode.Ignore;
hpText.style.position = Position.Absolute;
hpText.style.left = 0;
hpText.style.right = 0;
hpText.style.top = 0;
hpText.style.bottom = 0;
hpText.style.unityTextAlign = TextAnchor.MiddleCenter;
hpText.style.color = Color.white;
hpText.style.fontSize = 11;
hpBackground.Add(hpText);
hpRow.Add(hpBackground);
statLines.Add(hpRow);
// Cache references for per-frame updates.
enemyHealthBar = hpBackground;
enemyHealthFill = hpFill;
enemyHealthText = hpText;
// Initial value (will be refreshed every frame in UpdateEnemyInfoIfShown).
UpdateEnemyHpDisplay(enemy);
// ----- Speed + Bounty stat lines -----
if (def != null)
{
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
AddStatLine($"Bounty: {def.GoldReward} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
}
}
private void UpdateEnemyInfoIfShown()
{
if (enemyHealthFill == null) return; // no Enemy selected
var sel = SelectionState.Instance?.SelectedObject;
if (sel is EnemyHealth enemy && (UnityEngine.Object)enemy != null)
UpdateEnemyHpDisplay(enemy);
}
private void UpdateEnemyHpDisplay(EnemyHealth enemy)
{
float cur = Mathf.Max(0f, enemy.CurrentHp);
float max = Mathf.Max(1f, enemy.MaxHp);
float pct = Mathf.Clamp01(cur / max);
if (enemyHealthFill != null)
enemyHealthFill.style.width = Length.Percent(pct * 100f);
if (enemyHealthText != null)
enemyHealthText.text = $"{Mathf.CeilToInt(cur)} / {Mathf.CeilToInt(max)}";
}
private void AddStatLine(string text)
{
var label = new Label(text);
label.AddToClassList("stat-line");
statLines.Add(label);
}
// ----- Tooltip ----------------------------------------------------
private void ShowTooltip(TowerDefinition def)
{
if (ttTitle == null) return;
ttTitle.text = def.DisplayName;
ttDesc.text = def.Description ?? "";
if (def.Damage > 0 || def.Range > 0)
{
ttStats.text = $"DMG: {def.Damage} · Range: {def.Range:0}";
if (def.FireRate > 0)
ttStats.text += $"\nFire rate: {def.FireRate:0.0}/s";
if (def.SlowFactor < 1f)
ttStats.text += $"\nSlow: {(1f - def.SlowFactor) * 100f:0}%";
}
else
{
ttStats.text = "(stats pending)";
}
int sellValue = Mathf.RoundToInt(def.GoldCost * 0.7f);
ttCost.text = $"Cost: {def.GoldCost}g · Sell: {sellValue}g";
}
private void ClearTooltip()
{
if (ttTitle == null) return;
ttTitle.text = "";
ttDesc.text = "";
ttStats.text = "";
ttCost.text = "";
}
// ----- Rejection messages -----------------------------------------
private void ShowRejectionMessage(string message)
{
if (rejectionLabel == null) return;
rejectionLabel.text = message;
rejectionLabel.style.display = DisplayStyle.Flex;
if (rejectionFadeCoroutine != null)
StopCoroutine(rejectionFadeCoroutine);
rejectionFadeCoroutine = StartCoroutine(HideRejectionAfterDelay());
}
private IEnumerator HideRejectionAfterDelay()
{
yield return new WaitForSeconds(rejectionMessageDuration);
if (rejectionLabel != null)
rejectionLabel.style.display = DisplayStyle.None;
rejectionFadeCoroutine = null;
}
// ----- Portrait click: center camera on selection -----------------
// WC3 / SC2 convention: clicking the portrait recenters the camera on
// whoever is selected. Zoom is preserved (CameraController.JumpTo only
// moves the pivot). No-op when nothing is selected or the camera isn't wired.
private void OnPortraitClicked(ClickEvent evt)
{
var sel = SelectionState.Instance?.SelectedObject;
if (sel == null) return;
if (cameraController == null) return;
var t = sel.SelectionTransform;
if (t == null) return;
cameraController.JumpTo(t.position);
}
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
private void BuildMatchEndOverlay(VisualElement root)
{
matchEndOverlay = new VisualElement();
matchEndOverlay.style.position = Position.Absolute;
matchEndOverlay.style.left = 0;
matchEndOverlay.style.right = 0;
matchEndOverlay.style.top = 0;
matchEndOverlay.style.bottom = 0;
matchEndOverlay.style.alignItems = Align.Center;
matchEndOverlay.style.justifyContent = Justify.Center;
matchEndOverlay.style.backgroundColor = new Color(0f, 0f, 0f, 0.6f);
matchEndOverlay.style.display = DisplayStyle.None;
// The overlay swallows pointer events so the player can't click towers
// or builders through it while it's visible.
matchEndOverlay.pickingMode = PickingMode.Position;
var panel = new VisualElement();
panel.style.minWidth = 320;
panel.style.paddingTop = 24;
panel.style.paddingBottom = 24;
panel.style.paddingLeft = 32;
panel.style.paddingRight = 32;
panel.style.backgroundColor = new Color(0.08f, 0.08f, 0.10f, 0.95f);
panel.style.borderTopWidth = panel.style.borderBottomWidth =
panel.style.borderLeftWidth = panel.style.borderRightWidth = 2;
var border = new Color(0.4f, 0.4f, 0.45f);
panel.style.borderTopColor = panel.style.borderBottomColor =
panel.style.borderLeftColor = panel.style.borderRightColor = border;
panel.style.alignItems = Align.Center;
matchEndTitle = new Label("Victory");
matchEndTitle.style.fontSize = 32;
matchEndTitle.style.color = Color.white;
matchEndTitle.style.marginBottom = 16;
matchEndTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
panel.Add(matchEndTitle);
var retryBtn = new Button(OnRetryClicked) { text = "Retry" };
retryBtn.style.minWidth = 120;
retryBtn.style.height = 36;
retryBtn.style.fontSize = 16;
retryBtn.style.marginTop = 8;
panel.Add(retryBtn);
matchEndOverlay.Add(panel);
root.Add(matchEndOverlay);
}
private void HandlePhaseChanged(MatchPhase previous, MatchPhase next)
{
if (matchEndOverlay == null) return;
switch (next)
{
case MatchPhase.Victory:
if (matchEndTitle != null)
{
matchEndTitle.text = "Victory";
matchEndTitle.style.color = new Color(1f, 0.84f, 0.2f);
}
matchEndOverlay.style.display = DisplayStyle.Flex;
break;
case MatchPhase.Defeat:
if (matchEndTitle != null)
{
matchEndTitle.text = "Defeat";
matchEndTitle.style.color = new Color(0.95f, 0.25f, 0.25f);
}
matchEndOverlay.style.display = DisplayStyle.Flex;
break;
default:
matchEndOverlay.style.display = DisplayStyle.None;
break;
}
}
// Reloads the active scene and restarts the host. Host-only — testing
// convenience until a real lobby/restart flow exists. The static
// sceneLoaded callback survives the scene reload (HUDController dies),
// re-arms StartHost once the fresh scene has finished loading, and
// unsubscribes itself.
private void OnRetryClicked()
{
var nm = NetworkManager.Singleton;
if (nm == null)
{
Debug.LogWarning("[HUDController] Retry clicked but NetworkManager is null.");
return;
}
if (!nm.IsServer)
{
Debug.LogWarning("[HUDController] Retry only works on the host. " +
"Clients should ask the host to retry.");
return;
}
Scene active = SceneManager.GetActiveScene();
s_pendingHostRestartBuildIndex = active.buildIndex;
SceneManager.sceneLoaded += OnSceneLoadedForRetry;
nm.Shutdown();
SceneManager.LoadScene(active.buildIndex);
}
private static int s_pendingHostRestartBuildIndex = -1;
private static void OnSceneLoadedForRetry(Scene loaded, LoadSceneMode mode)
{
if (loaded.buildIndex != s_pendingHostRestartBuildIndex) return;
SceneManager.sceneLoaded -= OnSceneLoadedForRetry;
s_pendingHostRestartBuildIndex = -1;
var nm = NetworkManager.Singleton;
if (nm != null) nm.StartHost();
else Debug.LogWarning("[HUDController] Retry: no NetworkManager in reloaded scene.");
}
// ----- Helpers ----------------------------------------------------
private static T Require(VisualElement root, string name) where T : VisualElement
{
var el = root.Q(name);
if (el == null)
Debug.LogWarning($"[HUDController] Element not found: '{name}' ({typeof(T).Name}). " +
$"Check that the name matches the UXML.");
return el;
}
private static void SetPickIgnore(VisualElement root, string name)
{
var el = root.Q(name);
if (el != null) el.pickingMode = PickingMode.Ignore;
}
}
}