איך לעשות AI של צבי באחדות

בפיתוח משחקים, הוספת בינה מלאכותית פירושה כתיבת קוד שישלוט בישות המשחק ללא כל קלט חיצוני.

בינה מלאכותית של בעלי חיים במשחקים היא ענף של בינה מלאכותית שמטרתה לתרגם התנהגות של בעלי חיים לסביבה הדיגיטלית של המשחק כדי ליצור חוויה מציאותית.

במדריך זה, אראה כיצד ליצור בינה מלאכותית של חיה פשוטה (צבי) ב-Unity שתהיה לה שני מצבים, בטלה וברח.

שלב 1: הכן את הסצנה ואת דגם הצבאים

נצטרך רמה ודגם צבי.

עבור הרמה, אשתמש בשטח פשוט עם כמה דשא ועצים:

עבור דגם הצבי פשוט שילבתי כמה קוביות (אבל אתה יכול להשתמש ב-דגם צבי):

כעת נעבור לחלק הקידוד.

שלב 2: הגדר את בקר הנגן

אנו מתחילים בהגדרת בקר נגן כדי שנוכל להסתובב ולבדוק את ה-AI:

  • צור סקריפט חדש, שם לו SC_CharacterController והדבק בתוכו את הקוד למטה:

SC_CharacterController.cs

using UnityEngine;

[RequireComponent(typeof(CharacterController))]

public class SC_CharacterController : MonoBehaviour
{
    public float speed = 7.5f;
    public float jumpSpeed = 8.0f;
    public float gravity = 20.0f;
    public Camera playerCamera;
    public float lookSpeed = 2.0f;
    public float lookXLimit = 45.0f;

    CharacterController characterController;
    Vector3 moveDirection = Vector3.zero;
    Vector2 rotation = Vector2.zero;

    [HideInInspector]
    public bool canMove = true;

    void Start()
    {
        characterController = GetComponent<CharacterController>();
        rotation.y = transform.eulerAngles.y;
    }

    void Update()
    {
        if (characterController.isGrounded)
        {
            // We are grounded, so recalculate move direction based on axes
            Vector3 forward = transform.TransformDirection(Vector3.forward);
            Vector3 right = transform.TransformDirection(Vector3.right);
            float curSpeedX = speed * Input.GetAxis("Vertical");
            float curSpeedY = speed * Input.GetAxis("Horizontal");
            moveDirection = (forward * curSpeedX) + (right * curSpeedY);

            if (Input.GetButton("Jump"))
            {
                moveDirection.y = jumpSpeed;
            }
        }

        // Apply gravity. Gravity is multiplied by deltaTime twice (once here, and once below
        // when the moveDirection is multiplied by deltaTime). This is because gravity should be applied
        // as an acceleration (ms^-2)
        moveDirection.y -= gravity * Time.deltaTime;

        // Move the controller
        characterController.Move(moveDirection * Time.deltaTime);

        // Player and Camera rotation
        if (canMove)
        {
            rotation.y += Input.GetAxis("Mouse X") * lookSpeed;
            rotation.x += -Input.GetAxis("Mouse Y") * lookSpeed;
            rotation.x = Mathf.Clamp(rotation.x, -lookXLimit, lookXLimit);
            playerCamera.transform.localRotation = Quaternion.Euler(rotation.x, 0, 0);
            transform.eulerAngles = new Vector2(0, rotation.y);
        }
    }
}

  • צור GameObject חדש ושם לו "Player" ושנה את התג שלו ל "Player"
  • צור קפסולה חדשה (GameObject -> 3D Object -> Capsule), ולאחר מכן הפוך אותה לאובייקט ילד של האובייקט "Player", שנה את מיקומו ל- (0, 1, 0), והסר את רכיב ה-CapsuleCollider שלו.
  • הזז את המצלמה הראשית בתוך האובייקט "Player" ושנה את מיקומו ל- (0, 1.64, 0)
  • צרף SC_CharacterController לאובייקט "Player" (תבחין שהוא יוסיף גם רכיב נוסף בשם Character Controller. הגדר את הערך המרכזי שלו ל- (0, 1, 0))
  • הקצה את המצלמה הראשית למשתנה "Player Camera" ב-SC_CharacterController ואז שמור את הסצנה

בקר הנגן מוכן כעת.

שלב 3: תכנת AI של Deer

כעת נעבור לחלק בו אנו מתכנתים AI של Deer:

  • צור סקריפט חדש וקרא לו SC_DeerAI (סקריפט זה ישלוט בתנועת AI):

פתח את SC_DeerAI והמשך את השלבים הבאים:

בתחילת הסקריפט, אנו מוודאים שכל השיעורים הדרושים כלולים (במיוחד UnityEngine.AI):

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

public class SC_DeerAI : MonoBehaviour
{

כעת נוסיף את כל המשתנים:

    public enum AIState { Idle, Walking, Eating, Running }
    public AIState currentState = AIState.Idle;
    public int awarenessArea = 15; //How far the deer should detect the enemy
    public float walkingSpeed = 3.5f;
    public float runningSpeed = 7f;
    public Animator animator;

    //Trigger collider that represents the awareness area
    SphereCollider c; 
    //NavMesh Agent
    NavMeshAgent agent;

    bool switchAction = false;
    float actionTimer = 0; //Timer duration till the next action
    Transform enemy;
    float range = 20; //How far the Deer have to run to resume the usual activities
    float multiplier = 1;
    bool reverseFlee = false; //In case the AI is stuck, send it to one of the original Idle points

    //Detect NavMesh edges to detect whether the AI is stuck
    Vector3 closestEdge;
    float distanceToEdge;
    float distance; //Squared distance to the enemy
    //How long the AI has been near the edge of NavMesh, if too long, send it to one of the random previousIdlePoints
    float timeStuck = 0;
    //Store previous idle points for reference
    List<Vector3> previousIdlePoints = new List<Vector3>(); 

לאחר מכן אנו מאתחלים הכל ב-Void Start():

    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.stoppingDistance = 0;
        agent.autoBraking = true;

        c = gameObject.AddComponent<SphereCollider>();
        c.isTrigger = true;
        c.radius = awarenessArea;

        //Initialize the AI state
        currentState = AIState.Idle;
        actionTimer = Random.Range(0.1f, 2.0f);
        SwitchAnimationState(currentState);
    }

(כפי שניתן לראות אנו מוסיפים Sphere Collider אשר מסומן כטריגר. מתנגש זה ישמש כאזור מודעות כאשר האויב ייכנס אליו).

