// 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 -------------------------------------------------- /// /// 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; // Resolve the effective combat profile for this shot: the tower's authored // stats, overridden by its Paint color (Red = splash, Green = poison DoT, // Blue = slow). Unpainted towers fire exactly as authored. PaintColor paint = towerInstance.Paint; CombatProfile profile = BuildProfile(def, paint); if (def.ProjectilePrefab == null) { // Hitscan — apply damage this frame. ApplyDamageToTarget(profile, currentTarget, targetPos); } else { SpawnProjectile(def, profile, paint, currentTarget); } FireClientRpc(targetPos); } // ----- Paint → combat effect tuning -------------------------------- // // Paint overrides the tower's effect profile (paint takes precedence over any // authored effect). Centralized here so the numbers are easy to find and tweak; // promote to a ScriptableObject if designers need to tune them without a recompile. private const float PaintSplashRadius = 2.0f; // world units (tiles) private const float PaintPoisonDpsFraction = 0.5f; // DoT/sec = base Damage × this private const float PaintPoisonDuration = 3.0f; // seconds private const float PaintSlowSpeedRetained = 0.5f; // Cold magnitude: 0.5 = half speed private const float PaintSlowDuration = 2.0f; // seconds // The firing-relevant stats for a single shot, after applying paint overrides. // Mirrors the TowerDefinition fields the firing path reads so the rest of the // code is agnostic to whether a value came from the asset or from paint. private readonly struct CombatProfile { public readonly float Damage; public readonly DamageType DamageType; public readonly TargetType TargetType; public readonly float SplashRadius; public readonly float SlowFactor; public readonly float DotDamagePerSecond; public readonly float EffectDuration; public readonly float ProjectileSpeed; public readonly int ChainCount; public readonly float ChainRange; public CombatProfile(float damage, DamageType damageType, TargetType targetType, float splashRadius, float slowFactor, float dotDamagePerSecond, float effectDuration, float projectileSpeed, int chainCount, float chainRange) { Damage = damage; DamageType = damageType; TargetType = targetType; SplashRadius = splashRadius; SlowFactor = slowFactor; DotDamagePerSecond = dotDamagePerSecond; EffectDuration = effectDuration; ProjectileSpeed = projectileSpeed; ChainCount = chainCount; ChainRange = chainRange; } } private static CombatProfile BuildProfile(TowerDefinition def, PaintColor paint) { // Start from the tower's authored stats. float damage = def.Damage; DamageType damageType = def.DamageType; TargetType targetType = def.TargetType; float splash = def.SplashRadius; float slow = def.SlowFactor; float dot = def.DotDamagePerSecond; float duration = def.EffectDuration; switch (paint) { case PaintColor.Red: // area splash targetType = TargetType.Splash; splash = PaintSplashRadius; break; case PaintColor.Green: // poison damage-over-time damageType = DamageType.Poison; dot = def.Damage * PaintPoisonDpsFraction; duration = PaintPoisonDuration; break; case PaintColor.Blue: // chilling slow damageType = DamageType.Cold; slow = PaintSlowSpeedRetained; duration = PaintSlowDuration; break; case PaintColor.None: default: break; // fire as authored } return new CombatProfile(damage, damageType, targetType, splash, slow, dot, duration, def.ProjectileSpeed, def.ChainCount, def.ChainRange); } // ----- Damage application ------------------------------------------ private void ApplyDamageToTarget(in CombatProfile p, EnemyHealth primary, Vector3 primaryPos) { PlayerSlot owner = towerInstance.Owner; switch (p.TargetType) { case TargetType.Single: HitEnemy(p, primary, owner); break; case TargetType.Splash: HitEnemy(p, primary, owner); ApplySplash(p, primary, primaryPos, owner); break; case TargetType.Chain: ApplyChain(p, primary, owner); break; } } private void ApplySplash(in CombatProfile p, EnemyHealth primary, Vector3 origin, PlayerSlot owner) { if (p.SplashRadius <= 0f) return; int count = Physics.OverlapSphereNonAlloc( origin, p.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(p, eh, owner); } } private void ApplyChain(in CombatProfile p, EnemyHealth primary, PlayerSlot owner) { var hitPositions = new List { primary.transform.position }; var alreadyHit = new HashSet { primary }; HitEnemy(p, primary, owner); EnemyHealth current = primary; for (int jump = 0; jump < p.ChainCount; jump++) { EnemyHealth next = null; float bestSqr = float.MaxValue; int count = Physics.OverlapSphereNonAlloc( current.transform.position, p.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(p, next, owner); current = next; } ChainFiredClientRpc(hitPositions.ToArray()); } private void HitEnemy(in CombatProfile p, EnemyHealth target, PlayerSlot owner) { target.TakeDamage(p.Damage, p.DamageType, owner); ApplyStatusEffect(p, target, owner); } private void ApplyStatusEffect(in CombatProfile p, EnemyHealth target, PlayerSlot owner) { if (p.EffectDuration <= 0f) return; float magnitude = p.DamageType switch { DamageType.Cold => p.SlowFactor, DamageType.Fire => p.DotDamagePerSecond, DamageType.Poison => p.DotDamagePerSecond, _ => 0f, }; if (magnitude <= 0f) return; target.GetComponent() ?.ApplyEffect(p.DamageType, magnitude, p.EffectDuration, owner); } // ----- Projectile spawning ----------------------------------------- private void SpawnProjectile(TowerDefinition def, in CombatProfile p, PaintColor tint, EnemyHealth target) { 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, p.Damage, p.DamageType, p.TargetType, p.SplashRadius, p.SlowFactor, p.DotDamagePerSecond, p.EffectDuration, p.ProjectileSpeed, enemyLayerMask, towerInstance.Owner, tint); 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(); } } }