Waypoint Patrol AI Pro
Advanced patrol AI with NavMesh pathfinding, player detection with awareness meter, alert states, group coordination, and patrol schedules.
How to Use
Attach to enemy with NavMeshAgent on a baked NavMesh
Create empty GameObjects as waypoints, assign to array
Choose patrol mode: Loop, PingPong, or Random
Set detection radius, angle, and player layer mask
Awareness meter fills when player is in sight — triggers alert at threshold
In Alert state, AI chases player; if sight is lost, investigates last known position
Set Group ID for coordinated patrol — alerted enemies call nearby group members
Create PatrolSchedule entries for time-of-day route switching
Call SetTimeOfDay() from your DayNightCycle to update schedule
Gizmos show detection cone, awareness bar, and waypoint paths in Scene view
Source Code
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.Events;
using System.Collections.Generic;
[RequireComponent(typeof(NavMeshAgent))]
public class WaypointPatrolPro : MonoBehaviour
{
public enum PatrolMode { Loop, PingPong, Random }
public enum AIState { Patrol, Alert, Investigate, Return }
[System.Serializable]
public class PatrolSchedule
{
public string scheduleName = "Default";
[Range(0f, 24f)] public float startHour = 0f;
[Range(0f, 24f)] public float endHour = 24f;
public Transform[] waypoints;
}
[Header("Patrol")]
[SerializeField] private Transform[] waypoints;
[SerializeField] private PatrolMode patrolMode = PatrolMode.Loop;
[SerializeField] private float waitTimeAtPoint = 1.5f;
[SerializeField] private float patrolSpeed = 3f;
[Header("Detection")]
[SerializeField] private float detectionRadius = 12f;
[SerializeField] private float detectionAngle = 110f;
[SerializeField] private float awarenessRate = 1.5f;
[SerializeField] private float awarenessDrain = 0.8f;
[SerializeField] private float alertThreshold = 1f;
[SerializeField] private LayerMask playerLayer;
[SerializeField] private LayerMask obstacleMask;
[Header("Alert")]
[SerializeField] private float alertSpeed = 5f;
[SerializeField] private float investigateTime = 4f;
[SerializeField] private float chaseGiveUpDistance = 20f;
[Header("Group")]
[SerializeField] private string groupId = "default";
[SerializeField] private float alertCallRange = 15f;
[SerializeField] private bool canCallBackup = true;
[Header("Schedule")]
[SerializeField] private PatrolSchedule[] schedules;
[SerializeField] private float currentTimeOfDay = 12f;
[Header("Events")]
public UnityEvent<Transform> OnPlayerDetected;
public UnityEvent OnPlayerLost;
public UnityEvent OnAlertTriggered;
public UnityEvent<float> OnAwarenessChanged;
[Header("Debug")]
[SerializeField] private bool showDebugGizmos = true;
private NavMeshAgent agent;
private AIState currentState = AIState.Patrol;
private int currentWaypointIndex;
private int patrolDirection = 1;
private float waitTimer;
private float awareness;
private float investigateTimer;
private Transform detectedPlayer;
private Vector3 lastKnownPosition;
private Vector3 originPosition;
// Group coordination
private static readonly Dictionary<string, List<WaypointPatrolPro>> groups
= new Dictionary<string, List<WaypointPatrolPro>>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ClearGroups() { groups.Clear(); }
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
originPosition = transform.position;
}
private void OnEnable()
{
if (!groups.ContainsKey(groupId))
groups[groupId] = new List<WaypointPatrolPro>();
groups[groupId].Add(this);
}
private void OnDisable()
{
if (groups.ContainsKey(groupId))
groups[groupId].Remove(this);
}
private void Update()
{
UpdateSchedule();
DetectPlayer();
switch (currentState)
{
case AIState.Patrol: UpdatePatrol(); break;
case AIState.Alert: UpdateAlert(); break;
case AIState.Investigate: UpdateInvestigate(); break;
case AIState.Return: UpdateReturn(); break;
}
}
private void UpdatePatrol()
{
agent.speed = patrolSpeed;
if (waypoints == null || waypoints.Length == 0) return;
Transform target = waypoints[currentWaypointIndex];
if (target == null) return;
if (!agent.hasPath || agent.remainingDistance < 0.5f)
{
waitTimer -= Time.deltaTime;
if (waitTimer <= 0f)
{
AdvanceWaypoint();
Transform next = waypoints[currentWaypointIndex];
if (next != null)
agent.SetDestination(next.position);
waitTimer = waitTimeAtPoint;
}
}
}
private void AdvanceWaypoint()
{
switch (patrolMode)
{
case PatrolMode.Loop:
currentWaypointIndex = (currentWaypointIndex + 1) % waypoints.Length;
break;
case PatrolMode.PingPong:
currentWaypointIndex += patrolDirection;
if (currentWaypointIndex >= waypoints.Length - 1 || currentWaypointIndex <= 0)
patrolDirection *= -1;
break;
case PatrolMode.Random:
int next;
do { next = Random.Range(0, waypoints.Length); }
while (next == currentWaypointIndex && waypoints.Length > 1);
currentWaypointIndex = next;
break;
}
}
private void DetectPlayer()
{
Collider[] hits = Physics.OverlapSphere(transform.position, detectionRadius, playerLayer);
Transform closestPlayer = null;
float closestDist = float.MaxValue;
foreach (var hit in hits)
{
Vector3 dir = hit.transform.position - transform.position;
float angle = Vector3.Angle(transform.forward, dir);
float dist = dir.magnitude;
if (angle > detectionAngle * 0.5f) continue;
// Line-of-sight check
if (Physics.Raycast(transform.position + Vector3.up, dir.normalized,
dist, obstacleMask)) continue;
if (dist < closestDist)
{
closestDist = dist;
closestPlayer = hit.transform;
}
}
if (closestPlayer != null)
{
// Awareness increases based on proximity
float distFactor = 1f - (closestDist / detectionRadius);
awareness += awarenessRate * distFactor * Time.deltaTime;
OnAwarenessChanged?.Invoke(awareness);
if (awareness >= alertThreshold)
{
detectedPlayer = closestPlayer;
lastKnownPosition = closestPlayer.position;
if (currentState == AIState.Patrol)
{
currentState = AIState.Alert;
OnPlayerDetected?.Invoke(detectedPlayer);
OnAlertTriggered?.Invoke();
if (canCallBackup)
AlertGroup(closestPlayer);
}
}
}
else
{
awareness = Mathf.Max(0f, awareness - awarenessDrain * Time.deltaTime);
OnAwarenessChanged?.Invoke(awareness);
}
}
private void UpdateAlert()
{
agent.speed = alertSpeed;
if (detectedPlayer != null && detectedPlayer.gameObject.activeInHierarchy)
{
float dist = Vector3.Distance(transform.position, detectedPlayer.position);
// Check line of sight
Vector3 dir = detectedPlayer.position - transform.position;
bool canSee = !Physics.Raycast(transform.position + Vector3.up,
dir.normalized, dir.magnitude, obstacleMask);
if (canSee)
{
lastKnownPosition = detectedPlayer.position;
agent.SetDestination(detectedPlayer.position);
}
else
{
// Lost sight — investigate last known position
currentState = AIState.Investigate;
investigateTimer = investigateTime;
agent.SetDestination(lastKnownPosition);
return;
}
if (dist > chaseGiveUpDistance)
{
currentState = AIState.Investigate;
investigateTimer = investigateTime;
agent.SetDestination(lastKnownPosition);
}
}
else
{
currentState = AIState.Investigate;
investigateTimer = investigateTime;
agent.SetDestination(lastKnownPosition);
}
}
private void UpdateInvestigate()
{
agent.speed = patrolSpeed;
if (agent.remainingDistance < 1f)
{
// Disable agent rotation so manual look-around works
agent.updateRotation = false;
investigateTimer -= Time.deltaTime;
// Look around
transform.Rotate(0f, 90f * Time.deltaTime, 0f);
if (investigateTimer <= 0f)
{
agent.updateRotation = true;
TransitionToReturn();
OnPlayerLost?.Invoke();
}
}
}
private void TransitionToReturn()
{
currentState = AIState.Return;
awareness = 0f;
detectedPlayer = null;
if (waypoints != null && waypoints.Length > 0)
{
Transform wp = waypoints[currentWaypointIndex];
if (wp != null) agent.SetDestination(wp.position);
}
else
{
agent.SetDestination(originPosition);
}
}
private void UpdateReturn()
{
agent.speed = patrolSpeed;
if (agent.remainingDistance < 0.5f)
{
currentState = AIState.Patrol;
waitTimer = waitTimeAtPoint;
}
}
private void UpdateSchedule()
{
if (schedules == null || schedules.Length == 0) return;
foreach (var schedule in schedules)
{
bool inRange = schedule.startHour < schedule.endHour
? (currentTimeOfDay >= schedule.startHour && currentTimeOfDay < schedule.endHour)
: (currentTimeOfDay >= schedule.startHour || currentTimeOfDay < schedule.endHour);
if (inRange && schedule.waypoints != null && schedule.waypoints.Length > 0)
{
if (waypoints != schedule.waypoints)
{
waypoints = schedule.waypoints;
currentWaypointIndex = 0;
}
return;
}
}
}
private void AlertGroup(Transform target)
{
if (!groups.ContainsKey(groupId)) return;
foreach (var member in groups[groupId])
{
if (member == this) continue;
float dist = Vector3.Distance(transform.position, member.transform.position);
if (dist <= alertCallRange)
member.ReceiveAlert(target, target.position);
}
}
/// <summary>Receive an alert from a group member.</summary>
public void ReceiveAlert(Transform target, Vector3 position)
{
if (currentState != AIState.Patrol) return;
detectedPlayer = target;
lastKnownPosition = position;
currentState = AIState.Alert;
awareness = alertThreshold;
OnAlertTriggered?.Invoke();
}
/// <summary>Set the time of day for schedule-based patrol routes.</summary>
public void SetTimeOfDay(float hour)
{
currentTimeOfDay = Mathf.Repeat(hour, 24f);
}
/// <summary>Force return to patrol state.</summary>
public void ForceReturnToPatrol()
{
TransitionToReturn();
}
/// <summary>Current AI state.</summary>
public AIState CurrentState => currentState;
/// <summary>Current awareness level (0 to alertThreshold).</summary>
public float Awareness => awareness;
#if UNITY_EDITOR
private void OnDrawGizmosSelected()
{
if (!showDebugGizmos) return;
// Detection radius
Gizmos.color = new Color(1f, 1f, 0f, 0.1f);
Gizmos.DrawWireSphere(transform.position, detectionRadius);
// Detection cone
Vector3 leftDir = Quaternion.Euler(0, -detectionAngle * 0.5f, 0) * transform.forward;
Vector3 rightDir = Quaternion.Euler(0, detectionAngle * 0.5f, 0) * transform.forward;
Gizmos.color = new Color(1f, 0.5f, 0f, 0.5f);
Gizmos.DrawRay(transform.position, leftDir * detectionRadius);
Gizmos.DrawRay(transform.position, rightDir * detectionRadius);
// Awareness bar
if (Application.isPlaying)
{
Vector3 barPos = transform.position + Vector3.up * 2.5f;
Gizmos.color = Color.Lerp(Color.green, Color.red, awareness / Mathf.Max(alertThreshold, 0.01f));
float barWidth = awareness / Mathf.Max(alertThreshold, 0.01f) * 1f;
Gizmos.DrawCube(barPos, new Vector3(barWidth, 0.1f, 0.1f));
}
// Waypoint path
if (waypoints != null && waypoints.Length > 1)
{
Gizmos.color = new Color(0f, 0.8f, 1f, 0.5f);
for (int i = 0; i < waypoints.Length - 1; i++)
{
if (waypoints[i] != null && waypoints[i + 1] != null)
Gizmos.DrawLine(waypoints[i].position, waypoints[i + 1].position);
}
if (patrolMode == PatrolMode.Loop && waypoints[0] != null
&& waypoints[waypoints.Length - 1] != null)
{
Gizmos.DrawLine(waypoints[waypoints.Length - 1].position,
waypoints[0].position);
}
}
// Alert call range
if (canCallBackup)
{
Gizmos.color = new Color(1f, 0f, 0f, 0.05f);
Gizmos.DrawWireSphere(transform.position, alertCallRange);
}
// Last known position
if (Application.isPlaying && currentState == AIState.Investigate)
{
Gizmos.color = Color.yellow;
Gizmos.DrawSphere(lastKnownPosition, 0.3f);
}
}
#endif
}