212 lines
6.3 KiB
C#
212 lines
6.3 KiB
C#
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// Finds nearby enemies (tagged "Enemy") that satisfy the assist constraints.
|
|
/// Designed for zero-allocation queries during combat assists.
|
|
/// </summary>
|
|
[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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Optionally sets a manually chosen target that should be prioritised over soft candidates.
|
|
/// </summary>
|
|
public void SetHardLockTarget(Transform target)
|
|
{
|
|
hardLockOverride = target;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to find the best enemy target within the configured assist window.
|
|
/// </summary>
|
|
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
|
|
}
|