// 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 position to clients /// so the projectile is visible on all peers. /// /// Initialization: Call after /// Instantiate and before NetworkObject.Spawn(). /// /// Kill attribution: is the /// of the firing tower. It is passed to /// and /// so kill gold credits the correct player even for projectile kills. /// /// Chain + Projectile: is hitscan-only. /// A projectile with Chain type will hit the primary target only and log a warning. /// /// Prefab requirements: NetworkObject, NetworkTransform, /// and this component at the root. /// [RequireComponent(typeof(NetworkObject))] public class Projectile : NetworkBehaviour { private const float HitThresholdSq = 0.09f; // 0.3 world units 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 PlayerSlot sourceOwner; private bool initialized; private static readonly Collider[] s_overlapBuffer = new Collider[32]; // ----- Pre-spawn init --------------------------------------------- /// /// Called by after Instantiate and before /// NetworkObject.Spawn(). is the firing /// tower's for kill-gold attribution. /// public void InitializeServer( EnemyHealth target, float damage, DamageType damageType, TargetType targetType, float splashRadius, float slowFactor, float dotDamagePerSecond, float effectDuration, float speed, LayerMask enemyLayerMask, PlayerSlot sourceOwner) { 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; this.sourceOwner = sourceOwner; initialized = true; if (targetType == TargetType.Chain) Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " + "This projectile will hit the primary target only."); } // ----- Server movement + hit detection ---------------------------- private void Update() { if (!IsServer || !initialized) return; 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; } 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: 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, sourceOwner); 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, sourceOwner); } } } }