B button launches upgrade menu

This commit is contained in:
Ian Woods 2026-06-03 20:53:01 -07:00
parent 79cd331141
commit 3ada934e41

View file

@ -25,53 +25,56 @@ namespace TD.UI
// ----- Inspector -------------------------------------------------- // ----- Inspector --------------------------------------------------
[Header("Scene References")] [Header("Scene References")] [Tooltip("The local client's TowerPlacementController.")] [SerializeField]
[Tooltip("The local client's TowerPlacementController.")] private TowerPlacementController placementController;
[SerializeField] private TowerPlacementController placementController;
[Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")] [Tooltip("The local client's TowerPaintController (drives the Paint tab + paint cursor).")]
[SerializeField] private TowerPaintController paintController; [SerializeField] private TowerPaintController paintController;
[Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [Tooltip("The TowerPlacementManager NetworkObject in the scene.")] [SerializeField]
[SerializeField] private TowerPlacementManager placementManager; private TowerPlacementManager placementManager;
[Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " + [Tooltip("The local client's CameraController. Used by the minimap for click-to-jump " +
"and drag-to-pan.")] "and drag-to-pan.")]
[SerializeField] private CameraController cameraController; [SerializeField]
private CameraController cameraController;
[Header("Settings")] [Header("Settings")] [SerializeField] private float rejectionMessageDuration = 2.5f;
[SerializeField] private float rejectionMessageDuration = 2.5f;
[Tooltip("Maximum visible height of the chat feed in pixels. Content past this " + [Tooltip("Maximum visible height of the chat feed in pixels. Content past this " +
"height is clipped — older messages scroll off the top of the visible area " + "height is clipped — older messages scroll off the top of the visible area " +
"but stay in history (scroll up while chat is open to view).")] "but stay in history (scroll up while chat is open to view).")]
[SerializeField] private float chatMaxHeight = 280f; [SerializeField]
private float chatMaxHeight = 280f;
[Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " + [Tooltip("Maximum messages kept in chat history. Defaults to effectively unlimited " +
"(int.MaxValue) — every message sent during a match stays scrollable. " + "(int.MaxValue) — every message sent during a match stays scrollable. " +
"Lower the value if a long match ever shows DOM perf issues; this field " + "Lower the value if a long match ever shows DOM perf issues; this field " +
"is the safety valve, not a normal-play limit.")] "is the safety valve, not a normal-play limit.")]
[SerializeField] private int chatMaxMessages = int.MaxValue; [SerializeField]
private int chatMaxMessages = int.MaxValue;
[Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [Tooltip("Color used for SYSTEM chat messages (e.g. 'Life Lost', income changes).")] [SerializeField]
[SerializeField] private Color chatSystemColor = new Color(1f, 0.7f, 0.2f); private Color chatSystemColor = new Color(1f, 0.7f, 0.2f);
[Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")] [Tooltip("Color used for PLAYER chat message bodies. Sender prefix uses the player's slot color.")]
[SerializeField] private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f); [SerializeField]
private Color chatPlayerColor = new Color(0.92f, 0.92f, 0.92f);
// ----- Cached UI element references ------------------------------- // ----- Cached UI element references -------------------------------
private Label goldLabel; private Label goldLabel;
private Label waveLabel; private Label waveLabel;
private Label livesLabel; private Label livesLabel;
private Label nextWaveLabel; // prep countdown ("next: 0:12") private Label nextWaveLabel; // prep countdown ("next: 0:12")
private Label leakedLabel; // local player's origin-leak count ("leaked: 3") private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave") private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
private VisualElement playerListContainer; // right-panel scoreboard rows private VisualElement playerListContainer; // right-panel scoreboard rows
private Label portraitName; private Label portraitName;
private Label levelLabel; private Label levelLabel;
private VisualElement statLines; private VisualElement statLines;
private VisualElement commandGrid; private VisualElement commandGrid;
private VisualElement actionFrame; // hidden via display:none when no actions are available private VisualElement actionFrame; // hidden via display:none when no actions are available
private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection private VisualElement commandTabs; // Build/Paint tab row — shown only for a Builder selection
private Button tabBuild; private Button tabBuild;
@ -79,6 +82,7 @@ namespace TD.UI
private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections private VisualElement buildProgressContainer; // info-panel sub-view, shown for BuildSiteVisual selections
private VisualElement buildProgressFill; // width driven each frame from progress private VisualElement buildProgressFill; // width driven each frame from progress
private Label buildProgressPercent; private Label buildProgressPercent;
private Label ttTitle; private Label ttTitle;
private Label ttDesc; private Label ttDesc;
private Label ttStats; private Label ttStats;
@ -91,7 +95,7 @@ namespace TD.UI
// without rebuilding the elements. // without rebuilding the elements.
private VisualElement enemyHealthBar; private VisualElement enemyHealthBar;
private VisualElement enemyHealthFill; private VisualElement enemyHealthFill;
private Label enemyHealthText; private Label enemyHealthText;
// Match-end overlay — built once on Start and toggled on Phase changes. // Match-end overlay — built once on Start and toggled on Phase changes.
private VisualElement matchEndOverlay; private VisualElement matchEndOverlay;
@ -99,15 +103,15 @@ namespace TD.UI
// Buff menu overlay — toggled by the B key via ToggleBuffMenu(). // Buff menu overlay — toggled by the B key via ToggleBuffMenu().
private VisualElement buffMenuOverlay; private VisualElement buffMenuOverlay;
private VisualElement buffMenuContent; private VisualElement buffMenuContent;
private Label matchEndTitle; private Label matchEndTitle;
// Chat panel (bottom-left, above portrait) — programmatic. The container // Chat panel (bottom-left, above portrait) — programmatic. The container
// holds both the scrollable feed and the input. Highlight + scroll // holds both the scrollable feed and the input. Highlight + scroll
// interactivity are toggled on the container when typing. // interactivity are toggled on the container when typing.
private VisualElement chatContainer; private VisualElement chatContainer;
private ScrollView chatFeed; private ScrollView chatFeed;
private TextField chatInput; private TextField chatInput;
private bool chatInputOpen; private bool chatInputOpen;
// Frame on which the chat input was opened or closed. Enter on that frame // Frame on which the chat input was opened or closed. Enter on that frame
// and the next one is ignored to prevent the open/close-triggering keypress // and the next one is ignored to prevent the open/close-triggering keypress
@ -129,12 +133,12 @@ namespace TD.UI
private CommandTab activeTab = CommandTab.Build; private CommandTab activeTab = CommandTab.Build;
private Coroutine rejectionFadeCoroutine; private Coroutine rejectionFadeCoroutine;
private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null private bool placementManagerReady; // true once TowerPlacementManager.Instance is non-null
private bool uiInitialized; private bool uiInitialized;
private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged private bool selectionSubscribed; // true once we've successfully hooked SelectionState.OnSelectionChanged
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
private MinimapView minimapView; private MinimapView minimapView;
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
// ----- Hotkeys ---------------------------------------------------- // ----- Hotkeys ----------------------------------------------------
// //
@ -156,10 +160,15 @@ namespace TD.UI
private readonly struct HotkeyBinding private readonly struct HotkeyBinding
{ {
public readonly Key Key; public readonly Key Key;
public readonly VisualElement Button; // for enabledSelf gating public readonly VisualElement Button; // for enabledSelf gating
public readonly System.Action Action; public readonly System.Action Action;
public HotkeyBinding(Key k, VisualElement b, System.Action a) public HotkeyBinding(Key k, VisualElement b, System.Action a)
{ Key = k; Button = b; Action = a; } {
Key = k;
Button = b;
Action = a;
}
} }
// ----- Static hit-test probe -------------------------------------- // ----- Static hit-test probe --------------------------------------
@ -255,6 +264,7 @@ namespace TD.UI
// Cache element references — log a warning for any that are missing // Cache element references — log a warning for any that are missing
// so UXML/USS mismatches surface immediately. // so UXML/USS mismatches surface immediately.
goldLabel = Require<Label>(root, "gold-label"); goldLabel = Require<Label>(root, "gold-label");
waveLabel = Require<Label>(root, "wave-label"); waveLabel = Require<Label>(root, "wave-label");
livesLabel = Require<Label>(root, "lives-label"); livesLabel = Require<Label>(root, "lives-label");
@ -273,14 +283,15 @@ namespace TD.UI
if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build); if (tabBuild != null) tabBuild.clicked += () => SwitchTab(CommandTab.Build);
if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint); if (tabPaint != null) tabPaint.clicked += () => SwitchTab(CommandTab.Paint);
buildProgressContainer = Require<VisualElement>(root, "build-progress"); buildProgressContainer = Require<VisualElement>(root, "build-progress");
buildProgressFill = Require<VisualElement>(root, "build-progress-fill"); buildProgressFill = Require<VisualElement>(root, "build-progress-fill");
buildProgressPercent = Require<Label>(root, "build-progress-percent"); buildProgressPercent = Require<Label>(root, "build-progress-percent");
ttTitle = Require<Label>(root, "tt-title"); ttTitle = Require<Label>(root, "tt-title");
ttDesc = Require<Label>(root, "tt-desc"); ttDesc = Require<Label>(root, "tt-desc");
ttStats = Require<Label>(root, "tt-stats"); ttStats = Require<Label>(root, "tt-stats");
ttCost = Require<Label>(root, "tt-cost"); ttCost = Require<Label>(root, "tt-cost");
rejectionLabel = Require<Label>(root, "rejection-label"); rejectionLabel = Require<Label>(root, "rejection-label");
// Map area and its transparent ancestors must not consume pointer // Map area and its transparent ancestors must not consume pointer
// events so clicks reach the 3D scene underneath. The bottom-ui is now // events so clicks reach the 3D scene underneath. The bottom-ui is now
@ -340,8 +351,8 @@ namespace TD.UI
private void OnEnable() private void OnEnable()
{ {
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage; TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
WaveManager.OnLifeLost += HandleLifeLost; WaveManager.OnLifeLost += HandleLifeLost;
ChatService.OnMessageReceived += HandleChatMessage; ChatService.OnMessageReceived += HandleChatMessage;
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does // Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
// not guarantee Awake/OnEnable ordering across objects), Start will retry. // not guarantee Awake/OnEnable ordering across objects), Start will retry.
TrySubscribeSelection(); TrySubscribeSelection();
@ -350,13 +361,14 @@ namespace TD.UI
private void OnDisable() private void OnDisable()
{ {
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage; TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
WaveManager.OnLifeLost -= HandleLifeLost; WaveManager.OnLifeLost -= HandleLifeLost;
ChatService.OnMessageReceived -= HandleChatMessage; ChatService.OnMessageReceived -= HandleChatMessage;
if (selectionSubscribed && SelectionState.Instance != null) if (selectionSubscribed && SelectionState.Instance != null)
{ {
SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged; SelectionState.Instance.OnSelectionChanged -= HandleSelectionChanged;
selectionSubscribed = false; selectionSubscribed = false;
} }
if (matchStateSubscribed && MatchState.Instance != null) if (matchStateSubscribed && MatchState.Instance != null)
{ {
MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged; MatchState.Instance.OnPhaseChanged -= HandlePhaseChanged;
@ -421,6 +433,7 @@ namespace TD.UI
buildProgressFill.style.width = buildProgressFill.style.width =
new StyleLength(new Length(progress * 100f, LengthUnit.Percent)); new StyleLength(new Length(progress * 100f, LengthUnit.Percent));
} }
if (buildProgressPercent != null) if (buildProgressPercent != null)
{ {
buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%"; buildProgressPercent.text = $"{Mathf.RoundToInt(progress * 100f)}%";
@ -608,8 +621,8 @@ namespace TD.UI
nameLabel.style.color = PlayerColors.Get(pms.Slot); nameLabel.style.color = PlayerColors.Get(pms.Slot);
nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold; nameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
nameLabel.style.fontSize = 10; nameLabel.style.fontSize = 10;
nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line nameLabel.style.whiteSpace = WhiteSpace.NoWrap; // keep name on one line
nameLabel.style.flexGrow = 1; // takes all leftover width nameLabel.style.flexGrow = 1; // takes all leftover width
nameLabel.style.flexShrink = 1; nameLabel.style.flexShrink = 1;
nameLabel.style.overflow = Overflow.Hidden; nameLabel.style.overflow = Overflow.Hidden;
nameLabel.style.textOverflow = TextOverflow.Ellipsis; nameLabel.style.textOverflow = TextOverflow.Ellipsis;
@ -653,6 +666,7 @@ namespace TD.UI
// gated by the lobby), but the values do. // gated by the lobby), but the values do.
private static readonly System.Text.StringBuilder s_scoreboardSigBuf = private static readonly System.Text.StringBuilder s_scoreboardSigBuf =
new System.Text.StringBuilder(64); new System.Text.StringBuilder(64);
private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players) private string ComputeScoreboardSignature(System.Collections.Generic.List<PlayerMatchState> players)
{ {
s_scoreboardSigBuf.Clear(); s_scoreboardSigBuf.Clear();
@ -662,12 +676,14 @@ namespace TD.UI
var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId); var gm = PlayerGoldManager.GetForClient(pms.OwnerClientId);
int gold = gm != null ? gm.CurrentGold : 0; int gold = gm != null ? gm.CurrentGold : 0;
int leaks = (wm != null && pms.Slot != PlayerSlot.None) int leaks = (wm != null && pms.Slot != PlayerSlot.None)
? wm.GetZoneLeakCount(pms.Slot) : 0; ? wm.GetZoneLeakCount(pms.Slot)
: 0;
s_scoreboardSigBuf.Append((int)pms.Slot).Append(':') s_scoreboardSigBuf.Append((int)pms.Slot).Append(':')
.Append(pms.DisplayName ?? string.Empty).Append(':') .Append(pms.DisplayName ?? string.Empty).Append(':')
.Append(gold).Append(':') .Append(gold).Append(':')
.Append(leaks).Append(';'); .Append(leaks).Append(';');
} }
return s_scoreboardSigBuf.ToString(); return s_scoreboardSigBuf.ToString();
} }
@ -675,7 +691,7 @@ namespace TD.UI
private const int GRID_COLS = 5; private const int GRID_COLS = 5;
private const int GRID_ROWS = 3; private const int GRID_ROWS = 3;
private const int GRID_MAX = GRID_COLS * GRID_ROWS; private const int GRID_MAX = GRID_COLS * GRID_ROWS;
// First-time check that TowerPlacementManager exists. Once ready, populates the // First-time check that TowerPlacementManager exists. Once ready, populates the
// grid for the current selection. Subsequent populates flow through // grid for the current selection. Subsequent populates flow through
@ -685,7 +701,7 @@ namespace TD.UI
if (placementManager == null) if (placementManager == null)
placementManager = TowerPlacementManager.Instance; placementManager = TowerPlacementManager.Instance;
if (placementManager == null) return; // not spawned yet — retry next frame if (placementManager == null) return; // not spawned yet — retry next frame
placementManagerReady = true; placementManagerReady = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject); PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
@ -700,7 +716,7 @@ namespace TD.UI
private void PopulateGridForSelection(ISelectable selection) private void PopulateGridForSelection(ISelectable selection)
{ {
if (commandGrid == null) return; if (commandGrid == null) return;
if (!placementManagerReady) return; // deferred to TryReadyPlacementManager if (!placementManagerReady) return; // deferred to TryReadyPlacementManager
// Selection changed — invalidate the previous frame's hotkey bindings // Selection changed — invalidate the previous frame's hotkey bindings
// before creating new ones. Without this, stale buttons from a previous // before creating new ones. Without this, stale buttons from a previous
@ -710,8 +726,8 @@ namespace TD.UI
// Decide whether any actions exist for this selection. The action frame // Decide whether any actions exist for this selection. The action frame
// is hidden entirely when there are none — matches the WC3-style UX. // is hidden entirely when there are none — matches the WC3-style UX.
bool hasActions = selection is Builder bool hasActions = selection is Builder
|| selection is TowerInstance || selection is TowerInstance
|| selection is BuildSiteVisual; || selection is BuildSiteVisual;
if (actionFrame != null) if (actionFrame != null)
actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None; actionFrame.style.display = hasActions ? DisplayStyle.Flex : DisplayStyle.None;
@ -729,7 +745,7 @@ namespace TD.UI
RefreshTabActiveState(); RefreshTabActiveState();
commandGrid.Clear(); commandGrid.Clear();
if (!hasActions) return; // grid stays empty; frame is hidden anyway if (!hasActions) return; // grid stays empty; frame is hidden anyway
// Build the 15-cell action layout for the active selection kind. // Build the 15-cell action layout for the active selection kind.
var cells = new VisualElement[GRID_MAX]; var cells = new VisualElement[GRID_MAX];
@ -754,12 +770,14 @@ namespace TD.UI
cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]); cells[i] = CreateTowerButton(def, typeId, HotkeyLayout[i]);
i++; i++;
} }
} }
cells[GRID_MAX - 1] = CreateBuffMenuButton(HotkeyLayout[GRID_MAX - 1]);
} }
else if (selection is TowerInstance tower) else if (selection is TowerInstance tower)
{ {
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B). // WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]); cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]); cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
} }
else if (selection is BuildSiteVisual bsv) else if (selection is BuildSiteVisual bsv)
@ -908,6 +926,7 @@ namespace TD.UI
costLabel.pickingMode = PickingMode.Ignore; costLabel.pickingMode = PickingMode.Ignore;
btn.Add(costLabel); btn.Add(costLabel);
} }
return btn; return btn;
} }
@ -917,9 +936,12 @@ namespace TD.UI
private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey) private VisualElement CreateUpgradeButton(TowerInstance tower, Key hotkey)
{ {
var btn = CreateActionButton( var btn = CreateActionButton(
costText: "", // tier cost unknown until upgrade system lands costText: "", // tier cost unknown until upgrade system lands
hotkey: hotkey, hotkey: hotkey,
onClick: () => { /* TODO: upgrade flow */ }); onClick: () =>
{
/* TODO: upgrade flow */
});
btn.SetEnabled(false); btn.SetEnabled(false);
return btn; return btn;
} }
@ -931,12 +953,23 @@ namespace TD.UI
: 0; : 0;
var btn = CreateActionButton( var btn = CreateActionButton(
costText: sellValue > 0 ? $"+{sellValue}g" : "", costText: sellValue > 0 ? $"+{sellValue}g" : "",
hotkey: hotkey, hotkey: hotkey,
onClick: () => { /* TODO: sell flow */ }); onClick: () =>
{
/* TODO: sell flow */
});
btn.SetEnabled(false); btn.SetEnabled(false);
return btn; return btn;
} }
private VisualElement CreateBuffMenuButton(Key hotkey)
{
return CreateActionButton(
costText: "Buffs",
hotkey: hotkey,
onClick: () => ToggleBuffMenu());
}
// Cancel action for an in-progress build. Fires the owner-only RPC; the // Cancel action for an in-progress build. Fires the owner-only RPC; the
// server cancels the matching job (or, for shelved sites, refunds + despawns // server cancels the matching job (or, for shelved sites, refunds + despawns
// directly), full gold is refunded, the BuildSiteVisual is despawned, and // directly), full gold is refunded, the BuildSiteVisual is despawned, and