diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs index 4d0e7ea..34006b8 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs @@ -66,7 +66,6 @@ namespace TD.Gameplay private float pendingMaxHp = 100f; private int pendingLivesCost = 1; private bool pendingIsFlying; - private bool pendingIsHeld; private bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- @@ -98,17 +97,11 @@ namespace TD.Gameplay NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); - private readonly NetworkVariable isHeld = new NetworkVariable( - false, - NetworkVariableReadPermission.Everyone, - NetworkVariableWritePermission.Server); - // ----- Public state --------------------------------------------------- public float CurrentHp => hp.Value; public float MaxHp { get; private set; } = 100f; public bool IsDead => hp.Value <= 0f; - public bool IsHeld => isHeld.Value; /// /// True if this enemy flies over tower footprints. @@ -133,12 +126,11 @@ namespace TD.Gameplay /// and before NetworkObject.Spawn(). Mirrors the /// TowerInstance.InitializeServer pattern. /// - public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false) + public void InitializeServer(float maxHp, int livesCost, bool flying) { pendingMaxHp = maxHp; pendingLivesCost = livesCost; pendingIsFlying = flying; - pendingIsHeld = held; hasPendingInit = true; // Cache locally on the server immediately — clients resolve via NV. @@ -154,19 +146,12 @@ namespace TD.Gameplay { hp.Value = pendingMaxHp; isFlying.Value = pendingIsFlying; - isHeld.Value = pendingIsHeld; hasPendingInit = false; } // Non-server clients resolve MaxHp from the replicated hp initial value. if (!IsServer) MaxHp = hp.Value; - - // Apply initial held state on all peers and watch for future changes. - // isHeld.OnValueChanged doesn't fire for the initial replication, so we - // apply it explicitly here as well. - ApplyHeldState(isHeld.Value); - isHeld.OnValueChanged += (_, current) => ApplyHeldState(current); } public override void OnNetworkDespawn() @@ -181,23 +166,6 @@ namespace TD.Gameplay // ----- Server API ----------------------------------------------------- - /// - /// Sets whether this enemy is held. Held enemies are untargetable and do not - /// move. Server-only; the NetworkVariable replicates the change to all peers. - /// - public void SetHeld(bool held) - { - if (!IsServer) return; - isHeld.Value = held; - } - - private void ApplyHeldState(bool held) - { - if (IsDead) return; - foreach (var col in GetComponentsInChildren()) - col.enabled = !held; - } - /// /// Applies damage to this enemy. Server-only; silently no-ops on clients. /// is accepted for future resistance lookups (Phase 1.5+). diff --git a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs index c575a65..2ee92d4 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -71,7 +71,6 @@ namespace TD.Gameplay // possible if pathing is dynamic). private bool hasLeakedOriginZone; private EnemyStatus status; - private EnemyHealth health; private bool hasReachedGoal; private bool wasStuck; // dedupes the "no path" warning @@ -120,7 +119,6 @@ namespace TD.Gameplay public override void OnNetworkSpawn() { status = GetComponent(); - health = GetComponent(); // Randomize the walk-cycle phase on every peer independently. Animations // are purely visual and not replicated, so each client picks its own offset — @@ -174,7 +172,6 @@ namespace TD.Gameplay private void Update() { if (!IsServer) return; - if (health != null && health.IsHeld) return; if (remainingPath.Count == 0) return; float effectiveSpeed = moveSpeed * (status != null ? status.GetSpeedMultiplier() : 1f); diff --git a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs index 7398dd0..f487e7c 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs @@ -5,7 +5,8 @@ using UnityEngine; namespace TD.Gameplay { /// - /// A single spawn group within a wave: one enemy type and how many of them. + /// A single spawn group within a wave: one enemy type, how many of them, + /// and how long to wait between each spawn. /// [Serializable] public struct WaveEntry @@ -15,6 +16,10 @@ namespace TD.Gameplay [Tooltip("How many enemies of this type to spawn.")] public int Count; + + [Tooltip("Seconds between each burst. Enemies spawn in bursts of 10% of Count at a time.")] + public float SpawnInterval; + } /// @@ -24,21 +29,16 @@ namespace TD.Gameplay /// /// Entries are processed in array order. Multiple entries let designers mix enemy /// types within one wave (e.g. 10 fast scouts followed by 3 armoured brutes). - /// All enemies spawn held at wave start; they are released in 10% chunks separated - /// by seconds. The wave is not complete until all - /// enemies are dead or have leaked. + /// The wave is not considered complete until all spawned enemies are dead or have + /// leaked — not just until all entries are spawned. /// [CreateAssetMenu(fileName = "WaveDefinition", menuName = "TD/Wave Definition", order = 4)] public class WaveDefinition : ScriptableObject { [Tooltip("Seconds between the wave-number advancing (start of prep) and the " + - "first enemies becoming visible. Gives players time to build before the horde appears.")] + "first enemy spawning. Gives players time to build before the wave hits.")] public float PrepTime = 10f; - [Tooltip("Seconds between each chunk release. All enemies spawn held at wave " + - "start and are released 10% at a time on this interval.")] - public float ReleaseInterval = 2f; - [Tooltip("Enemy groups that make up this wave. Processed in order.")] public WaveEntry[] Entries; } diff --git a/Assets/_Project/Scripts/Gameplay/WaveManager.cs b/Assets/_Project/Scripts/Gameplay/WaveManager.cs index 7b9e2ae..d2d5c8b 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -100,9 +100,6 @@ namespace TD.Gameplay private int currentWaveIndex = -1; // -1 = not yet started private Coroutine activeWaveCoroutine; - private readonly System.Collections.Generic.List heldEnemies - = new System.Collections.Generic.List(); - // ----- NGO lifecycle ---------------------------------------------- public override void OnNetworkSpawn() @@ -317,7 +314,6 @@ namespace TD.Gameplay // skipping the prep timer so spawning starts immediately. activeEnemyCount = 0; spawningComplete = false; - heldEnemies.Clear(); StartNextWave(skipPrep: true); } @@ -353,33 +349,24 @@ namespace TD.Gameplay // whether prep was skipped or just expired. prepCountdown.Value = 0f; - // Spawn all enemies at once in a held (untargetable, immobile) state. + // Spawn phase. if (def.Entries != null) { foreach (var entry in def.Entries) { if (entry.EnemyType == null || entry.Count <= 0) continue; - for (int i = 0; i < entry.Count; i++) - SpawnEnemyInAllZones(entry.EnemyType, held: true); - } - } - // Release in chunks of 10% of the total held count. - int total = heldEnemies.Count; - int chunkSize = Mathf.Max(1, Mathf.RoundToInt(total * 0.1f)); - while (heldEnemies.Count > 0) - { - int burst = Mathf.Min(chunkSize, heldEnemies.Count); - for (int i = 0; i < burst; i++) - { - var h = heldEnemies[0]; - heldEnemies.RemoveAt(0); - if (h != null && !h.IsDead) - h.SetHeld(false); - } + int chunkSize = Mathf.Max(1, Mathf.RoundToInt(entry.Count * 0.1f)); + for (int i = 0; i < entry.Count; i += chunkSize) + { + int burst = Mathf.Min(chunkSize, entry.Count - i); + for (int j = 0; j < burst; j++) + SpawnEnemyInAllZones(entry.EnemyType); - if (heldEnemies.Count > 0) - yield return new WaitForSeconds(def.ReleaseInterval); + if (entry.SpawnInterval > 0f) + yield return new WaitForSeconds(entry.SpawnInterval); + } + } } spawningComplete = true; @@ -391,7 +378,7 @@ namespace TD.Gameplay // ----- Spawn helpers ---------------------------------------------- - private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false) + private void SpawnEnemyInAllZones(EnemyDefinition def) { var loader = LevelLoader.Instance; if (loader?.LevelData?.PlayerZones == null) return; @@ -414,7 +401,7 @@ namespace TD.Gameplay // PlayerZoneVolume (so OwnerGrid[spawnerTile] = None). var spawner = zone.Spawners[0]; (float xHalf, float zHalf) = ComputeSpawnerHalfExtents(spawner.TileArea); - SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held); + SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf); } } @@ -440,7 +427,7 @@ namespace TD.Gameplay } private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, - float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false) + float xHalfExtent = 0f, float zHalfExtent = 0f) { if (def.EnemyPrefab == null) { @@ -467,9 +454,8 @@ namespace TD.Gameplay return; } - health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held); + health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); - if (held) heldEnemies.Add(health); health.OnDied += HandleEnemyKilled; movement.OnZoneLeaked += HandleZoneLeak;