495 lines
19 KiB
C#
495 lines
19 KiB
C#
// 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();
|
||
}
|
||
}
|
||
}
|