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:
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:
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:
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.
-
Create the EnemyFireController component and paste the following code into it:
EnemyFireController.h
#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
#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.
- If necessary, enable editing of the robot_enemy node and assign the EnemyFireController component to the robot_root Dummy Object.
-
Drag and drop the LeftGunMuzzle and RightGunMuzzle Dummy Nodes to the corresponding fields of the EnemyFireController component.
-
Drag and drop data/fps/bullet/bullet.node to the Bullet Prefab field.
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.
- Add the data/fps/bullet/bullet.node asset to the scene.
-
Create the Bullet component and copy the following code:
Bullet.h
#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
#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.
- Enable editing of the bullet node and assign the bullet component to its Static Mesh node.
-
Drag data/fps/bullet/bullet_hit.node to the Hit Prefab field.
- Assign the LifeTime.cs component to the bullet (Static Mesh) node and set its Life Time value to 5 seconds.
- 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
#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
#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);
}
-
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.
-
Enable editing of the robot_enemy node and assign the new component to the robot_root Dummy Node in the Parameters window.
- 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.
-
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: