UnityTowerDefense/Assets/_Project/Scripts/UI/HUDController.cs

687 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Assets/_Project/Scripts/UI/HUDController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
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 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;
// ----- 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 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<HotkeyBinding> hotkeyBindings = new List<HotkeyBinding>();
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;
/// <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();
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);
}
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");
levelLabel = Require<Label>(root, "level-label");
statLines = Require<VisualElement>(root, "stat-lines");
commandGrid = Require<VisualElement>(root, "command-grid");
actionFrame = Require<VisualElement>(root, "action-frame");
buildProgressContainer = Require<VisualElement>(root, "build-progress");
buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
buildProgressPercent = Require<Label>(root, "build-progress-percent");
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. 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<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;
// 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;
}
}
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();
RefreshGoldDisplay();
UpdateBuildProgressIfShown();
HandleHotkeys();
minimapView?.Tick();
}
/// <summary>
/// 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).
/// </summary>
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)}%";
}
}
}
/// <summary>
/// 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).
/// </summary>
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";
}
// ----- 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);
}
/// <summary>
/// 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).
/// </summary>
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<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;
}
/// <summary>
/// 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
/// <see cref="hotkeyBindings"/> so Update can fire <paramref name="onClick"/>
/// on keypress (gated on the button's <c>enabledSelf</c>).
/// </summary>
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 ----------------------------------
/// <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(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);
}
/// <summary>
/// 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).
/// </summary>
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.
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}%");
}
}
// BuildSiteVisual: no stat lines — progress bar conveys the state.
}
}
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;
}
// ----- 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;
}
}
}