Major updates to the HUD and selectable objects

This commit is contained in:
Matt F 2026-05-11 23:57:35 -07:00
parent 5bc757b385
commit c100db52e5
23 changed files with 1615 additions and 614 deletions

View file

@ -1,90 +0,0 @@
// Assets/_Project/Scripts/UI/BuildProgressBar.cs
using UnityEngine;
using UnityEngine.UI;
using TD.Gameplay;
namespace TD.UI
{
// Local (non-networked) world-space progress bar that tracks a BuildSiteVisual.
// Visible while Constructing (green) or Paused (yellow). Hidden while Queued.
// Billboards to face Camera.main each LateUpdate.
// Destroyed automatically when its parent BuildSiteVisual is despawned.
public class BuildProgressBar : MonoBehaviour
{
private BuildSiteVisual source;
private GameObject canvasGO;
private Image fillImage;
private const float BarWorldWidth = 1.8f;
private const float BarWorldHeight = 0.15f;
private const float HeightAboveSite = 1.5f;
private static readonly Color ColorConstructing = new Color(0.15f, 0.85f, 0.15f, 1f);
private static readonly Color ColorPaused = new Color(0.90f, 0.75f, 0.10f, 1f);
public void Initialize(BuildSiteVisual visual)
{
source = visual;
BuildHierarchy();
}
private void BuildHierarchy()
{
// World-space Canvas — 100 canvas units = 1 world unit via localScale 0.01.
canvasGO = new GameObject("Canvas");
canvasGO.transform.SetParent(transform, false);
var canvas = canvasGO.AddComponent<Canvas>();
canvas.renderMode = RenderMode.WorldSpace;
canvas.sortingOrder = 10;
var rt = (RectTransform)canvasGO.transform;
rt.sizeDelta = new Vector2(BarWorldWidth * 100f, BarWorldHeight * 100f);
rt.localPosition = new Vector3(0f, HeightAboveSite, 0f);
rt.localScale = Vector3.one * 0.01f;
// Background
var bgGO = new GameObject("Background");
bgGO.transform.SetParent(canvasGO.transform, false);
var bgImg = bgGO.AddComponent<Image>();
bgImg.color = new Color(0.05f, 0.05f, 0.05f, 0.85f);
Stretch((RectTransform)bgGO.transform);
// Fill (rendered on top; fillAmount drives visible width)
var fillGO = new GameObject("Fill");
fillGO.transform.SetParent(canvasGO.transform, false);
fillImage = fillGO.AddComponent<Image>();
fillImage.color = ColorConstructing;
fillImage.type = Image.Type.Filled;
fillImage.fillMethod = Image.FillMethod.Horizontal;
fillImage.fillOrigin = 0; // left to right
fillImage.fillAmount = 0f;
Stretch((RectTransform)fillGO.transform);
}
private static void Stretch(RectTransform rt)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.offsetMin = Vector2.zero;
rt.offsetMax = Vector2.zero;
}
private void LateUpdate()
{
if (source == null) return;
var stage = source.CurrentStage;
bool show = stage == BuildStage.Constructing || stage == BuildStage.Paused;
canvasGO.SetActive(show);
if (!show) return;
fillImage.color = stage == BuildStage.Paused ? ColorPaused : ColorConstructing;
fillImage.fillAmount = source.ComputeProgressNormalized();
var cam = Camera.main;
if (cam != null)
canvasGO.transform.rotation = cam.transform.rotation;
}
}
}

View file

