개발 이야기 안하는 개발자

언리얼5_2_4 : 게임 데이터와 인공지능 본문

Unreal/이득우의 언리얼 프로그래밍

언리얼5_2_4 : 게임 데이터와 인공지능

07e 2024. 3. 28. 19:01
반응형

데이터를 csv로 읽어와서 이를 언리얼에 적용할 것이다.

 

이를 읽어오는 하나의 폼을 스크립트로 제작하고 이를 블루프린트로 만들예정이다.

DataAsset과 유사하게 FTableRowBase를 상속받은 구조체를 선언해야 한다.

엑셀의 Name 컬럼을 제외한 컬럼과 동일하게 UPROPERTY속성을 선언하고 csv를 언리얼 엔진에 임포트한다.

Source파일 안에 원하는 곳에 메모장을 제작하고 이름뒤에 헤더를 붙인다 (.h)

USTRUCT(BlueprintType)
struct FABCharacterStat : public FTableRowBase
{
	GENERATED_BODY()

public:
	FABCharacterStat() : MaxHp(0.0f), Attack(0.0f), AttackRange(0.0f), AttackSpeed(0.0f) {}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
	float MaxHp;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
	float Attack;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
	float AttackRange;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
	float AttackSpeed;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
	float MovementSpeed;

	FABCharacterStat operator+(const FABCharacterStat& Other) const
	{
		const float* const ThisPtr = reinterpret_cast<const float* const>(this);
		const float* const OtherPtr = reinterpret_cast<const float* const>(&Other);

		FABCharacterStat Result;
		float* ResultPtr = reinterpret_cast<float*>(&Result);
		int32 StatNum = sizeof(FABCharacterStat) / sizeof(float);
		for (int32 i = 0; i < StatNum; i++)
		{
			ResultPtr[i] = ThisPtr[i] + OtherPtr[i];
		}

		return Result;
	}
};

reinterpret_cast는 포인터 변환이다.

 

제작한 스크립트로 DataTable 블루프린트를 제작한다. 

블루프린트를 열고 Reimport하면 해당 csv파일이 열리게 된다.

언리얼에서 게임 데이터를 관리할 싱글톤 클래스를 제작할 수 있다.

DECLARE_LOG_CATEGORY_EXTERN(LogABGameSingleton, Error, All);

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABGameSingleton : public UObject
{
	GENERATED_BODY()
	
public:
	UABGameSingleton();
	static UABGameSingleton& Get();

// Character Stat Data Section
public:
	FORCEINLINE FABCharacterStat GetCharacterStat(int32 InLevel) const { return CharacterStatTable.IsValidIndex(InLevel - 1) ? CharacterStatTable[InLevel - 1] : FABCharacterStat(); }

	UPROPERTY()
	int32 CharacterMaxLevel;

private:
	TArray<FABCharacterStat> CharacterStatTable;
	
};

싱글톤으로 Get()으로 가져오면 된다.

데이터를 찾을때 사용하면 된다.

 

DEFINE_LOG_CATEGORY(LogABGameSingleton);

UABGameSingleton::UABGameSingleton()
{
	static ConstructorHelpers::FObjectFinder<UDataTable> DataTableRef(TEXT("/Script/Engine.DataTable'/Game/ArenaBattle/GameData/ABCharacterStatTable.ABCharacterStatTable'"));
	if (nullptr != DataTableRef.Object)
	{
		const UDataTable* DataTable = DataTableRef.Object;
		check(DataTable->GetRowMap().Num() > 0);

		TArray<uint8*> ValueArray;
		DataTable->GetRowMap().GenerateValueArray(ValueArray);
		Algo::Transform(ValueArray, CharacterStatTable,
			[](uint8* Value)
			{
				return *reinterpret_cast<FABCharacterStat*>(Value);
			}
		);
	}

	CharacterMaxLevel = CharacterStatTable.Num();
	ensure(CharacterMaxLevel > 0);
}

UABGameSingleton& UABGameSingleton::Get()
{
	UABGameSingleton* Singleton = CastChecked< UABGameSingleton>(GEngine->GameSingleton);
	if (Singleton)
	{
		return *Singleton;
	}

	UE_LOG(LogABGameSingleton, Error, TEXT("Invalid Game Singleton"));
	return *NewObject<UABGameSingleton>();
}

엔진이 초기화 될때 싱글톤을 정의하기 때문에 이를 활용하면 된다.

ValueArray로 데이터를 가져와서 이를 CharacterStatTable로 이동시켜 데이터 테이블을 미리 읽어둔다.

 

General Settings로 들어가서 제작한 SingleTon으로 변경해준다.

 

캐릭터 스탯을 관리했던 ActorComponent 상속받은 ABCharacterStatComponent를 본다.

	FOnHpZeroDelegate OnHpZero;
	FOnHpChangedDelegate OnHpChanged;

	void SetLevelStat(int32 InNewLevel);
	FORCEINLINE float GetCurrentLevel() const { return CurrentLevel; }
	FORCEINLINE void SetModifierStat(const FABCharacterStat& InModifierStat) { ModifierStat = InModifierStat; }
	FORCEINLINE FABCharacterStat GetTotalStat() const { return BaseStat + ModifierStat; }
	FORCEINLINE float GetCurrentHp() const { return CurrentHp; }
	float ApplyDamage(float InDamage);

protected:
	void SetHp(float NewHp);

	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
	float CurrentHp;

	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
	float CurrentLevel;
	
	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
	FABCharacterStat BaseStat;

	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
	FABCharacterStat ModifierStat;

기존에 몇몇 삭제했다.

중요한건 BaseStat으로서 최초 기본 스텟이 있고, 수정되는 ModifierStat 두개로 각각 관리한 다는 것이다.

inline으로 간단한 값들은 바로 정의한다.

void UABCharacterStatComponent::BeginPlay()
{
	Super::BeginPlay();

	SetLevelStat(CurrentLevel);
	SetHp(BaseStat.MaxHp);
}

void UABCharacterStatComponent::SetLevelStat(int32 InNewLevel)
{
	CurrentLevel = FMath::Clamp(InNewLevel, 1, UABGameSingleton::Get().CharacterMaxLevel);
	BaseStat = UABGameSingleton::Get().GetCharacterStat(CurrentLevel);
	check(BaseStat.MaxHp > 0.0f);
}

float UABCharacterStatComponent::ApplyDamage(float InDamage)
{
	const float PrevHp = CurrentHp;
	const float ActualDamage = FMath::Clamp<float>(InDamage, 0, InDamage);

	SetHp(PrevHp - ActualDamage);
	if (CurrentHp <= KINDA_SMALL_NUMBER)
	{
		OnHpZero.Broadcast();
	}

	return ActualDamage;
}

void UABCharacterStatComponent::SetHp(float NewHp)
{
	CurrentHp = FMath::Clamp<float>(NewHp, 0.0f, BaseStat.MaxHp);
	
	OnHpChanged.Broadcast(CurrentHp);
}

싱글톤을 활용해서 바로 데이터를 가져와서 레벨에 맞는 데이터를 연결한다.

데이터를 몇몇 상수로 정의한 변수들이 프로젝트에 있는데,

대부분을 Stat->GetTottalStat().GetData 를 활용해서 필요한 값들을 가져오도록 수정한다.

현재 레벨에 맞는 Stat은 여기 클래스에 정리하기때문이다.

 

SpawnActor와 SpawnActorDeferred 차이

SpawnActor는 초기화와 BeginPlay를 동시에 실행한다.

SpawnActorDeferred는 초기화 이후 FinishSpawning을 호출하면 그 다음에 BeginPlay가 호출된다.

BeginPlay에서 뭔가 설정이 있다면 SpawnActorDeffered로 지연생성을 호출해서 일단 셋팅(초기화)를 모두 진행 한 다음 FinishSpawing을 호출해서 시작하면 된다.

 

기믹에도 레벨이 있다고 가정한다. (StageGimmick)

// Stage Stat
protected:
	UPROPERTY(VisibleInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
	int32 CurrentStageNum;
void AABStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	...

	if (!bResult)
	{
		FTransform NewTransform(NewLocation);
		AABStageGimmick* NewGimmick = GetWorld()->SpawnActorDeferred<AABStageGimmick>(AABStageGimmick::StaticClass(), NewTransform);
		if (NewGimmick)
		{
			NewGimmick->SetStageNum(CurrentStageNum + 1);
			NewGimmick->FinishSpawning(NewTransform);
		}
	}
}

void AABStageGimmick::OnOpponentSpawn()
{
	const FTransform SpawnTransform(GetActorLocation() + FVector::UpVector * 88.0f);
	AABCharacterNonPlayer* ABOpponentCharacter = GetWorld()->SpawnActorDeferred<AABCharacterNonPlayer>(OpponentClass, SpawnTransform);
	if (ABOpponentCharacter)
	{
		ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestroyed);
		ABOpponentCharacter->SetLevel(CurrentStageNum);
		ABOpponentCharacter->FinishSpawning(SpawnTransform);
	}
}

맵의 난이도 역시 현재 스테이지에 맞게 변경된다.

void AABStageGimmick::SpawnRewardBoxes()
{
	for (const auto& RewardBoxLocation : RewardBoxLocations)
	{
		FTransform SpawnTransform(GetActorLocation() + RewardBoxLocation.Value + FVector(0.0f, 0.0f, 30.0f));
		AABItemBox* RewardBoxActor = GetWorld()->SpawnActorDeferred<AABItemBox>(RewardBoxClass, SpawnTransform);
		if (RewardBoxActor)
		{
			RewardBoxActor->Tags.Add(RewardBoxLocation.Key);
			RewardBoxActor->GetTrigger()->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnRewardTriggerBeginOverlap);
			RewardBoxes.Add(RewardBoxActor);
		}
	}

	for (const auto& RewardBox : RewardBoxes)
	{
		if (RewardBox.IsValid())
		{
			RewardBox.Get()->FinishSpawning(RewardBox.Get()->GetActorTransform());
		}
	}
}

상자도 Deferred를 넣어준다. 완료가 되면 모든 상자의 FinishSpawing을 호출해준다.

 

무기의 설정을 추가한다

	UPROPERTY(EditAnywhere, Category = Stat)
	FABCharacterStat ModifierStat;

이 설정은 위에서 구조체에서 만들었던 operation+ 를 사용하기 위해서 같은 구조체로 제작한 것이다.

이 웨폰을 습득하면 두 구조체를 더하면 능력치가 적용된다.

CharacaterBase.cpp


void AABCharacterBase::EquipWeapon(UABItemData* InItemData)
{
	UABWeaponItemData* WeaponItemData = Cast<UABWeaponItemData>(InItemData);
	if (WeaponItemData)
	{
		if (WeaponItemData->WeaponMesh.IsPending())
		{
			WeaponItemData->WeaponMesh.LoadSynchronous();
		}
		Weapon->SetSkeletalMesh(WeaponItemData->WeaponMesh.Get());
		Stat->SetModifierStat(WeaponItemData->ModifierStat);
	}
}

무기를 장착하면 무기가 가지고 있는 Stat을 가져오도록 로직을 추가했다.

 

이제 적군을 따로 만들어 본다.

메인 캐릭터처럼 CharacterBase를 상속받아 정의한다.

UCLASS(config=ArenaBattle)
class ARENABATTLE_API AABCharacterNonPlayer : public AABCharacterBase
{
	GENERATED_BODY()
	
public:
	AABCharacterNonPlayer();

protected:
	virtual void PostInitializeComponents() override;

protected:
	void SetDead() override;
	void NPCMeshLoadCompleted();

	UPROPERTY(config)
	TArray<FSoftObjectPath> NPCMeshes;
	
	TSharedPtr<FStreamableHandle> NPCMeshHandle;
};

맨 위에 UCLASS 에서 작성된 config=ArenaBattle은 config 파일에 있는 DefaultArenaBattle.ini를 적용한다는 뜻이다.

NPCMeshes라는 곳에 더한다는 뜻이다. NPCMeshes는 TArray로 되어있기 때문에 모두 배열에 추가가 될 것이다.

비동기로드를 사용하기 위해선 FStreamableHandle을 사용해야 한다.

 

AABCharacterNonPlayer::AABCharacterNonPlayer()
{
	GetMesh()->SetHiddenInGame(true);
}

void AABCharacterNonPlayer::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	ensure(NPCMeshes.Num() > 0);
	int32 RandIndex = FMath::RandRange(0, NPCMeshes.Num() - 1);
	NPCMeshHandle = UAssetManager::Get().GetStreamableManager().RequestAsyncLoad(NPCMeshes[RandIndex], FStreamableDelegate::CreateUObject(this, &AABCharacterNonPlayer::NPCMeshLoadCompleted));
}

void AABCharacterNonPlayer::SetDead()
{
	Super::SetDead();

	FTimerHandle DeadTimerHandle;
	GetWorld()->GetTimerManager().SetTimer(DeadTimerHandle, FTimerDelegate::CreateLambda(
		[&]()
		{
			Destroy();
		}
	), DeadEventDelayTime, false);
}

void AABCharacterNonPlayer::NPCMeshLoadCompleted()
{
	if (NPCMeshHandle.IsValid())
	{
		USkeletalMesh* NPCMesh = Cast<USkeletalMesh>(NPCMeshHandle->GetLoadedAsset());
		if (NPCMesh)
		{
			GetMesh()->SetSkeletalMesh(NPCMesh);
			GetMesh()->SetHiddenInGame(false);
		}
	}

	NPCMeshHandle->ReleaseHandle();
}

RequestAsyncLoad를 통해서 비동기 방식 로드를 진행한다.

끝나면 호출되는 메소드를 등록한다.

NPCMeshLoadCompleted가 호출되는데 이를 보고 NPCMesh를 가져오게 되고, 메쉬 로드가 모두 종료가 되면 Mesh가 보이게 될 것이다.

 

 

 

Behavior Tree를 제작하고 AI를 제작해본다.

 

BehaviroTree

왼쪽으로 내려가면서 실행하게 된다.

왼쪽부터 오른쪽으로 넘어온다.

 

행동을 중심을 설계한다.

 

셀렉터 - 여러 행동중 하나의 행동을 지정

시퀀스 - 여러 행동을 순차 수행

패러럴 - 여러 행동을 동시 수행

 

행동결과에 맞춰 성공 / 실패 / 중지 / 진행 으로 결과 처리

셀렉터 - 왼쪽이 성공하면 끝. 왼쪽실패하면 오른쪽 시도

시퀀스 - 둘다 성공해야 함. (패러럴도)

 

컴포짓노드에 부착하는 다양한 추가 기능

데코레이터(Decorator) : 컴포짓 실행되는 조건 지정

서비스(Service) : 컴포짓 활성화 될때 주기적으로 실행하는 부가 명령

관찰자 중단(Abort) : 데코레이터 조건에 부합되면 컴포짓 내 활동 모두 중단

 

예시)

 

 

Auto Possess AI 어떠한 형태로 제공된 캐릭터를 AI 적용을 할지 물어봄.

-Placed In World(배치되었던) , Spawned(나중에 생성했던)

AIControllerClass는 어떤 형태의 클래스를 제작할 것인지를 물어봄.

 

AI를 만들기 위해서 BehaviorTree와 Blackboard를 하나씩 만든다.

블랙보드는 앞으로 Behavior에 사용될 다양한 값들과 상태들을 나타내게 될 것이다.

여기에 나타나는 변화로 BehaviroTree에 영향을 줄것이다.

BehaviorTree는 Blackboard가 필요하기 때문에 우측에 하나 설정이 된것을 볼 수 있다.

5초기다리는 내용의 BT(BehaviorTree)이다.

 

 

AIControllerClass를 만들어 본다.

class ARENABATTLE_API AABAIController : public AAIController
{
	GENERATED_BODY()
	
public:
	AABAIController();

	void RunAI();
	void StopAI();

protected:
	virtual void OnPossess(APawn* InPawn) override;

private:
	UPROPERTY()
	TObjectPtr<class UBlackboardData> BBAsset;
	
	UPROPERTY()
	TObjectPtr<class UBehaviorTree> BTAsset;
};
AABAIController::AABAIController()
{
	static ConstructorHelpers::FObjectFinder<UBlackboardData> BBAssetRef(TEXT("/Script/AIModule.BlackboardData'/Game/ArenaBattle/AI/BB_ABCharacter.BB_ABCharacter'"));
	if (nullptr != BBAssetRef.Object)
	{
		BBAsset = BBAssetRef.Object;
	}

	static ConstructorHelpers::FObjectFinder<UBehaviorTree> BTAssetRef(TEXT("/Script/AIModule.BehaviorTree'/Game/ArenaBattle/AI/BT_ABCharacter.BT_ABCharacter'"));
	if (nullptr != BTAssetRef.Object)
	{
		BTAsset = BTAssetRef.Object;
	}
}

void AABAIController::RunAI()
{
	UBlackboardComponent* BlackboardPtr = Blackboard.Get();
	if (UseBlackboard(BBAsset, BlackboardPtr))
	{
		bool RunResult = RunBehaviorTree(BTAsset);
		ensure(RunResult);
	}
}

void AABAIController::StopAI()
{
	UBehaviorTreeComponent* BTComponent = Cast<UBehaviorTreeComponent>(BrainComponent);
	if (BTComponent)
	{
		BTComponent->StopTree();
	}
}

void AABAIController::OnPossess(APawn* InPawn)
{
	Super::OnPossess(InPawn);

	RunAI();
}

OnPossess는 어떠한 폰이 이 AIController에 빙의할때 호출되는 코드이다.

시작하면 BT가 실행될 것이다.

Stop이 호출되면 멈출것이다.

 

이후 NPC캐릭터에서 이 스크립트로 지정해주어야 한다.

AABCharacterNonPlayer::AABCharacterNonPlayer()
{
	GetMesh()->SetHiddenInGame(true);

	AIControllerClass = AABAIController::StaticClass();
	AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}

 

이렇게 함으로써 적 NPC의 BehaviorTree가 작동하는것을 볼 수 있다.

 

행동트리 모델을 본격적으로 구현해본다.

NPC가 서성거릴 위치 변수를 제작한다.

블랙보드를 켜고 vector3 변수를 추가한다

 

AI에 사용될 이동 범위를 셋팅해주는데, 이는 PlaceActors에 NavMeshBoundsVolume를 배치하면 된다.

p를 눌러주면 이동가능한 영역이 초록색을 띈다.

맵은 런타임중에 계속 생성되기 때문에 영역을 실시간으로 계산해주는 Dynamic으로 변경해준다.

ProjectSettings의 Navagation Mesh에 있다.

 

블랙보드에 Vector3로 HomePos를 제작한다 (최초 생성하는 위치)

이 HomePos를 기준으로 Patrol 범위를 제작할 것이다.

MoveTo는 Vector3값으로 위치이동하는 태스크인데, Blackboard의 값을 제작했던 PatrolPos로 지정하면 된다.

 

위치를 랜덤하게 찍어야 하기 때문에 Task를 새로 제작한다.

NavigationSystem, AIModule, GameplayTasks 를 모듈에 추가해주어야 한다.

 

우선 BlackBoard에서 값을 C++로 가져올 것이기 때문에 정의부터 해준다.

txt파일을 만들고 .h로 수정해서 작성한다.

#pragma once

#define BBKEY_HOMEPOS TEXT("HomePos")
#define BBKEY_PATROLPOS TEXT("PatrolPos")
#define BBKEY_TARGET TEXT("Target")

 

NPC가 AI에서 사용할 변수값들을 포함한Interface를 상속받아 정의한다.

