Sådan laver du en AI af en hjort i enhed
I spiludvikling betyder tilføjelse af Artificial Intelligence at skrive kode, der vil kontrollere spilentiteten uden eksternt input.
Animal AI in games er en gren af AI, der har til formål at oversætte dyrenes adfærd til spillets digitale miljø for at skabe en realistisk oplevelse.
I denne tutorial vil jeg vise, hvordan man laver et simpelt dyr (hjort) AI i Unity, der vil have to tilstande, tomgang og flugt.
Trin 1: Forbered scenen og hjortemodellen
Vi skal bruge et niveau og en hjortemodel.
Til niveauet vil jeg bruge et simpelt terræn med lidt græs og træer:
Til hjortemodellen kombinerede jeg simpelthen nogle terninger (men du kan bruge denne hjortemodel):
Lad os nu gå til kodningsdelen.
Trin 2: Konfigurer Player Controller
Vi begynder med at konfigurere en spillercontroller, så vi kan gå rundt og teste AI:
- Opret et nyt script, navngiv det SC_CharacterController og indsæt koden nedenfor i det:
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);
}
}
}
- Opret et nyt GameObject og navngiv det "Player" og skift dets tag til "Player"
- Opret en ny kapsel (GameObject -> 3D Object -> Capsule), og gør den derefter til et underordnet objekt af "Player"-objektet, skift dets position til (0, 1, 0), og fjern dets CapsuleCollider-komponent.
- Flyt hovedkameraet inde i "Player"-objektet og skift dets position til (0, 1.64, 0)
- Vedhæft SC_CharacterController script til et "Player" objekt (Du vil bemærke, at det også vil tilføje en anden komponent kaldet Character Controller. Indstil dens midterværdi til (0, 1, 0))
- Tildel hovedkameraet til "Player Camera"-variablen ved SC_CharacterController og Gem derefter scenen
Player-controlleren er nu klar.
Trin 3: Programmer Deer AI
Lad os nu gå til den del, hvor vi programmerer en Deer AI:
- Opret et nyt script og navngiv det SC_DeerAI (dette script vil styre AI-bevægelsen):
Åbn SC_DeerAI og fortsæt nedenstående trin:
Ved starten af scriptet sørger vi for, at alle de nødvendige klasser er inkluderet (specifikt UnityEngine.AI):
using UnityEngine;
using UnityEngine.AI;
using System.Collections.Generic;
public class SC_DeerAI : MonoBehaviour
{
Lad os nu tilføje alle variablerne:
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>();
Så initialiserer vi alt i det tomme 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);
}
(Som du kan se tilføjer vi en Sphere Collider, der er markeret som Trigger. Denne kolliderer vil fungere som et opmærksomhedsområde, når fjenden kommer ind i det).
Den faktiske AI-logik udføres i void Update() med nogle hjælpefunktioner:
// 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;
}
(Hver tilstand initialiserer værdierne og NavMesh Agent-målet for den næste tilstand. For eksempel har Idle-tilstanden 2 mulige udfald, den initialiserer enten Running-tilstanden, hvis fjenden er til stede, eller spisetilstanden, hvis ingen fjende krydsede opmærksomhedsområdet.
En gåtilstand bruges mellem spisetilstandene for at flytte til den nye destination.
Løbende tilstand beregner retningen i forhold til fjendens position, for at løbe direkte fra den.
Hvis den sidder fast i hjørnet, trækker AI'en sig tilbage til en af de tidligere gemte tomgangspositioner. Fjenden er tabt, efter at AI er langt nok fra fjenden).
Og til sidst tilføjer vi en OnTriggerEnter hændelse, som vil overvåge Sphere Collider (aka Awareness Area) og vil initialisere Running-tilstanden, når fjenden kommer for tæt på:
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);
}
Så snart spilleren går ind i triggeren, tildeles fjendens variabel, og Idle-tilstanden initialiseres, derefter initialiseres Running-tilstanden.
Nedenfor er det endelige SC_DeerAI.cs-script:
//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);
}
}
- Placer Deer-modellen i scenen, og vedhæft en NavMesh Agent, SC_DeerAI-script og Animator-komponent til den:
SC_DeerAI har kun én variabel, der skal tildeles, som er "Animator".
Animatorkomponenten kræver en controller med 4 animationer: Inaktiv animation, Gå-animation, Spise-animation og Løb-animation og 3 bool-parametre: isEating, isRunning og isWalking:
Du kan lære, hvordan du opsætter en simpel Animator Controller ved at klikke her
Når alt er tildelt, er der en sidste ting tilbage at gøre, som er at bage en NavMesh.
- Vælg alle de sceneobjekter, der vil være statiske (f.eks. terræn, træer osv.), og markér dem som "Navigation Static":
- Gå til Navigationsvindue (Vindue -> AI -> Navigation) og klik på fanen "Bake" og klik derefter på knappen "Bake". Efter at NavMesh er bagt skulle det se sådan ud:
Efter at NavMesh er blevet bagt, kan vi teste AI:
Alt fungerer som forventet. Hjorten løber væk, når fjenden er tæt på, og genoptager sine sædvanlige aktiviteter, når fjenden er langt nok.