Scale difficulty by run position; wipe towers at phase end

Follow-up pass on the 2.0 refactor, closing the gap between "waves are drawn
at random" and "difficulty still lives in the enemy assets".

- Enemy health is no longer authored per enemy type. New EnemyScalingConfig
  scales a per-zone HP *budget* by encounter number; per-enemy health is that
  budget divided by the wave's enemy count. Scaling the total rather than the
  per-enemy value keeps enemy count a texture knob (few tanks vs many swarmers)
  instead of a second, uncontrolled difficulty axis. EnemyDefinition.MaxHp
  becomes HpMultiplier, a deviation around 1.0. Speed stays archetype-only and
  unscaled -- escalating it compounds with health and invalidates tower balance
  mid-run.
- GoldConfig entries no longer reference a WaveDefinition. Payout is a property
  of run position, not of which wave got drawn: WaveGoldEntry ->
  EncounterGoldEntry, Waves -> Encounters, keyed by global encounter number.
  Its inspector labels elements "Encounter N" and projects cumulative earnings.
- The wave's voted buffs now show as icon badges in the top bar, read from the
  same RunState slot the spawn path uses so the display can't drift from what
  the enemies actually carry. Hover names the buff; unillustrated cards fall
  back to a lettered badge rather than vanishing.
- Clearing a phase's boss destroys every built tower, unrefunded. Queued build
  jobs still refund -- those towers were never delivered. Runs with the field
  empty, so the walkability churn doesn't hit the re-path scheduler.
- Fixed an RPC codegen break: [ClientRpc] requires a ClientRpc suffix, unlike
  the newer [Rpc(SendTo...)] style this file doesn't use.
- Setup checklist reordered into dependency order; it previously asked for a
  RunDefinition two sections before creating one.

Also carries the editor-side asset reorganisation into Definitions/RunDefinitions
and the sprite move into Enemy/Player draft icon folders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Matt F 2026-07-31 00:32:33 -07:00
parent 4892d7253d
commit 16706a1ecf
154 changed files with 2608 additions and 287 deletions

View file

