diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs
index bd4cd8b..c8780d4 100644
--- a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs
+++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowAreaSpellDefinition.cs
@@ -9,11 +9,16 @@ namespace TD.Gameplay.BuilderSpells
/// Builder spell #2 — slow every enemy within an area for a duration.
///
///
- /// is
+ /// is
/// — the cast controller previews as a decal
/// while aiming, and this spell resolves against that same radius (no separate field).
- /// Reuses the existing slow/DoT system () rather than
- /// introducing a new status mechanism.
+ ///
+ /// Zone, not a snapshot. spawns a that keeps re-checking the area for
+ /// seconds, so only enemies currently standing inside the radius are ever slowed — walking
+ /// out drops the slow almost immediately. Always succeeds and starts the cooldown, even if
+ /// no enemy is in the radius at the moment of cast, since the zone can still catch one later.
+ ///
///
[CreateAssetMenu(fileName = "SlowAreaSpell", menuName = "TD/Builder Spells/Slow Area")]
public class SlowAreaSpellDefinition : BuilderSpellDefinition
@@ -35,7 +40,8 @@ namespace TD.Gameplay.BuilderSpells
[Range(0f, 1f)]
public float SlowFactor = 0.5f;
- [Tooltip("Seconds the slow lasts. Re-casting on an already-slowed enemy refreshes it.")]
+ [Tooltip("Seconds the zone itself persists. Enemies are only slowed while standing " +
+ "inside it — walking out drops the slow almost immediately.")]
[Min(0f)]
public float EffectDuration = 3f;
@@ -43,23 +49,14 @@ namespace TD.Gameplay.BuilderSpells
{
PlayerSlot owner = PlayerMatchState.SlotForClient(clientId);
- int count = Physics.OverlapSphereNonAlloc(
- targetPoint, Mathf.Max(Radius, 0.01f), s_overlapBuffer, enemyLayerMask);
+ var zoneObject = new GameObject("SlowZone (server)");
+ zoneObject.AddComponent().Initialize(
+ targetPoint, Mathf.Max(Radius, 0.01f), SlowFactor, EffectDuration,
+ enemyLayerMask, owner);
- bool hitAny = false;
- for (int i = 0; i < count; i++)
- {
- var enemyHealth = s_overlapBuffer[i].GetComponent();
- if (enemyHealth == null || enemyHealth.IsDead) continue;
-
- var enemyStatus = s_overlapBuffer[i].GetComponent();
- if (enemyStatus == null) continue;
-
- enemyStatus.ApplyEffect(DamageType.Cold, SlowFactor, EffectDuration, owner);
- hitAny = true;
- }
-
- return hitAny;
+ // Always succeeds — placing the zone starts the cooldown even if no enemy is
+ // standing in it yet, since it keeps slowing whoever walks in for EffectDuration.
+ return true;
}
public override void ClientSpawnVfx(Vector3 targetPoint)
diff --git a/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowZoneRuntime.cs b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowZoneRuntime.cs
new file mode 100644
index 0000000..a6e02f2
--- /dev/null
+++ b/Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowZoneRuntime.cs
@@ -0,0 +1,80 @@
+// Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowZoneRuntime.cs
+using UnityEngine;
+using TD.Core;
+
+namespace TD.Gameplay.BuilderSpells
+{
+ ///
+ /// Server-only runtime object for a Slow Area cast. Re-scans its radius every frame for the
+ /// rest of the zone's lifetime, continuously refreshing the Cold status on whatever enemies
+ /// currently overlap it.
+ ///
+ ///
+ /// Zone, not a snapshot. Replaces 's old
+ /// single OverlapSphereNonAlloc call at cast time, which slowed whoever was standing
+ /// there for a fixed duration regardless of whether they walked out. This ticks continuously
+ /// instead, so only enemies currently inside the radius are ever affected.
+ ///
+ /// Refresh, not enter/exit events. Each tick re-applies
+ /// with a short fixed window (),
+ /// not the zone's remaining lifetime. An enemy that walks out of the zone simply stops being
+ /// refreshed and the slow expires on its own seconds later —
+ /// no explicit exit detection needed.
+ ///
+ /// Not a NetworkObject. It only ever runs where runs (the server), and it never needs to exist
+ /// on clients — the zone's visual is spawned independently on every peer via
+ /// .
+ ///
+ public class SlowZoneRuntime : MonoBehaviour
+ {
+ // Comfortably longer than one frame so a single missed tick doesn't let the slow
+ // visibly drop while an enemy is still inside, but short enough that leaving the
+ // zone reads as an almost-immediate loss of the slow.
+ private const float RefreshWindow = 0.35f;
+
+ private static readonly Collider[] s_overlapBuffer = new Collider[64];
+
+ private float radius;
+ private float slowFactor;
+ private float remainingLifetime;
+ private LayerMask enemyLayerMask;
+ private PlayerSlot owner;
+
+ /// Positions and configures the zone. Call immediately after AddComponent.
+ public void Initialize(Vector3 position, float radius, float slowFactor, float lifetime,
+ LayerMask enemyLayerMask, PlayerSlot owner)
+ {
+ transform.position = position;
+ this.radius = radius;
+ this.slowFactor = slowFactor;
+ this.remainingLifetime = lifetime;
+ this.enemyLayerMask = enemyLayerMask;
+ this.owner = owner;
+ }
+
+ private void Update()
+ {
+ remainingLifetime -= Time.deltaTime;
+ if (remainingLifetime <= 0f)
+ {
+ Destroy(gameObject);
+ return;
+ }
+
+ int count = Physics.OverlapSphereNonAlloc(
+ transform.position, radius, s_overlapBuffer, enemyLayerMask);
+
+ for (int i = 0; i < count; i++)
+ {
+ var enemyHealth = s_overlapBuffer[i].GetComponent();
+ if (enemyHealth == null || enemyHealth.IsDead) continue;
+
+ var enemyStatus = s_overlapBuffer[i].GetComponent();
+ if (enemyStatus == null) continue;
+
+ enemyStatus.ApplyEffect(DamageType.Cold, slowFactor, RefreshWindow, owner);
+ }
+ }
+ }
+}