공격하는 메소드와 공격이 끝났는지를 확인하는 델리게이트를 하나 받는다.

DECLARE_DELEGATE(FAICharacterAttackFinished);

class ARENABATTLE_API IABCharacterAIInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual float GetAIPatrolRadius() = 0;
	virtual float GetAIDetectRange() = 0;
	virtual float GetAIAttackRange() = 0;
	virtual float GetAITurnSpeed() = 0;

	virtual void SetAIAttackDelegate(const FAICharacterAttackFinished& InOnAttackFinished) = 0;
	virtual void AttackByAI() = 0;
};

 

float AABCharacterNonPlayer::GetAIPatrolRadius()
{
	return 800.0f;
}

float AABCharacterNonPlayer::GetAIDetectRange()
{
	return 400.0f;
}

float AABCharacterNonPlayer::GetAIAttackRange()
{
	return Stat->GetTotalStat().AttackRange + Stat->GetAttackRadius() * 2;
}

float AABCharacterNonPlayer::GetAITurnSpeed()
{
	return 2.0f;
}

void AABCharacterNonPlayer::SetAIAttackDelegate(const FAICharacterAttackFinished& InOnAttackFinished)
{
	OnAttackFinished = InOnAttackFinished;
}

void AABCharacterNonPlayer::AttackByAI()
{
	ProcessComboCommand();
}

//NPC에서 별도 구현
void AABCharacterNonPlayer::NotifyComboActionEnd()
{
	Super::NotifyComboActionEnd();
	OnAttackFinished.ExecuteIfBound();
}

NPC에서 이렇게 공격을 진행하고 공격이 끝나면 공격이 끝났다고 델리게이트 이벤트를 호출하게 된다.

NotifyComoActionEnd가 NPC 상위 Base가 ComoactionEnd인 상태에서 공격이 끝나면 호출하는 메소드기 때문에 재정의만하면 공격끝나는 타이밍을 알 수 있을 것이

 

이후 제작한 Task를 정의한다.

class ARENABATTLE_API UBTTask_FindPatrolPos : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_FindPatrolPos();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
	
};

태스크 노드가 실행되기 위해선 ExecuteTask 메소드가 정의되어야 한다.

 

UBTTask_FindPatrolPos::UBTTask_FindPatrolPos()
{
}

EBTNodeResult::Type UBTTask_FindPatrolPos::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn)
	{
		return EBTNodeResult::Failed;
	}

	UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(ControllingPawn->GetWorld());
	if (nullptr == NavSystem)
	{
		return EBTNodeResult::Failed;
	}

	IABCharacterAIInterface* AIPawn = Cast<IABCharacterAIInterface>(ControllingPawn);
	if (nullptr == AIPawn)
	{
		return EBTNodeResult::Failed;
	}

	FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(BBKEY_HOMEPOS);
	float PatrolRadius = AIPawn->GetAIPatrolRadius();
	FNavLocation NextPatrolPos;

	if (NavSystem->GetRandomPointInNavigableRadius(Origin, PatrolRadius, NextPatrolPos))
	{
		OwnerComp.GetBlackboardComponent()->SetValueAsVector(BBKEY_PATROLPOS, NextPatrolPos.Location);
		return EBTNodeResult::Succeeded;
	}

	return EBTNodeResult::Failed;
}

NavigationSystem의 이름이 변경되어 뒤에 v1이 붙는다.

이후 컨트롤러 폰을 가져와서 폰의 주변 반경의 Radius 내에 있는 랜덤한 Navigaion 범위를 찍는다.

이후 블랙보드의 PatrolPos에 셋팅해주고 성공을 반환한다.

 

AIController에 입장에선 최초 시작점을 기억해야 한다.

블랙보드에 있는 홈포스값을 생서안 지금 최초위치를 set한다.

void AABAIController::RunAI()
{
	UBlackboardComponent* BlackboardPtr = Blackboard.Get();
	if (UseBlackboard(BBAsset, BlackboardPtr))
	{
		Blackboard->SetValueAsVector(BBKEY_HOMEPOS, GetPawn()->GetActorLocation());

		bool RunResult = RunBehaviorTree(BTAsset);
		ensure(RunResult);
	}
}

