This page has been translated automatically.
Unigine Basics
1. Introduction
2. Managing Virtual Worlds
3. Preparing 3D Models
4. Materials
5. Cameras and Lighting
6. Implementing Application Logic
7. Making Cutscenes and Recording Videos
8. Preparing Your Project for Release
9. Physics
10. Optimization Basics
12. PROJECT3: Third-Person Cross-Country Arcade Racing Game
13. PROJECT4: VR Application With Simple Interaction
注意! 这个版本的文档是过时的,因为它描述了一个较老的SDK版本!请切换到最新SDK版本的文档。
注意! 这个版本的文档描述了一个不再受支持的旧SDK版本!请升级到最新的SDK版本。

Adding Enemies With AI

Enemies are the important part of any shooter. We are going to create an enemy that moves around the scene chasing the player, starts firing at a certain distance from the player, and gets killed (deleted) if hit by the player's bullets.

Before adding an enemy model, you should create it in a 3D modeling software.

Find our ready-to-use robot_enemy.node enemy template in the data/fps/robot folder and place it in the scene.

Applying a Finite-State Machine for AI#

To be a strong opponent, your enemy must have a certain level of intelligence. A simple AI can be implemented using a Finite-State Machine — a concept allowing you to describe the logic in terms of states and transitions between them.

For simplicity, consider three states: Idle, Chase, and Attack/Fire.

The following diagram describes what the enemy should do in each state, and how it will switch different states. The typical transitions would be from Idle to Chase, from Chase to Attack, and vice versa.

Raycasts to Determine Visibility#

How will the enemy "see" us? This can be implemented with the help of raycast (Intersections), which we have already used to determine the bullet hits. The algorithm is simple: we shoot a ray from the enemy's location in the direction he is looking at, detect the first object intersected by the ray and check if it is the player. All this can be described using the following function:

Source code (C#)
private bool IsTargetVisible()
{
    Vec3 direction = (player.WorldPosition - enemy.WorldPosition);
    Vec3 p0 = intersectionSocket.WorldPosition;
    Vec3 p1 = p0 + direction;

    Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
    if (!hitObject)
        return false;

    return player.ID == hitObject.ID;
}

To implement transition between states in each frame, we are going to do the following:

Source code (C#)
private void Update()
{
    // update the information on the target, path to it and orientation
    UpdateTargetState();
    UpdateOrientation();
    UpdateRoute();

    switch (currentState)
    {
        case EnemyLogicState.Idle: ProcessIdleState(); break;
        case EnemyLogicState.Chase: ProcessChaseState(); break;
        case EnemyLogicState.Attack: ProcessAttackState(); break;
    }
}
    private void ProcessIdleState()
    {
        // if the target (player) is visible, transition Idle -> Chase
        if (targetIsVisible)
            currentState = EnemyLogicState.Chase;
    }

private void ProcessChaseState()
{
    // recalculation of direction and acceleration coordinates
  
    // if the target (player) is not visible, transition Chase -> Idle
    if (!targetIsVisible)
   	 currentState = EnemyLogicState.Idle;
  
    // check the distance, transition Chase -> Attack
    if (targetIsVisible && lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius)
    {
        currentState = EnemyLogicState.Attack;

        // start shooting
    }

    // approaching the target
}

private void ProcessAttackState()
{
    // check the distance, transition Attack -> Chase
    if (!targetIsVisible || lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius)
    {
        currentState = EnemyLogicState.Chase;

        // stop shooting
    }
}

Using Navigation#

Just seeing the object is not enough, one must also get within shooting distance (within the attack radius). The enemy should be able to chase the player correctly — build a route from its current position to the player's current position, without walking through obstacles or getting stuck halfway. To give the enemy additional knowledge about how it can navigate through the level, you can use navigation. The PathRoute class in the UNIGINE API is responsible for finding path on the plane or in three-dimensional space. Pathfinding is performed only within the Navigation Area, which can be either of the following two types:

  • Navigation Sector is used to search for a path both in three-dimensional space (a multi-story house, for example) and on the plane — in the sector projection area (in this case the Z coordinate is ignored). Sectors can be combined to build complex areas — a set of intersecting sectors forms a single navigation area.
  • Navigation Mesh is used for pathfinding only on the plane at a specified height above the mesh polygons — i.e. polygons in this case show where you can walk. Unlike sectors, Navigation Mesh is always on its own, i.e. you cannot create areas by combining several meshes or a mesh and sectors.

In our case, since our characters move in a relatively simple environment, we will use Navigation Mesh to define the navigation area.

Such a mesh can be generated based on the FBX model of the scene using special tools, for example, RecastBlenderAddon. We already have prepater such a mesh and added it in the data/fps/navigation folder.

To place the mesh in the scene, click Create -> Navigation -> NavigationMesh in the Menu Bar and select the navigation/navmesh.mesh file. Align the mesh with the area to cover all areas where walking is allowed.

In the Parameters window, set the Height of the navigation mesh to 3 for proper route calculation.

Now that we have a navigation area, we can start pathfinding. In the Chase state, our enemy, instead of rushing to the last visible position of the player along a straight line, will follow the path using the Navigation Mesh we added. The path consists of a queue of route points calculated using the functionality of the PathRoute class. It looks something like this:

Source code (C#)
private void UpdateRoute()
{
   	 if (Game.Time - lastCalculationTime < routeRecalculationInterval)
   		 return;

   	 if (shouldUpdateRoute)
   	 {
   		 // calculate the path to the player
   		 route.Create2D(node.WorldPosition, lastSeenPosition, 1);
   		 shouldUpdateRoute = false;
   	 }

   	 // if the route is calculated
   	 if (route.IsReady)
   	 {
   		 // check if the target point of the route is reached
   		 if (route.IsReached)
   		 {
   			 // clear the queue of route points
   			 calculatedRoute.Clear();

   			 // add all root points to the queue
   			 for(int i = 1; i < route.NumPoints; ++i)
   				 calculatedRoute.Enqueue(route.GetPoint(i));

   			 shouldUpdateRoute = true;
   			 lastCalculationTime = Game.Time;
   		 }
   		 else
   			 // recalculate the route if the target point isn't reached
   			 shouldUpdateRoute = true;
   	 }
}

Teaching the Enemy to Shoot#

After teaching the enemy to chase the player, we need to teach it to shoot. You don't want to strangle the player, do you?

To implement the shooting ability, we need a bullet NodeReference that will be created at the moment of shooting when the robot is in the Attack state.

Let's add the shooting logic in the EnemyFireController component to make the robot shoot alternately from the left and right muzzle. The positions of their muzzles where bullets will be spawned are defined by the positions of two Dummy Nodes that are assigned to the Left Muzzle and Right Muzzle fields of the component.

  1. Create the EnemyFireController.cs componentt and paste the following code into it:

    EnemyFireController.cs

    Source code (C#)
    public class EnemyFireController : Component
    {
        public Node leftMuzzle = null;
        public Node rightMuzzle = null;
    
        public AssetLink bulletPrefab = null;
    
        public float shootInterval = 1.0f;
    
        private float currentTime = 0.0f;
        private bool isLeft = false;
        private bool isFiring = false;
    
        public void StartFiring()
        {
       	 isFiring = true;
        }
    
        public void StopFiring()
        {
       	 isFiring = false;
        }
    
        private void Init()
        {
       	 // reset the current time
       	 currentTime = 0.0f;
       	 // switch shooting to the right muzzle
       	 isLeft = false;
        }
    
        private void Update()
        {
       	 // if the enemy is not the Attack state (Idle or Chase), do nothing
       	 if (!isFiring)
       		 return;
    
       	 // update the current time
       	 currentTime += Game.IFps;
    
       	 // check the shooting interval
       	 if (currentTime > shootInterval)
       	 {
       		 // reset the timer
       		 currentTime -= shootInterval;
       		 // create the bullet using the asset assigned in bulletPrefab
       		 Node bullet = World.LoadNode(bulletPrefab.AbsolutePath);
    
       		 // set the bullet position depending on the shooting side
       		 bullet.WorldTransform = (isLeft) ? leftMuzzle.WorldTransform : rightMuzzle.WorldTransform;
       		 // switch the muzzle for the next shot
       		 isLeft = !isLeft;
    
       	 }
        }
    }
  2. If necessary, enable editing of the robot_enemy node and assign the EnemyFireController.cs component to the robot_root Dummy Object.
  3. Drag and drop the LeftGunMuzzle and RightGunMuzzle Dummy Nodes to the corresponding fields of the EnemyFireController component.

  4. Drag and drop data/fps/bullet/bullet.node to the Bullet Prefab field.

The right gun muzzle is selected

After spawning, the bullet should move in the appropriate direction changing its position in the world. If the bullet intersects with an object, a hit effect should be spawned at the point of impact. And if this object can take damage (i.e., it has a Health component, we'll do that a bit later), its health should be decreased by a certain value. Also, you can make the bullet apply an impulse to physical objects.

  1. Add the data/fps/bullet/bullet.node asset to the scene.
  2. Create the Bullet.cs component and copy the following code:

    Bullet.cs

    Source code (C#)
    using System;
    using System.Collections;
    using System.Collections.Generic;
    using Unigine;
    #region Math Variables
    #if UNIGINE_DOUBLE
    using Vec3 = Unigine.dvec3;
    #else
    using Vec3 = Unigine.vec3;
    #endif
    #endregion
    
    [Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- this line is generated automatically for a new component
    public class Bullet : Component
    {
    	public float speed = 10.0f;
    	public int damage = 1;
    
    	[ParameterFile]
    	public string hitPrefab = null;
    
    	[ParameterMask]
    	public int intersectionMask = ~0;
    
    	private WorldIntersectionNormal hitInfo = new WorldIntersectionNormal();
    
    	private void Init()
    	{
    	}
    	
    	private void Update()
    	{
    		// set the current position of the bullet
    		Vec3 currentPosition = node.WorldPosition;
    		// set the current world direction vector of the bullet pointing along the Y axis
    		vec3 currentDirection = node.GetWorldDirection(MathLib.AXIS.Y);
    
    		// change the bullet position
    		node.WorldPosition += currentDirection * speed * Game.IFps;
    
    		// get the first intersected object
    		Unigine.Object hitObject = World.GetIntersection(currentPosition, node.WorldPosition, intersectionMask, hitInfo);
    
    		// if no intersections are found, do nothing
    		if (hitObject == null)
    			return;
    
    		// load a prefab for hit visualization
    		Node hitEffect = World.LoadNode(hitPrefab);
    		// place the prefab in the hit point and set its direction according to the hit normal
    		hitEffect.WorldPosition = hitInfo.Point;
    		hitEffect.SetWorldDirection(hitInfo.Normal, vec3.UP, MathLib.AXIS.Y);
    
    		// delete the bullet
    		node.DeleteLater();
    	}
    }
  3. Enable editing of the bullet node and assign the bullet component to its Static Mesh node.
  4. Drag data/fps/bullet/bullet_hit.node to the Hit Prefab field.

  5. Assign the LifeTime.cs component to the bullet (Static Mesh) node and set its Life Time value to 5 seconds.
  6. Select the bullet Node Reference and click Apply to save changes and remove the bullet node from the scene.

Putting All Together#

Now summarizing the above, let's create the EnemyLogic.cs component with the following code:

EnemyLogic.cs

Source code (C#)
using System;
using System.Collections;
using System.Collections.Generic;
using Unigine;

#region Math Variables
#if UNIGINE_DOUBLE
using Vec3 = Unigine.dvec3;
#else
using Vec3 = Unigine.vec3;
#endif
#endregion

// declare the enemy states
public enum EnemyLogicState
{
    Idle,
    Chase,
    Attack,
}

[Component(PropertyGuid = "AUTOGENERATED_GUID")] // <-- identifier is generated automatically for a new component
public class EnemyLogic : Component
{
    public Node player = null;
    public Node intersectionSocket = null;
    public float reachRadius = 0.5f;
    public float attackInnerRadius = 5.0f;
    public float attackOuterRadius = 7.0f;
    public float speed = 1.0f;
    public float rotationStiffness = 8.0f;
    public float routeRecalculationInterval = 3.0f;

    [ParameterMask(MaskType = ParameterMaskAttribute.TYPE.INTERSECTION)]
    public int playerIntersectionMask = ~0;

    // initialize the enemy state
    private EnemyLogicState currentState = EnemyLogicState.Idle;

    private bool targetIsVisible;
    private Vec3 lastSeenPosition;
    private vec3 lastSeenDirection;
    private float lastSeenDistanceSqr;

    private BodyRigid bodyRigid = null;
    private WorldIntersection hitInfo = new WorldIntersection();
    private Node[] hitExcludes = new Node[2];

    private EnemyFireController fireController = null;
    // create a queue of the route points
    private Queue calculatedRoute = new Queue();

    private PathRoute route = new PathRoute();
    private bool shouldUpdateRoute = true;
    private float lastCalculationTime = 0.0f;
    private bool IsTargetVisible()
    {
   	 Vec3 direction = (player.WorldPosition - intersectionSocket.WorldPosition);
   	 Vec3 p0 = intersectionSocket.WorldPosition;
   	 Vec3 p1 = p0 + direction;

   	 Unigine.Object hitObject = World.GetIntersection(p0, p1, playerIntersectionMask, hitExcludes, hitInfo);
   	 if (!hitObject)
   		 return false;

   	 return player.ID == hitObject.ID;
    }

    private void Init()
    {
   	 // initialize parameters of the point moving along the route inside the navigation mesh
   	 route.Radius = 0.0f;
   	 route.Height = 1.0f;
   	 route.MaxAngle = 0.5f;

   	 bodyRigid = node.ObjectBodyRigid;
   	 hitExcludes[0] = node;
   	 hitExcludes[1] = node.GetChild(0);

   	 targetIsVisible = false;
   	 // grab the EnemyFireController component
   	 fireController = node.GetComponent<EnemyFireController>();
   	 shouldUpdateRoute = true;
   	 lastCalculationTime = Game.Time;

    }

    private void Update()
    {
   	 UpdateTargetState();
   	 UpdateOrientation();
   	 UpdateRoute();

   	 // switch between the enemy states
   	 switch (currentState)
   	 {
   		 case EnemyLogicState.Idle: ProcessIdleState(); break;
   		 case EnemyLogicState.Chase: ProcessChaseState(); break;
   		 case EnemyLogicState.Attack: ProcessAttackState(); break;
   	 }

   	 // switch the colors indicating the enemy states
   	 vec4 color = vec4.BLACK;
   	 switch (currentState)
   	 {
   		 case EnemyLogicState.Idle: color = vec4.BLUE; break;
   		 case EnemyLogicState.Chase: color = vec4.YELLOW; break;
   		 case EnemyLogicState.Attack: color = vec4.RED; break;
   	 }

   	 // visualize the enemy states
   	 Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 2.0f, 0.25f, color);
   	 Visualizer.RenderPoint3D(node.WorldPosition + vec3.UP * 3.0f, 0.25f, IsTargetVisible() ? vec4.GREEN : vec4.RED);
   	 Visualizer.RenderPoint3D(lastSeenPosition, 0.1f, vec4.MAGENTA);

   	 // visualize the attack radus
   	 Visualizer.RenderSphere(attackInnerRadius, node.WorldTransform, vec4.RED);
   	 Visualizer.RenderSphere(attackOuterRadius, node.WorldTransform, vec4.RED);

   	 // visualize the route points
   	 foreach (vec3 route_point in calculatedRoute)
   		 Visualizer.RenderPoint3D(route_point + vec3.UP, 0.25f, vec4.BLACK);

    }
    private void UpdateRoute()
    {
   	 if (Game.Time - lastCalculationTime < routeRecalculationInterval)
   		 return;

   	 if (shouldUpdateRoute)
   	 {
   		 // calculate the route to the player
   		 route.Create2D(node.WorldPosition, lastSeenPosition, 1);
   		 shouldUpdateRoute = false;
   	 }

   	 // if the route is calculated
   	 if (route.IsReady)
   	 {
   		 // check if the target point of the route is reached
   		 if (route.IsReached)
   		 {
   			 // clear the points queue
   			 calculatedRoute.Clear();

   			 // add all root points to the queue
   			 for(int i = 1; i < route.NumPoints; ++i)
   				 calculatedRoute.Enqueue(route.GetPoint(i));

   			 shouldUpdateRoute = true;
   			 lastCalculationTime = Game.Time;
   		 }
   		 else
   			 // recalculate the route if the target point isn't reached
   			 shouldUpdateRoute = true;
   	 }
    }

    private void UpdateTargetState()
    {
   	 targetIsVisible = IsTargetVisible();
   	 if (targetIsVisible)
   		 lastSeenPosition = player.WorldPosition;

   	 lastSeenDirection = (vec3)(lastSeenPosition - node.WorldPosition);
   	 lastSeenDistanceSqr = lastSeenDirection.Length2;
   	 lastSeenDirection.Normalize();
    }

    private void UpdateOrientation()
    {
   	 vec3 direction = lastSeenDirection;
   	 direction.z = 0.0f;

   	 quat targetRotation = new quat(MathLib.SetTo(vec3.ZERO, direction.Normalized, vec3.UP, MathLib.AXIS.Y));
   	 quat currentRotation = node.GetWorldRotation();

   	 currentRotation = MathLib.Slerp(currentRotation, targetRotation, Game.IFps * rotationStiffness);
   	 node.SetWorldRotation(currentRotation);
    }

    private void ProcessIdleState()
    {
   	 // if the target (player) is visible, switch Idle -> Chase
   	 if (targetIsVisible)
   		 currentState = EnemyLogicState.Chase;
    }

    private void ProcessChaseState()
    {

   	 vec3 currentVelocity = bodyRigid.LinearVelocity;
   	 currentVelocity.x = 0.0f;
   	 currentVelocity.y = 0.0f;
   	 if (calculatedRoute.Count > 0)
   	 {
   		 float distanceToTargetSqr = (float)(calculatedRoute.Peek() - node.WorldPosition).Length2;

   		 bool targetReached = (distanceToTargetSqr < reachRadius * reachRadius);
   		 if (targetReached)
   			 calculatedRoute.Dequeue();

   		 if (calculatedRoute.Count > 0)
   		 {
   			 vec3 direction = (vec3)(calculatedRoute.Peek() - node.WorldPosition);
   			 direction.z = 0.0f;
   			 direction.Normalize();
   			 currentVelocity.x = direction.x * speed;
   			 currentVelocity.y = direction.y * speed;
   		 }

   	 }

   	 // if target is not visible, switch Chase -> Idle
   	 if (!targetIsVisible)
   		 currentState = EnemyLogicState.Idle;

   	 // check the distance and switch Chase -> Attack
   	 else if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius)
   	 {
   		 currentState = EnemyLogicState.Attack;
   		 currentVelocity.x = 0.0f;
   		 currentVelocity.y = 0.0f;
   		 // start shooting
   		 if (fireController)
   			 fireController.StartFiring();
   	 }

   	 bodyRigid.LinearVelocity = currentVelocity;
    }

    private void ProcessAttackState()
    {
   	 // check the distance and switch Attack -> Chase
   	 if (!targetIsVisible || lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius)
   	 {
   		 currentState = EnemyLogicState.Chase;
   		 // stop shooting
   		 if (fireController)
   			 fireController.StopFiring();
   	 }
    }
}
  1. Enable editing of the robot_enemy node and assign the new component to the robot_root Dummy Node in the Parameters window.
  2. Right-click the player Node Reference in the World Nodes window and select Unpack to Node Content. The Node Reference will be removed and its contents will be displayed in the World Nodes hierarchy.

  3. Drag and drop the player_hit_box node to the Player field of the EnemyLogic component. This node imitates the player body and is used in calculations. Make sure that the Intersection option is checked for player_hit_box.
  4. Drag and drop the robot_intersection_socket node of the robot_enemy node to Intersection Socket field. This is the node from which the robot will do intersection checks.

For debugging, you can enable Visualizer that will display the inner and outer attack radius, as well as the colored squares above the robot indicating:

  • The state of the robot: Idle — BLUE, Chase — YELLOW, Attack — RED.
  • If the target is visible: Yes — GREEN, No — RED.

And the points of the calculated path:

Last update: 2024-04-04
Build: ()