From 7419e9a14012624042169ff9b191ef26535bca36 Mon Sep 17 00:00:00 2001 From: Ian Woods Date: Sat, 18 Jul 2026 21:58:34 -0700 Subject: [PATCH] slow area persists as an area rather than instantaneously applying once to enemies in the area --- .../BuilderSpells/SlowAreaSpellDefinition.cs | 37 ++++----- .../Gameplay/BuilderSpells/SlowZoneRuntime.cs | 80 +++++++++++++++++++ 2 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 Assets/_Project/Scripts/Gameplay/BuilderSpells/SlowZoneRuntime.cs 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); + } + } + } +}