321 lines
11 KiB
C#
321 lines
11 KiB
C#
// Assets/_Project/Scripts/UI/HUDController.cs
|
|
using System.Collections;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using TD.Gameplay;
|
|
using TD.Towers;
|
|
|
|
namespace TD.UI
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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;
|
|
|
|
[Header("Minimap")]
|
|
[Tooltip("RenderTexture that the minimap camera renders into.")]
|
|
[SerializeField] private RenderTexture minimapRenderTexture;
|
|
|
|
[Header("Settings")]
|
|
[SerializeField] private float rejectionMessageDuration = 2.5f;
|
|
|
|
// ----- Cached UI element references -------------------------------
|
|
|
|
private Label goldLabel;
|
|
private Label waveLabel;
|
|
private Label portraitName;
|
|
private VisualElement commandGrid;
|
|
private Label ttTitle;
|
|
private Label ttDesc;
|
|
private Label ttStats;
|
|
private Label ttCost;
|
|
private Label rejectionLabel;
|
|
|
|
// ----- State ------------------------------------------------------
|
|
|
|
private Coroutine rejectionFadeCoroutine;
|
|
private bool gridPopulated;
|
|
private bool uiInitialized;
|
|
|
|
// ----- 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();
|
|
TryPopulateCommandGrid();
|
|
}
|
|
|
|
private void InitializeUI()
|
|
{
|
|
var doc = GetComponent<UIDocument>();
|
|
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<Label>(root, "gold-label");
|
|
waveLabel = Require<Label>(root, "wave-label");
|
|
portraitName = Require<Label>(root, "portrait-name");
|
|
commandGrid = Require<VisualElement>(root, "command-grid");
|
|
ttTitle = Require<Label>(root, "tt-title");
|
|
ttDesc = Require<Label>(root, "tt-desc");
|
|
ttStats = Require<Label>(root, "tt-stats");
|
|
ttCost = Require<Label>(root, "tt-cost");
|
|
rejectionLabel = Require<Label>(root, "rejection-label");
|
|
|
|
// Map area and its transparent ancestors must not consume pointer
|
|
// events so clicks reach the 3D scene underneath.
|
|
SetPickIgnore(root, "hud-root");
|
|
SetPickIgnore(root, "main-area");
|
|
SetPickIgnore(root, "map-area");
|
|
if (rejectionLabel != null)
|
|
rejectionLabel.pickingMode = PickingMode.Ignore;
|
|
|
|
// Upgrade/sell have no backing system yet.
|
|
SetEnabled(root, "upgrade-btn", false);
|
|
SetEnabled(root, "sell-btn", false);
|
|
|
|
// Minimap RenderTexture.
|
|
if (minimapRenderTexture != null)
|
|
{
|
|
var minimap = root.Q<VisualElement>("minimap");
|
|
if (minimap != null)
|
|
minimap.style.backgroundImage =
|
|
Background.FromRenderTexture(minimapRenderTexture);
|
|
}
|
|
|
|
uiInitialized = true;
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!uiInitialized) return;
|
|
|
|
if (!gridPopulated)
|
|
TryPopulateCommandGrid();
|
|
|
|
RefreshGoldDisplay();
|
|
}
|
|
|
|
// ----- Gold display -----------------------------------------------
|
|
|
|
private void RefreshGoldDisplay()
|
|
{
|
|
if (goldLabel == null) return;
|
|
var gm = PlayerGoldManager.Local;
|
|
goldLabel.text = gm != null ? $"{gm.CurrentGold:N0} g" : "-- g";
|
|
}
|
|
|
|
// ----- Command grid -----------------------------------------------
|
|
|
|
private void TryPopulateCommandGrid()
|
|
{
|
|
if (commandGrid == null) return;
|
|
|
|
if (placementManager == null)
|
|
placementManager = TowerPlacementManager.Instance;
|
|
|
|
if (placementManager == null) return; // not spawned yet — retry next frame
|
|
|
|
commandGrid.Clear();
|
|
|
|
const int COLS = 4;
|
|
const int ROWS = 3;
|
|
const int MAX = COLS * ROWS;
|
|
|
|
var defs = new System.Collections.Generic.List<(TowerDefinition def, int typeId)>();
|
|
foreach (var entry in placementManager.GetAvailableDefinitions())
|
|
{
|
|
defs.Add(entry);
|
|
if (defs.Count >= MAX) break;
|
|
}
|
|
|
|
for (int row = 0; row < ROWS; row++)
|
|
{
|
|
var rowEl = new VisualElement();
|
|
rowEl.AddToClassList("cmd-row");
|
|
|
|
for (int col = 0; col < COLS; col++)
|
|
{
|
|
int index = row * COLS + col;
|
|
VisualElement btn = index < defs.Count
|
|
? CreateTowerButton(defs[index].def, defs[index].typeId)
|
|
: CreateEmptySlot();
|
|
rowEl.Add(btn);
|
|
}
|
|
|
|
commandGrid.Add(rowEl);
|
|
}
|
|
|
|
gridPopulated = true;
|
|
}
|
|
|
|
private VisualElement CreateTowerButton(TowerDefinition def, int typeId)
|
|
{
|
|
var btn = new Button(() =>
|
|
{
|
|
if (placementController != null)
|
|
placementController.BeginPlacement(def, typeId);
|
|
else
|
|
Debug.LogWarning("[HUDController] No TowerPlacementController assigned.");
|
|
});
|
|
|
|
btn.AddToClassList("cmd-btn");
|
|
|
|
// Icon placeholder — swap for background-image on btn when art exists.
|
|
var iconPlaceholder = new VisualElement();
|
|
iconPlaceholder.AddToClassList("cmd-icon-placeholder");
|
|
iconPlaceholder.pickingMode = PickingMode.Ignore;
|
|
|
|
var costLabel = new Label($"{def.GoldCost}g");
|
|
costLabel.AddToClassList("cmd-cost");
|
|
costLabel.pickingMode = PickingMode.Ignore;
|
|
|
|
btn.Add(iconPlaceholder);
|
|
btn.Add(costLabel);
|
|
|
|
btn.RegisterCallback<MouseEnterEvent>(_ => ShowTooltip(def));
|
|
btn.RegisterCallback<MouseLeaveEvent>(_ => 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;
|
|
}
|
|
|
|
// ----- Portrait / selection context ----------------------------------
|
|
|
|
/// <summary>
|
|
/// Called by the selection system when a unit is selected or deselected.
|
|
/// Pass null or empty to clear (nothing selected).
|
|
/// </summary>
|
|
public void SetSelectedUnitName(string unitName)
|
|
{
|
|
if (portraitName == null) return;
|
|
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
|
|
}
|
|
|
|
// ----- 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;
|
|
}
|
|
|
|
// ----- Helpers ----------------------------------------------------
|
|
|
|
private static T Require<T>(VisualElement root, string name) where T : VisualElement
|
|
{
|
|
var el = root.Q<T>(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<VisualElement>(name);
|
|
if (el != null) el.pickingMode = PickingMode.Ignore;
|
|
}
|
|
|
|
private static void SetEnabled(VisualElement root, string name, bool enabled)
|
|
{
|
|
var el = root.Q<Button>(name);
|
|
if (el != null) el.SetEnabled(enabled);
|
|
}
|
|
}
|
|
}
|