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:
parent
7e5c3a8279
commit
4892d7253d
64 changed files with 4023 additions and 344 deletions
|
|
@ -74,6 +74,11 @@ namespace TD.UI
|
|||
public void SpawnGoldReward(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"+{amount}", goldColor);
|
||||
|
||||
/// <summary>Spawns a gold-loss popup (e.g. "-15") at the given world position. Same colour
|
||||
/// as a reward so it reads as gold; the sign carries the meaning.</summary>
|
||||
public void SpawnGoldLoss(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"-{amount}", goldColor);
|
||||
|
||||
/// <summary>Spawns a life-loss popup (e.g. "-1") at the given world position.</summary>
|
||||
public void SpawnLifeLoss(Vector3 worldPos, int amount)
|
||||
=> SpawnInternal(worldPos, $"-{amount}", livesColor);
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue