Skip to content

Creating custom AI senses

In this post I’m going to show you how to create your own custom AI senses. For demonstration purposes I’m going to create a new AI sense named “Aquaphobia” which will be responsible for identifying nearby water resources and register stimulus for each wate resouce. Then, the controller of the AI pawn will move the pawn away from these resources. Here’s the end result of the blog post:

This post was created using the 4.21 version of the engine. Depending on the version you’re using you may need to adjust the demonstrated code to match the corresponding API.

In order to add a new, custom AI sense in the engine we really need two classes:

  • The AI sense class, which will contain the core functionality regarding the logic of the sense
  • The config class, which will contain the properties that we’re going to use for the AI sense

With that said, add two new class, named AISense_Aquaphobia and AISenseConfig_Aquaphobia which will inherit the AISense and AISenseConfig classes respectively.

Creating the sense config

Now that we have inherited the correct classes, provide the following logic inside your config class:

#pragma once

#include "CoreMinimal.h"
#include "Perception/AISenseConfig.h"
#include "AISense_Aquaphobia.h"
#include "AISenseConfig_Aquaphobia.generated.h"

/**
 * 
 */
UCLASS(meta = (DisplayName = "AI Aquaphobia Config"))
class CUSTOMAISENSE_API UAISenseConfig_Aquaphobia : public UAISenseConfig
{
	GENERATED_BODY()
	
public:

	/*The class reference which contains the logic for this sense config */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", NoClear, config)
	TSubclassOf<UAISense_Aquaphobia> Implementation;

	/* The radius around the pawn that we're checking for "water resources" */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", config, meta = (UIMin = 0.0, ClampMin = 0.0))
	float PhobiaRadius;

	/* True if you want to display the debug sphere around the pawn */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Sense", config)
	bool bDisplayDebugSphere;

	UAISenseConfig_Aquaphobia(const FObjectInitializer& ObjectInitializer);

	/* The editor will call this when we're about to assign an implementation for this config */
	virtual TSubclassOf<UAISense> GetSenseImplementation() const override;
};

Then, add the following logic for the source file:

UAISenseConfig_Aquaphobia::UAISenseConfig_Aquaphobia(const FObjectInitializer& ObjectInitializer) : Super(ObjectInitializer)
{
	DebugColor = FColor::Blue;
	Implementation = GetSenseImplementation();		
}

TSubclassOf<UAISense> UAISenseConfig_Aquaphobia::GetSenseImplementation() const
{
	return *Implementation;
}

We’re done with the config class for our case so let’s move on to the core logic for our sense

Creating the actual sense

Here are the whole header and source files needed for our simple AI sense for this post:

#include "CoreMinimal.h"
#include "Perception/AISense.h"
#include "AISense_Aquaphobia.generated.h"

/**
 * 
 */
class UAISenseConfig_Aquaphobia; //forward declaration to avoid circular dependencies

UCLASS(meta=(DisplayName="AI Aquaphobia config"))
class CUSTOMAISENSE_API UAISense_Aquaphobia : public UAISense
{
	GENERATED_BODY()

public:

	/*
	 * After inspecting the engine's code it seems like there's a pattern to use a struct
	 * to consume the properties from the config class so it's better to follow the engine's
	 * standards and workflows.
	 */
	struct FDigestedAquaProperties
	{
		float PhobiaRadius;
		bool bDisplayDebugSphere;

		FDigestedAquaProperties();
		FDigestedAquaProperties(const UAISenseConfig_Aquaphobia& SenseConfig);
	};

	/* Consumed properties from config */
	TArray<FDigestedAquaProperties> DigestedProperties;

	UAISense_Aquaphobia();
	
protected:

	/* Core logic for the sense */
	virtual float Update() override;
	
	/* A listener is someone who has a Perception component with various senses
	 * This function will be called when a new listener gained this sense 
	 */
	void OnNewListenerImpl(const FPerceptionListener& NewListener);

	/*
	 * Called whenever the listener is removed (eg destroyed or game has stopped)
	 */
	void OnListenerRemovedImpl(const FPerceptionListener& UpdatedListener);
};
#include "AISense_Aquaphobia.h"
#include "Kismet/GameplayStatics.h"
#include "DrawDebugHelpers.h"
#include "AISenseConfig_Aquaphobia.h"
#include "Perception/AIPerceptionComponent.h"

UAISense_Aquaphobia::UAISense_Aquaphobia()
{
	//Bind the required functions
	OnNewListenerDelegate.BindUObject(this, &UAISense_Aquaphobia::OnNewListenerImpl);
	OnListenerRemovedDelegate.BindUObject(this, &UAISense_Aquaphobia::OnListenerRemovedImpl);
}