ההיגיון בפועל של AI נעשה ב-void Update() עם כמה פונקציות עוזר:

    // Update is called once per frame
    void Update()
    {
        //Wait for the next course of action
        if (actionTimer > 0)
        {
            actionTimer -= Time.deltaTime;
        }
        else
        {
            switchAction = true;
        }

        if (currentState == AIState.Idle)
        {
            if(switchAction)
            {
                if (enemy)
                {
                    //Run away
                    agent.SetDestination(RandomNavSphere(transform.position, Random.Range(1, 2.4f)));
                    currentState = AIState.Running;
                    SwitchAnimationState(currentState);
                }
                else
                {
                    //No enemies nearby, start eating
                    actionTimer = Random.Range(14, 22);

                    currentState = AIState.Eating;
                    SwitchAnimationState(currentState);

                    //Keep last 5 Idle positions for future reference
                    previousIdlePoints.Add(transform.position);
                    if (previousIdlePoints.Count > 5)
                    {
                        previousIdlePoints.RemoveAt(0);
                    }
                }
            }
        }
        else if (currentState == AIState.Walking)
        {
            //Set NavMesh Agent Speed
            agent.speed = walkingSpeed;

            // Check if we've reached the destination
            if (DoneReachingDestination())
            {
                currentState = AIState.Idle;
            }
        }
        else if (currentState == AIState.Eating)
        {
            if (switchAction)
            {
                //Wait for current animation to finish playing
                if(!animator || animator.GetCurrentAnimatorStateInfo(0).normalizedTime - Mathf.Floor(animator.GetCurrentAnimatorStateInfo(0).normalizedTime) > 0.99f)
                {
                    //Walk to another random destination
                    agent.destination = RandomNavSphere(transform.position, Random.Range(3, 7));
                    currentState = AIState.Walking;
                    SwitchAnimationState(currentState);
                }
            }
        }
        else if (currentState == AIState.Running)
        {
            //Set NavMesh Agent Speed
            agent.speed = runningSpeed;

            //Run away
            if (enemy)
            {
                if (reverseFlee)
                {
                    if (DoneReachingDestination() && timeStuck < 0)
                    {
                        reverseFlee = false;
                    }
                    else
                    {
                        timeStuck -= Time.deltaTime;
                    }
                }
                else
                {
                    Vector3 runTo = transform.position + ((transform.position - enemy.position) * multiplier);
                    distance = (transform.position - enemy.position).sqrMagnitude;

                    //Find the closest NavMesh edge
                    NavMeshHit hit;
                    if (NavMesh.FindClosestEdge(transform.position, out hit, NavMesh.AllAreas))
                    {
                        closestEdge = hit.position;
                        distanceToEdge = hit.distance;
                        //Debug.DrawLine(transform.position, closestEdge, Color.red);
                    }

                    if (distanceToEdge < 1f)
                    {
                        if(timeStuck > 1.5f)
                        {
                            if(previousIdlePoints.Count > 0)
                            {
                                runTo = previousIdlePoints[Random.Range(0, previousIdlePoints.Count - 1)];
                                reverseFlee = true;
                            } 
                        }
                        else
                        {
                            timeStuck += Time.deltaTime;
                        }
                    }

                    if (distance < range * range)
                    {
                        agent.SetDestination(runTo);
                    }
                    else
                    {
                        enemy = null;
                    }
                }
                
                //Temporarily switch to Idle if the Agent stopped
                if(agent.velocity.sqrMagnitude < 0.1f * 0.1f)
                {
                    SwitchAnimationState(AIState.Idle);
                }
                else
                {
                    SwitchAnimationState(AIState.Running);
                }
            }
            else
            {
                //Check if we've reached the destination then stop running
                if (DoneReachingDestination())
                {
                    actionTimer = Random.Range(1.4f, 3.4f);
                    currentState = AIState.Eating;
                    SwitchAnimationState(AIState.Idle);
                }
            }
        }

        switchAction = false;
    }

    bool DoneReachingDestination()
    {
        if (!agent.pathPending)
        {
            if (agent.remainingDistance <= agent.stoppingDistance)
            {
                if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)
                {
                    //Done reaching the Destination
                    return true;
                }
            }
        }

        return false;
    }

    void SwitchAnimationState(AIState state)
    {
        //Animation control
        if (animator)
        {
            animator.SetBool("isEating", state == AIState.Eating);
            animator.SetBool("isRunning", state == AIState.Running);
            animator.SetBool("isWalking", state == AIState.Walking);
        }
    }

    Vector3 RandomNavSphere(Vector3 origin, float distance)
    {
        Vector3 randomDirection = Random.insideUnitSphere * distance;

        randomDirection += origin;

        NavMeshHit navHit;

        NavMesh.SamplePosition(randomDirection, out navHit, distance, NavMesh.AllAreas);

        return navHit.position;
    }

(כל מצב מאתחל את הערכים ואת יעד ה-NavMesh Agent למצב הבא. לדוגמה, למצב Idle יש 2 תוצאות אפשריות, הוא מאתחל את מצב הריצה אם האויב נוכח או את מצב האכילה אם אף אויב לא חצה את אזור המודעות.

מצב הליכה משמש בין מצבי האכילה כדי לעבור ליעד החדש.

מצב ריצה מחשב את הכיוון ביחס לעמדת האויב, לרוץ ישירות ממנה.

אם נתקע בפינה, הבינה המלאכותית נסוגה לאחת ממצבי ה-Idle שנשמרו בעבר. האויב הולך לאיבוד לאחר שה-AI רחוק מספיק מהאויב).

ולבסוף, אנו מוסיפים אירוע OnTriggerEnter שינטר את ה-Sphere Collider (המכונה גם אזור המודעות) ויאתחל את מצב הריצה ברגע שהאויב יתקרב מדי:

    void OnTriggerEnter(Collider other)
    {
        //Make sure the Player instance has a tag "Player"
        if (!other.CompareTag("Player"))
            return;

        enemy = other.transform;

        actionTimer = Random.Range(0.24f, 0.8f);
        currentState = AIState.Idle;
        SwitchAnimationState(currentState);
    }

