- Builder.DisplayName: stub property ("Builder (P{n})") for portrait label; swaps
to real player name when MatchState lands in Phase 1.3
- HUDController: subscribe SelectionState.OnSelectionChanged in OnEnable/OnDisable;
HandleSelectionChanged drives portrait label + grays out command grid when nothing
is selected; Start() seeds initial state in case builder auto-selected before Start
- BuildSiteVisual: ComputeProgressNormalized() public API ([0,1], safe on any client);
OnNetworkSpawn spawns a non-networked BuildProgressBar child
- BuildProgressBar: new world-space uGUI Canvas bar; green while Constructing, yellow
while Paused, hidden while Queued; billboards to Camera.main each LateUpdate;
auto-destroyed when parent BuildSiteVisual despawns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
16 KiB
C#
401 lines
16 KiB
C#
// Assets/_Project/Scripts/UI/HUDController.cs
|
|
using System.Collections;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using TD.Gameplay;
|
|
using TD.Towers;
|
|
using TD.UI.Minimap;
|
|
|
|
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;
|
|
|
|
[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 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;
|
|
private MinimapView minimapView;
|
|
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
|
|
|
|
// ----- 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;
|
|
|
|
/// <summary>
|
|
/// True if <paramref name="screenMousePos"/> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Convention: <paramref name="screenMousePos"/> 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.
|
|
/// </remarks>
|
|
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();
|
|
TryPopulateCommandGrid();
|
|
// Seed portrait/grid state in case the builder already auto-selected before Start.
|
|
HandleSelectionChanged(SelectionState.Instance?.SelectedBuilder);
|
|
}
|
|
|
|
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. 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<VisualElement>("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);
|
|
}
|
|
|
|
// 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;
|
|
if (SelectionState.Instance != null)
|
|
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
|
|
if (SelectionState.Instance != null)
|
|
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!uiInitialized) return;
|
|
|
|
if (!gridPopulated)
|
|
TryPopulateCommandGrid();
|
|
|
|
RefreshGoldDisplay();
|
|
minimapView?.Tick();
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
// ----- 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;
|
|
}
|
|
|
|
private void HandleSelectionChanged(Builder builder)
|
|
{
|
|
SetSelectedUnitName(builder != null ? builder.DisplayName : null);
|
|
if (commandGrid != null)
|
|
commandGrid.SetEnabled(builder != null);
|
|
}
|
|
|
|
// ----- 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);
|
|
}
|
|
}
|
|
}
|