강의에서 AIController를 구현하는 중이다.
AI가 플레이어 캐릭터를 추적할 수 있도록 참조시켜줘야 한다. 강의에서 짜준 로직은 플레이어 캐릭터에게 Player 태그(GameplayTag 말고 ActorTag)를, EnemyBase에게 Enemy 태그를 할당한 뒤 BTService 클래스에서 GetAllActorsWithTag
로 플레이어 캐릭터를 가져오고 있었다.
이 함수의 내부 구조는 이렇다.
if (UWorld* World = GEngine->GetWorldFromContextObject(WorldContextObject, EGetWorldErrorMode::LogAndReturnNull))
{
for (FActorIterator It(World); It; ++It)
{
AActor* Actor = *It;
if (Actor->ActorHasTag(Tag))
{
OutActors.Add(Actor);
}
}
}
월드에 존재하는 모든 Actor를 돌아다니며 주어진 태그와 일치하는 태그를 갖고 있으면 Array에 추가하고, 그 Array를 반환한다. 게임 시작 시점에 1번만 호출하거나, 플레이어 리스폰 지점을 탐색하는 등의 정말 드물게 호출되는 로직에선 써도 되겠지만.. 강의에선 ReceiveTickAI에서 호출하고 있었다. Tick의 주기를 결정할 수 있다고는 해도 0.5초 내외로는 꼭 누구를 추적할 것인지 새로고침해줘야 하기 때문에 굉장히 무거운 로직이라고 볼 수 있다.
그래서 PlayerPawnManagerSubsystem부터 만들었다.
// PlayerPawnManagerSubsystem.cpp
void UPlayerPawnManagerSubsystem::RegisterPlayerPawn(APawn* InPawn)
{
PlayerPawns.Add(InPawn);
}
void UPlayerPawnManagerSubsystem::UnregisterPlayerPawn(APawn* InPawn)
{
PlayerPawns.Remove(InPawn);
}
TArray<APawn*> UPlayerPawnManagerSubsystem::GetAllPlayerPawns()
{
return PlayerPawns;
}
Pawn을 매개변수로 받아 갖고 있는 TArray에 추가, 제거 및 그것을 반환하는 로직이다.
void AAuraPlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
if (HasAuthority())
{
UPlayerPawnManagerSubsystem* PawnManager = GetGameInstance()->GetSubsystem<UPlayerPawnManagerSubsystem>();
PawnManager->RegisterPlayerPawn(InPawn);
}
}
void AAuraPlayerController::OnUnPossess()
{
if (HasAuthority())
{
UPlayerPawnManagerSubsystem* PawnManager = GetGameInstance()->GetSubsystem<UPlayerPawnManagerSubsystem>();
PawnManager->UnregisterPlayerPawn(GetPawn());
}
Super::OnUnPossess();
}
이걸 PlayerController의 OnPossess와 OnUnPossess에서 호출한다. OnUnPossess에서 Super 함수를 호출하기 전까진 GetPawn이 유효하다. 이러면 이제 GetAllActorsWithTag를 사용하지 않아도 AI가 모든 플레이어들의 Pawn을 참조할 수 있다.
이제 BTService로 가서 ReceiveTickAI에 함수를 붙이고
구현은.. 대충 위에서 구현한 PlayerCharacter들을 참조해 가장 가까운 객체를 찾고 그 거리를 구하는 거다.
중요한 건 여기다. SetBlackboardValueAs~~의 내부 구현은 사용자로선 몰라도 된다.
BTService에서 선언한 BlackboardKeySelector 변수를 키로 해서 값을 넣어준다고만 생각하면 된다. 선언할 때 주의할 점은 오른쪽의 눈(Instance Editable)을 꼭 켜야 한다. 켜지 않으면 Behavior Tree에서 볼 수 없다.
그리고 Blackboard로 가서 해당하는 Key들을 선언해준다. SelfActor는 무시해도 된다. 각각 ObjectKey, FloatKey를 선언하고 적절한 이름을 붙여줬다.
마지막으로 Behavior Tree로 가서 Selector를 우클릭, Add Service로 만든 BTS를 넣고
BTS를 클릭해 Details 패널에서 볼 수 있는 항목들에 알맞게 연결해주면 된다.
그 아래로 Move To 노드를 추가
Blackboard Key에 TargetToFollow를 넣어주면
잘 작동한다.
로직을 정리하면 이렇다.
서로 밀접하게 연관되어있지만 역할은 철저하게 분리된 3개의 클래스가 잘 작동하는 걸 볼 수 있었다.
Behavior Tree - AI가 어떤 행동을 취할지 결정
Blackboard - Behavior Tree가 행동을 결정할 때 필요한 데이터들을 저장
BTService - Blackboard에 저장할 값을 계산
//////////////////////수정
인터페이스에 함수들을 선언해서 Player Pawn만이 아니라 Enemy Pawn들도 등록하도록 했으며, Controller들이 PawnManagerSubsystem을 알 필요가 없도록 했다.
void AAuraAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
if (HasAuthority())
{
Cast<ICombatInterface>(InPawn)->RegisterPawn();
}
}
void AAuraAIController::OnUnPossess()
{
if (HasAuthority())
{
Cast<ICombatInterface>(GetPawn())->UnregisterPawn();
}
Super::OnUnPossess();
}
AIController 말고도 PlayerController도 이 방식으로 호출하고 있다.
void AAuraEnemy::RegisterPawn()
{
if (HasAuthority())
{
if (UPawnManagerSubsystem* PawnManager = GetGameInstance()->GetSubsystem<UPawnManagerSubsystem>())
{
PawnManager->RegisterAIPawn(this);
}
}
}
void AAuraEnemy::UnregisterPawn()
{
if (HasAuthority())
{
if (UPawnManagerSubsystem* PawnManager = GetGameInstance()->GetSubsystem<UPawnManagerSubsystem>())
{
PawnManager->UnregisterAIPawn(this);
}
}
}
캐릭터 클래스에서의 구현은 이렇게 생겼다. 이걸 Controller에서만 호출하는 게 아니라, BeginPlay와 Die 함수에서도 호출한다.
// PawnManagerSubsystem.h
UPROPERTY()
TArray<TWeakObjectPtr<APawn>> AIPawns;
UPROPERTY()
TArray<TObjectPtr<APawn>> CachedAIPawns;
// PawnManagerSubsystem.cpp
void UPawnManagerSubsystem::RegisterAIPawn(APawn* InPawn)
{
if (!AIPawns.Contains(InPawn))
{
AIPawns.Add(InPawn);
bAIPawnsCacheDirty = true;
}
}
void UPawnManagerSubsystem::UnregisterAIPawn(APawn* InPawn)
{
AIPawns.RemoveSingleSwap(InPawn);
bAIPawnsCacheDirty = true;
}
TArray<APawn*> UPawnManagerSubsystem::GetAllAIPawns()
{
if (bAIPawnsCacheDirty)
{
CachedAIPawns.Reset();
for (const TWeakObjectPtr<APawn>& AIPawn : AIPawns)
{
if (AIPawn.IsValid())
{
CachedAIPawns.Add(AIPawn.Get());
}
}
bAIPawnsCacheDirty = false;
}
return CachedAIPawns;
}
그리고 각종 안전장치를 추가했으며, TArray의 자료형을 WeakPtr로 바꿨다. 블루프린트에서는 WeakPtr을 사용할 수 없길래 위와 같은 로직을 작성했다.
C++에선 WeakPtr로 안정성을 확보하고, 블루프린트에선 WeakPtr을 쓸 수 없으니 캐싱된 배열을 반환한다. 배열에 추가 및 제거가 일어나면 이를 bool로 기록해뒀다가 GetAllAIPawns가 호출될 때 캐싱된 배열을 새로고침한다. 언뜻 보기엔 메모리를 2배로 낭비할 뿐인 것처럼 보인다. 하지만 개인적인 생각으로는 WeakPtr은 GC가 자동으로 수거해가므로 생각보다 큰 낭비는 아니며, Invalid 상태가 되면 자동으로 TArray에서 제거되는 WeakPtr특성으로 발생하는 C++에서의 안전성, View 역할만 담당하는 RawPtr Array로 블루프린트와의 호환성을 챙길 수 있으므로 이득이라고 생각한다.