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:
parent
4892d7253d
commit
16706a1ecf
154 changed files with 2608 additions and 287 deletions
|
|
@ -83,6 +83,11 @@ namespace TD.Gameplay
|
|||
"no kill/wave rewards (designer-error indicator, not a supported runtime mode).")]
|
||||
[SerializeField] private GoldConfig goldConfig;
|
||||
|
||||
[Tooltip("How enemy health scales across the run. Required for a real match; if unset, " +
|
||||
"every enemy spawns at a flat fallback health and difficulty never progresses " +
|
||||
"(designer-error indicator, not a supported runtime mode).")]
|
||||
[SerializeField] private EnemyScalingConfig enemyScalingConfig;
|
||||
|
||||
// ----- Networked state --------------------------------------------
|
||||
|
||||
// Per-slot total leak counters across the whole match. Index = (int)PlayerSlot;
|
||||
|
|
@ -372,9 +377,18 @@ namespace TD.Gameplay
|
|||
break;
|
||||
|
||||
case RunAdvance.NextPhase:
|
||||
{
|
||||
// A phase boundary resets the board as well as the enemies: the maze players
|
||||
// spent the phase building is torn down, unrefunded, so the new phase starts
|
||||
// from bare ground. Runs here rather than inside RunState.ServerAdvance
|
||||
// because it touches player structures, which the run has no business owning.
|
||||
int destroyed = TowerPlacementManager.Instance?.ServerDestroyAllTowers() ?? 0;
|
||||
NotifyTowersWipedClientRpc(destroyed);
|
||||
|
||||
Debug.Log($"[WaveManager] Boss down. New phase drawn: {run.ProgressLabel}. " +
|
||||
$"Enemy upgrades wiped.");
|
||||
$"Enemy upgrades wiped; {destroyed} tower(s) destroyed.");
|
||||
break;
|
||||
}
|
||||
|
||||
case RunAdvance.NextCycle:
|
||||
Debug.Log($"[WaveManager] Cycle complete — same waves return upgraded " +
|
||||
|
|
@ -492,6 +506,7 @@ namespace TD.Gameplay
|
|||
// Must run AFTER the inter-wave step, since the vote that just closed may have added
|
||||
// to a slot this very encounter re-runs on a later cycle.
|
||||
ResolveCurrentWaveAbilities();
|
||||
ResolveCurrentEncounterHealth(def);
|
||||
|
||||
// Spawn all enemies at once in a held (untargetable, immobile) state.
|
||||
if (def.Entries != null)
|
||||
|
|
@ -668,6 +683,58 @@ namespace TD.Gameplay
|
|||
$"{currentWaveAbilities.Count} wave buff(s).");
|
||||
}
|
||||
|
||||
// ----- Encounter health resolution ---------------------------------
|
||||
|
||||
// Health one enemy of this encounter gets BEFORE its type's HpMultiplier is applied:
|
||||
// the encounter's budget divided across the wave's bodies. Resolved once per encounter
|
||||
// for the same reason the ability list is — it's fixed for the whole wave, and the
|
||||
// divisor would otherwise be recomputed for every spawn.
|
||||
private float currentEncounterHpShare = FallbackEnemyHp;
|
||||
|
||||
// Used only when no EnemyScalingConfig is assigned. Enemies stay killable so the match
|
||||
// is still playable, but difficulty never progresses — see the field's tooltip.
|
||||
private const float FallbackEnemyHp = 100f;
|
||||
|
||||
/// <summary>
|
||||
/// Server-only: work out the per-enemy health share for the encounter about to spawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Splits the encounter's total budget across the wave's enemy count, so a swarm and an
|
||||
/// elite wave in the same slot present the same threat. The type's
|
||||
/// <see cref="EnemyDefinition.HpMultiplier"/> is applied later, per enemy, so a wave
|
||||
/// mixing types skews each one independently off the shared share.
|
||||
/// </remarks>
|
||||
private void ResolveCurrentEncounterHealth(WaveDefinition def)
|
||||
{
|
||||
if (enemyScalingConfig == null)
|
||||
{
|
||||
currentEncounterHpShare = FallbackEnemyHp;
|
||||
Debug.LogWarning("[WaveManager] No EnemyScalingConfig assigned — enemies spawn at " +
|
||||
$"a flat {FallbackEnemyHp} HP and difficulty will not progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
var run = RunState.Instance;
|
||||
int encounter = CurrentEncounterNumber;
|
||||
bool isBoss = run?.IsBossStage ?? false;
|
||||
|
||||
int bodies = def != null ? def.TotalEnemyCount : 0;
|
||||
if (bodies <= 0)
|
||||
{
|
||||
// An empty wave can't be divided into. Nothing will spawn from it either, so this
|
||||
// only guards against a divide-by-zero on a misauthored asset.
|
||||
currentEncounterHpShare = FallbackEnemyHp;
|
||||
return;
|
||||
}
|
||||
|
||||
currentEncounterHpShare =
|
||||
enemyScalingConfig.ResolvePerEnemyHp(encounter, isBoss, bodies, archetypeMultiplier: 1f);
|
||||
|
||||
Debug.Log($"[WaveManager] Encounter {encounter}{(isBoss ? " (BOSS)" : "")}: " +
|
||||
$"{enemyScalingConfig.GetEncounterHpBudget(encounter, isBoss):0} HP budget " +
|
||||
$"across {bodies} enemies = {currentEncounterHpShare:0} each before type modifiers.");
|
||||
}
|
||||
|
||||
// ----- Spawn helpers ----------------------------------------------
|
||||
|
||||
private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false)
|
||||
|
|
@ -742,7 +809,8 @@ namespace TD.Gameplay
|
|||
// Resolve the stats this enemy will actually spawn with. Abilities get first say —
|
||||
// flight, health and size all have to be settled before the position is computed and
|
||||
// before EnemyHealth/EnemyMovement read them, since each is captured once.
|
||||
var context = contextOverride ?? EnemySpawnContext.FromDefinition(def);
|
||||
var context = contextOverride
|
||||
?? EnemySpawnContext.FromDefinition(def, currentEncounterHpShare * def.HpMultiplier);
|
||||
if (contextOverride == null && abilities != null)
|
||||
{
|
||||
for (int i = 0; i < abilities.Count; i++)
|
||||
|
|
@ -857,11 +925,11 @@ namespace TD.Gameplay
|
|||
|
||||
private void HandleEnemyKilled(EnemyHealth health)
|
||||
{
|
||||
// Kill reward comes from GoldConfig for the current wave — same value for
|
||||
// every enemy in the wave regardless of EnemyDefinition type. Missing config
|
||||
// or out-of-range wave → 0 reward (gold flow disabled, designer-error mode).
|
||||
// Kill reward comes from GoldConfig for the current encounter — same value for
|
||||
// every enemy in it regardless of EnemyDefinition type. Missing config or an
|
||||
// unauthored encounter → 0 reward (gold flow disabled, designer-error mode).
|
||||
int killReward = 0;
|
||||
var goldEntry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
|
||||
var goldEntry = goldConfig?.GetEncounterEntry(CurrentEncounterNumber);
|
||||
if (goldEntry != null) killReward = goldEntry.GoldPerEnemy;
|
||||
|
||||
// Wave buffs get to alter the bounty before anything else sees it — this is what
|
||||
|
|
@ -983,8 +1051,24 @@ namespace TD.Gameplay
|
|||
ShowGoldLossClientRpc(worldPos, amount);
|
||||
}
|
||||
|
||||
// Announces the end-of-phase tower wipe on every peer. Sent rather than derived locally
|
||||
// because the despawns arrive as individual NetworkObject removals with nothing tying them
|
||||
// together — without this a client just watches its maze evaporate for no stated reason.
|
||||
[ClientRpc]
|
||||
private void NotifyTowersWipedClientRpc(int destroyedCount)
|
||||
{
|
||||
OnTowersWiped?.Invoke(destroyedCount);
|
||||
}
|
||||
|
||||
// ----- Local-only notification events -----------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer when a phase ends and every built tower is destroyed. Argument is
|
||||
/// how many towers went down. HUD subscribes to explain what just happened; audio and
|
||||
/// camera-shake hooks can use it too.
|
||||
/// </summary>
|
||||
public static event System.Action<int> OnTowersWiped;
|
||||
|
||||
/// <summary>
|
||||
/// Fired on every peer immediately after a life-loss popup spawns.
|
||||
/// HUD subscribes to flash a centered banner; gameplay code can also
|
||||
|
|
@ -1045,10 +1129,10 @@ namespace TD.Gameplay
|
|||
// Server-only. Iterates active players, awards CompletionBonus to each, plus
|
||||
// NoLeaksBonus to those whose per-wave leak counter is zero. Floating-text popups
|
||||
// are spawned at each player's builder position so the reward is visible in-world.
|
||||
// Skipped silently if no goldConfig or no entry for this wave.
|
||||
// Skipped silently if no goldConfig or no entry for this encounter.
|
||||
private void AwardWaveCompletionBonuses()
|
||||
{
|
||||
var entry = goldConfig?.GetWaveEntry(CurrentEncounterNumber);
|
||||
var entry = goldConfig?.GetEncounterEntry(CurrentEncounterNumber);
|
||||
if (entry == null) return;
|
||||
|
||||
int completionBonus = entry.CompletionBonus;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue