First pass at full refactor to 2.0 design

Restructures the game around the cyclical run loop from Game Design Doc V2:
5 waves = a cycle, 3 cycles = a phase, each phase ends in a boss.

- New TD.Gameplay.Waves: WaveGroup / PhaseDefinition / RunDefinition author the
  run as draggable weighted pools; RunState owns phase/cycle position, the drawn
  wave slots, and the per-slot enemy buff sets. WaveManager's flat wave array is
  gone -- it now only runs the encounter RunState points at.
- New TD.Gameplay.EnemyUpgrades: the post-wave enemy-buff vote, with public
  live-replicated ballots so the HUD can show who voted for what.
- Inter-wave flow is now strictly sequential: draft -> vote -> build, each stage
  ending early once every player has acted.
- Enemy abilities inverted from per-instance random rolls to deterministic,
  stacking per-wave-slot sets. Six cards ship: Split (reworked), Flight, Blink,
  No Bounty, Gold Theft, Double Up.
- Tower upgrades are a two-step tree: a draft pick unlocks a node, gold converts
  an already-placed tower in place.
- Boss encounters flag their enemies and drive a boss HP bar.
- Player cap reduced to 3 via MatchRules.MaxPlayers.
- GoldConfig is now keyed by global encounter number rather than wave index.

Compiles clean; NOT yet verified in-engine. Editor wiring still required --
see Docs/2.0_Setup_Checklist.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-30 17:45:11 -07:00
parent 7e5c3a8279
commit 4892d7253d
64 changed files with 4023 additions and 344 deletions

View file

