Adding major combat changes and features

This commit is contained in:
Matt F 2026-05-12 21:31:10 -07:00
parent abcefcd7f1
commit 42ee0bf65d
28 changed files with 1653 additions and 46 deletions

View file

@ -0,0 +1,177 @@
// Assets/_Project/Scripts/Combat/Projectile.cs
using Unity.Netcode;
using UnityEngine;
using TD.Core;
using TD.Gameplay;
namespace TD.Combat
{
/// <summary>
/// A traveling projectile spawned by <see cref="TowerCombat"/> when a tower has
/// a non-null <c>ProjectilePrefab</c> configured.
/// </summary>
/// <remarks>
/// <b>Authority:</b> Movement and hit detection run server-only.
/// <c>NetworkTransform</c> (required on the prefab) replicates the position to
/// clients so the projectile is visible on all peers.
///
/// <b>Initialization:</b> Mirrors the <c>TowerInstance.InitializeServer</c> pattern —
/// <see cref="InitializeServer"/> is called by <c>TowerCombat</c> immediately after
/// <c>Instantiate</c> and before <c>NetworkObject.Spawn()</c>, which avoids writing
/// to NetworkVariables before spawn.
///
/// <b>Target loss:</b> If the target dies or is destroyed before the projectile
/// arrives, the projectile despawns silently (no hit, no damage).
///
/// <b>Chain + Projectile:</b> By design, TargetType.Chain is hitscan. If a designer
/// sets TargetType = Chain on a tower that has a ProjectilePrefab, the projectile
/// will hit the primary target only and ignore the chain. Log a warning to surface
/// the misconfiguration.
///
/// <b>Prefab requirements:</b> Must have <c>NetworkObject</c>, <c>NetworkTransform</c>,
/// and this <c>Projectile</c> component at the root.
/// </remarks>
[RequireComponent(typeof(NetworkObject))]
public class Projectile : NetworkBehaviour
{
// Hit threshold: squared distance at which the projectile considers itself
// to have reached the target. 0.09 = 0.3 world units; small enough to
// feel accurate, large enough to survive a high-speed frame where the
// projectile could skip past the target's transform in one step.
private const float HitThresholdSq = 0.09f;
// All fields are server-local. Set by InitializeServer before Spawn.
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 bool initialized;
// Shared with TowerCombat's overlap calls. Both components run on the
// server main thread so there is no concurrent access.
private static readonly Collider[] s_overlapBuffer = new Collider[32];
// ----- Initialization (server-only, called before Spawn) -----------
/// <summary>
/// Stores all data this projectile needs to travel and apply damage.
/// Call this immediately after <c>Instantiate</c> and before
/// <c>NetworkObject.Spawn()</c>.
/// </summary>
public void InitializeServer(
EnemyHealth target,
float damage,
DamageType damageType,
TargetType targetType,
float splashRadius,
float slowFactor,
float dotDamagePerSecond,
float effectDuration,
float speed,
LayerMask enemyLayerMask)
{
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;
initialized = true;
if (targetType == TargetType.Chain)
Debug.LogWarning("[Projectile] TargetType.Chain is hitscan-only. " +
"This projectile will hit the primary target only. " +
"Consider using hitscan (null ProjectilePrefab) for chain towers.");
}
// ----- Server movement + hit detection -----------------------------
private void Update()
{
if (!IsServer || !initialized) return;
// Target gone — silently despawn, no damage applied.
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;
}
// Rotate to face the target so the projectile mesh looks correct
// on all clients (NetworkTransform replicates both position and rotation).
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: // chain falls back to single-target on projectiles
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<EnemyHealth>();
if (eh == null || eh.IsDead || (object)eh == (object)target) continue;
HitEnemy(eh);
}
}
break;
}
}
private void HitEnemy(EnemyHealth eh)
{
eh.TakeDamage(damage, damageType);
if (effectDuration > 0f)
{
float magnitude = damageType switch
{
DamageType.Cold => slowFactor,
DamageType.Fire => dotDamagePerSecond,
DamageType.Poison => dotDamagePerSecond,
_ => 0f,
};
if (magnitude > 0f)
eh.GetComponent<EnemyStatus>()
?.ApplyEffect(damageType, magnitude, effectDuration);
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a6854f71c9fdcda42b297c397f96c8be

View file

@ -0,0 +1,409 @@
// 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>
/// Server-local reference to the current attack target.
/// Null on clients — use <see cref="replicatedTarget"/> there.
/// </summary>
public EnemyHealth CurrentTarget => currentTarget;
/// <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;
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<EnemyHealth>();
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<Vector3> { primary.transform.position };
var alreadyHit = new HashSet<EnemyHealth> { 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<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(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<EnemyStatus>()
?.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<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,
def.Damage,
def.DamageType,
def.TargetType,
def.SplashRadius,
def.SlowFactor,
def.DotDamagePerSecond,
def.EffectDuration,
def.ProjectileSpeed,
enemyLayerMask);
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();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7eb6cce6cd96b23478b7d2173cebf74d

View file

@ -0,0 +1,135 @@
// Assets/_Project/Scripts/Combat/TowerRangeIndicator.cs
using UnityEngine;
using UnityEngine.Rendering.Universal;
using TD.Gameplay;
namespace TD.Combat
{
/// <summary>
/// Displays a translucent decal circle representing this tower's attack range
/// when the local player selects the tower.
/// </summary>
/// <remarks>
/// <b>Visual only.</b> No networking — selection is a local UI concept.
/// All clients independently show the range indicator for whatever tower
/// they have selected.
///
/// <b>Prefab setup:</b>
/// <list type="number">
/// <item>Add this component to the tower prefab root (alongside TowerInstance).</item>
/// <item>Add a child GameObject named "RangeIndicator".</item>
/// <item>Add a <c>DecalProjector</c> to that child and assign it to
/// <see cref="rangeProjector"/> (or leave it unassigned — auto-found
/// via <c>GetComponentInChildren</c> in Start).</item>
/// <item>Assign a translucent range-circle material to the DecalProjector.</item>
/// </list>
///
/// <b>Sizing:</b> The projector diameter is set once in <c>Start</c> from
/// <c>TowerDefinition.Range</c>. Towers are static, so no per-frame resize is needed.
///
/// <b>Subscription timing:</b> Follows the same deferred-subscribe pattern as
/// <see cref="SelectionVisualizer"/> — retries until <see cref="SelectionState"/>
/// is available, then stops polling.
/// </remarks>
public class TowerRangeIndicator : MonoBehaviour
{
[Tooltip("DecalProjector used to render the range circle on the ground. " +
"Auto-found via GetComponentInChildren if left empty.")]
[SerializeField] private DecalProjector rangeProjector;
[Tooltip("Vertical extent of the decal projection volume in world units. " +
"Must be tall enough to project onto terrain at any height in your map.")]
[SerializeField] private float projectionDepth = 50f;
private TowerInstance towerInstance;
private bool subscribed;
// ----- Lifecycle ---------------------------------------------------
private void Start()
{
towerInstance = GetComponent<TowerInstance>();
if (towerInstance == null)
{
Debug.LogError("[TowerRangeIndicator] No TowerInstance found on this " +
"GameObject. TowerRangeIndicator must sit on the same " +
"prefab root as TowerInstance.");
enabled = false;
return;
}
if (rangeProjector == null)
rangeProjector = GetComponentInChildren<DecalProjector>();
if (rangeProjector == null)
{
Debug.LogError("[TowerRangeIndicator] No DecalProjector found. " +
"Add one as a child of this GameObject and assign it " +
"to the rangeProjector field.");
enabled = false;
return;
}
// TowerInstance resolves its Definition in OnNetworkSpawn, which runs
// before Start, so Definition is guaranteed available here.
float range = towerInstance.Definition != null ? towerInstance.Definition.Range : 0f;
float diameter = range * 2f;
rangeProjector.size = new Vector3(diameter, diameter, projectionDepth);
rangeProjector.pivot = Vector3.zero;
// DecalProjector projects along its local +Z axis; rotate 90° around X
// to project downward onto the ground plane.
rangeProjector.transform.localRotation = Quaternion.Euler(90f, 0f, 0f);
rangeProjector.transform.localPosition = Vector3.zero;
rangeProjector.enabled = false;
TrySubscribe();
}
private void OnDestroy()
{
Unsubscribe();
}
private void Update()
{
// Retry subscription each frame until SelectionState is ready.
// Once subscribed the branch short-circuits immediately.
if (!subscribed) TrySubscribe();
}
// ----- Subscription -----------------------------------------------
private void TrySubscribe()
{
if (subscribed) return;
var sel = SelectionState.Instance;
if (sel == null) return;
sel.OnSelectionChanged += HandleSelectionChanged;
subscribed = true;
// Catch whatever is already selected before we subscribed.
HandleSelectionChanged(sel.SelectedObject);
}
private void Unsubscribe()
{
if (!subscribed) return;
var sel = SelectionState.Instance;
if (sel != null) sel.OnSelectionChanged -= HandleSelectionChanged;
subscribed = false;
}
// ----- Selection handler ------------------------------------------
private void HandleSelectionChanged(ISelectable newSelection)
{
if (rangeProjector == null) return;
// Show only when THIS tower's TowerInstance is the selected object.
rangeProjector.enabled = (object)newSelection == (object)towerInstance;
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c0edb0c5206ca454bbd7c300c6cc7574