using UnityEngine;
///
/// Finds nearby enemies (tagged "Enemy") that satisfy the assist constraints.
/// Designed for zero-allocation queries during combat assists.
///
[DisallowMultipleComponent]
public sealed class EnemyTargetDetector : MonoBehaviour
{
private const float MinAssistRadius = 0.5f;
[Header("Assist Window")]
[Tooltip("Maximum distance at which the assist can consider enemies.")]
[SerializeField] private float assistRadius = 4.0f;
[Tooltip("Maximum field of view in degrees around the forward vector where assist is allowed.")]
[SerializeField, Range(30f, 180f)] private float assistFOVDegrees = 130f;
[Tooltip("Optional hard cap on the delta angle to avoid 180° turns.")]
[SerializeField, Range(45f, 180f)] private float maxAssistAngle = 150f;
[Tooltip("Layer mask used for enemy overlap queries.")]
[SerializeField] private LayerMask enemyLayers = ~0;
[Tooltip("Maximum number of enemy candidates that will be considered per query.")]
[SerializeField, Min(1)] private int maxCandidates = 12;
[Header("Line Of Sight")]
[Tooltip("If enabled, a raycast must reach the target without hitting the obstruction layers.")]
[SerializeField] private bool requireLineOfSight = false;
[SerializeField] private LayerMask obstructionLayers = ~0;
[SerializeField, Tooltip("Height offset for line of sight ray origin.")]
private float losHeightOffset = 1.6f;
[SerializeField, Tooltip("Height offset for line of sight ray destination.")]
private float targetHeightOffset = 1.2f;
private Collider[] candidateBuffer;
private Transform hardLockOverride;
private void Awake()
{
maxCandidates = Mathf.Max(1, maxCandidates);
candidateBuffer = new Collider[maxCandidates];
if (assistRadius < MinAssistRadius)
{
assistRadius = MinAssistRadius;
}
}
///
/// Optionally sets a manually chosen target that should be prioritised over soft candidates.
///
public void SetHardLockTarget(Transform target)
{
hardLockOverride = target;
}
///
/// Attempts to find the best enemy target within the configured assist window.
///
public bool TryFindBestTarget(out Transform target, out Vector3 targetPosition)
{
target = null;
targetPosition = Vector3.zero;
if (!enabled)
return false;
if (hardLockOverride != null && IsTargetValid(hardLockOverride, out var hardLockPos))
{
target = hardLockOverride;
targetPosition = hardLockPos;
return true;
}
var origin = transform.position;
var forward = transform.forward;
forward.y = 0f;
forward.Normalize();
int hits = Physics.OverlapSphereNonAlloc(origin, assistRadius, candidateBuffer, enemyLayers, QueryTriggerInteraction.Collide);
if (hits <= 0)
return false;
float bestScore = float.MaxValue;
Transform bestTarget = null;
Vector3 bestPosition = Vector3.zero;
for (int i = 0; i < hits; i++)
{
Collider candidate = candidateBuffer[i];
if (candidate == null)
continue;
Transform candidateTransform = candidate.transform;
if (!candidateTransform.CompareTag("Enemy"))
continue;
if (!IsTargetValid(candidateTransform, out var candidatePosition))
continue;
Vector3 toTarget = candidatePosition - origin;
toTarget.y = 0f;
float sqrDist = toTarget.sqrMagnitude;
if (sqrDist < 0.0001f)
continue;
Vector3 direction = toTarget / Mathf.Sqrt(sqrDist);
float angle = Vector3.Angle(forward, direction);
if (angle > assistFOVDegrees)
continue;
if (angle > maxAssistAngle)
continue;
if (requireLineOfSight && !HasLineOfSight(origin, candidatePosition))
continue;
// Score by angle first, then distance.
float score = angle * 10f + sqrDist;
if (score < bestScore)
{
bestScore = score;
bestTarget = candidateTransform;
bestPosition = candidatePosition;
}
}
if (bestTarget == null)
return false;
target = bestTarget;
targetPosition = bestPosition;
return true;
}
private bool IsTargetValid(Transform candidate, out Vector3 targetPosition)
{
targetPosition = candidate.position;
if (candidate == null)
return false;
// Verify active in hierarchy (common alive check surrogate).
if (!candidate.gameObject.activeInHierarchy)
return false;
return true;
}
private bool HasLineOfSight(Vector3 origin, Vector3 targetPosition)
{
Vector3 rayOrigin = origin + Vector3.up * losHeightOffset;
Vector3 rayTarget = targetPosition + Vector3.up * targetHeightOffset;
Vector3 delta = rayTarget - rayOrigin;
float distance = delta.magnitude;
if (distance <= 0.001f)
return true;
Vector3 direction = delta / distance;
if (Physics.Raycast(rayOrigin, direction, distance, obstructionLayers, QueryTriggerInteraction.Ignore))
{
return false;
}
return true;
}
#region Inspector API
public float AssistRadius
{
get => assistRadius;
set => assistRadius = Mathf.Max(MinAssistRadius, value);
}
public float AssistFOVDegrees
{
get => assistFOVDegrees;
set => assistFOVDegrees = Mathf.Clamp(value, 30f, 180f);
}
public float MaxAssistAngle
{
get => maxAssistAngle;
set => maxAssistAngle = Mathf.Clamp(value, 45f, 180f);
}
public LayerMask EnemyLayers
{
get => enemyLayers;
set => enemyLayers = value;
}
public bool RequireLineOfSight
{
get => requireLineOfSight;
set => requireLineOfSight = value;
}
public LayerMask ObstructionLayers
{
get => obstructionLayers;
set => obstructionLayers = value;
}
#endregion
}