float UAISense_Aquaphobia::Update()
{
	AIPerception::FListenerMap& ListenersMap = *GetListeners();

	//For each listener who has this sense we're going to perform a sweep to determine nearby aqua actors
	for (auto& Elem : ListenersMap)
	{
		//Get the listener
		FPerceptionListener Listener = Elem.Value;
		const AActor* ListenerBodyActor = Listener.GetBodyActor();

		for (int32 DigestedPropertyIndex = 0; DigestedPropertyIndex < DigestedProperties.Num(); DigestedPropertyIndex++)
		{
			//Create the sphere for this sense and perform the sweep to determine nearby actors
			FCollisionShape CollisionSphere = FCollisionShape::MakeSphere(DigestedProperties[DigestedPropertyIndex].PhobiaRadius);
			TArray<FHitResult> HitResults;
			GetWorld()->SweepMultiByChannel(HitResults, ListenerBodyActor->GetActorLocation(), ListenerBodyActor->GetActorLocation() + FVector::UpVector*CollisionSphere.GetSphereRadius(), FQuat(), ECollisionChannel::ECC_WorldDynamic, CollisionSphere);

			//Draw debug sphere if we have activated it via the config
			if (DigestedProperties[DigestedPropertyIndex].bDisplayDebugSphere)
			{
				DrawDebugSphere(GetWorld(), ListenerBodyActor->GetActorLocation(), DigestedProperties[DigestedPropertyIndex].PhobiaRadius, 8, FColor::Blue, false, 30.f, 1, 2.f);
			}
			

			//Check hit results for aqua actors
			for (int32 i = 0; i < HitResults.Num(); i++)
			{
				FHitResult hit = HitResults[i];
				//To simplify things, we're going to assume that "water resources" for this post are actors that have the following game tag
				if (hit.GetActor()->ActorHasTag(FName("AquaActor")))
				{
					if ((hit.GetActor()->GetActorLocation() - ListenerBodyActor->GetActorLocation()).Size() <= DigestedProperties[DigestedPropertyIndex].PhobiaRadius)
					{
						Elem.Value.RegisterStimulus(hit.GetActor(), FAIStimulus(*this, 5.f, hit.GetActor()->GetActorLocation(), ListenerBodyActor->GetActorLocation()));
						GLog->Log("registered stimulus!");
					}

				}

			}
		}
		
		
	}
	//Time until next update; in this case we're forcing the update to happen in each frame
	return 0.f;
}

void UAISense_Aquaphobia::OnNewListenerImpl(const FPerceptionListener& NewListener)
{
	//Since we have at least one AI actor with this sense this function will fire when the game starts
	GLog->Log("hello new listener!");
	check(NewListener.Listener.IsValid());
	//Get the config
	UAISenseConfig* Config = NewListener.Listener->GetSenseConfig(GetSenseID());
	const UAISenseConfig_Aquaphobia* SenseConfig = Cast<const UAISenseConfig_Aquaphobia>(Config);
	check(SenseConfig);
	//Consume properties from the sense config
	FDigestedAquaProperties PropertyDigest(*SenseConfig);
	DigestedProperties.Add(PropertyDigest);
	RequestImmediateUpdate();
}

void UAISense_Aquaphobia::OnListenerRemovedImpl(const FPerceptionListener& UpdatedListener)
{
	//In our case, executes when we stop playing
	GLog->Log("on listener removed!");
}

UAISense_Aquaphobia::FDigestedAquaProperties::FDigestedAquaProperties()
{
	//Init.
	PhobiaRadius = 15.f;
	bDisplayDebugSphere = false;
}

UAISense_Aquaphobia::FDigestedAquaProperties::FDigestedAquaProperties(const UAISenseConfig_Aquaphobia& SenseConfig)
{
	//Copy constructor
	PhobiaRadius = SenseConfig.PhobiaRadius;
	bDisplayDebugSphere = SenseConfig.bDisplayDebugSphere;
}

At this point we’re almost. Once you compile your code you need to create an AI pawn and AI controller who was the “AIPerception” component. Then, you can assign the sense config and the sense itself like it was displayed in the video at the start of the post.

The sense we have created is only responsible for registering stimulus to each listener. In order to determine where the pawn needs to move every time we sense a water resource I created the following blueprint code inside the AI controller to reduce the overall code need for this post:

At this point, don’t forget to add a nav mesh to your level and tag some actors as “AquaActor” so you can test the functionality of the sense. Moreover, please note that you may have to restart the engine so your AI sense can be configured correctly.

Thanks for reading!

Avatar photo

Comments (3)

  1. I do this lesson, but there are errors
    Severity Code Description Project File Line Suppression State
    Error LNK2019 unresolved external symbol “public: __cdecl UAISenseConfig_Aquaphobia::UAISenseConfig_Aquaphobia(class FObjectInitializer const &)” (??0UAISenseConfig_Aquaphobia@@QEAA@AEBVFObjectInitializer@@@Z) referenced in function “public: static void __cdecl UAISenseConfig_Aquaphobia::__DefaultConstructor(class FObjectInitializer const &)” (?__DefaultConstructor@UAISenseConfig_Aquaphobia@@SAXAEBVFObjectInitializer@@@Z) AquaFobia1 D:\UnrealEngine4_Project\AquaFobia1\Intermediate\ProjectFiles\AISenseConfig_Aquaphobia.cpp.obj 1

  2. Hi, I used to work with blueprints but there is no option to create custom eqs tests, so i decided to learn a little bit of C++ and I wonder if you could make a tutorial about it. Thanks ! PS: Sorry for language, I am a French student.

  3. I have for example UStaticMeshComponent in a parent class and I wish to override it with another mesh in a child class. I made blueprints for both the parent class and the child class. I’m asking because I cannot attach the child class object to a socket (in my player class). I can only do that with the parent class. How to properly get child components and attach to a socket in a player.
    ShapeClass -> CubeClass
    ShapeClass->SphereClass
    I can attach the ShapeClass, but not the CubeClass or SphereClass.

    ChildComponent->AttachToComponent(GetMesh(), FAttachmentTransformRules::SnapToTargetNotIncludingScale, SocketName);

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Back To Top