이후 BT에 FindPatrolPos를 추가한다.

 

AI가 유저 캐릭터를 서칭하도록 태스크를 제작한다.

class ARENABATTLE_API UBTService_Detect : public UBTService
{
	GENERATED_BODY()
	
public:
	UBTService_Detect();

protected:
	virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
	
};

제작한 BTService는 매Tick마다 TickNode가 호출되는 태스크이다.

 

UBTService_Detect::UBTService_Detect()
{
	NodeName = TEXT("Detect");
	Interval = 1.0f; //반복하는 텀
}

void UBTService_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
	Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);

	APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn)
	{
		return;
	}

	FVector Center = ControllingPawn->GetActorLocation();
	UWorld* World = ControllingPawn->GetWorld();
	if (nullptr == World)
	{
		return;
	}

	IABCharacterAIInterface* AIPawn = Cast<IABCharacterAIInterface>(ControllingPawn);
	if (nullptr == AIPawn)
	{
		return;
	}

	float DetectRadius = AIPawn->GetAIDetectRange();

	TArray<FOverlapResult> OverlapResults;
	FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(Detect), false, ControllingPawn);
	bool bResult = World->OverlapMultiByChannel(
		OverlapResults,
		Center,
		FQuat::Identity,
		CCHANNEL_ABACTION,
		FCollisionShape::MakeSphere(DetectRadius),
		CollisionQueryParam
	);

	if (bResult)
	{
		for (auto const& OverlapResult : OverlapResults)
		{
			APawn* Pawn = Cast<APawn>(OverlapResult.GetActor());
			if (Pawn && Pawn->GetController()->IsPlayerController())
			{
				OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, Pawn);
				DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.2f);

				DrawDebugPoint(World, Pawn->GetActorLocation(), 10.0f, FColor::Green, false, 0.2f);
				DrawDebugLine(World, ControllingPawn->GetActorLocation(), Pawn->GetActorLocation(), FColor::Green, false, 0.27f);
				return;
			}
		}
	}

	OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, nullptr);
	DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}

콜리전 충돌을 모두 가져오게 된다.

모두 가져온 다음 결과값이 있는 경우 타겟을 지정하도록 로직을 구성한다.

이후 블랙보드에 타겟 값을 지정한다.

 

컴포짓 노드에 Service를 부착한다.

이후 Blackboard에서 Target이라는 Object타입을 추가해준다. (Base Class는 Pawn)

 

타겟을 찾는 기능까지 했고 이제 타겟이 있는경우 타겟을 향해 따라가도록 BT를 수정한다. 

우선순위가 높기 때문에 왼쪽에 배치한다.

Decorator를 컴포짓에 추가한다.

이 데코레이터는 Target이 is Set 상태일때만 호출이 된다.

오른쪽 노드에는 NoTarget으로 Is not Set 상태로 변경해준다.

이후 Notify Observer를 On Value Change 로 변경하고 값이 변경이 된다면 현재 모든 기능을 중단하라고 지정한다.(self)

 

 

공격 하는 기능의 Task를 추가한다.

우선 범위를 좁혀야 하기 때문에 따라가는 Decorator를 제작한다.

class ARENABATTLE_API UBTDecorator_AttackInRange : public UBTDecorator
{
	GENERATED_BODY()
	
public:
	UBTDecorator_AttackInRange();

protected:
	virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
};
UBTDecorator_AttackInRange::UBTDecorator_AttackInRange()
{
	NodeName = TEXT("CanAttack");
}

bool UBTDecorator_AttackInRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
	bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);

	APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
	if (nullptr == ControllingPawn)
	{
		return false;
	}

	IABCharacterAIInterface* AIPawn = Cast<IABCharacterAIInterface>(ControllingPawn);
	if (nullptr == AIPawn)
	{
		return false;
	}

	APawn* Target = Cast<APawn>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBKEY_TARGET));
	if (nullptr == Target)
	{
		return false;
	}

	float DistanceToTarget = ControllingPawn->GetDistanceTo(Target);
	float AttackRangeWithRadius = AIPawn->GetAIAttackRange();
	bResult = (DistanceToTarget <= AttackRangeWithRadius);
	return bResult;
}

