// Assets/_Project/Scripts/Combat/TowerCombat.cs using System.Collections.Generic; using Unity.Netcode; using UnityEngine; using TD.Core; using TD.Gameplay; using TD.Towers; namespace TD.Combat { /// /// Per-tower combat controller. Handles target acquisition, attack timing, /// damage application, and projectile spawning. /// /// /// Authority: All combat logic (targeting, damage, projectile spawn) runs /// on the server only. Clients receive two signals for visual feedback: /// /// — NetworkVariable clients can read to know /// what the tower is aiming at (drives future rotation/lean visuals). /// — one-shot broadcast at each attack, carrying /// the target world position for muzzle flash, tracer FX, etc. /// /// /// Component coupling: Reads all stats from /// on the same GameObject. Does not modify TowerInstance. /// /// Inspector setup required: /// /// Assign to the "Enemy" physics layer. /// /// [RequireComponent(typeof(TowerInstance))] public class TowerCombat : NetworkBehaviour { [Tooltip("Physics layer(s) that enemies occupy. " + "OverlapSphere uses this to find targets efficiently without " + "touching non-enemy colliders.")] [SerializeField] private LayerMask enemyLayerMask; // Replicated so all peers know the current aim target. // Visual consumers (barrel rotation, etc.) subscribe to OnValueChanged. // Default (NetworkObjectId = 0) = no target. private readonly NetworkVariable replicatedTarget = new NetworkVariable( default, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // Server-local reference — cleared when the target dies or leaves range. // Clients should use replicatedTarget.Value instead. private EnemyHealth currentTarget; // Attack cooldown decrements each server frame. When ≤ 0 and a target // is in range, the tower fires and the timer resets to 1 / FireRate. private float attackCooldown; // Cached on OnNetworkSpawn — avoids GetComponent every Update. private TowerInstance towerInstance; // Shared OverlapSphere result buffer. 32 covers any realistic enemy // density; size up if profiling reveals overflow. private static readonly Collider[] s_overlapBuffer = new Collider[32]; // ----- Public API -------------------------------------------------- /// /// Server-local reference to the current attack target. /// Null on clients — use there. /// public EnemyHealth CurrentTarget => currentTarget; /// /// Fired locally on ALL peers when the tower acquires a new target. /// Driven by .OnValueChanged, and also /// fired in for late-joining clients so visual /// consumers always initialise from the correct state. /// public event System.Action OnTargetAcquired; /// Fired locally on ALL peers when the tower loses its target. public event System.Action OnTargetLost; /// /// Fired locally on ALL peers each time the tower attacks. /// Argument is the world-space position of the primary target at the moment /// of fire — use it to aim muzzle flash, spawn a tracer, etc. /// public event System.Action OnFire; // ----- NGO lifecycle ----------------------------------------------- public override void OnNetworkSpawn() { towerInstance = GetComponent(); replicatedTarget.OnValueChanged += HandleReplicatedTargetChanged; // Late-joining clients receive the NV's current value in the initial // sync message but won't get an OnValueChanged callback for it. // Fire OnTargetAcquired here so visual consumers initialise correctly // regardless of when this client connected. if (replicatedTarget.Value.TryGet(out _)) OnTargetAcquired?.Invoke(replicatedTarget.Value); } public override void OnNetworkDespawn() { replicatedTarget.OnValueChanged -= HandleReplicatedTargetChanged; // Unsubscribe from the current target's death event to prevent // a dangling delegate on the enemy after this tower despawns. if (currentTarget != null) currentTarget.OnDied -= HandleTargetDied; } // ----- Server update ----------------------------------------------- private void Update() { if (!IsServer) return; TowerDefinition def = towerInstance?.Definition; if (def == null || def.Range <= 0f || def.FireRate <= 0f) return; // Only fire during active gameplay. var ms = MatchState.Instance; if (ms == null || ms.Phase != MatchPhase.Playing) { if (currentTarget != null) ClearTarget(); return; } ValidateTarget(def); if (currentTarget == null) AcquireTarget(def); if (currentTarget != null) TickAttack(def); } // ----- Target management ------------------------------------------- private void ValidateTarget(TowerDefinition def) { if (currentTarget == null) return; bool dead = currentTarget.IsDead; bool destroyed = (currentTarget as UnityEngine.Object) == null; bool outOfRange = Vector3.Distance( transform.position, currentTarget.transform.position) > def.Range; if (dead || destroyed || outOfRange) ClearTarget(); } private void AcquireTarget(TowerDefinition def) { int count = Physics.OverlapSphereNonAlloc( transform.position, def.Range, s_overlapBuffer, enemyLayerMask); EnemyHealth best = null; float bestScore = 0f; for (int i = 0; i < count; i++) { var eh = s_overlapBuffer[i].GetComponent(); if (eh == null || eh.IsDead) continue; if (def.GroundedOnly && eh.IsFlying) continue; float score = ScoreTarget(eh, def.TargetPriority); bool isBetter = best == null || (def.TargetPriority == TargetPriority.Strongest ? score > bestScore // higher HP wins : score < bestScore); // lower score wins for Closest/Weakest if (isBetter) { best = eh; bestScore = score; } } if (best != null) SetTarget(best); } // Returns a scalar used to rank candidates. // Closest → sqrMagnitude (lower = better). // Weakest → CurrentHp (lower = better). // Strongest → CurrentHp (higher = better — caller inverts the comparison). private float ScoreTarget(EnemyHealth eh, TargetPriority priority) { return priority switch { TargetPriority.Closest => (transform.position - eh.transform.position).sqrMagnitude, TargetPriority.Weakest => eh.CurrentHp, TargetPriority.Strongest => eh.CurrentHp, _ => 0f, }; } private void SetTarget(EnemyHealth eh) { currentTarget = eh; currentTarget.OnDied += HandleTargetDied; replicatedTarget.Value = new NetworkObjectReference(eh.NetworkObject); } private void ClearTarget() { if (currentTarget != null) currentTarget.OnDied -= HandleTargetDied; currentTarget = null; replicatedTarget.Value = default; } private void HandleTargetDied(EnemyHealth dead) { if ((object)dead == (object)currentTarget) ClearTarget(); } // ----- Attack tick ------------------------------------------------- private void TickAttack(TowerDefinition def) { attackCooldown -= Time.deltaTime; if (attackCooldown > 0f) return; attackCooldown = 1f / def.FireRate; Fire(def); } private void Fire(TowerDefinition def) { Vector3 targetPos = currentTarget.transform.position; if (def.ProjectilePrefab == null) { // Hitscan — apply damage this frame. ApplyDamageToTarget(def, currentTarget, targetPos); } else { SpawnProjectile(def, currentTarget, targetPos); } FireClientRpc(targetPos); } // ----- Damage application ------------------------------------------ private void ApplyDamageToTarget(TowerDefinition def, EnemyHealth primary, Vector3 primaryPos) { switch (def.TargetType) { case TargetType.Single: HitEnemy(def, primary); break; case TargetType.Splash: HitEnemy(def, primary); ApplySplash(def, primary, primaryPos); break; case TargetType.Chain: ApplyChain(def, primary); break; } } private void ApplySplash(TowerDefinition def, EnemyHealth primary, Vector3 origin) { if (def.SplashRadius <= 0f) return; int count = Physics.OverlapSphereNonAlloc( origin, def.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)primary) continue; HitEnemy(def, eh); } } private void ApplyChain(TowerDefinition def, EnemyHealth primary) { // Chain hit positions are collected and sent to clients for the // future lightning-arc visual. The list is per-fire, so allocation // here is acceptable; optimise to a pool if chain towers become hot. var hitPositions = new List { primary.transform.position }; var alreadyHit = new HashSet { primary }; HitEnemy(def, primary); EnemyHealth current = primary; for (int jump = 0; jump < def.ChainCount; jump++) { EnemyHealth next = null; float bestSqr = float.MaxValue; int count = Physics.OverlapSphereNonAlloc( current.transform.position, def.ChainRange, s_overlapBuffer, enemyLayerMask); for (int i = 0; i < count; i++) { var eh = s_overlapBuffer[i].GetComponent(); if (eh == null || eh.IsDead || alreadyHit.Contains(eh)) continue; float sqr = (current.transform.position - eh.transform.position).sqrMagnitude; if (sqr < bestSqr) { next = eh; bestSqr = sqr; } } if (next == null) break; alreadyHit.Add(next); hitPositions.Add(next.transform.position); HitEnemy(def, next); current = next; } ChainFiredClientRpc(hitPositions.ToArray()); } private void HitEnemy(TowerDefinition def, EnemyHealth target) { target.TakeDamage(def.Damage, def.DamageType); ApplyStatusEffect(def, target); } private void ApplyStatusEffect(TowerDefinition def, EnemyHealth target) { if (def.EffectDuration <= 0f) return; float magnitude = def.DamageType switch { DamageType.Cold => def.SlowFactor, DamageType.Fire => def.DotDamagePerSecond, DamageType.Poison => def.DotDamagePerSecond, _ => 0f, }; if (magnitude <= 0f) return; target.GetComponent() ?.ApplyEffect(def.DamageType, magnitude, def.EffectDuration); } // ----- Projectile spawning ----------------------------------------- private void SpawnProjectile(TowerDefinition def, EnemyHealth target, Vector3 targetPos) { var go = Instantiate(def.ProjectilePrefab, transform.position, Quaternion.identity); var proj = go.GetComponent(); if (proj == null) { Debug.LogError($"[TowerCombat] ProjectilePrefab '{def.ProjectilePrefab.name}' " + $"has no Projectile component. The prefab must have Projectile, " + $"NetworkObject, and NetworkTransform at its root."); Destroy(go); return; } proj.InitializeServer( target, def.Damage, def.DamageType, def.TargetType, def.SplashRadius, def.SlowFactor, def.DotDamagePerSecond, def.EffectDuration, def.ProjectileSpeed, enemyLayerMask); go.GetComponent().Spawn(); } // ----- ClientRpcs -------------------------------------------------- [ClientRpc] private void FireClientRpc(Vector3 targetPos) { // Visual consumers subscribe to OnFire for muzzle flash, tracers, etc. OnFire?.Invoke(targetPos); } [ClientRpc] private void ChainFiredClientRpc(Vector3[] hitPositions) { // STUB: lightning-arc visual between hitPositions goes here. // A future visual component will subscribe to an OnChainFired event // and draw a line renderer through these world positions. } // ----- NV callback (fires on all peers) ---------------------------- private void HandleReplicatedTargetChanged( NetworkObjectReference prev, NetworkObjectReference next) { bool hadTarget = prev.TryGet(out _); bool hasTarget = next.TryGet(out _); if (hasTarget) OnTargetAcquired?.Invoke(next); else if (hadTarget) OnTargetLost?.Invoke(); } } }