How to improve this enemy script?

I’m making a fangame, but there seems to be something wrong with this AI, and I don’t know where these bugs are coming from.

While playing in the editor, I found a few issues with this script:

  1. The DefindGrannyDestinations() method can sometimes get messy, specifically because it’s duplicated (probably because of FixedUpdate(), but I don’t know how to deal with it).
  2. The jump in bool value is also confusing, and I have absolutely no idea what causes this phenomenon.
    By the way, when the bool value jump and the DefindGrannyDestinations() method is executed in the initial automatic patrol, as long as the Player does not interfere with Granny, everything will be fine, but once Granny finds the Player and chases it, the above “bool jump and DefindGrannyDestinations()” problem will become very confusing.

Not only that, but playerCaught() is also very chaotic in execution, and playing GrannyJumpscare may also be due to the intervention of FixedUpdate() so that it is played once per frame…

In addition to that, I’d like to ask for some advice on how to give Granny more flexibility to “discover” the Player, rather than using a bunch of rays to detect the Player. Thank you for your help!

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

//This is a script for testing GrannyAI!
[RequireComponent(typeof(AudioSource))]
public class EnemyGrannyAI : MonoBehaviour
{
    //Jumpscare Sound
    public AudioClip GrannyJumpscare;
    
    //Vocies of Granny
    public AudioClip[] GrannySounds;

    //Musiken Elements
    public GameObject chaseMusik;


    //Granny Animator
    public Animator GrannyAnim;


    //AI Elements
    public NavMeshAgent Granny;

    private float waypointTime = 7f;
    private float lookAroundTime = 6.32f;
    private float walkSpeed = 1.4f;
    private float followSpeed = 3f;
    private float caughtSpeed = 0f;
    private float raycastLength = 40f;
    private float playerSafeTime= 7f;
    
    public bool GrannyisSearching;
    public bool GrannySearchedWaiting = false;
    public bool GrannyisChasing;
    public bool GrannyChasedWaiting = false;
    private bool SeePlayer;
    public bool playerGetCaught = false;
    private bool canFadeMusik;

    public Transform[] patrolPoints;
    public Transform granny;
    public Transform player;

    public GameObject SeePlayerPos;
    public GameObject Player;
    public GameObject PlayerCam;

    private void Start()
    {
        walkSpeed = Granny.speed;
        Granny.stoppingDistance = 2f;
        DefindGrannyDestination();
        playerGetCaught = false;
        GrannyisSearching = true;
        GrannySearchedWaiting = false;
        GrannyisChasing = false;
        GrannyChasedWaiting = false;
    }