Target과의 거리를 비교해서 충분히 가까우면 공격하도록 로직을 제작한다.

 

 

공격하는 노드를 제작한다.

class ARENABATTLE_API UBTTask_Attack : public UBTTaskNode
{
	GENERATED_BODY()
	
public:
	UBTTask_Attack();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
UBTTask_Attack::UBTTask_Attack()
{
}

EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
	if (nullptr == ControllingPawn)
	{
		return EBTNodeResult::Failed;
	}

	IABCharacterAIInterface* AIPawn = Cast<IABCharacterAIInterface>(ControllingPawn);
	if (nullptr == AIPawn)
	{
		return EBTNodeResult::Failed;
	}

	FAICharacterAttackFinished OnAttackFinished;
	OnAttackFinished.BindLambda(
		[&]()
		{
			FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
		}
	);

	AIPawn->SetAIAttackDelegate(OnAttackFinished);
	AIPawn->AttackByAI();
	return EBTNodeResult::InProgress;
}

해당 태스크가 공격을 진행하고, 이벤트를 등록한다.

FinishLatentTask는 해당 태스트를 종료한다고 알리는 메소드이다.

해당 공격이 끝나면 FinishLatenTask를 호출해서 태스크가 종료되었다는 것을 알릴것이다.

 

 

 

 

타겟이 범위에 들어왔다면 공격하는 내용도 추가한다.

범위에 들어왔다면 Attack이란 태스크를 진행하고, InverseCondintion인 경우(범위 밖) 그냥 Move To 하도록 한다.

또 InverseCondition에서 범위에 들어왔다면 다시 하던일을 모두 멈추라고 지정해야 한다

그렇기 위해서 Notify Observer를 On ValueChange와 self 로 지정한다.

 

공격이 끝나고 플레이어가 죽어도 NPC는 계속 공격을 진행한다.

죽었지만 Target의 값이 계속 남아있기 때문이다.

왼쪽 컴포짓에 Detect를 넣어서 타겟이 죽어서 못찾기 때문에 null이 되면 종료되도록 한다.

 

 

NPC가 공격진행중에 Turn을 안하는 문제가 있다.

그래서 공격때마다 Turn을 해서 유저를 바라보도록 한다.

class ARENABATTLE_API UBTTask_TurnToTarget : public UBTTaskNode
{
	GENERATED_BODY()

public:
	UBTTask_TurnToTarget();

	virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
};
UBTTask_TurnToTarget::UBTTask_TurnToTarget()
{
	NodeName = TEXT("Turn");
}

EBTNodeResult::Type UBTTask_TurnToTarget::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
	EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);

	APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
	if (nullptr == ControllingPawn)
	{
		return EBTNodeResult::Failed;
	}

	APawn* TargetPawn = Cast<APawn>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBKEY_TARGET));
	if (nullptr == TargetPawn)
	{
		return EBTNodeResult::Failed;
	}

	IABCharacterAIInterface* AIPawn = Cast<IABCharacterAIInterface>(ControllingPawn);
	if (nullptr == AIPawn)
	{
		return EBTNodeResult::Failed;
	}

	float TurnSpeed = AIPawn->GetAITurnSpeed();
	FVector LookVector = TargetPawn->GetActorLocation() - ControllingPawn->GetActorLocation();
	LookVector.Z = 0.0f;
	FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
	ControllingPawn->SetActorRotation(FMath::RInterpTo(ControllingPawn->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), TurnSpeed));

	return EBTNodeResult::Succeeded;
}

타겟을 바라보도록 로직을 구현한다.

Attack Task와 Turn을 각각하면 의미가 없기 때문에

Parallel을 사용해서 동시에 하도록 한다.

 

 

 

반응형