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

@ -388,6 +388,76 @@ namespace TD.Gameplay
spawner.Play(worldPos);
}
// ----- Phase teardown ---------------------------------------------
/// <summary>
/// Server-only: destroys every built tower on the map, refunding nothing. Called when a
/// phase ends so the next phase starts from bare ground.
/// </summary>
/// <returns>How many towers were destroyed.</returns>
/// <remarks>
/// <b>Deliberately not the sell path.</b> Selling refunds gold, plays the coin VFX and is
/// owner-gated; none of that applies here. This is confiscation, not a transaction — the
/// maze players spent a whole phase building is the price of the boss going down.
///
/// <para><b>Cleanup rides on despawn.</b> <see cref="TowerInstance.OnNetworkDespawn"/>
/// already un-stamps the footprint, deregisters from the minimap and clears any local
/// selection pointing at it, so this method only has to despawn. Shelved
/// <see cref="BuildSiteVisual"/>s self-clean the same way; queued and in-progress jobs are
/// cancelled through their own <c>Builder</c>, which restores their footprint tiles.</para>
///
/// <para><b>Queued jobs DO refund.</b> Those towers were never delivered, so eating the
/// gold would punish players for having a build queued when the boss happened to die. The
/// no-refund rule applies to towers that got built.</para>
///
/// <para><b>Timing matters for performance.</b> Every despawn fires a walkability change,
/// and a full maze is a lot of them. This runs with the field empty — the boss is dead and
/// the next wave hasn't spawned — so no enemy is registered with the re-path scheduler and
/// the recompute storm that would cause mid-wave never happens.</para>
/// </remarks>
public int ServerDestroyAllTowers()
{
if (!IsServer) return 0;
// Cancel build queues first. This despawns their non-shelved visuals and frees the
// footprint tiles they had reserved, so the sweep below only has to deal with what's
// left standing.
foreach (var pms in PlayerMatchState.AllPlayers)
{
var builder = Builder.GetForClient(pms.OwnerClientId);
builder?.ServerCancelAllJobs();
}
var spawnManager = NetworkManager.Singleton?.SpawnManager;
if (spawnManager == null) return 0;
// Snapshot before iterating — despawning mutates the live spawned-objects collection.
var snapshot = new System.Collections.Generic.List<NetworkObject>(
spawnManager.SpawnedObjectsList);
int destroyed = 0;
foreach (var no in snapshot)
{
if (no == null || !no.IsSpawned) continue;
if (no.GetComponent<TowerInstance>() != null)
{
no.Despawn(destroy: true);
destroyed++;
continue;
}
// Shelved build sites outlive their Builder's queue, so cancelling jobs above
// doesn't reach them. Left alone they'd sit on the map as ghosts of towers that
// no longer exist, still holding their footprint tiles occupied.
var visual = no.GetComponent<BuildSiteVisual>();
if (visual != null && visual.IsShelved)
no.Despawn(destroy: true);
}
return destroyed;
}
// ----- Server-side commit hooks called by Builder ------------------
/// <summary>