@ -77,6 +77,19 @@ namespace TD.UI
private Label nextWaveLabel; // prep countdown ("next: 0:12")
private Label leakedLabel; // local player's origin-leak count ("leaked: 3")
private Label incomeLabel; // top-bar per-wave gold-earned counter ("+150 g/wave")
// Top-bar row of buff badges for the wave currently spawning. Rebuilt only when the
// underlying set changes — see waveBuffSignature.
private VisualElement waveBuffRow;
// Cheap change-detector for the buff row. RefreshTopBar runs every frame, and rebuilding
// a handful of VisualElements at 60 Hz would churn the UI system for no reason. Encodes
// the slot plus the option ids on it, so it changes when the wave advances OR when a vote
// lands on the slot the players are about to face again.
private int waveBuffSignature = int.MinValue;
private readonly System.Collections.Generic.List<int> waveBuffScratch
= new System.Collections.Generic.List<int>();
private VisualElement playerListContainer; // right-panel scoreboard rows
private Label portraitName;
private Label levelLabel;
@ -977,6 +990,7 @@ namespace TD.UI
nextWaveLabel = Require<Label>(root, "next-wave-label");
leakedLabel = Require<Label>(root, "leaked-label");
incomeLabel = Require<Label>(root, "income-label");
waveBuffRow = Require<VisualElement>(root, "wave-buff-row");
playerListContainer = Require<VisualElement>(root, "player-list");
portraitName = Require<Label>(root, "portrait-name");
levelLabel = Require<Label>(root, "level-label");
@ -1075,6 +1089,7 @@ namespace TD.UI
{
TowerPlacementController.OnRejectionMessageReady += ShowRejectionMessage;
WaveManager.OnLifeLost += HandleLifeLost;
WaveManager.OnTowersWiped += HandleTowersWiped;
ChatService.OnMessageReceived += HandleChatMessage;
// Try to subscribe now; if SelectionState.Awake hasn't run yet (Unity does
// not guarantee Awake/OnEnable ordering across objects), Start will retry.
@ -1085,6 +1100,7 @@ namespace TD.UI
{
TowerPlacementController.OnRejectionMessageReady -= ShowRejectionMessage;
WaveManager.OnLifeLost -= HandleLifeLost;
WaveManager.OnTowersWiped -= HandleTowersWiped;
ChatService.OnMessageReceived -= HandleChatMessage;
if (selectionSubscribed && SelectionState.Instance != null)
{
@ -1352,6 +1368,8 @@ namespace TD.UI
: "Wave --";
}
RefreshWaveBuffRow();
// 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
@ -1408,6 +1426,89 @@ namespace TD.UI
RefreshScoreboard();
}
// ----- Wave buff badges --------------------------------------------
/// <summary>
/// Rebuilds the top-bar badges showing which buffs the wave currently spawning carries.
/// </summary>
/// <remarks>
/// <b>Reads the same slot the spawn path does.</b> The source is
/// <c>RunState.CurrentUpgradeSlot</c> — exactly what <c>WaveManager.ResolveCurrentWaveAbilities</c>
/// uses to decide what the enemies actually get. Coupling them to one accessor means the
/// display can't drift from the truth: if they ever disagree that's a run-state bug, not a
/// HUD bug.
///
/// <para>Purely client-side — <c>RunState</c>'s upgrade list is a replicated NetworkList,
/// so every peer resolves the same icons without an RPC.</para>
/// </remarks>
private void RefreshWaveBuffRow()
{
if (waveBuffRow == null) return;
var run = RunState.Instance;
var pool = EnemyUpgradePool.Instance;
if (run == null || pool == null)
{
if (waveBuffSignature != int.MinValue)
{
waveBuffRow.Clear();
waveBuffSignature = int.MinValue;
}
return;
}
int slot = run.CurrentUpgradeSlot;
run.GetUpgradesForSlot(slot, waveBuffScratch);
// Order-sensitive hash of slot + ids. Buffs are appended in vote order and never
// removed, so this changes on exactly the two events that matter: the run advancing
// to a different wave, and a new buff landing on this one.
int signature = slot * 397;
for (int i = 0; i < waveBuffScratch.Count; i++)
signature = signature * 31 + waveBuffScratch[i];
if (signature == waveBuffSignature) return;
waveBuffSignature = signature;
waveBuffRow.Clear();
foreach (int id in waveBuffScratch)
{
var option = pool.Get(id);
if (option == null) continue;
waveBuffRow.Add(CreateWaveBuffBadge(option));
}
}
// One badge. Falls back to the card's initial when no icon is authored, so an
// unillustrated buff still shows up and can be counted rather than silently vanishing.
private VisualElement CreateWaveBuffBadge(EnemyUpgradeOption option)
{
var sprite = option.ResolveIcon();
VisualElement badge;
if (sprite != null)
{
badge = new Image { sprite = sprite };
}
else
{
string name = option.DisplayName;
badge = new Label(string.IsNullOrEmpty(name) ? "?" : name.Substring(0, 1).ToUpperInvariant());
badge.AddToClassList("wave-buff-fallback");
}
badge.AddToClassList("wave-buff-icon");
// Hovering names the buff. Without this the row is a puzzle — players see three
// unfamiliar glyphs and no way to learn what they mean mid-wave.
badge.tooltip = string.IsNullOrEmpty(option.Description)
? option.DisplayName
: $"{option.DisplayName}\n{option.Description}";
return badge;
}
// ----- Scoreboard --------------------------------------------------
// Snapshot of last-rebuilt scoreboard state so we only rebuild when something
@ -2052,10 +2153,10 @@ namespace TD.UI
if (def != null)
{
AddStatLine($"Speed: {def.MoveSpeed:0.0}");
// 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.
// Bounty is per-encounter now (GoldConfig.Encounters[N].GoldPerEnemy) rather than
// per-enemy-type. Read the current encounter's value so the tooltip is accurate.
var wm = WaveManager.Instance;
var goldEntry = wm?.GoldConfig?.GetWaveEntry(wm.CurrentEncounterNumber);
var goldEntry = wm?.GoldConfig?.GetEncounterEntry(wm.CurrentEncounterNumber);
if (goldEntry != null)
AddStatLine($"Bounty: {goldEntry.GoldPerEnemy} g");
// (Weaknesses/resistances will go here once the resistance system lands.)
@ -2544,6 +2645,16 @@ namespace TD.UI
ChatService.PostLocalSystem(text);
}
// Fires once when a phase ends and the board is cleared. Uses the same system-message
// channel as life loss so it lands where players are already looking for run events —
// and, more importantly, so an entire maze vanishing has a stated cause.
private void HandleTowersWiped(int destroyedCount)
{
ChatService.PostLocalSystem(destroyedCount == 1
? "Phase complete — 1 tower lost to the collapse. No refunds."
: $"Phase complete — {destroyedCount} towers lost to the collapse. No refunds.");
}
// ----- Match-end overlay (Victory / Defeat + Retry) ---------------
private void BuildMatchEndOverlay(VisualElement root)