Need something simpler? See the (basic version).
Part of these game systems:
intermediate AI & Pathfinding PRO

Waypoint Patrol AI Pro

Advanced patrol AI with NavMesh pathfinding, player detection with awareness meter, alert states, group coordination, and patrol schedules.

Unity 2022.3+ · 6.0 KB · WaypointPatrolPro.cs

How to Use

1

Attach to enemy with NavMeshAgent on a baked NavMesh

2

Create empty GameObjects as waypoints, assign to array

3

Choose patrol mode: Loop, PingPong, or Random

4

Set detection radius, angle, and player layer mask

5

Awareness meter fills when player is in sight — triggers alert at threshold

6

In Alert state, AI chases player; if sight is lost, investigates last known position

7

Set Group ID for coordinated patrol — alerted enemies call nearby group members

8

Create PatrolSchedule entries for time-of-day route switching

9

Call SetTimeOfDay() from your DayNightCycle to update schedule

10

Gizmos show detection cone, awareness bar, and waypoint paths in Scene view

Source Code

WaypointPatrolPro.cs
C#
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
}