@ -1,6 +1,8 @@
// 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;
@ -37,7 +39,13 @@ namespace TD.UI
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;
@ -47,11 +55,38 @@ namespace TD.UI
// ----- State ------------------------------------------------------
private Coroutine rejectionFadeCoroutine;
private bool gridPopulated;
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,
@ -104,9 +139,16 @@ namespace TD.UI
// 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);
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()
@ -128,28 +170,34 @@ namespace TD.UI
// 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");
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.
// 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;
// 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().
@ -174,28 +222,95 @@ namespace TD.UI
private void OnEnable()
{
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
if (SelectionState.Instance != null)
SelectionState.Instance.OnSelectionChanged += HandleSelectionChanged;
// 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 (SelectionState.Instance != null)
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 (!gridPopulated)
TryPopulateCommandGrid();
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();
@ -217,75 +332,109 @@ namespace TD.UI
// ----- Command grid -----------------------------------------------
private void TryPopulateCommandGrid()
{
if (commandGrid == null) return;
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
const int COLS = 4;
const int ROWS = 3;
const int MAX = COLS * ROWS;
// Build the 15-cell action layout for the active selection kind.
var cells = new VisualElement[GRID_MAX];
var defs = new System.Collections.Generic.List<(TowerDefinition def, int typeId)>();
foreach (var entry in placementManager.GetAvailableDefinitions())
if (selection is Builder)
{
defs.Add(entry);
if (defs.Count >= MAX) break;
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]);
}
for (int row = 0; row < ROWS; row++)
// 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 < COLS; col++)
{
int index = row * COLS + col;
VisualElement btn = index < defs.Count
? CreateTowerButton(defs[index].def, defs[index].typeId)
: CreateEmptySlot();
rowEl.Add(btn);
}
for (int col = 0; col < GRID_COLS; col++)
rowEl.Add(cells[row * GRID_COLS + col]);
commandGrid.Add(rowEl);
}
gridPopulated = true;
}
private VisualElement CreateTowerButton(TowerDefinition def, int typeId)
// Tower button: clicking begins placement; hovering drives the Tool Tip.
private VisualElement CreateTowerButton(TowerDefinition def, int typeId, Key hotkey)
{
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);
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;
}
@ -298,6 +447,92 @@ namespace TD.UI
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>
@ -310,11 +545,68 @@ namespace TD.UI
portraitName.text = string.IsNullOrEmpty(unitName) ? "" : unitName;
}
private void HandleSelectionChanged(Builder builder)
private void HandleSelectionChanged(ISelectable selection)
{
SetSelectedUnitName(builder != null ? builder.DisplayName : null);
if (commandGrid != null)
commandGrid.SetEnabled(builder != null);
// 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 ----------------------------------------------------
@ -391,11 +683,5 @@ namespace TD.UI
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);
}
}
}

View file

@ -60,6 +60,15 @@ namespace TD.UI.Minimap
// Outline added to builder icons so they read against same-color zone fill.
private static readonly Color BuilderOutline = new Color(1f, 1f, 1f, 0.85f);
// Viewport trapezoid (the "what the player sees" rectangle on the minimap).
// Drawn on top of all entities so it's always readable; matches WC3 visual style.
private static readonly Color ViewportColor = new Color(1f, 1f, 1f, 0.85f);
private const float ViewportLineWidth = 1.5f;
// Reused buffer for the camera's four world-space view corners. Heap-allocated
// once and refilled every repaint to avoid per-frame GC.
private readonly Vector3[] viewCornersBuf = new Vector3[4];
// ----- Refs -------------------------------------------------------
private readonly VisualElement container;
@ -309,12 +318,15 @@ namespace TD.UI.Minimap
private void HandleRightClickMove(Vector2 uiLocal)
{
// Right-click on the minimap = "send my builder there". Only meaningful when
// the LOCAL BUILDER is selected; a tower or enemy selection has no move action.
var selection = SelectionState.Instance;
if (selection == null || !selection.HasSelection) return;
var builder = selection?.SelectedBuilder;
if (builder == null) return;
Vector3 worldTarget = UIToWorld(uiLocal);
// Same RPC the world right-click uses; server validates and side-effects the queue.
selection.SelectedBuilder.RequestMoveAndPauseRpc(worldTarget);
builder.RequestMoveAndPauseRpc(worldTarget);
}
// ----- Zoom -------------------------------------------------------
@ -436,6 +448,40 @@ namespace TD.UI.Minimap
// (e.g., on a dedicated server or before the local client's builder arrives).
if (localBuilder != null)
DrawOneEntity(painter, localBuilder, pxPerWorld);
// Pass 3: the camera-viewport trapezoid sits on top of everything so the
// player can always see where they're looking, regardless of zone tint or
// unit density underneath.
DrawViewportRect(painter);
}
// Draws a thin white outline matching the camera's footprint on the buildable
// plane. Because the camera is angled, the on-plane footprint is a TRAPEZOID
// (far edge wider than near edge) — that's the visual we want, since it tells
// the player how much of the world they're actually seeing at the current pitch.
private void DrawViewportRect(Painter2D painter)
{
if (cameraController == null) return;
// Even when one or more corners can't be projected onto the plane (camera at
// the horizon), the fallback puts them at a far point along the ray. The
// resulting UI coords land outside the container and get clipped by
// overflow:hidden — perfectly acceptable visual.
cameraController.TryGetViewportWorldCorners(viewCornersBuf);
Vector2 a = WorldToUI(viewCornersBuf[0]);
Vector2 b = WorldToUI(viewCornersBuf[1]);
Vector2 c = WorldToUI(viewCornersBuf[2]);
Vector2 d = WorldToUI(viewCornersBuf[3]);
painter.strokeColor = ViewportColor;
painter.lineWidth = ViewportLineWidth;
painter.BeginPath();
painter.MoveTo(a);
painter.LineTo(b);
painter.LineTo(c);
painter.LineTo(d);
painter.ClosePath();
painter.Stroke();
}
private void DrawOneEntity(Painter2D p, IMinimapEntity entity, float pxPerWorld)