ברגע שהשחקן נכנס לטריגר, משתנה האויב מוקצה ואותחל מצב ה-Idle, לאחר מכן, אתחול מצב Running.

להלן התסריט הסופי של SC_DeerAI.cs:

//You are free to use this script in Free or Commercial projects
//sharpcoderblog.com @2019

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

public class SC_DeerAI : MonoBehaviour
{
    public enum AIState { Idle, Walking, Eating, Running }
    public AIState currentState = AIState.Idle;
    public int awarenessArea = 15; //How far the deer should detect the enemy
    public float walkingSpeed = 3.5f;
    public float runningSpeed = 7f;
    public Animator animator;

    //Trigger collider that represents the awareness area
    SphereCollider c; 
    //NavMesh Agent
    NavMeshAgent agent;

    bool switchAction = false;
    float actionTimer = 0; //Timer duration till the next action
    Transform enemy;
    float range = 20; //How far the Deer have to run to resume the usual activities
    float multiplier = 1;
    bool reverseFlee = false; //In case the AI is stuck, send it to one of the original Idle points

    //Detect NavMesh edges to detect whether the AI is stuck
    Vector3 closestEdge;
    float distanceToEdge;
    float distance; //Squared distance to the enemy
    //How long the AI has been near the edge of NavMesh, if too long, send it to one of the random previousIdlePoints
    float timeStuck = 0;
    //Store previous idle points for reference
    List<Vector3> previousIdlePoints = new List<Vector3>(); 

    // Start is called before the first frame update
    void Start()
    {
        agent = GetComponent<NavMeshAgent>();
        agent.stoppingDistance = 0;
        agent.autoBraking = true;

        c = gameObject.AddComponent<SphereCollider>();
        c.isTrigger = true;
        c.radius = awarenessArea;

        //Initialize the AI state
        currentState = AIState.Idle;
        actionTimer = Random.Range(0.1f, 2.0f);
        SwitchAnimationState(currentState);
    }

    // Update is called once per frame
    void Update()
    {
        //Wait for the next course of action
        if (actionTimer > 0)
        {
            actionTimer -= Time.deltaTime;
        }
        else
        {
            switchAction = true;
        }

        if (currentState == AIState.Idle)
        {
            if(switchAction)
            {
                if (enemy)
                {
                    //Run away
                    agent.SetDestination(RandomNavSphere(transform.position, Random.Range(1, 2.4f)));
                    currentState = AIState.Running;
                    SwitchAnimationState(currentState);
                }
                else
                {
                    //No enemies nearby, start eating
                    actionTimer = Random.Range(14, 22);

                    currentState = AIState.Eating;
                    SwitchAnimationState(currentState);

                    //Keep last 5 Idle positions for future reference
                    previousIdlePoints.Add(transform.position);
                    if (previousIdlePoints.Count > 5)
                    {
                        previousIdlePoints.RemoveAt(0);
                    }
                }
            }
        }
        else if (currentState == AIState.Walking)
        {
            //Set NavMesh Agent Speed
            agent.speed = walkingSpeed;

            // Check if we've reached the destination
            if (DoneReachingDestination())
            {
                currentState = AIState.Idle;
            }
        }
        else if (currentState == AIState.Eating)
        {
            if (switchAction)
            {
                //Wait for current animation to finish playing
                if(!animator || animator.GetCurrentAnimatorStateInfo(0).normalizedTime - Mathf.Floor(animator.GetCurrentAnimatorStateInfo(0).normalizedTime) > 0.99f)
                {
                    //Walk to another random destination
                    agent.destination = RandomNavSphere(transform.position, Random.Range(3, 7));
                    currentState = AIState.Walking;
                    SwitchAnimationState(currentState);
                }
            }
        }
        else if (currentState == AIState.Running)
        {
            //Set NavMesh Agent Speed
            agent.speed = runningSpeed;

            //Run away
            if (enemy)
            {
                if (reverseFlee)
                {
                    if (DoneReachingDestination() && timeStuck < 0)
                    {
                        reverseFlee = false;
                    }
                    else
                    {
                        timeStuck -= Time.deltaTime;
                    }
                }
                else
                {
                    Vector3 runTo = transform.position + ((transform.position - enemy.position) * multiplier);
                    distance = (transform.position - enemy.position).sqrMagnitude;

                    //Find the closest NavMesh edge
                    NavMeshHit hit;
                    if (NavMesh.FindClosestEdge(transform.position, out hit, NavMesh.AllAreas))
                    {
                        closestEdge = hit.position;
                        distanceToEdge = hit.distance;
                        //Debug.DrawLine(transform.position, closestEdge, Color.red);
                    }

                    if (distanceToEdge < 1f)
                    {
                        if(timeStuck > 1.5f)
                        {
                            if(previousIdlePoints.Count > 0)
                            {
                                runTo = previousIdlePoints[Random.Range(0, previousIdlePoints.Count - 1)];
                                reverseFlee = true;
                            } 
                        }
                        else
                        {
                            timeStuck += Time.deltaTime;
                        }
                    }

                    if (distance < range * range)
                    {
                        agent.SetDestination(runTo);
                    }
                    else
                    {
                        enemy = null;
                    }
                }
                
                //Temporarily switch to Idle if the Agent stopped
                if(agent.velocity.sqrMagnitude < 0.1f * 0.1f)
                {
                    SwitchAnimationState(AIState.Idle);
                }
                else
                {
                    SwitchAnimationState(AIState.Running);
                }
            }
            else
            {
                //Check if we've reached the destination then stop running
                if (DoneReachingDestination())
                {
                    actionTimer = Random.Range(1.4f, 3.4f);
                    currentState = AIState.Eating;
                    SwitchAnimationState(AIState.Idle);
                }
            }
        }

        switchAction = false;
    }