    private void FixedUpdate()
    {
        //RayForward
        CastRay(transform.forward);
        //RayLeft
        CastRay(Quaternion.Euler(0, -90, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, -75, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, -60, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, -45, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, -30, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, -15, 0) * transform.forward);
        //RayRight
        CastRay(Quaternion.Euler(0, 90, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, 75, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, 60, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, 45, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, 30, 0) * transform.forward);
        CastRay(Quaternion.Euler(0, 15, 0) * transform.forward);
        //RayUp
        CastRay(Quaternion.Euler(80, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(75, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(60, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(45, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(30, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(15, 0, 0) * transform.forward);
        //RayDown
        CastRay(Quaternion.Euler(-80, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(-75, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(-60, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(-45, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(-30, 0, 0) * transform.forward);
        CastRay(Quaternion.Euler(-15, 0, 0) * transform.forward);

        //Function Group
        JudgeGrannyStatus();
        GrannyAnimationsChecker();
        GrannyReachedChecker();
        FadeChaseMusik();
    }

    private void CastRay(Vector3 direction)
    {
        RaycastHit hit;
        if (Physics.Raycast(SeePlayerPos.transform.position, direction, out hit, raycastLength) && hit.collider.gameObject.tag == "Player")
        {
            SeePlayer = true;
            Debug.DrawLine(SeePlayerPos.transform.position, hit.point, Color.red);
        }
        else
        {
            SeePlayer = false;
            Debug.DrawLine(SeePlayerPos.transform.position, hit.point, Color.red);
        }
    }

    private void GrannyAnimationsChecker()
    {
        if(GrannyisSearching == true && GrannySearchedWaiting == false && GrannyisChasing == false && GrannyChasedWaiting == false && playerGetCaught == false)
        {
            GrannyAnim.Play("Walk_1");
            walkSpeed = Granny.speed;
        }
        else if(GrannyisSearching == false && GrannySearchedWaiting == true && GrannyisChasing == false && GrannyChasedWaiting == false && playerGetCaught == false)
        {
            GrannyAnim.Play("idle_4");
            walkSpeed = Granny.speed;
        }
        else if (GrannyisSearching == false && GrannySearchedWaiting == false && GrannyisChasing == true && GrannyChasedWaiting == false && playerGetCaught == false)
        {
            GrannyAnim.Play("EasyGranny");
            followSpeed = Granny.speed;
        }
        else if (GrannyisSearching == false && GrannySearchedWaiting == false && GrannyisChasing == false && GrannyChasedWaiting == true && playerGetCaught == false)
        {
            GrannyAnim.Play("Look");
            walkSpeed = Granny.speed;
        }
        else if (GrannyisSearching == false && GrannySearchedWaiting == false && GrannyisChasing == false && GrannyChasedWaiting == false && playerGetCaught == true)
        {
            GrannyAnim.Play("Hit_0");
            caughtSpeed = Granny.speed;
        }
    }
    
    private void GrannyReachedChecker()
    {
        if (GrannyisChasing == true)
        {
            Granny.stoppingDistance = 3f;

            if (Granny.remainingDistance < Granny.stoppingDistance)
            {
                StartCoroutine(playerCaught());
                playerGetCaught = true;
                GrannyisSearching = false;
                GrannySearchedWaiting = false;
                GrannyisChasing = false;
                GrannyChasedWaiting = false;
            }
        }
        else
        {
            Granny.stoppingDistance = 2f;
            
            if (Granny.remainingDistance < Granny.stoppingDistance)
            {
                StartCoroutine(GrannyWaitedAtRandomDestination());
                playerGetCaught = false;
                GrannyisSearching = false;
                GrannySearchedWaiting = true;
                GrannyisChasing = false;
                GrannyChasedWaiting = false;
            }
            else
            {
                playerGetCaught = false;
                GrannyisSearching = true;
                GrannySearchedWaiting = false;
                GrannyisChasing = false;
                GrannyChasedWaiting = false;
            }
        }
    }

    private void JudgeGrannyStatus()
    {
        if (SeePlayer == true)
        {
            GrannyisSearching = false;
            GrannySearchedWaiting = false;
            GrannyisChasing = true;
            GrannyChasedWaiting = false;
            StartCoroutine(followPlayer());
        }
    }

    private void FadeChaseMusik()
    {
        if(canFadeMusik == true)
        {
            chaseMusik.SetActive(true);
            chaseMusik.GetComponent<AudioSource>().volume = chaseMusik.GetComponent<AudioSource>().volume + 0.3f * Time.deltaTime;
        }
        else
        {
            chaseMusik.GetComponent<AudioSource>().volume = chaseMusik.GetComponent<AudioSource>().volume - 0.3f * Time.deltaTime;
            if(chaseMusik.GetComponent<AudioSource>().volume <= 0f)
            {
                chaseMusik.SetActive(false);
            }
            else
            {
                chaseMusik.SetActive(true);
            }
        }
    }

    IEnumerator followPlayer()
    {
        Granny.SetDestination(player.position);
        canFadeMusik = true;

        yield return new WaitForSeconds(playerSafeTime);
        if(SeePlayer == true)
        {
            GrannyisSearching = false;
            GrannySearchedWaiting = false;
            GrannyisChasing = true;
            GrannyChasedWaiting = false;
            StartCoroutine(followPlayer());
        }
        else
        {
            GrannyisSearching = false;
            GrannySearchedWaiting = false;
            GrannyisChasing = false;
            GrannyChasedWaiting = true;
            StartCoroutine(GrannyWaitedAtPlayerDisappearingDestinations());
            StopCoroutine(followPlayer());
        }
        
        if(playerGetCaught == true)
        {
            GrannyisSearching = false;
            GrannySearchedWaiting = false;
            GrannyisChasing = false;
            GrannyChasedWaiting = false;
            StopCoroutine(GrannyWaitedAtPlayerDisappearingDestinations());
            StopCoroutine(followPlayer());
        }
    }

    IEnumerator GrannyWaitedAtPlayerDisappearingDestinations()
    {
        if (GrannyChasedWaiting == true)
        {
            canFadeMusik = false;
            GrannyisSearching = false;
            GrannySearchedWaiting = false;
            GrannyisChasing = false;

            yield return new WaitForSeconds(lookAroundTime);
            DefindGrannyDestination();
            GrannyisSearching = true;
            GrannySearchedWaiting = false;
            GrannyisChasing = false;
            UpdateGrannySounds();
            GrannyChasedWaiting = false;
            StopCoroutine(GrannyWaitedAtPlayerDisappearingDestinations());
        }
        else
        {
            yield return null;
        }
    }

    IEnumerator GrannyWaitedAtRandomDestination()
    {
        if (GrannySearchedWaiting == true)
        {
            yield return new WaitForSeconds(waypointTime);
            DefindGrannyDestination();
            GrannyisSearching = true;
            GrannySearchedWaiting = false;
            StopCoroutine(GrannyWaitedAtRandomDestination());
        }
        else
        {
            yield return null;
        }
    }
    IEnumerator playerCaught()
    {
        if(playerGetCaught == true)
        {
            canFadeMusik = false;
            player.LookAt(granny);
            ((PlayerController)Player.GetComponent(typeof(PlayerController))).canMove = false;
            ((CameraController)PlayerCam.GetComponent(typeof(CameraController))).canRotate = false;
            GetComponent<AudioSource>().PlayOneShot(GrannyJumpscare);
            playerGetCaught = false;
            StopCoroutine(playerCaught());
        }
        yield break;
    }

    public virtual void DefindGrannyDestination()
    {
        int randomIndex = Random.Range(0, patrolPoints.Length);
        Granny.SetDestination(patrolPoints[randomIndex].position);
    }


    public virtual void UpdateGrannySounds()
    {
        GetComponent<AudioSource>().clip = GrannySounds[Random.Range(0, GrannySounds.Length)];
        GetComponent<AudioSource>().Play();
    }
}

9 Answers

9

BUG: Player detection is broken

This is a bug, because CastRay overrides SeePlayer at every call:

This results in SeePlayer being true only when the very last call to CastRay hits the player. Functionally it works like this at the moment:

private void FixedUpdate()
{
    CastRay(Quaternion.Euler(-15, 0, 0) * transform.forward);

    //Function Group
    JudgeGrannyStatus();
    GrannyAnimationsChecker();
    GrannyReachedChecker();
    FadeChaseMusik();
}

This must be completely game-breaking so this is the bug you are looking for, I imagine.

BUGFIX

void FixedUpdate()
{
    bool castRay(Vector3 direction)
    {
        bool didHitPlayer = Physics.Raycast(SeePlayerPos.transform.position, direction, out RaycastHit hit, raycastLength) && hit.collider.CompareTag("Player");
        Debug.DrawLine(SeePlayerPos.transform.position, hit.point, didHitPlayer ? Color.red : Color.yellow);
        return didHitPlayer;
    }

    Vector3 forward = transform.forward;
    SeePlayer =
        //RayForward
        castRay(forward)
        //RayLeft
        || castRay(Quaternion.Euler(0, -90, 0) * forward)
        || castRay(Quaternion.Euler(0, -75, 0) * forward)
        || castRay(Quaternion.Euler(0, -60, 0) * forward)
        || castRay(Quaternion.Euler(0, -45, 0) * forward)
        || castRay(Quaternion.Euler(0, -30, 0) * forward)
        || castRay(Quaternion.Euler(0, -15, 0) * forward)
        //RayRight
        || castRay(Quaternion.Euler(0, 90, 0) * forward)
        || castRay(Quaternion.Euler(0, 75, 0) * forward)
        || castRay(Quaternion.Euler(0, 60, 0) * forward)
        || castRay(Quaternion.Euler(0, 45, 0) * forward)
        || castRay(Quaternion.Euler(0, 30, 0) * forward)
        || castRay(Quaternion.Euler(0, 15, 0) * forward)
        //RayUp
        || castRay(Quaternion.Euler(80, 0, 0) * forward)
        || castRay(Quaternion.Euler(75, 0, 0) * forward)
        || castRay(Quaternion.Euler(60, 0, 0) * forward)
        || castRay(Quaternion.Euler(45, 0, 0) * forward)
        || castRay(Quaternion.Euler(30, 0, 0) * forward)
        || castRay(Quaternion.Euler(15, 0, 0) * forward)
        //RayDown
        || castRay(Quaternion.Euler(-80, 0, 0) * forward)
        || castRay(Quaternion.Euler(-75, 0, 0) * forward)
        || castRay(Quaternion.Euler(-60, 0, 0) * forward)
        || castRay(Quaternion.Euler(-45, 0, 0) * forward)
        || castRay(Quaternion.Euler(-30, 0, 0) * forward)
        || castRay(Quaternion.Euler(-15, 0, 0) * forward);

    //Function Group
    JudgeGrannyStatus();
    GrannyAnimationsChecker();
    GrannyReachedChecker();
    FadeChaseMusik();
}

BUG: This is not how you stop a coroutine

What this does is actually starts a coroutine and then stops it at the first yield keyword.

BUGFIX

Coroutines stops automagically when they hit end of their method’s scope (execute to the end).

If you need to stop a coroutine from outside call it by name (no () at the end):

StopCoroutine(nameof(GrannyWaitedAtRandomDestination));

If you need to exit a coroutine from inside & immediately call:

yield break;

Note that you are right that this doesn't stop any coroutines, but it also doesn't start any. It just creates the IEnumerator object and does nothing with it. This specific IEnumerator object is not registrated by Unity's coroutine scheduler so passing it to StopCoroutine simply does nothing and the created IEnumerator is up for garbage collection. Nothing else would happen. MoveNext of the IEnumerator would not be called even once.

Thanks for letting me know @Bunny83! You're the best :) That makes absolute sense when I think about it now; should have tried it empirically before posting it.

BUG: followPlayer coroutine stops the NavMeshAgent in place.

My assumption here: followPlayer should cause NavMeshAgent to follow the player.

When SeePlayer is true this code is starting followPlayer coroutine 50 times a second…

FixedUpdate() calls → JudgeGrannyStatusStartCoroutine(followPlayer());

This can’t work.

But wait. There is more.

followPlayer coroutine immediately calls Granny.SetDestination(player.position); at the same frequency of 50Hz.

BUGFIX:

Delete this code, rewrite - it does nothing but produce issues right now.

I thought about it just now. I also think it's because of FixedUpdate() that the coroutine is executed repeatedly. The executed coroutine contains the content of changing the bool value, and I start the coroutine and animation through the condition of determining the bool value. Such...This also leads to multiple coroutines executing at the same time at 50 times per second, and the playback of animations and the execution of DefindGrannyDestinations() are messed up (somewhat funny)

I dont know how experienced you are but here is a Sight script:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class PlayerSight : MonoBehaviour
{
    // Start is called before the first frame update
    public float distance = 10;
    public float angle = 30;
    public float height = 1.0f;
    public Color meshColor = Color.red;
    public int scanFrequency = 30;
    public LayerMask layers;
    public LayerMask occlusionLayers;
    public List<GameObject> Objects = new List<GameObject>();
    public bool IsSpotted;

    Collider[] colliders = new Collider[50];
    Mesh mesh;
    int count;
    float scanInterval;
    float scanTimer;

    void Start()
    {
        scanInterval = 1.0f / scanFrequency;
    }

    // Update is called once per frame
    void Update()
    {
        scanTimer -= Time.deltaTime;
        if (scanTimer < 0)
        {
            scanTimer += scanInterval;
            Scan();
        }
    }
    private void Scan()
    {
        IsSpotted = false;
        count = Physics.OverlapSphereNonAlloc(transform.position, distance, colliders, layers, QueryTriggerInteraction.Collide);

        Objects.Clear();
        for (int i = 0; i < count; ++i)
        {
            GameObject obj = colliders[i].gameObject;
            if (IsInSight(obj))
            {
                Objects.Add(obj);
                IsSpotted = true;
            }
        }
        if (Objects.Count == 0)
        {
            IsSpotted = false;
        }
    }
    public bool IsInSight(GameObject obj)
    {

        Vector3 origin = transform.position;
        Vector3 dest = obj.transform.position;
        Vector3 direction = dest - origin;
        if (direction.y < 0 || direction.y > height)
        {
            return false;
        }

        direction.y = 0;
        float deltaAngle = Vector3.Angle(direction, transform.forward);
        if (deltaAngle > angle)
        {
            return false;
        }
        origin.y += height / 2;
        dest.y = origin.y;
        if (Physics.Linecast(origin, dest, occlusionLayers))
        {
            return false;
        }
        return true;
    }
    Mesh CreateWedgeMesh()
    {
        Mesh mesh = new Mesh();

        int segments = 10;
        int numTriangles = (segments * 4) + 2 + 2;
        int numVertices = numTriangles * 3;

        Vector3[] vertices = new Vector3[numVertices];
        int[] triangles = new int[numVertices];

        Vector3 bottomCenter = Vector3.zero;
        Vector3 bottomLeft = Quaternion.Euler(0, -angle, 0) * Vector3.forward * distance;
        Vector3 bottomRight = Quaternion.Euler(0, angle, 0) * Vector3.forward * distance;

        Vector3 topCenter = bottomCenter + Vector3.up * height;
        Vector3 topRight = bottomRight + Vector3.up * height;
        Vector3 topLeft = bottomLeft + Vector3.up * height;

        int vert = 0;
        //left side
        vertices[vert++] = bottomCenter;
        vertices[vert++] = bottomLeft;
        vertices[vert++] = topLeft;

        vertices[vert++] = topLeft;
        vertices[vert++] = topCenter;
        vertices[vert++] = bottomCenter;
        //right side
        vertices[vert++] = bottomCenter;
        vertices[vert++] = topCenter;
        vertices[vert++] = topRight;

        vertices[vert++] = topRight;
        vertices[vert++] = bottomRight;
        vertices[vert++] = bottomCenter;

        float currentAngle = -angle;
        float deltaAngle = (angle * 2) / segments;
        for (int i = 0; i < segments; ++i)
        {

            bottomLeft = Quaternion.Euler(0, currentAngle, 0) * Vector3.forward * distance;
            bottomRight = Quaternion.Euler(0, currentAngle + deltaAngle, 0) * Vector3.forward * distance;


            topRight = bottomRight + Vector3.up * height;
            topLeft = bottomLeft + Vector3.up * height;
            // far side
            vertices[vert++] = bottomLeft;
            vertices[vert++] = bottomRight;
            vertices[vert++] = topRight;

            vertices[vert++] = topRight;
            vertices[vert++] = topLeft;
            vertices[vert++] = bottomLeft;
            //top
            vertices[vert++] = topCenter;
            vertices[vert++] = topLeft;
            vertices[vert++] = topRight;
            //bottom
            vertices[vert++] = bottomCenter;
            vertices[vert++] = bottomRight;
            vertices[vert++] = bottomLeft;


            currentAngle += deltaAngle;
        }



        for (int i = 0; i < numVertices; ++i)
        {
            triangles[i] = i;
        }


        mesh.vertices = vertices;
        mesh.triangles = triangles;
        mesh.RecalculateNormals();

        return mesh;
    }
    private void OnValidate()
    {
        mesh = CreateWedgeMesh();

        scanInterval = 1.0f / scanFrequency;

    }

    private void OnDrawGizmos()
    {
        if (mesh)
        {
            Gizmos.color = meshColor;
            Gizmos.DrawMesh(mesh, transform.position, transform.rotation);

        }
        Gizmos.DrawWireSphere(transform.position, distance);
        for (int i = 0; i < count; ++i)
        {
            Gizmos.DrawSphere(colliders[i].transform.position, 0.2f);
        }
        Gizmos.color = Color.green;
        foreach (var obj in Objects)
        {
            Gizmos.DrawSphere(obj.transform.position, 0.2f);
        }
    }
}

the layer or occlusion layer i dont remember you have to try needs to be the same layer as the player and you can change the public class to Enemy Sight i forgot to change it. When a object with tag player or whatever is in Sight it will show it on the Objects list.

Update is for rendering and input readouts, not physics simulation calls. For that use FixedUpdate instead.

Considerable percentage of these bugs must have been caused by these 6 innocent-looking lines of code:

Why is this even a problem - you might ask. Well, take a look at occurrences of a single bool across this file:

it’s all over the place!

I’m aware this seemed very simple, initially, but now:

  1. This is a complex and error-prone control over how many states are active at once.

  2. Nearly impossible mentalization of what code is even executed when transitions happen between these fuzzy sets of states.

  3. Adding an additional bools to this becomes nearly impossible to do without introducing bugs, because number or state permutations can grow ±exponentially here.


This code screams for a state machine.


FSM

Turn this into a FSM and you will halve number of existing bugs and reduce chances for introducing new ones in the future. What this will allow you to do is to silo the separate state logic from each other and make adding new states straight-forward again.

Imagine refactoring all these bools into a list of states:

var start = new GrannyIdleState();
var wait = new GrannyIdleState();
var search = new GrannyHunterState();
var chase = new GrannyAgroState();
var kill = new GrannyKillerState();
// etc.

list of allowed transitions between these states:

GrannyStateTransition start_to_wait, wait_to_search, search_to_chase,
                         search_to_kill, search_to_wait;// etc.

define conditions and destinations for these transitions:

search_to_chase = new GrannyStateTransition(
    predicate:      (state,time) => owner.SeePlayer ,
    destination:    chase ,
    label:          "granny moving for a kill"
);
// etc.

and put this state-related code in a single place - a state class:

public class GrannyHunterState : GrannyState
{
    public override void OnEnter ( GrannyState previous , float time )
    {
        /* code executed when this state starts*/
        // granny speeds & animation state changes
    }
    public override void OnExit ( GrannyState next )
    {
        /* code executed when this state ends*/
        // granny speeds up & animation state resets
    }
    public override void Tick ( float time )
    {
        /* code executed every frame while this state stays active*/
        // granny checks if it still wants to chase that poor bastard
    }
}

and adding more states ceases to be a problem.

If you don’t know where to start with FSM, I wrote a simple to use FSM for cases like this one:

EDIT:

Read this post linked below. I think it makes creating a FSMs super straight-forward, immediately useful and requires no external packages at all.

How to improve this enemy script? - #8 by andrew-lukasik

First of all, thank you for providing me with a new way of thinking about solving this problem, and I will try it too. However, in this project, I prefer to change a little bit of code from the original script to solve the problem, because I use the original game author's AI script as a reference and make changes from it, and constantly try to add "my own elements". So, if it's just a change from the original script, what else do you suggest?

BUG: Animations are probably stuck

This strikes me a potential bug-material. I can’t test it but you are calling GrannyAnim.Play("ANIM_STATE_NAME"); something like 50 times per second.

Does animation works fine here?

Or does it get stuck in certain poses or very short & looping movements?
Haven’t tested it but my bet is on broken.

BUGFIX

Clean and simple. Call GrannyAnim.Play(_) only at the exact moment when you change related bool states. For example:

GrannyisChasing = true;
GrannyAnim.Play("EasyGranny");

and

GrannyisSearching = true;
GrannyAnim.Play("Walk_1");

This is the way

I suggest going for FSM even more now. But. But this time I would suggest a super straight-forward, inline FSM implementation that anyone will understand.

Check this out. All you need to define a FSM here is this code:

public enum EState : byte
{
    START = 0,
    SEARCHING,
    SEARCHED_WAITING,
    CHASING,
    CHASED_WAITING,
    PLAYER_CAUGHT,
}

EState _state = EState.START;
float _stateChangeTime;

void FixedUpdate()
{
    OnStateTick(_state, Time.time - _stateChangeTime);
}

void ChangeState(EState nextState)
{
    if (nextState == _state)
    {
        // do NOT comment this out, rethink the call logic to fix the damn warning
        Debug.LogError($"{nameof(ChangeState)} fails as {nextState} is a current state already.");
        return;
    }

    OnStateExit(_state);
    _state = nextState;
    _stateChangeTime = Time.time;
    OnStateEnter(_state);
}

/// <summary>Good place to change animations, moving speed or play sounds etc.</summary>
void OnStateEnter (EState state)
{
    // state starts:
    if (state == EState.START)
    {

    }
    else if (state == EState.SEARCHING)
    {

    }
    else if (state == EState.SEARCHED_WAITING)
    {

    }
    else if (state == EState.CHASING)
    {

    }
    else if (state == EState.CHASED_WAITING)
    {

    }
    else if (state == EState.PLAYER_CAUGHT)
    {

    }
    else throw new System.NotImplementedException($"{state} not implemented");
}

/// <summary>Good place to reset values</summary>
void OnStateExit(EState state)
{
    // state ends:
    if (state == EState.START)
    {

    }
    else if (state == EState.SEARCHING)
    {

    }
    else if (state == EState.SEARCHED_WAITING)
    {

    }
    else if (state == EState.CHASING)
    {

    }
    else if (state == EState.CHASED_WAITING)
    {

    }
    else if (state == EState.PLAYER_CAUGHT)
    {

    }
    else throw new System.NotImplementedException($"{state} not implemented");
}

/// <summary>Good place to change states - always using ChangeState(_)!.</summary>
void OnStateTick(EState state, float duration)
{
    // state update:
    if (state == EState.START)
    {
        ChangeState(EState.CHASING);
    }
    else if (state == EState.SEARCHING)
    {

    }
    else if (state == EState.SEARCHED_WAITING)
    {

    }
    else if (state == EState.CHASING)
    {

    }
    else if (state == EState.CHASED_WAITING)
    {

    }
    else if (state == EState.PLAYER_CAUGHT)
    {

    }
    else throw new System.NotImplementedException($"{state} not implemented");
}

#if UNITY_EDITOR
void OnDrawGizmos()
{
    if( Application.isPlaying ) UnityEditor.Handles.Label(transform.position, $"[{_state}]");
}
#endif

The rules here are:

  1. Do not change _state manually (i.e. using _state = EState.___)
  2. Use ChangeState(EState.___); calls to change between states. Ideal place for these calls is OnStateTick.
  3. Move all the code that should be executed only once (exactly when state change happens) to either OnStateEnter or OnStateExit into relevant if(state == EState.___){ scope here}
  4. Move all the code that should execute at 50Hz (every FixedUpdate) to OnStateTick into relevant if(state == EState.___){ scope here}

Refactored

Once your read and deeply understand advantages coming from of this FSM-based OnStateEnter/OnStateTick/OnStateExit code organization - you will (eventually) become relieved just how much more sense it all makes now.

Try this out. It’s impossible that I made no errors here, haven’t even once executed this code. But you should get the idea:

using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.AI;

/// <summary>This is a script for testing GrannyAI!</summary>
[RequireComponent(typeof(AudioSource))]
public class EnemyGrannyAI : MonoBehaviour
{
    [SerializeField][FormerlySerializedAs("GrannyJumpscare")] AudioClip _jumpscareSound;
    [SerializeField][FormerlySerializedAs("GrannySounds")] AudioClip[] _voices;
    [SerializeField][FormerlySerializedAs("chaseMusik")] GameObject _chaseMusik;
    [SerializeField][FormerlySerializedAs("GrannyAnim")] Animator _animator;
    [SerializeField][FormerlySerializedAs("Granny")] NavMeshAgent _navMeshAgent;
    [SerializeField][FormerlySerializedAs("patrolPoints")] Transform[] _patrolPoints;
    [SerializeField][FormerlySerializedAs("granny")] Transform _granny;
    [SerializeField][FormerlySerializedAs("player")] Transform _player;
    [SerializeField] Transform _playerDetectionOrigin;
    [SerializeField] PlayerController _playerController;
    [SerializeField] CameraController _playerCam;

    public bool GrannyisSearching => _state == EState.SEARCHING;
    public bool GrannySearchedWaiting => _state == EState.SEARCHED_WAITING;
    public bool GrannyisChasing => _state == EState.CHASING;
    public bool GrannyChasedWaiting => _state == EState.CHASED_WAITING;
    public bool playerGetCaught => _state == EState.PLAYER_CAUGHT;

    EState _state = EState.START;
    float _stateChangeTime = 0;
    float _waypointTime = 7f;
    float _lookAroundTime = 6.32f;
    float _walkSpeed = 1.4f;
    float _followSpeed = 3f;
    float _caughtSpeed = 0f;
    float _raycastLength = 40f;
    float _playerSafeTime = 7f;
    bool _canFadeMusik;

    void FixedUpdate()
    {
        OnStateTick(_state, Time.time - _stateChangeTime);
        FadeChaseMusik();
    }

#if UNITY_EDITOR
    void OnDrawGizmos()
    {
        if (Application.isPlaying) UnityEditor.Handles.Label(transform.position, $"[{_state}]");
    }
#endif

    void ChangeState(EState nextState)
    {
        if (nextState == _state)
        {
            // do NOT comment this out, rethink the call logic to fix the damn warning
            Debug.LogError($"{nameof(ChangeState)} fails as {nextState} is a current state already.");
            return;
        }

        OnStateExit(_state);
        _state = nextState;
        _stateChangeTime = Time.time;
        OnStateEnter(_state);
    }

    /// <summary>Good place to change animations, moving speed or play sounds etc.</summary>
    /// <remarks>DO NOT change states here as it will likely cause stackoverflow</remarks>
    void OnStateEnter(EState state)
    {
        // state starts:
        if (state == EState.START)
        {
            
        }
        else if (state == EState.SEARCHING)
        {
            _navMeshAgent.stoppingDistance = 2f;
            SetRandomDestinationToRandomPatrolPoint();

            _animator.Play("Walk_1");
            _walkSpeed = _navMeshAgent.speed;
        }
        else if (state == EState.SEARCHED_WAITING)
        {
            _animator.Play("idle_4");
            _walkSpeed = _navMeshAgent.speed;
        }
        else if (state == EState.CHASING)
        {
            _navMeshAgent.stoppingDistance = 3f;
            _navMeshAgent.SetDestination(_navMeshAgent.transform.position);
            _canFadeMusik = true;

            _animator.Play("EasyGranny");
            _followSpeed = _navMeshAgent.speed;
        }
        else if (state == EState.CHASED_WAITING)
        {
            _animator.Play("Look");
            _walkSpeed = _navMeshAgent.speed;

            _canFadeMusik = false;
        }
        else if (state == EState.PLAYER_CAUGHT)
        {
            _animator.Play("Hit_0");
            _caughtSpeed = _navMeshAgent.speed;
            _player.LookAt(_granny);

            _canFadeMusik = false;

            _playerController.canMove = false;
            _playerCam.canRotate = false;

            GetComponent<AudioSource>().PlayOneShot(_jumpscareSound);
        }
        else throw new System.NotImplementedException($"{state} not implemented");
    }

    /// <summary>Good place to reset values</summary>
    /// <remarks>DO NOT change states here as it will likely cause stackoverflow</remarks>
    void OnStateExit(EState state)
    {
        // state ends:
        if (state == EState.START)
        {
            
        }
        else if (state == EState.SEARCHING)
        {

        }
        else if (state == EState.SEARCHED_WAITING)
        {

        }
        else if (state == EState.CHASING)
        {
            _navMeshAgent.stoppingDistance = 2f;
        }
        else if (state == EState.CHASED_WAITING)
        {
            SetRandomDestinationToRandomPatrolPoint();

            var audioSource = GetComponent<AudioSource>();
            audioSource.clip = _voices[Random.Range(0, _voices.Length)];
            audioSource.Play();
        }
        else if (state == EState.PLAYER_CAUGHT)
        {
            _playerController.canMove = true;
            _playerCam.canRotate = true;
            _canFadeMusik = true;
        }
        else throw new System.NotImplementedException($"{state} not implemented");
    }

    /// <summary>Good place to change states - always using ChangeState(_)!.</summary>
    void OnStateTick(EState state, float duration)
    {
        // state update:
        if (state == EState.START)
        {
            ChangeState(EState.CHASING);
        }
        else if (state == EState.SEARCHING)
        {
            if (CanSeePlayer())
            {
                ChangeState(EState.CHASING);
            }

            if (_navMeshAgent.remainingDistance < _navMeshAgent.stoppingDistance)
            {
                ChangeState(EState.SEARCHED_WAITING);
            }
        }
        else if (state == EState.SEARCHED_WAITING)
        {
            if (CanSeePlayer())
            {
                ChangeState(EState.CHASING);
            }

            if (duration > _waypointTime)
            {
                ChangeState(EState.SEARCHING);
            }
        }
        else if (state == EState.CHASING)
        {
            if (duration > _playerSafeTime)
            {
                _navMeshAgent.SetDestination(_player.position);
            }

            if (_navMeshAgent.remainingDistance < _navMeshAgent.stoppingDistance)
            {
                ChangeState(EState.PLAYER_CAUGHT);
            }

            if (CanSeePlayer() == false)
            {
                ChangeState(EState.CHASED_WAITING);
            }
        }
        else if (state == EState.CHASED_WAITING)
        {
            if (duration > _lookAroundTime)
            {
                ChangeState(EState.SEARCHING);
            }
        }
        else if (state == EState.PLAYER_CAUGHT)
        {
            if (duration > _lookAroundTime)
            {
                ChangeState(EState.SEARCHING);
            }
        }
        else throw new System.NotImplementedException($"{state} not implemented");
    }

    bool CanSeePlayer()
    {
        bool castRay(Vector3 origin, Vector3 direction)
        {
            bool didHitPlayer = Physics.Raycast(origin, direction, out RaycastHit hit, _raycastLength) && hit.collider.CompareTag("Player");
#if DEBUG
            Debug.DrawLine(origin, hit.point, didHitPlayer ? Color.red : Color.yellow);
#endif
            return didHitPlayer;
        }

        Vector3 origin = _playerDetectionOrigin.position;
        Vector3 forward = transform.forward;
        Vector3 right = transform.right;
        return
            //RayForward
            castRay(origin, forward)
            //RayLeft
            || castRay(origin, -right)
            || castRay(origin, Quaternion.Euler(0, -75, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, -60, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, -45, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, -30, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, -15, 0) * forward)
            //RayRight
            || castRay(origin, right)
            || castRay(origin, Quaternion.Euler(0, 75, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, 60, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, 45, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, 30, 0) * forward)
            || castRay(origin, Quaternion.Euler(0, 15, 0) * forward)
            //RayUp
            || castRay(origin, Quaternion.Euler(80, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(75, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(60, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(45, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(30, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(15, 0, 0) * forward)
            //RayDown
            || castRay(origin, Quaternion.Euler(-80, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(-75, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(-60, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(-45, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(-30, 0, 0) * forward)
            || castRay(origin, Quaternion.Euler(-15, 0, 0) * forward);
    }

    void FadeChaseMusik()
    {
        var audioComp = _chaseMusik.GetComponent<AudioSource>();
        if (_canFadeMusik)
        {
            _chaseMusik.SetActive(true);
            audioComp.volume = audioComp.volume + 0.3f * Time.deltaTime;
        }
        else
        {
            audioComp.volume = audioComp.volume - 0.3f * Time.deltaTime;
            _chaseMusik.SetActive(audioComp.volume > 0f);
        }
    }

    void SetRandomDestinationToRandomPatrolPoint()
    {
        int randomIndex = Random.Range(0, _patrolPoints.Length);
        _navMeshAgent.SetDestination(_patrolPoints[randomIndex].position);
    }

    public enum EState : byte
    {
        START = 0,
        SEARCHING,
        SEARCHED_WAITING,
        CHASING,
        CHASED_WAITING,
        PLAYER_CAUGHT,
    }

}

As you can see, this code gets rid of coroutines completely. One of the advantages of FSM.

I met a problem, in the following answer

I updated the code in the post. Changed the undefined state to a more fitting START, added matching if (state == EState.START) to all 3 transition events, replaced ChangeState(EState.undefined) on Start with ChangeState(EState.CHASING) on OnStateTick's if (state == EState.START). This should fix this issue.

Fixed another issues: stackoverflow caused by ChangeState calls from OnStateEnter & OnStateExit. Added warning notes that OnStateTick is a place to do that instead.

uhhhhh…I met a problem when i changed the code.

void OnStateTick(EState state, float duration) threw a message to editor:

NotImplementedException: undefined not implemented

I cannot find the issue come from.

void OnStateTick(GrannyState state, float duration)
{
    // state update:
    if (state == GrannyState.SEARCHING)
    {
        if (CanSeePlayer())
        {
            ChangeState(GrannyState.CHASING);
        }

        if (Granny.remainingDistance < Granny.stoppingDistance)
        {
            ChangeState(GrannyState.SEARCHED_WAITING);
        }
    }
    else if (state == GrannyState.SEARCHED_WAITING)
    {
        if (CanSeePlayer())
        {
            ChangeState(GrannyState.CHASING);
        }

        if (duration >= waypointTime)
        {
            ChangeState(GrannyState.SEARCHING);
        }
    }
    else if (state == GrannyState.CHASING)
    {
        if (duration >= playerSafeTime)
        {
            if (!CanSeePlayer())
            {
                ChangeState(GrannyState.CHASED_WAITING);
            }
        }

        if (Granny.remainingDistance < Granny.stoppingDistance)
        {
            ChangeState(GrannyState.PLAYER_CAUGHT);
        }
    }
    else if (state == GrannyState.CHASED_WAITING)
    {
        if (duration >= lookAroundTime)
        {
            ChangeState(GrannyState.SEARCHING);
        }

        if (CanSeePlayer())
        {
            ChangeState(GrannyState.CHASING);
        }
    }
    else throw new System.NotImplementedException($"{state} not implemented");
}

I updated the code in the post. Changed the undefined state to a more fitting START, added matching if (state == EState.START) to all 3 transition events, replaced ChangeState(EState.undefined) on Start with ChangeState(EState.CHASING) on OnStateTick's if (state == EState.START). This should fix this issue.