687 lines
30 KiB
C#
687 lines
30 KiB
C#
// 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;
|
||
}
|
||
}
|
||
}
|