@ -10,6 +10,8 @@ using TD.Core;
using TD.Gameplay;
using TD.Gameplay.BuilderSpells;
using TD.Gameplay.Draft;
using TD.Gameplay.EnemyUpgrades;
using TD.Gameplay.Waves;
using TD.Towers;
using TD.UI.Minimap;
@ -112,10 +114,34 @@ namespace TD.UI
// shown when no draft is pending so a paid roll can be bought any time.
private VisualElement draftPanel;
private VisualElement draftCardRow;
private Button draftBuyButton;
private bool draftSubscribed;
private PlayerDraft subscribedDraft;
// Enemy-buff vote overlay. Shares the draft overlay's position and card styling, but the
// cards are shared rather than per-player and carry live voter badges.
private VisualElement votePanel;
private VisualElement voteCardRow;
private Label voteTitle;
private bool voteSubscribed;
private WaveVote subscribedVote;
// Boss health bar. Shown only while a boss is alive; polled per frame like lives/gold
// rather than event-driven, since HP changes continuously anyway.
private VisualElement bossBarPanel;
private VisualElement bossHealthFill;
private Label bossHealthText;
private Label bossNameLabel;
// Reused when painting voter badges so a rebuild-per-ballot doesn't allocate a list per
// card. The HUD is single-threaded, so one shared buffer is safe.
private readonly System.Collections.Generic.List<PlayerSlot> voteVoterScratch
= new System.Collections.Generic.List<PlayerSlot>();
// Reused when building a selected tower's upgrade buttons.
private readonly System.Collections.Generic.List<(TowerDefinition Def, int TypeId)>
upgradeOptionScratch
= new System.Collections.Generic.List<(TowerDefinition, int)>();
// Spell hotbar (bottom-ui Section 6). Frame is visibility:hidden (but still occupies
// its reserved width, so the centered command bar never shifts) while the local player
// has no granted spells; rebuilt on grant (append-only, so this is rare). Cooldown
@ -201,6 +227,8 @@ namespace TD.UI
private bool matchStateSubscribed; // true once OnPhaseChanged is hooked
private bool deckSubscribed; // true once we've hooked the local PlayerTowerDeck.OnDeckChanged
private PlayerTowerDeck subscribedDeck; // the deck we hooked, so we can unsubscribe the same instance
private bool upgradesSubscribed; // true once we've hooked the local PlayerTowerUpgrades
private PlayerTowerUpgrades subscribedUpgrades;
private MinimapView minimapView;
private GameMenuView gameMenu; // gear button + modal menu overlay
private IPanel myPanel; // tracked separately so OnDestroy only clears the static if it still points at us
@ -330,6 +358,19 @@ namespace TD.UI
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
// Same idea for unlocked upgrade nodes: a draft pick can grant an upgrade while the
// player already has the target tower selected, and the new branch should appear in the
// action grid without needing a reselect.
private void TrySubscribeUpgrades()
{
var upgrades = PlayerTowerUpgrades.Local;
if (upgrades == null) return;
upgrades.OnUpgradesChanged += HandleDeckChanged;
subscribedUpgrades = upgrades;
upgradesSubscribed = true;
PopulateGridForSelection(SelectionState.Instance?.SelectedObject);
}
// Hook the local player's draft so the overlay rebuilds when options are offered,
// picked, or rerolled. Retried each Update until the local PlayerDraft exists.
private void TrySubscribeDraft()
@ -366,9 +407,9 @@ namespace TD.UI
// ----- Draft overlay ----------------------------------------------
// Non-modal draft UI: a card row floats at top-center while a draft is active, and
// a "Buy Roll" button shows when no draft is pending. The host panel ignores picks
// so empty space clicks through to the world — the player keeps building during prep.
// Non-modal draft UI: a card row floats at top-center while the local player has an
// unresolved offer. The host panel ignores picks so empty space clicks through to the
// world — the player can still pan and inspect their maze while deciding.
private void BuildDraftOverlay(VisualElement root)
{
draftPanel = new VisualElement();
@ -390,12 +431,8 @@ namespace TD.UI
draftCardRow.pickingMode = PickingMode.Ignore;
box.Add(draftCardRow);
draftBuyButton = new Button(() => PlayerDraft.Local?.RequestBuyRerollRpc())
{
text = "Buy Roll"
};
draftBuyButton.style.marginTop = 6;
box.Add(draftBuyButton);
// The "Buy Roll" button lived here. Removed with the extra-draft shop for the 2.0 MVP
// — see PlayerDraft's commented-out RequestBuyRerollRpc for why the backend was kept.
draftPanel.Add(box);
root.Add(draftPanel);
@ -474,35 +511,318 @@ namespace TD.UI
return card;
}
// Per-frame: toggles the card row vs. the buy button and keeps the buy button's
// label/enabled state in sync with gold. Cheap (mirrors the gold label's per-frame
// refresh); only allocates a string while the buy button is shown.
// Shows the draft row only while the local player has an unresolved offer. With the
// extra-draft shop gone there is nothing else in this panel, so it hides entirely once
// the player has picked — which also gives them a clear "I'm done, waiting on others"
// signal during the barrier.
private void UpdateDraftVisibility()
{
if (draftPanel == null) return;
var draft = PlayerDraft.Local;
var service = DraftService.Instance;
var gold = PlayerGoldManager.Local;
bool hasDraft = draft != null && draft.HasActiveDraft;
bool canShowBuy = draft != null && service != null && !hasDraft;
var draft = PlayerDraft.Local;
bool hasDraft = draft != null && draft.HasActiveDraft;
if (draftCardRow != null)
draftCardRow.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
if (draftBuyButton != null)
draftPanel.style.display = hasDraft ? DisplayStyle.Flex : DisplayStyle.None;
}
// ----- Enemy-buff vote overlay --------------------------------------
// Shared vote UI. Same anchor and card language as the draft row (they never show at the
// same time — the inter-wave stages are strictly sequential), but every peer sees the same
// three cards, and each card carries badges for the players currently voting for it.
private void BuildVoteOverlay(VisualElement root)
{
votePanel = new VisualElement();
votePanel.style.position = Position.Absolute;
votePanel.style.left = 0;
votePanel.style.right = 0;
votePanel.style.top = 70; // below the top bar, same as the draft row
votePanel.style.alignItems = Align.Center;
votePanel.pickingMode = PickingMode.Ignore;
votePanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.flexDirection = FlexDirection.Column;
box.style.alignItems = Align.Center;
box.pickingMode = PickingMode.Ignore;
voteTitle = new Label("Choose how this wave comes back");
voteTitle.style.fontSize = 15;
voteTitle.style.color = new Color(0.95f, 0.72f, 0.55f);
voteTitle.style.unityFontStyleAndWeight = FontStyle.Bold;
voteTitle.style.marginBottom = 6;
box.Add(voteTitle);
voteCardRow = new VisualElement();
voteCardRow.style.flexDirection = FlexDirection.Row;
voteCardRow.pickingMode = PickingMode.Ignore;
box.Add(voteCardRow);
votePanel.Add(box);
root.Add(votePanel);
UpdateVoteVisibility();
}
// Hook the shared vote so the panel rebuilds whenever a ballot lands. Retried each Update
// until the scene's WaveVote has network-spawned.
private void TrySubscribeVote()
{
var vote = WaveVote.Instance;
if (vote == null) return;
vote.OnVoteChanged += HandleVoteChanged;
subscribedVote = vote;
voteSubscribed = true;
RebuildVoteCards();
}
private void HandleVoteChanged()
{
RebuildVoteCards();
}
// Rebuilt on every ballot change, not just when the offered set changes — the voter
// badges are the point of the panel, so they have to track votes as they land.
private void RebuildVoteCards()
{
if (voteCardRow == null) return;
voteCardRow.Clear();
var vote = WaveVote.Instance;
var pool = EnemyUpgradePool.Instance;
if (vote != null && pool != null && vote.IsOpen)
{
draftBuyButton.style.display = canShowBuy ? DisplayStyle.Flex : DisplayStyle.None;
if (canShowBuy)
for (int i = 0; i < vote.OptionCount; i++)
{
int cost = service.RerollCost;
draftBuyButton.text = $"Buy Roll ({cost}g)";
draftBuyButton.SetEnabled(gold != null && gold.CurrentGold >= cost);
int id = vote.GetOptionId(i);
var option = pool.Get(id);
if (option == null) continue;
voteCardRow.Add(CreateVoteCard(option, id, vote));
}
}
draftPanel.style.display = (hasDraft || canShowBuy) ? DisplayStyle.Flex : DisplayStyle.None;
UpdateVoteVisibility();
}
private VisualElement CreateVoteCard(EnemyUpgradeOption option, int optionId, WaveVote vote)
{
var localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
bool isLocalChoice = localSlot != PlayerSlot.None
&& vote.GetBallot(localSlot) == optionId;
var card = new VisualElement();
card.style.width = 160;
card.style.marginLeft = card.style.marginRight = 4;
card.style.paddingTop = card.style.paddingBottom = 10;
card.style.paddingLeft = card.style.paddingRight = 10;
card.style.backgroundColor = new Color(0.13f, 0.09f, 0.09f, 0.96f);
card.style.borderTopWidth = card.style.borderBottomWidth =
card.style.borderLeftWidth = card.style.borderRightWidth = 2;
// The local player's current choice gets a bright border so they can tell at a glance
// which one is theirs among everyone else's badges.
var border = isLocalChoice
? new Color(0.95f, 0.72f, 0.35f)
: new Color(0.45f, 0.30f, 0.30f);
card.style.borderTopColor = card.style.borderBottomColor =
card.style.borderLeftColor = card.style.borderRightColor = border;
card.style.alignItems = Align.Center;
var title = new Label(option.DisplayName);
title.style.fontSize = 14;
title.style.color = Color.white;
title.style.unityFontStyleAndWeight = FontStyle.Bold;
title.style.whiteSpace = WhiteSpace.Normal;
title.style.unityTextAlign = TextAnchor.MiddleCenter;
title.style.marginBottom = 6;
card.Add(title);
var iconSprite = option.ResolveIcon();
if (iconSprite != null)
{
var img = new Image { sprite = iconSprite };
img.style.width = 48;
img.style.height = 48;
img.style.marginBottom = 6;
card.Add(img);
}
var desc = new Label(option.Description ?? "");
desc.style.fontSize = 11;
desc.style.color = new Color(0.82f, 0.82f, 0.82f);
desc.style.whiteSpace = WhiteSpace.Normal;
desc.style.unityTextAlign = TextAnchor.MiddleCenter;
desc.style.marginBottom = 8;
card.Add(desc);
// Live voter badges — one per player currently voting for this card, in their own
// player color. This row is why the panel rebuilds on every ballot change.
var badgeRow = new VisualElement();
badgeRow.style.flexDirection = FlexDirection.Row;
badgeRow.style.justifyContent = Justify.Center;
badgeRow.style.minHeight = 24;
badgeRow.style.marginBottom = 6;
voteVoterScratch.Clear();
vote.GetVotersFor(optionId, voteVoterScratch);
foreach (var voter in voteVoterScratch)
badgeRow.Add(CreateVoterBadge(voter));
card.Add(badgeRow);
var pick = new Button(() => WaveVote.Instance?.RequestVoteRpc(optionId))
{
text = isLocalChoice ? "Voted" : "Vote"
};
card.Add(pick);
return card;
}
// Small filled circle carrying the player's slot number, in their canonical player color.
// Same visual vocabulary as the race-selection overlay's picker badges, so players read it
// as "who" without a legend.
private VisualElement CreateVoterBadge(PlayerSlot slot)
{
var badge = new Label(((int)slot).ToString());
badge.style.width = 20;
badge.style.height = 20;
badge.style.marginLeft = badge.style.marginRight = 2;
badge.style.backgroundColor = PlayerColors.Get(slot);
badge.style.color = Color.black;
badge.style.fontSize = 11;
badge.style.unityFontStyleAndWeight = FontStyle.Bold;
badge.style.unityTextAlign = TextAnchor.MiddleCenter;
badge.style.borderTopLeftRadius = badge.style.borderTopRightRadius =
badge.style.borderBottomLeftRadius = badge.style.borderBottomRightRadius = 10;
return badge;
}
private void UpdateVoteVisibility()
{
if (votePanel == null) return;
var vote = WaveVote.Instance;
bool open = vote != null && vote.IsOpen && vote.OptionCount > 0;
votePanel.style.display = open ? DisplayStyle.Flex : DisplayStyle.None;
}
// ----- Boss bar -----------------------------------------------------
// Prominent health bar for the phase boss. Hidden whenever no boss is alive, so it costs
// nothing outside a boss encounter.
private void BuildBossBar(VisualElement root)
{
bossBarPanel = new VisualElement();
bossBarPanel.style.position = Position.Absolute;
bossBarPanel.style.left = 0;
bossBarPanel.style.right = 0;
bossBarPanel.style.top = 70;
bossBarPanel.style.alignItems = Align.Center;
bossBarPanel.pickingMode = PickingMode.Ignore;
bossBarPanel.style.display = DisplayStyle.None;
var box = new VisualElement();
box.style.width = 440;
box.style.alignItems = Align.Center;
box.pickingMode = PickingMode.Ignore;
bossNameLabel = new Label("BOSS");
bossNameLabel.style.fontSize = 18;
bossNameLabel.style.color = new Color(0.95f, 0.45f, 0.4f);
bossNameLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
bossNameLabel.style.marginBottom = 3;
box.Add(bossNameLabel);
var track = new VisualElement();
track.style.width = 440;
track.style.height = 18;
track.style.backgroundColor = new Color(0.08f, 0.06f, 0.06f, 0.95f);
track.style.borderTopWidth = track.style.borderBottomWidth =
track.style.borderLeftWidth = track.style.borderRightWidth = 2;
var trackBorder = new Color(0.5f, 0.25f, 0.22f);
track.style.borderTopColor = track.style.borderBottomColor =
track.style.borderLeftColor = track.style.borderRightColor = trackBorder;
track.pickingMode = PickingMode.Ignore;
bossHealthFill = new VisualElement();
bossHealthFill.style.height = Length.Percent(100);
bossHealthFill.style.width = Length.Percent(100);
bossHealthFill.style.backgroundColor = new Color(0.75f, 0.18f, 0.16f);
bossHealthFill.pickingMode = PickingMode.Ignore;
track.Add(bossHealthFill);
bossHealthText = new Label("");
bossHealthText.style.position = Position.Absolute;
bossHealthText.style.left = 0;
bossHealthText.style.right = 0;
bossHealthText.style.top = 0;
bossHealthText.style.bottom = 0;
bossHealthText.style.fontSize = 12;
bossHealthText.style.color = Color.white;
bossHealthText.style.unityTextAlign = TextAnchor.MiddleCenter;
bossHealthText.pickingMode = PickingMode.Ignore;
track.Add(bossHealthText);
box.Add(track);
bossBarPanel.Add(box);
root.Add(bossBarPanel);
}
// Polled from the per-frame refresh, like lives and gold. Picks the boss in the local
// player's own zone when there is one — the boss encounter spawns one per zone, and the
// one actually walking through your maze is the one you need to watch.
private void RefreshBossBar()
{
if (bossBarPanel == null) return;
var boss = ResolveDisplayedBoss();
if (boss == null)
{
bossBarPanel.style.display = DisplayStyle.None;
return;
}
bossBarPanel.style.display = DisplayStyle.Flex;
float max = Mathf.Max(1f, boss.MaxHp);
float pct = Mathf.Clamp01(boss.CurrentHp / max);
bossHealthFill.style.width = Length.Percent(pct * 100f);
bossHealthText.text = $"{Mathf.CeilToInt(boss.CurrentHp)} / {Mathf.CeilToInt(max)}";
bossNameLabel.text = boss.DisplayName;
}
private EnemyHealth ResolveDisplayedBoss()
{
var bosses = EnemyHealth.ActiveBosses;
if (bosses.Count == 0) return null;
var localSlot = PlayerMatchState.Local != null
? PlayerMatchState.Local.Slot
: PlayerSlot.None;
EnemyHealth fallback = null;
for (int i = 0; i < bosses.Count; i++)
{
var boss = bosses[i];
if (boss == null || boss.IsDead) continue;
fallback ??= boss;
if (localSlot != PlayerSlot.None)
{
var movement = boss.GetComponent<EnemyMovement>();
if (movement != null && movement.OriginZone == localSlot) return boss;
}
}
return fallback;
}
// ----- Spell hotbar -------------------------------------------------
@ -715,9 +1035,17 @@ namespace TD.UI
BuildMatchEndOverlay(root);
// Build the draft overlay (roguelike between-waves draft). Hidden until the
// local player has an active draft (or to show the paid "Buy Roll" button).
// local player has an active draft.
BuildDraftOverlay(root);
// Build the shared enemy-buff vote overlay. Hidden until a vote is open; never shows
// at the same time as the draft row, since the inter-wave stages run in sequence.
BuildVoteOverlay(root);
// Boss health bar. Shares the top-centre anchor with the draft/vote rows, which is
// safe because a boss is only ever alive once the inter-wave stages have finished.
BuildBossBar(root);
// Chat feed + input. Anchored bottom-left, just above the portrait/bottom-ui bar.
// Player typing toggled with Enter; system messages (e.g. life lost) post via
// ChatService.PostLocalSystem on every peer.
@ -777,6 +1105,13 @@ namespace TD.UI
deckSubscribed = false;
subscribedDeck = null;
if (upgradesSubscribed && subscribedUpgrades != null)
{
subscribedUpgrades.OnUpgradesChanged -= HandleDeckChanged;
}
upgradesSubscribed = false;
subscribedUpgrades = null;
if (draftSubscribed && subscribedDraft != null)
{
subscribedDraft.OnDraftChanged -= HandleDraftChanged;
@ -784,6 +1119,13 @@ namespace TD.UI
draftSubscribed = false;
subscribedDraft = null;
if (voteSubscribed && subscribedVote != null)
{
subscribedVote.OnVoteChanged -= HandleVoteChanged;
}
voteSubscribed = false;
subscribedVote = null;
if (spellLoadoutSubscribed && subscribedSpellLoadout != null)
{
subscribedSpellLoadout.OnLoadoutChanged -= HandleSpellLoadoutChanged;
@ -813,9 +1155,15 @@ namespace TD.UI
if (!deckSubscribed)
TrySubscribeDeck();
if (!upgradesSubscribed)
TrySubscribeUpgrades();
if (!draftSubscribed)
TrySubscribeDraft();
if (!voteSubscribed)
TrySubscribeVote();
if (!spellLoadoutSubscribed)
TrySubscribeSpellLoadout();
@ -992,27 +1340,40 @@ namespace TD.UI
if (livesLabel != null)
livesLabel.text = ms != null ? $"lives: {ms.Lives}" : "lives: --";
RefreshBossBar();
// Run progress reads "Phase 1 · Cycle 2 · Wave 3/5" (or "· BOSS"). RunState builds
// the string since it owns the phase/cycle/slot maths; the HUD just displays it.
if (waveLabel != null)
{
int total = wm?.TotalWaves ?? 0;
waveLabel.text = ms != null && ms.CurrentWave > 0 && total > 0
? $"Wave {ms.CurrentWave} / {total}"
var run = RunState.Instance;
waveLabel.text = run != null && ms != null && ms.CurrentWave > 0
? run.ProgressLabel
: "Wave --";
}
// Next-wave countdown. Shows during prep ("next: 0:12") and clears the
// moment the wave actually starts spawning. WaveManager.PrepCountdown
// Inter-wave countdown. One timer serves all three stages (they never overlap), so
// the label is prefixed with which stage is running — "draft: 0:12", "vote: 0:08",
// "build: 0:15". Clears the moment the wave starts spawning. WaveManager.PrepCountdown
// is networked so this reads the same value on every peer.
if (nextWaveLabel != null)
{
float t = wm != null ? wm.PrepCountdown : 0f;
if (t > 0f)
{
string stage = wm.CurrentInterWaveStage switch
{
InterWaveStage.Draft => "draft",
InterWaveStage.Vote => "vote",
InterWaveStage.Build => "build",
_ => "next",
};
// Ceiling so the user sees a full "0:01" tick before "0:00".
int seconds = Mathf.CeilToInt(t);
int mm = seconds / 60;
int ss = seconds % 60;
nextWaveLabel.text = $"next: {mm}:{ss:00}";
nextWaveLabel.text = $"{stage}: {mm}:{ss:00}";
}
else
{
@ -1276,8 +1637,27 @@ namespace TD.UI
}
else if (selection is TowerInstance tower)
{
// WC3 layout convention: primary action top-left (Q), sell bottom-right (B).
cells[0] = CreateUpgradeButton(tower, HotkeyLayout[0]);
// WC3 layout convention: primary actions top-left (Q onward), sell bottom-right.
// One button per unlocked upgrade branch rather than a single generic "Upgrade" —
// a tree node can have several children, and a lone button couldn't express the
// choice between them without an extra submenu layer.
upgradeOptionScratch.Clear();
var localClientId = NetworkManager.Singleton != null
? NetworkManager.Singleton.LocalClientId
: 0UL;
bool ownsTower = PlayerMatchState.Local != null
&& tower.Owner == PlayerMatchState.Local.Slot;
if (ownsTower)
tower.CollectAvailableUpgrades(localClientId, upgradeOptionScratch);
int upgradeSlots = Mathf.Min(upgradeOptionScratch.Count, GRID_MAX - 1);
for (int i = 0; i < upgradeSlots; i++)
{
var (def, typeId) = upgradeOptionScratch[i];
cells[i] = CreateUpgradeButton(tower, def, typeId, HotkeyLayout[i]);
}
cells[GRID_MAX - 1] = CreateSellButton(tower, HotkeyLayout[GRID_MAX - 1]);
}
else if (selection is BuildSiteVisual bsv)
@ -1439,19 +1819,35 @@ namespace TD.UI
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)
// One button per unlocked upgrade branch on the selected tower. A tree node can have
// several children, so a single generic "Upgrade" button couldn't express the choice
// between them without an extra submenu layer — the grid already has the slots.
private VisualElement CreateUpgradeButton(TowerInstance tower, TowerDefinition target,
int targetTypeId, Key hotkey)
{
int cost = TowerInstance.GetUpgradeCost(target);
var gold = PlayerGoldManager.Local;
bool affordable = gold != null && gold.CurrentGold >= cost;
var btn = CreateActionButton(
costText: "", // tier cost unknown until upgrade system lands
costText: cost > 0 ? $"{cost}g" : "",
hotkey: hotkey,
onClick: () =>
{
/* TODO: upgrade flow */
});
btn.SetEnabled(false);
onClick: () => tower.RequestUpgradeServerRpc(targetTypeId));
if (target.Icon != null)
{
var img = new Image { sprite = target.Icon };
img.AddToClassList("cmd-icon");
img.pickingMode = PickingMode.Ignore;
btn.Insert(0, img);
}
btn.tooltip = $"Upgrade to {target.DisplayName}";
// Shown but disabled when unaffordable, rather than hidden — a player saving toward
// an upgrade should be able to see the price. Disabling also makes the hotkey handler
// skip it, since that gates on enabledSelf.
btn.SetEnabled(affordable);
return btn;
}
@ -1659,8 +2055,7 @@ namespace TD.UI
// Bounty is per-wave now (GoldConfig.Waves[N].GoldPerEnemy) rather than
// per-enemy-type. Read the current wave's value so the tooltip is accurate.
var wm = WaveManager.Instance;
int currentWave = MatchState.Instance != null ? MatchState.Instance.CurrentWave : 0;
var goldEntry = wm?.GoldConfig?.GetWaveEntry(currentWave);
var goldEntry = wm?.GoldConfig?.GetWaveEntry(wm.CurrentEncounterNumber);
if (goldEntry != null)
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
// (Weaknesses/resistances will go here once the resistance system lands.)