diff --git a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs index 34006b8..4d0e7ea 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyHealth.cs @@ -66,6 +66,7 @@ namespace TD.Gameplay private float pendingMaxHp = 100f; private int pendingLivesCost = 1; private bool pendingIsFlying; + private bool pendingIsHeld; private bool hasPendingInit; // ----- Server-local runtime state ------------------------------------- @@ -97,11 +98,17 @@ 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. @@ -126,11 +133,12 @@ namespace TD.Gameplay /// and before NetworkObject.Spawn(). Mirrors the /// TowerInstance.InitializeServer pattern. /// - public void InitializeServer(float maxHp, int livesCost, bool flying) + public void InitializeServer(float maxHp, int livesCost, bool flying, bool held = false) { pendingMaxHp = maxHp; pendingLivesCost = livesCost; pendingIsFlying = flying; + pendingIsHeld = held; hasPendingInit = true; // Cache locally on the server immediately — clients resolve via NV. @@ -146,12 +154,19 @@ 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() @@ -166,6 +181,23 @@ 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 2ee92d4..c575a65 100644 --- a/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs +++ b/Assets/_Project/Scripts/Gameplay/EnemyMovement.cs @@ -71,6 +71,7 @@ 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 @@ -119,6 +120,7 @@ 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 — @@ -172,6 +174,7 @@ 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 f487e7c..7398dd0 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveDefinition.cs @@ -5,8 +5,7 @@ using UnityEngine; namespace TD.Gameplay { /// - /// A single spawn group within a wave: one enemy type, how many of them, - /// and how long to wait between each spawn. + /// A single spawn group within a wave: one enemy type and how many of them. /// [Serializable] public struct WaveEntry @@ -16,10 +15,6 @@ 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; - } /// @@ -29,16 +24,21 @@ 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). - /// The wave is not considered complete until all spawned enemies are dead or have - /// leaked — not just until all entries are spawned. + /// 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. /// [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 enemy spawning. Gives players time to build before the wave hits.")] + "first enemies becoming visible. Gives players time to build before the horde appears.")] 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 d2d5c8b..7b9e2ae 100644 --- a/Assets/_Project/Scripts/Gameplay/WaveManager.cs +++ b/Assets/_Project/Scripts/Gameplay/WaveManager.cs @@ -100,6 +100,9 @@ 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() @@ -314,6 +317,7 @@ namespace TD.Gameplay // skipping the prep timer so spawning starts immediately. activeEnemyCount = 0; spawningComplete = false; + heldEnemies.Clear(); StartNextWave(skipPrep: true); } @@ -349,26 +353,35 @@ namespace TD.Gameplay // whether prep was skipped or just expired. prepCountdown.Value = 0f; - // Spawn phase. + // Spawn all enemies at once in a held (untargetable, immobile) state. if (def.Entries != null) { foreach (var entry in def.Entries) { if (entry.EnemyType == null || entry.Count <= 0) continue; - - 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 (entry.SpawnInterval > 0f) - yield return new WaitForSeconds(entry.SpawnInterval); - } + 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); + } + + if (heldEnemies.Count > 0) + yield return new WaitForSeconds(def.ReleaseInterval); + } + spawningComplete = true; // If every spawned enemy was already resolved before this coroutine finished @@ -378,7 +391,7 @@ namespace TD.Gameplay // ----- Spawn helpers ---------------------------------------------- - private void SpawnEnemyInAllZones(EnemyDefinition def) + private void SpawnEnemyInAllZones(EnemyDefinition def, bool held = false) { var loader = LevelLoader.Instance; if (loader?.LevelData?.PlayerZones == null) return; @@ -401,7 +414,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); + SpawnEnemy(def, spawner.TilePosition, zone.Owner, xHalf, zHalf, held); } } @@ -427,7 +440,7 @@ namespace TD.Gameplay } private void SpawnEnemy(EnemyDefinition def, Vector2Int spawnerTile, PlayerSlot ownerSlot, - float xHalfExtent = 0f, float zHalfExtent = 0f) + float xHalfExtent = 0f, float zHalfExtent = 0f, bool held = false) { if (def.EnemyPrefab == null) { @@ -454,8 +467,9 @@ namespace TD.Gameplay return; } - health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying); + health.InitializeServer(def.MaxHp, def.LivesCost, def.IsFlying, held); movement.InitializeServer(def.MoveSpeed, spawnerTile, ownerSlot); + if (held) heldEnemies.Add(health); health.OnDied += HandleEnemyKilled; movement.OnZoneLeaked += HandleZoneLeak;