// Assets/_Project/Scripts/Combat/Projectile.cs using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Gameplay; namespace TD.Combat { /// /// A traveling projectile spawned by when a tower has /// a non-null ProjectilePrefab configured. /// /// /// Authority: Movement and hit detection run server-only. /// NetworkTransform (required on the prefab) replicates the position to /// clients so the projectile is visible on all peers. /// /// Initialization: Mirrors the TowerInstance.InitializeServer pattern — /// is called by TowerCombat immediately after /// Instantiate and before NetworkObject.Spawn(), which avoids writing /// to NetworkVariables before spawn. /// /// Target loss: If the target dies or is destroyed before the projectile /// arrives, the projectile despawns silently (no hit, no damage). /// /// Chain + Projectile: By design, TargetType.Chain is hitscan. If a designer /// sets TargetType = Chain on a tower that has a ProjectilePrefab, the projectile /// will hit the primary target only and ignore the chain. Log a warning to surface /// the misconfiguration. /// /// Prefab requirements: Must have NetworkObject, NetworkTransform, /// and this Projectile component at the root. /// [RequireComponent(typeof(NetworkObject))] public class Projectile : NetworkBehaviour { // Hit threshold: squared distance at which the projectile considers itself // to have reached the target. 0.09 = 0.3 world units; small enough to // feel accurate, large enough to survive a high-speed frame where the // projectile could skip past the target's transform in one step. private const float HitThresholdSq = 0.09f; // All fields are server-local. Set by InitializeServer before Spawn. private EnemyHealth target; private float damage; private DamageType damageType; private TargetType targetType; private float splashRadius; private float slowFactor; private float dotDamagePerSecond; private float effectDuration; private float speed; private LayerMask enemyLayerMask; private bool initialized; // Shared with TowerCombat's overlap calls. Both components run on the // server main thread so there is no concurrent access. private static readonly Collider[] s_overlapBuffer = new Collider[32]; // ----- Initialization (server-only, called before Spawn) ----------- /// /// Stores all data this projectile needs to travel and apply damage. /// Call this immediately after Instantiate and before /// NetworkObject.Spawn(). /// public void InitializeServer( EnemyHealth target, float damage, DamageType damageType, TargetType targetType, float splashRadius, float slowFactor, float dotDamagePerSecond, float effectDuration, float speed, LayerMask enemyLayerMask) { this.target = target; this.damage = damage; this.damageType = damageType; this.targetType = targetType; this.splashRadius = splashRadius; this.slowFactor = slowFactor; this.dotDamagePerSecond = dotDamagePerSecond; this.effectDuration = effectDuration; this.speed = speed; this.enemyLayerMask = enemyLayerMask; initialized = true; if (targetType == TargetType.Chain) Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " + "This projectile will hit the primary target only. " + "Consider using hitscan (null ProjectilePrefab) for chain towers."); } // ----- Server movement + hit detection ----------------------------- private void Update() { if (!IsServer || !initialized) return; // Target gone — silently despawn, no damage applied. if (target == null || target.IsDead || (target as UnityEngine.Object) == null) { NetworkObject.Despawn(); return; } Vector3 toTarget = target.transform.position - transform.position; if (toTarget.sqrMagnitude <= HitThresholdSq) { ApplyHit(); NetworkObject.Despawn(); return; } // Rotate to face the target so the projectile mesh looks correct // on all clients (NetworkTransform replicates both position and rotation). transform.rotation = Quaternion.LookRotation(toTarget); transform.position += toTarget.normalized * (speed * Time.deltaTime); } // ----- Hit application --------------------------------------------- private void ApplyHit() { switch (targetType) { case TargetType.Single: case TargetType.Chain: // chain falls back to single-target on projectiles HitEnemy(target); break; case TargetType.Splash: HitEnemy(target); if (splashRadius > 0f) { int count = Physics.OverlapSphereNonAlloc( transform.position, splashRadius, s_overlapBuffer, enemyLayerMask); for (int i = 0; i < count; i++) { var eh = s_overlapBuffer[i].GetComponent(); if (eh == null || eh.IsDead || (object)eh == (object)target) continue; HitEnemy(eh); } } break; } } private void HitEnemy(EnemyHealth eh) { eh.TakeDamage(damage, damageType); if (effectDuration > 0f) { float magnitude = damageType switch { DamageType.Cold => slowFactor, DamageType.Fire => dotDamagePerSecond, DamageType.Poison => dotDamagePerSecond, _ => 0f, }; if (magnitude > 0f) eh.GetComponent() ?.ApplyEffect(damageType, magnitude, effectDuration); } } } }