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

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++)
bool isTargetVisible()
{
	Vec3 direction = (player->getWorldPosition() - intersectionSocket->getWorldPosition());
	Vec3 p0 = intersectionSocket->getWorldPosition();
	Vec3 p1 = p0 + direction;

	Unigine::ObjectPtr hitObject = World::getIntersection(p0, p1, playerIntersectionMask.get(), hitExcludes, hitInfo);
	if (!hitObject)
		return false;

	return player->getID() == hitObject->getID();
}

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

Source code (C++)
void EnemyLogic::update()
{
	// update the information on the target, path to it and orientation
	updateTargetState();
	updateOrientation();
	updateRoute();

	// switching between enemy states
	switch (currentState)
	{
		case EnemyLogicState::Idle: processIdleState(); break;
		case EnemyLogicState::Chase: processChaseState(); break;
		case EnemyLogicState::Attack: processAttackState(); break;
	}
}

void EnemyLogic::processIdleState()
{
	// if the target (player) is visible, transition Idle -> Chase
	if (targetIsVisible)
		currentState = EnemyLogicState::Chase;
}

void EnemyLogic::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
	else if (lastSeenDistanceSqr < attackInnerRadius * attackInnerRadius)
	{
		currentState = EnemyLogicState::Attack;
		// start shooting
	}

	// approaching the target
}

void EnemyLogic::processAttackState()
{
	// check the distance, transition Chase -> Attack
	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.

To place the mesh in the scene, click Create -> Navigation -> NavigationMesh in the Menu Bar and select the core/meshes/plain.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++)
void EnemyLogic::updateRoute()
{
	if (Game::getTime() - lastCalculationTime < routeRecalculationInterval)
		return;

	if (shouldUpdateRoute)
	{
		// calculate the path to the player
		route->create2D(node->getWorldPosition(), 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->getNumPoints(); ++i)
				calculatedRoute.append(route->getPoint(i));

			shouldUpdateRoute = true;
			lastCalculationTime = Game::getTime();
		}
		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 component and paste the following code into it:

    EnemyFireController.h

    Source code (C++)
    #pragma once
    #include <UnigineComponentSystem.h>
    #include <UnigineGame.h>
    #include <UnigineWorld.h>
    class EnemyFireController :
    	public Unigine::ComponentBase
    {
    public:
    	COMPONENT_DEFINE(EnemyFireController, Unigine::ComponentBase);
    	PROP_PARAM(Node, leftMuzzle, nullptr);
    	PROP_PARAM(Node, rightMuzzle, nullptr);
    	// crosshair parameters
    	PROP_PARAM(File, bulletPrefab, "");
    
    	PROP_PARAM(Float, shootInterval, 1.0f);
    
    	// declare methods to be called at the corresponding stages of the execution sequence
    	COMPONENT_INIT(init);
    	COMPONENT_UPDATE(update);
    	void startFiring();
    	void stopFiring();
    
    protected:
    	float currentTime = 0.0f;
    	bool isLeft = false;
    	bool isFiring = false;
    	// world main loop overrides
    	void init();
    	void update();
    };

    EnemyFireController.cpp

    Source code (C++)
    #include "EnemyFireController.h"
    REGISTER_COMPONENT(EnemyFireController);
    using namespace Unigine;
    using namespace Math;
    
    void EnemyFireController::startFiring()
    {
    	isFiring = true;
    }
    
    void EnemyFireController::stopFiring()
    {
    	isFiring = false;
    }
    
    void EnemyFireController::init()
    {
    	// timer reset
    	currentTime = 0.0f;
    	// switch fire to the right muzzle
    	isLeft = false;
    }
    
    void EnemyFireController::update()
    {
    	// if the robot is not in the Attack state (Idle or Chase), do nothing
    	if (!isFiring || bulletPrefab.nullCheck())
    		return;
    
    	// timer updating
    	currentTime += Game::getIFps();
    
    	// check the shooting interval
    	if (currentTime > shootInterval)
    	{
    		// timer reset
    		currentTime -= shootInterval;
    		// create a bullet from the asset assigned to the bulletPrefab
    		NodePtr bullet = World::loadNode(Unigine::FileSystem::guidToPath(FileSystem::getGUID(bulletPrefab.getRaw())));
    
    		// set the bullet position depending on the side of the shot
    		bullet->setWorldTransform((isLeft) ? leftMuzzle->getWorldTransform() : rightMuzzle->getWorldTransform());
    		// switch the muzzle for the next shot
    		isLeft = !isLeft;
    	}
    }

    Save all the files that we modified and then build and run the application by hitting Ctrl + F5 to make the Component System update properties used to assign the components to nodes. Close the application after running it and switch to UnigineEditor.

  2. If necessary, enable editing of the robot_enemy node and assign the EnemyFireController 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 component and copy the following code:

    Bullet.h

    Source code (C++)
    #pragma once
    #include <UnigineComponentSystem.h>
    #include <UnigineWorld.h>
    class Bullet :
    	public Unigine::ComponentBase
    {
    public:
    	COMPONENT_DEFINE(Bullet, Unigine::ComponentBase);
    
    	PROP_PARAM(File, hitPrefab, "");
    	PROP_PARAM(Float, speed, 10.0f);
    	PROP_PARAM(Int, damage, 1);
    
    
    	PROP_PARAM(Mask, intersectionMask, ~0);
    
    	// declare methods to be called at the corresponding stages of the execution sequence
    	COMPONENT_UPDATE(update);
    
    protected:
    	Unigine::WorldIntersectionNormalPtr hitInfo = Unigine::WorldIntersectionNormal::create();
    	// world main loop overrides
    	void update();
    };

    Bullet.cpp

    Source code (C++)
    #include "Bullet.h"
    #include "PlayerLogic.h"
    #include <UnigineGame.h>
    REGISTER_COMPONENT(Bullet);
    using namespace Unigine;
    using namespace Math;
    
    void Bullet::update()
    {
    	// set the current bullet position
    	Vec3 currentPosition = node->getWorldPosition();
    	// set the direction of the bullet movement along the Y axis
    	vec3 currentDirection = node->getWorldDirection(Math::AXIS_Y);
    
    	// update bullet position along the trajectory according to the set speed
    	node->setWorldPosition(node->getWorldPosition() + currentDirection * speed * Game::getIFps());
    
    	// find the intersection of the bullet's trajectory with some of objects
    	Unigine::ObjectPtr hitObject = World::getIntersection(currentPosition, node->getWorldPosition(), intersectionMask, hitInfo);
    
    	// if intersections weren't found, do nothing
    	if (!hitObject)
    		return;
    
    	// otherwise load NodeReference with hit effect
    	NodePtr hitEffect = World::loadNode(Unigine::FileSystem::guidToPath(FileSystem::getGUID(hitPrefab.getRaw())));
    	// set NodeReference to the hit point and set its direction according to the hit normal
    	hitEffect->setParent(hitObject);
    	hitEffect->setWorldPosition(hitInfo->getPoint());
    	hitEffect->setWorldDirection(hitInfo->getNormal(), vec3_up, Math::AXIS_Y);
    
    	// delete the bullet
    	node.deleteLater();
    }

    Save all the files that we modified and then build and run the application by hitting Ctrl + F5 to make the Component System update properties used to assign the components to nodes. Close the application after running it and switch to UnigineEditor.

  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 component with the following code:

EnemyLogic.h

Source code (C++)
#pragma once
#include <UnigineComponentSystem.h>
#include <UniginePathFinding.h>
#include <UnigineVisualizer.h>
#include "EnemyFireController.h"

class EnemyLogic :
	public Unigine::ComponentBase
{
public:
	COMPONENT_DEFINE(EnemyLogic, Unigine::ComponentBase);

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

	PROP_PARAM(Node, player, nullptr);
	PROP_PARAM(Node, intersectionSocket, nullptr);
	PROP_PARAM(Mask, playerIntersectionMask, ~0);

	PROP_PARAM(File, hitPrefab, "");
	PROP_PARAM(Float, reachRadius, 0.5);
	PROP_PARAM(Float, attackInnerRadius, 5.0f);
	PROP_PARAM(Float, attackOuterRadius, 7.0f);
	PROP_PARAM(Float, speed, 1.0f);
	PROP_PARAM(Float, rotationStiffness, 8);
	PROP_PARAM(Float, routeRecalculationInterval, 3.0f);

	PROP_PARAM(Int, damage, 1);

	PROP_PARAM(Mask, intersectionMask, ~0);

	// declare methods to be called at the corresponding stages of the execution sequence
	COMPONENT_INIT(init);
	COMPONENT_UPDATE(update);

protected:
	// world main loop overrides
	void init();
	void update();

	bool isTargetVisible();
	void updateRoute();
	void updateTargetState();
	void updateOrientation();
	void processIdleState();
	void processChaseState();
	void processAttackState();
private:
	// initializing the enemy state
	EnemyLogicState currentState = EnemyLogicState::Idle;

	bool targetIsVisible;
	Unigine::Math::Vec3 lastSeenPosition;
	Unigine::Math::vec3 lastSeenDirection;
	float lastSeenDistanceSqr;

	Unigine::BodyRigidPtr bodyRigid = nullptr;
	Unigine::WorldIntersectionPtr hitInfo = Unigine::WorldIntersection::create();
	Unigine::Vector<Unigine::NodePtr> hitExcludes;

	EnemyFireController *fireController = nullptr;
	// creating a queue for route points
	Unigine::Vector<Unigine::Math::Vec3> calculatedRoute;
	Unigine::PathRoutePtr route = Unigine::PathRoute::create();
	bool shouldUpdateRoute = true;
	float lastCalculationTime = 0.0f;
};

EnemyLogic.cpp

Source code (C++)
#include "EnemyLogic.h"
REGISTER_COMPONENT(EnemyLogic);
using namespace Unigine;
using namespace Math;

bool EnemyLogic::isTargetVisible()
{
	Vec3 direction = (player->getWorldPosition() - intersectionSocket->getWorldPosition());
	Vec3 p0 = intersectionSocket->getWorldPosition();
	Vec3 p1 = p0 + direction;
	
	Unigine::ObjectPtr hitObject = World::getIntersection(p0, p1, playerIntersectionMask.get(), hitExcludes, hitInfo);
	if (!hitObject)
		return false;

	return player->getID() == hitObject->getID();
}

void EnemyLogic::updateRoute()
{
	if (Game::getTime() - lastCalculationTime < routeRecalculationInterval)
		return;

	if (shouldUpdateRoute)
	{
		// calculate the route to the player
		route->create2D(node->getWorldPosition(), lastSeenPosition, 1);
		
		shouldUpdateRoute = false;
	}

	//  if route calculation is over
	if (route->isReady())
	{
		// check if the target point was reached
		if (route->isReached())
		{
			// clear the queue of path points
			calculatedRoute.clear();

			// add all root points to the queue
			for (int i = 1; i < route->getNumPoints(); ++i)
				calculatedRoute.append(route->getPoint(i));

			shouldUpdateRoute = true;
			lastCalculationTime = Game::getTime();
		}
		else
			// recalculate the route if the target point wasn't reached
			shouldUpdateRoute = true;
	}
}

void EnemyLogic::updateTargetState()
{
	// refresh the current visibility state
	targetIsVisible = isTargetVisible();

	// if the player is visible, remember his latest registered position
	if (targetIsVisible)
		lastSeenPosition = player->getWorldPosition();

	lastSeenDirection = (vec3)(lastSeenPosition - node->getWorldPosition());
		lastSeenDistanceSqr = lastSeenDirection.length2();
	lastSeenDirection.normalize();
}

void EnemyLogic::updateOrientation()
{
	vec3 direction = lastSeenDirection;
	direction.z = 0.0f;

	quat targetRotation = quat(Math::setTo(vec3_zero, direction.normalize(), vec3_up, Math::AXIS_Y));
	quat currentRotation = node->getWorldRotation();

	currentRotation = Math::slerp(currentRotation, targetRotation, Game::getIFps() * rotationStiffness);
	node->setWorldRotation(currentRotation);
}

void EnemyLogic::processIdleState()
{
	// if the target is visible (player) - shift Idle -> Chase
	if (targetIsVisible)
		currentState = EnemyLogicState::Chase;
}

void EnemyLogic::processChaseState()
{
	vec3 currentVelocity = bodyRigid->getLinearVelocity();
	currentVelocity.x = 0.0f;
	currentVelocity.y = 0.0f;
	if (calculatedRoute.size() > 0)
	{
		float distanceToTargetSqr = (calculatedRoute.first() - node->getWorldPosition()).length2();

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

		if (calculatedRoute.size() > 0)
		{
			vec3 direction = calculatedRoute.first() - node->getWorldPosition();
			direction.z = 0.0f;
			direction.normalize();
			currentVelocity.x = direction.x * speed;
			currentVelocity.y = direction.y * speed;
		}
	}

	// check distance and shift Chase -> Attack
	if (!targetIsVisible)
		currentState = EnemyLogicState::Idle;

	// check distance and shift 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->setLinearVelocity(currentVelocity);
}

void EnemyLogic::processAttackState()
{
	// check distance and shift Attack -> Chase
	if (!targetIsVisible || lastSeenDistanceSqr > attackOuterRadius * attackOuterRadius)
	{
		currentState = EnemyLogicState::Chase;
		// stop shooting
		if (fireController)
			fireController->stopFiring();
	}
}


void EnemyLogic::init()
{
	// initialize the parameters of the point moving along the path within navigational mesh
	route->setRadius(0.0f);
	route->setHeight(1.0f);
	route->setMaxAngle(0.5f);

	bodyRigid = node->getObjectBodyRigid();
	hitExcludes.append(node);
	hitExcludes.append(node->getChild(0));

	targetIsVisible = false;
	// getting the EnemyFireController component
	fireController = ComponentSystem::get()->getComponent<EnemyFireController>(node);
	
	shouldUpdateRoute = true;
	lastCalculationTime = Game::getTime();
}

void EnemyLogic::update()
{
	updateTargetState();
	updateOrientation();
	updateRoute();

	// switching between the enemy states
	switch (currentState)
	{
		case EnemyLogicState::Idle: processIdleState(); break;
		case EnemyLogicState::Chase: processChaseState(); break;
		case EnemyLogicState::Attack: processAttackState(); break;
	}

	// changing color depending on the current state
	vec4 color = vec4_black;
	switch (currentState)
	{
		case EnemyLogicState::Idle: color = vec4_blue; break;
		case EnemyLogicState::Chase: color = vec4(1.0f, 1.0f, 0.0f, 1.0f); break;
		case EnemyLogicState::Attack: color = vec4_red; break;
	}

	// visualize the enemy states
	Visualizer::renderPoint3D(node->getWorldPosition() + vec3_up * 2.0f, 0.25f, color);
	Visualizer::renderPoint3D(node->getWorldPosition() + vec3_up * 3.0f, 0.25f, isTargetVisible() ? vec4_green : vec4_red);
	Visualizer::renderPoint3D(lastSeenPosition, 0.1f, vec4(1.0f, 0.0f, 1.0f, 1.0f));


	// visualize the attack radius
	Visualizer::renderSphere(attackInnerRadius, node->getWorldTransform(), vec4_red);
	Visualizer::renderSphere(attackOuterRadius, node->getWorldTransform(), vec4_red);

	// visualize the route points
	for(Vec3 route_point: calculatedRoute)
		Visualizer::renderPoint3D(route_point + vec3_up, 0.25f, vec4_black);
}
  1. Save all the files that we modified and then build and run the application by hitting Ctrl + F5 to make the Component System update properties used to assign the components to nodes. Close the application after running it and switch to UnigineEditor.

  2. Enable editing of the robot_enemy node and assign the new component to the robot_root Dummy Node in the Parameters window.

  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-11-06
Build: ()