UnityTowerDefense/Assets/_Project/Scripts/Combat/TowerCombat.cs
2026-06-09 21:50:03 -07:00

495 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
{
/// <summary>
/// Per-tower combat controller. Handles target acquisition, attack timing,
/// damage application, and projectile spawning.
/// </summary>
/// <remarks>
/// <b>Authority:</b> All combat logic (targeting, damage, projectile spawn) runs
/// on the server only. Clients receive two signals for visual feedback:
/// <list type="bullet">
/// <item><see cref="replicatedTarget"/> — NetworkVariable clients can read to know
/// what the tower is aiming at (drives future rotation/lean visuals).</item>
/// <item><see cref="FireClientRpc"/> — one-shot broadcast at each attack, carrying
/// the target world position for muzzle flash, tracer FX, etc.</item>
/// </list>
///
/// <b>Component coupling:</b> Reads all stats from <see cref="TowerInstance.Definition"/>
/// on the same GameObject. Does not modify TowerInstance.
///
/// <b>Inspector setup required:</b>
/// <list type="bullet">
/// <item>Assign <see cref="enemyLayerMask"/> to the "Enemy" physics layer.</item>
/// </list>
/// </remarks>
[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<NetworkObjectReference> replicatedTarget =
new NetworkVariable<NetworkObjectReference>(
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 --------------------------------------------------
/// <summary>
/// Fired locally on ALL peers when the tower acquires a new target.
/// Driven by <see cref="replicatedTarget"/>.<c>OnValueChanged</c>, and also
/// fired in <see cref="OnNetworkSpawn"/> for late-joining clients so visual
/// consumers always initialise from the correct state.
/// </summary>
public event System.Action<NetworkObjectReference> OnTargetAcquired;
/// <summary>Fired locally on ALL peers when the tower loses its target.</summary>
public event System.Action OnTargetLost;
/// <summary>
/// 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.
/// </summary>
public event System.Action<Vector3> OnFire;
// ----- NGO lifecycle -----------------------------------------------
public override void OnNetworkSpawn()
{
towerInstance = GetComponent<TowerInstance>();
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<EnemyHealth>();
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<EnemyHealth>();
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<Vector3> { primary.transform.position };
var alreadyHit = new HashSet<EnemyHealth> { 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<EnemyHealth>();
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<EnemyStatus>()
?.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<Projectile>();
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<NetworkObject>().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();
}
}
}