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>
85 lines
4.5 KiB
C#
85 lines
4.5 KiB
C#
// Assets/_Project/Scripts/Gameplay/EnemyScalingConfig.cs
|
||
using UnityEngine;
|
||
|
||
namespace TD.Gameplay
|
||
{
|
||
/// <summary>
|
||
/// How enemy health scales across a run. Single source of truth for difficulty progression,
|
||
/// the health counterpart to <see cref="GoldConfig"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>The problem this solves.</b> Waves are drawn at random from a phase pool, so difficulty
|
||
/// can no longer live in the <see cref="EnemyDefinition"/> assets — an enemy authored at
|
||
/// 1000 HP is brutal at encounter 1 and trivial at encounter 40, and the draw decides which
|
||
/// you get. Progression has to be a property of <i>how far the players have come</i>, exactly
|
||
/// as gold payouts are.
|
||
///
|
||
/// <para><b>Budget, not per-enemy HP.</b> This config scales the <i>total</i> health an
|
||
/// encounter presents; per-enemy health is that budget divided by the wave's enemy count. If
|
||
/// the curve set per-enemy health instead, enemy count would silently become a second
|
||
/// difficulty axis and a 150-strong swarm would hit three times harder than a 50-strong wave
|
||
/// in the same slot. Dividing a fixed budget turns count into a <i>texture</i> knob: eight
|
||
/// giants and a hundred rats threaten equally, but demand different mazes.</para>
|
||
///
|
||
/// <para><b>Budget is per zone.</b> Every player zone spawns the full wave, so this is the
|
||
/// health one player faces, not the lobby total. It does not change with player count.</para>
|
||
///
|
||
/// <para><b>Speed is deliberately absent.</b> Speed stays on the
|
||
/// <see cref="EnemyDefinition"/>, unscaled. Escalating speed alongside health compounds into
|
||
/// a difficulty cliff and quietly invalidates tower balance as a run goes — projectile lead,
|
||
/// slow-effect value and time-under-fire all shift. Fast-versus-slow reads best as a fixed
|
||
/// character trait; health and the voted wave buffs carry escalation.</para>
|
||
/// </remarks>
|
||
[CreateAssetMenu(fileName = "EnemyScalingConfig", menuName = "TD/Enemy Scaling Config", order = 3)]
|
||
public class EnemyScalingConfig : ScriptableObject
|
||
{
|
||
[Header("Health curve")]
|
||
[Tooltip("Total enemy health the run's FIRST encounter presents, per player zone. " +
|
||
"Per-enemy health is this divided by the wave's enemy count.")]
|
||
[Min(1f)]
|
||
public float BaseHpBudget = 5000f;
|
||
|
||
[Tooltip("Multiplier applied per encounter. 1.18 ≈ +18% health each encounter, so the " +
|
||
"budget roughly doubles every four. Encounters are counted continuously across " +
|
||
"the whole run, so cycle 2 is strictly harder than cycle 1.")]
|
||
[Min(1f)]
|
||
public float GrowthPerEncounter = 1.18f;
|
||
|
||
[Header("Boss")]
|
||
[Tooltip("Extra multiplier on a boss encounter's budget, on top of the curve. Bosses " +
|
||
"spawn few enemies, so nearly all of this lands on the single boss body.")]
|
||
[Min(1f)]
|
||
public float BossHpMultiplier = 6f;
|
||
|
||
/// <summary>
|
||
/// Total health budget for the given 1-based encounter number
|
||
/// (<c>RunState.GlobalEncounterNumber</c>), per player zone.
|
||
/// </summary>
|
||
public float GetEncounterHpBudget(int encounterNumber, bool isBoss)
|
||
{
|
||
int steps = Mathf.Max(0, encounterNumber - 1);
|
||
float budget = BaseHpBudget * Mathf.Pow(GrowthPerEncounter, steps);
|
||
if (isBoss) budget *= BossHpMultiplier;
|
||
return budget;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Health for one enemy in the given encounter: the encounter's budget shared across
|
||
/// <paramref name="waveEnemyCount"/> bodies, then skewed by the enemy type's
|
||
/// <see cref="EnemyDefinition.HpMultiplier"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The archetype multiplier is a deliberate <i>deviation</i> from budget, not a
|
||
/// redistribution of it — a wave of 1.25× enemies really is 25% tougher than its slot
|
||
/// calls for. Keep multipliers near 1.0 and let the wave's enemy count express tankiness;
|
||
/// that is the knob the budget is designed to divide.
|
||
/// </remarks>
|
||
public float ResolvePerEnemyHp(int encounterNumber, bool isBoss, int waveEnemyCount,
|
||
float archetypeMultiplier)
|
||
{
|
||
int bodies = Mathf.Max(1, waveEnemyCount);
|
||
float share = GetEncounterHpBudget(encounterNumber, isBoss) / bodies;
|
||
return Mathf.Max(1f, share * Mathf.Max(0.01f, archetypeMultiplier));
|
||
}
|
||
}
|
||
}
|