using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Events;
///
/// Le PNJ poursuit une cible (le joueur) sur le NavMesh, selon deux zones autour de lui :
/// - Zone de contact (cercle 1) : la cible est atteinte, le PNJ s'arrête (et attaque si Can Attack est coché).
/// - Zone de détection (cercle 2) : la cible est repérée, le PNJ court (ou marche) vers elle.
/// Si elle en sort, il abandonne : il reste sur place, ou erre si Wander When Lost est coché.
/// Le PNJ se tourne avant d'avancer et, au contact, se tourne vers la cible.
/// Hitbox (optionnelle) : allumée et éteinte par les événements HitboxOn / HitboxOff du clip d'attaque.
///
[RequireComponent(typeof(NavMeshAgent))]
public class NPCFollower : MonoBehaviour
{
[Header("Target")]
[Tooltip("Vide = l'objet qui a le tag Player")]
[SerializeField] private Transform target;
[Header("Zones")]
[Tooltip("Cercle 1 (magenta). Cible à l'intérieur = le PNJ s'arrête et attaque si \"Can Attack\" est coché")]
[SerializeField] private float contactRadius = 1f;
[Tooltip("Cercle 2 (rouge). Cible à l'intérieur = le PNJ court (ou marche) vers elle. Cible à l'extérieur = il abandonne")]
[SerializeField] private float detectionRadius = 10f;
[Header("Movement")]
[Tooltip("Vitesse de marche (m/s), en errance et en poursuite si Run When Chasing est décoché. Idéalement la vitesse naturelle du clip de marche (Average Velocity Z), pour que les pieds ne glissent pas")]
[SerializeField] private float walkSpeed = 1.3f;
[Tooltip("Temps (en secondes) pour faire un demi-tour complet. Utilisé en poursuite, en errance et au contact")]
[SerializeField] private float halfTurnDuration = 0.25f;
[Tooltip("Au-delà de cet angle, le PNJ pivote sur place avant d'avancer")]
[SerializeField] private float maxAngleToMove = 30f;
[Tooltip("Coché = le PNJ court vers la cible. Décoché = il marche vers elle (ex. : zombie, PNJ curieux)")]
[SerializeField] private bool runWhenChasing = false;
[Tooltip("Vitesse de course (m/s). Idéalement la vitesse naturelle du clip de course (Average Velocity Z), pour que les pieds ne glissent pas")]
[SerializeField] private float chaseSpeed = 3f;
[Header("Animation")]
[SerializeField] private Animator animator;
[Tooltip("Nom exact du Bool de marche dans l'Animator Controller (vrai dès que le PNJ se déplace, qu'il marche ou court)")]
[SerializeField] private string walkParameter = "walking";
[Tooltip("Nom exact du Bool de course dans l'Animator Controller (vrai quand le PNJ court vers la cible)")]
[SerializeField] private string chaseParameter = "run";
[Header("Attack")]
[Tooltip("Coché = le PNJ attaque la cible quand elle est dans la zone de contact")]
[SerializeField] private bool canAttack = false;
[Tooltip("Délai minimal (en secondes) entre le début de deux attaques. Avec une animation d'attaque, 0 = le PNJ enchaîne les coups tant que la cible est au contact. Sans animation, ne pas mettre 0 : le PNJ attaquerait à chaque image")]
[SerializeField] private float attackCooldown = 1.5f;
[Tooltip("Nom exact du Trigger d'attaque dans l'Animator Controller. Vide = aucune animation, l'événement est quand même déclenché")]
[SerializeField] private string attackTrigger = "attack";
[Tooltip("Coché = le PNJ n'attaque que s'il voit la cible (aucun obstacle entre ses yeux et elle)")]
[SerializeField] private bool requireLineOfSight = true;
[Tooltip("Cube trigger (CES) enfant de la main. Allumé par l'événement d'animation HitboxOn, éteint par HitboxOff (ou par le CES quand le coup touche). Vide = aucune hitbox")]
[SerializeField] private GameObject hitbox;
[Space(6)]
[Tooltip("Actions déclenchées à chaque attaque (son, dégâts, particules…)")]
[SerializeField] private UnityEvent onAttack;
[Header("Wander")]
[Tooltip("Coché = quand la cible est hors de la zone de détection, le PNJ erre au hasard. Décoché = il reste sur place")]
[SerializeField] private bool wanderWhenLost = false;
[Tooltip("Distance minimale entre le PNJ et son prochain point d'errance")]
[SerializeField] private float wanderMinDistance = 2f;
[Tooltip("Distance maximale entre le PNJ et son prochain point d'errance")]
[SerializeField] private float wanderMaxDistance = 6f;
[Tooltip("Durée de la pause à chaque arrivée, entre deux valeurs (en secondes)")]
[SerializeField] private Vector2 pauseMinMax = new Vector2(1f, 3f);
// Temps entre deux calculs de chemin (en secondes)
private const float RepathInterval = 0.2f;
// Vitesse (m/s) sous laquelle le PNJ est considéré immobile
private const float WalkThreshold = 0.1f;
// Marges pour éviter les allers-retours quand la cible est pile sur un cercle (m) :
// le PNJ quitte le contact à contactRadius + ContactMargin, et abandonne à detectionRadius + LoseMargin
private const float ContactMargin = 0.5f;
private const float LoseMargin = 1f;
// Limites de la vitesse de lecture de l'animation (1 = vitesse normale du clip)
private const float MinAnimSpeed = 0.5f;
private const float MaxAnimSpeed = 2.5f;
// Nombre d'essais pour trouver un point d'errance assez loin avant de se contenter du plus éloigné
private const int MaxWanderAttempts = 10;
// Le PNJ doit faire face à la cible (écart d'angle maximal, en degrés) pour attaquer
private const float AttackMaxAngle = 30f;
// Ligne de vue : des yeux du PNJ vers le centre du corps de la cible (hauteurs en m)
private const float EyeHeight = 1.6f;
private const float TargetAimHeight = 1f;
// État d'attaque dans l'Animator : reconnu par son nom OU par son Tag (champ Tag de l'état)
private const string AttackStateName = "Attack";
// Délai laissé à l'Animator pour entrer dans l'état d'attaque après le Trigger (s)
private const float AttackStartGrace = 0.2f;
private NavMeshAgent agent;
private bool lost = true; // la cible est hors de la zone de détection
private bool inContact; // la cible est dans la zone de contact
private bool paused;
private float pauseEnd;
private float nextRepath;
private float desiredSpeed;
private bool hasWalkParameter;
private bool hasChaseParameter;
private bool hasAttackTrigger;
private float nextAttackTime;
private float attackStartTime = -10f;
private bool Chasing => !lost && !inContact;
// Le PNJ court seulement en poursuite, et seulement si Run When Chasing est coché
private bool Running => Chasing && runWhenChasing;
private void Awake()
{
agent = GetComponent();
agent.updateRotation = false; // la rotation est gérée par ce script
// L'agent commence à freiner en approchant du bord de la zone de contact
agent.stoppingDistance = contactRadius;
if (animator != null) animator.applyRootMotion = false;
}
private void Start()
{
// La hitbox est toujours éteinte au départ
HitboxOff();
// Paramètres de l'Animator : vérifiés une seule fois, au démarrage
if (animator != null)
{
hasWalkParameter = CheckParameter(walkParameter, AnimatorControllerParameterType.Bool, "l'animation de marche ne jouera pas");
if (runWhenChasing)
hasChaseParameter = CheckParameter(chaseParameter, AnimatorControllerParameterType.Bool, "la course utilisera l'animation de marche");
if (canAttack)
hasAttackTrigger = CheckParameter(attackTrigger, AnimatorControllerParameterType.Trigger, "l'attaque n'aura pas d'animation");
}
if (target == null)
{
GameObject player = GameObject.FindGameObjectWithTag("Player");
if (player != null) target = player.transform;
}
if (target == null)
{
Debug.LogWarning($"{name} n'a pas de cible : glisser le joueur dans le champ Target, ou lui donner le tag Player.", this);
enabled = false;
return;
}
if (!agent.isOnNavMesh)
{
Debug.LogWarning($"{name} n'est pas sur le NavMesh : vérifier le Bake et la position du personnage.", this);
enabled = false;
}
}
private void Update()
{
float distance = Vector3.Distance(transform.position, target.position);
// Pendant l'animation d'attaque, le PNJ reste sur place : ni déplacement, ni rotation.
// À la fin du coup, les zones sont réévaluées : si la cible est partie, il repart aussitôt.
if (IsAttacking())
{
if (agent.hasPath) agent.ResetPath();
agent.velocity = Vector3.zero;
UpdateAnimation();
return;
}
// Hors de l'animation d'attaque, la hitbox est toujours éteinte
// (sécurité si le coup a été interrompu avant l'événement HitboxOff)
HitboxOff();
// Zone de détection : la cible entre = poursuite, la cible sort = abandon
if (lost && distance <= detectionRadius)
{
lost = false;
paused = false;
agent.ResetPath(); // abandonne le point d'errance en cours
nextRepath = 0f; // calcul immédiat du chemin vers la cible
}
else if (!lost && distance > detectionRadius + LoseMargin)
{
lost = true;
inContact = false;
agent.ResetPath();
// L'errance commence par une pause : le PNJ « cherche » avant de repartir
paused = true;
pauseEnd = Time.time + Random.Range(pauseMinMax.x, pauseMinMax.y);
}
// Zone de contact : la cible entre = arrêt, la cible sort = reprise de la poursuite
if (!lost)
{
if (!inContact && distance <= contactRadius)
{
inContact = true;
agent.ResetPath();
// Arrêt net : sans ça, l'agent glisse sur son élan et finit collé à la cible
agent.velocity = Vector3.zero;
}
else if (inContact && distance > contactRadius + ContactMargin)
{
inContact = false;
nextRepath = 0f;
}
}
if (Chasing)
{
desiredSpeed = runWhenChasing ? chaseSpeed : walkSpeed;
if (Time.time >= nextRepath)
{
agent.SetDestination(target.position);
nextRepath = Time.time + RepathInterval;
}
FaceTowards(agent.steeringTarget, true);
}
else if (inContact)
{
FaceTowards(target.position, false); // au contact : face à la cible
TryAttack(distance);
}
else if (wanderWhenLost)
{
Wander();
}
UpdateAnimation();
}
///
/// Attaque si la cible est au contact, que le PNJ lui fait face, qu'il la voit et que le délai est écoulé.
///
private void TryAttack(float distance)
{
if (!canAttack || Time.time < nextAttackTime) return;
// Faire face à la cible avant de frapper
Vector3 toTarget = target.position - transform.position;
toTarget.y = 0f;
if (Vector3.Angle(transform.forward, toTarget) > AttackMaxAngle) return;
// Ne pas attaquer à travers un mur
if (requireLineOfSight && !HasLineOfSight()) return;
nextAttackTime = Time.time + attackCooldown;
attackStartTime = Time.time;
if (hasAttackTrigger) animator.SetTrigger(attackTrigger);
onAttack.Invoke();
}
///
/// Appelée par un événement d'animation du clip d'attaque, au début de l'impact.
///
public void HitboxOn()
{
if (hitbox != null) hitbox.SetActive(true);
}
///
/// Appelée par un événement d'animation du clip d'attaque, à la fin de l'impact,
/// et par le CES de la hitbox quand le coup touche (une seule touche par coup).
///
public void HitboxOff()
{
if (hitbox != null && hitbox.activeSelf) hitbox.SetActive(false);
}
///
/// Vrai pendant l'animation d'attaque : de l'envoi du Trigger jusqu'au début de la transition de sortie.
/// L'état d'attaque est reconnu par son nom (Attack) ou son Tag (Attack).
///
private bool IsAttacking()
{
if (!hasAttackTrigger) return false;
// Juste après le Trigger, l'Animator n'a pas encore commencé la transition
if (Time.time < attackStartTime + AttackStartGrace) return true;
// En transition : c'est l'état de destination qui compte (entrée = oui, sortie = non)
AnimatorStateInfo state = animator.IsInTransition(0)
? animator.GetNextAnimatorStateInfo(0)
: animator.GetCurrentAnimatorStateInfo(0);
return state.IsName(AttackStateName) || state.IsTag(AttackStateName);
}
private Vector3 EyePosition => transform.position + Vector3.up * EyeHeight;
private Vector3 AimPoint => target.position + Vector3.up * TargetAimHeight;
///
/// Vrai si rien ne bloque la vue entre les yeux du PNJ et la cible.
/// Toucher un collider de la cible elle-même compte comme « vue dégagée ».
///
private bool HasLineOfSight()
{
if (!Physics.Linecast(EyePosition, AimPoint, out RaycastHit hit, Physics.DefaultRaycastLayers, QueryTriggerInteraction.Ignore))
return true;
return hit.transform.IsChildOf(target);
}
///
/// Errance : marcher vers un point au hasard autour du PNJ, faire une pause, recommencer.
/// Même logique que NPCWander.
///
private void Wander()
{
desiredSpeed = walkSpeed;
if (agent.pathPending) return;
bool arrived = !agent.hasPath || agent.remainingDistance <= agent.stoppingDistance;
if (!arrived)
{
FaceTowards(agent.steeringTarget, true);
return;
}
if (!paused)
{
paused = true;
pauseEnd = Time.time + Random.Range(pauseMinMax.x, pauseMinMax.y);
}
else if (Time.time >= pauseEnd)
{
if (PickWanderDestination()) paused = false;
}
}
///
/// Tire un point au hasard autour du PNJ, entre wanderMinDistance et wanderMaxDistance.
/// Si aucun essai ne convient (bord du NavMesh, obstacles), le point le plus éloigné est retenu.
/// Retourne false si aucun point n'a été trouvé.
///
private bool PickWanderDestination()
{
bool found = false;
Vector3 best = Vector3.zero;
float bestDistance = -1f;
for (int i = 0; i < MaxWanderAttempts; i++)
{
// Direction au hasard à plat, puis distance au hasard entre min et max
Vector2 direction = Random.insideUnitCircle.normalized;
float range = Random.Range(wanderMinDistance, wanderMaxDistance);
Vector3 point = transform.position + new Vector3(direction.x, 0f, direction.y) * range;
// Ramené sur le NavMesh, dans les zones permises à l'agent (ex. : pas dans la rue)
if (!NavMesh.SamplePosition(point, out NavMeshHit hit, wanderMaxDistance, agent.areaMask))
continue;
// SamplePosition peut rapprocher le point : distance revérifiée, à plat
Vector3 offset = hit.position - transform.position;
offset.y = 0f;
float distance = offset.magnitude;
if (distance >= wanderMinDistance)
{
agent.SetDestination(hit.position);
return true;
}
if (distance > bestDistance)
{
best = hit.position;
bestDistance = distance;
found = true;
}
}
if (found) agent.SetDestination(best);
return found;
}
///
/// Tourne le PNJ vers un point. En déplacement, l'agent est freiné tant que
/// l'écart d'angle est grand : le PNJ se tourne d'abord, puis avance.
///
private void FaceTowards(Vector3 point, bool moving)
{
Vector3 direction = point - transform.position;
direction.y = 0f;
if (direction.sqrMagnitude < 0.0001f) return;
Quaternion desiredRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.RotateTowards(transform.rotation, desiredRotation, (180f / halfTurnDuration) * Time.deltaTime);
if (moving)
{
float angle = Quaternion.Angle(transform.rotation, desiredRotation);
float factor = Mathf.Clamp01(1f - angle / maxAngleToMove);
agent.speed = desiredSpeed * factor;
}
}
private void UpdateAnimation()
{
if (animator == null) return;
bool isMoving = agent.velocity.magnitude > WalkThreshold;
if (hasWalkParameter) animator.SetBool(walkParameter, isMoving);
if (hasChaseParameter) animator.SetBool(chaseParameter, isMoving && Running);
// Vitesse de lecture : l'animation suit le déplacement réel (accélération, freinage, virages),
// pour que les pieds ne glissent pas. À pleine vitesse, elle joue à sa vitesse normale (1).
// Sans paramètre de course, la course joue l'animation de marche, accélérée.
float fullSpeed = (Running && hasChaseParameter) ? chaseSpeed : walkSpeed;
if (isMoving && fullSpeed > 0f)
animator.speed = Mathf.Clamp(agent.velocity.magnitude / fullSpeed, MinAnimSpeed, MaxAnimSpeed);
else
animator.speed = 1f; // à l'arrêt (idle, attaque), l'animation joue normalement
}
///
/// Vérifie qu'un paramètre existe dans l'Animator Controller, avec le bon type.
/// Sinon, affiche un seul avertissement clair (au lieu de celui de Unity à chaque image).
///
private bool CheckParameter(string parameterName, AnimatorControllerParameterType type, string consequence)
{
if (string.IsNullOrEmpty(parameterName)) return false;
foreach (AnimatorControllerParameter parameter in animator.parameters)
if (parameter.name == parameterName && parameter.type == type) return true;
string controller = animator.runtimeAnimatorController != null
? animator.runtimeAnimatorController.name
: "(aucun Animator Controller)";
Debug.LogWarning($"{name} : pas de {type} \"{parameterName}\" dans {controller} : {consequence}.", this);
return false;
}
// Affiche les deux zones au sol dans la vue Scene (sélectionner le PNJ)
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
DrawRing(contactRadius, Color.magenta, canAttack ? "Contact / attaque" : "Contact");
DrawRing(detectionRadius, Color.red, "Détection");
#endif
// Ligne de vue vers la cible (en Play) : verte = dégagée, rouge = bloquée
if (canAttack && requireLineOfSight && Application.isPlaying && target != null)
{
Gizmos.color = HasLineOfSight() ? Color.green : Color.red;
Gizmos.DrawLine(EyePosition, AimPoint);
}
}
#if UNITY_EDITOR
///
/// Dessine un cercle à plat au sol, avec son nom et son rayon (vue Scene seulement).
///
private void DrawRing(float radius, Color color, string label)
{
if (radius <= 0f) return;
Vector3 center = transform.position + Vector3.up * 0.05f; // juste au-dessus du sol
UnityEditor.Handles.color = color;
UnityEditor.Handles.DrawWireDisc(center, Vector3.up, radius, 2f);
GUIStyle style = new GUIStyle(UnityEditor.EditorStyles.boldLabel);
style.normal.textColor = color;
UnityEditor.Handles.Label(center + Vector3.right * radius, $" {label} {radius:0.#} m", style);
}
#endif
}