// 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; // Paint tint. Set pre-spawn (pendingTint), committed to the replicated // NetworkVariable in OnNetworkSpawn on the server, and applied as a mesh tint // on every client so a painted tower's projectiles match its color. private PaintColor pendingTint; private readonly NetworkVariable tintColor = new( PaintColor.None, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); private MaterialPropertyBlock tintBlock; private static readonly int ColorPropertyId = Shader.PropertyToID("_Color"); private static readonly int BaseColorPropertyId = Shader.PropertyToID("_BaseColor"); 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, PaintColor tint = PaintColor.None) { 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; this.pendingTint = tint; initialized = true; if (targetType == TargetType.Chain) Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " + "This projectile will hit the primary target only."); } // ----- Spawn lifecycle (tint) ------------------------------------- public override void OnNetworkSpawn() { // Commit the pre-spawn tint on the server; NGO includes this write in the // initial sync so every client reads the right value in its OnNetworkSpawn. if (IsServer) tintColor.Value = pendingTint; tintColor.OnValueChanged += HandleTintChanged; ApplyTint(); } public override void OnNetworkDespawn() { tintColor.OnValueChanged -= HandleTintChanged; } private void HandleTintChanged(PaintColor previous, PaintColor current) => ApplyTint(); // Tints all renderers to the paint color via a MaterialPropertyBlock. No-op when // unpainted so the prefab's authored projectile material shows through. private void ApplyTint() { if (tintColor.Value == PaintColor.None) return; Color c = PaintColors.Get(tintColor.Value); c.a = 1f; tintBlock ??= new MaterialPropertyBlock(); tintBlock.SetColor(ColorPropertyId, c); tintBlock.SetColor(BaseColorPropertyId, c); foreach (var rend in GetComponentsInChildren()) rend.SetPropertyBlock(tintBlock); } // ----- 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); } } } }