    bool DoneReachingDestination()
    {
        if (!agent.pathPending)
        {
            if (agent.remainingDistance <= agent.stoppingDistance)
            {
                if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)
                {
                    //Done reaching the Destination
                    return true;
                }
            }
        }

        return false;
    }

    void SwitchAnimationState(AIState state)
    {
        //Animation control
        if (animator)
        {
            animator.SetBool("isEating", state == AIState.Eating);
            animator.SetBool("isRunning", state == AIState.Running);
            animator.SetBool("isWalking", state == AIState.Walking);
        }
    }

    Vector3 RandomNavSphere(Vector3 origin, float distance)
    {
        Vector3 randomDirection = Random.insideUnitSphere * distance;

        randomDirection += origin;

        NavMeshHit navHit;

        NavMesh.SamplePosition(randomDirection, out navHit, distance, NavMesh.AllAreas);

        return navHit.position;
    }

    void OnTriggerEnter(Collider other)
    {
        //Make sure the Player instance has a tag "Player"
        if (!other.CompareTag("Player"))
            return;

        enemy = other.transform;

        actionTimer = Random.Range(0.24f, 0.8f);
        currentState = AIState.Idle;
        SwitchAnimationState(currentState);
    }
}

ל-SC_DeerAI יש רק משתנה אחד שצריך להקצות שהוא "Animator".

רכיב האנימציה דורש בקר עם 4 אנימציות: אנימציה סרק, אנימציה הליכה, אנימציה אכילה, ואנימציה הפעלה, ו-3 פרמטרים של bool: isEating, isRunning, ו-isWalking:

אתה יכול ללמוד כיצד להגדיר בקר אנימטור פשוט על ידי לחיצה על כאן

אחרי שהכל מוקצה נשאר דבר אחרון לעשות, זה לאפות NavMesh.

  • בחר את כל אובייקטי הסצנה שיהיו סטטיים (למשל שטח, עצים וכו') וסמן אותם כ-"Navigation Static":

  • עבור אל חלון הניווט (חלון -> AI -> ניווט) ולחץ על הכרטיסייה "Bake" ואז לחץ על כפתור "Bake". לאחר אפיית ה-NavMesh הוא אמור להיראות בערך כך:

לאחר אפיית ה-NavMesh, נוכל לבדוק את ה-AI:

Sharp Coder נגן וידאו

הכל עובד כמצופה. הצבי בורח כשהאויב קרוב ומחדש את פעילותו הרגילה ברגע שהאויב מספיק רחוק.

מָקוֹר
DeerAI.unitypackage3.36 MB