Unreal/Ureal Engine 5 정리

Chapter 12. AI 컨트롤러와 비헤이비어 트리

GameStudy 2023. 3. 3. 17:04

 

 

 

 

 

12.1 AI Controller와 네비게이션 시스템

Prologue)

  - 언리얼 엔진의 폰은 조종당할 수 있게 설계된 액터를 의미함.

    지금까지 폰은 플레이어에 의해 수동적으로 조종당함.

  - 비헤이비어 트리 모델을 사용해 컴퓨터 인공지능을 설계하고,

    플레이어가 아닌 인공지능이 NPC를 제어하도록 AI 컨트롤러를 활용해봄.

    폰은 플레이어 컨트롤러와 동일한 방식으로 AI 컨트롤러에 빙의되어 짐.

 

Def) NPC(Non-Player Character)

  플레이어가 조종하지 않지만 레벨에 배치되어 스스로 행동하는 캐릭터를 NPC라고 함.

  ex. 드래그해서 배치한 GWCharacter는 레벨에 가만히 서있는 NPC.

 

Note) AI 컨트롤러를 생성해서 NPC에게 부여

  - 파일 > 새로운 C++ 클래스 > AIController 부모 클래스 > "GWAIController"

  - GWCharacter가 이를 사용하도록 AIControllerClass 멤버를

    GWAIController 클래스로 지정.

  - AI의 생성 옵션을 PlaceInWorldOrSpawned로 설정함.

    그러면 앞으로 레벨에 배치하거나 새롭게 생성되는 GWGhost마다

    GWAIController 액터가 생성되고, 플레이어가 조종하는 캐릭터를 제외한

    모든 캐릭터는 GWAIController의 빙의를 받게됨.

<hide/>

// GWCharacter.cpp

...
#include "GWAIController.h"

AGWCharacter::AGWCharacter()
    ...
{
    ...
    
    AIControllerClass = AGWAIController::StaticClass();
    AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
}

...

 

Note) 내비 메시 영역 생성

  - NPC는 스스로 움직여야 하기 때문에 이를 보조할 장치가 필요함.

    이때 많이 사용하는 것이 네비게이션 메시(Navigation Mesh)

  - 파일 > 현재 레벨을 다른 이름으로 저장 > Step3으로 저장.

    액터 배치 > 볼륨 > NavMeshBoundsVolume을 월드에 배치. 트랜스폼 위치 초기화.

    디테일 > Brush Settings > 10000 X 10000 X 500cm

  - 뷰포트 클릭 > P키

    그러면 에디터에서 빌드한 내비 메시 영역이 녹색으로 뷰포트에 표시됨.

  - 빙의된 폰에게 목적지를 알려줘서 목적지까지 스스로 움직이도록 명령 추가.

    AI 컨트롤러에 타이머를 설치해서 3초마다 폰에게 목적지로 이동하는 명령.

    네비게이션 시스템은 이동 가능한 목적지를 랜덤으로 가져오는

    GetRandomPointInNavigableRadius() 함수와

    목적지로 폰을 이동시키는 SimpleMoveToLocation() 함수를 제공함.

  - 컴파일 후 월드에 배치 되어있던 Character는 지우고, 다시 배치해야 함.

<hide/>

// GhostWar5.Build.cs

using UnrealBuildTool;

public class GhostWar5 : ModuleRules
{
    public GhostWar5(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
    
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "NavigationSystem" });

        PrivateDependencyModuleNames.AddRange(new string[] {  });

    }
}
<hide/>

// GWAIController.h

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "GWAIController.generated.h"

UCLASS()
class GHOSTWAR_API AGWAIController : public AAIController
{
    GENERATED_BODY()

public:
    AGWAIController();

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

private:
    void OnRepeatTimer();

private:
    FTimerHandle RepeatTimerHandle;
    float RepeatInterval;
    
};
<hide/>

// GWAIController.cpp

#include "GWAIController.h"
#include "NavigationSystem.h"
#include "Blueprint/AIBlueprintHelperLibrary.h"

AGWAIController::AGWAIController()
    : RepeatTimerHandle()
    , RepeatInterval(3.f)
{
}

void AGWAIController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    
    GetWorld()->GetTimerManager().SetTimer(RepeatTimerHandle, this, &AGWAIController::OnRepeatTimer, RepeatInterval, true);
}

void AGWAIController::OnUnPossess()
{
    Super::OnUnPossess();

    GetWorld()->GetTimerManager().ClearTimer(RepeatTimerHandle);
}

void AGWAIController::OnRepeatTimer()
{
    UE_LOG(LogTemp, Error, TEXT("OnRepeatTimer()"));
    APawn* ControllingPawn = GetPawn();
    if (nullptr != ControllingPawn)
    {
        UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(GetWorld());
        if (nullptr != NavSystem)
        {
            FNavLocation NextLocation;
            if (true == NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, 500.f, NextLocation))
            {
                UAIBlueprintHelperLibrary::SimpleMoveToLocation(this, NextLocation.Location);
            }
        }
    }
}

 

 

12.2 비헤이비어 트리 시스템

Def) 비헤이비어 트리

  좀 더 복잡한 NPC의 행동 패턴을 구현하기 위해 비헤이비어 트리 모델과 에디터가 제공됨.

  이를 사용하면 AI 컨트롤러가 수행해야 하는 행동 패턴을 체계적으로 설계할 수 있음.

  비헤이비어 트리는 NPC가 해야할 행동을 분석하고 우선순위가 높은 행동부터

  NPC가 실행할 수 있도록 트리 구조로 설계하는 기법.

 

Note) 블랙보드와 비헤이비어 트리

  - 블랙보드

    인공지능 판단에 사용하는 데이터 집합

  - 비헤이비어 트리

    블랙보드 데이터에 기반해 설계한 트리

 

Note) 블랙 보드와 비헤이비어 트리 생성

  - 기능을 사용하려면 AIModule 모듈을 추가해야 함.

  - 콘텐츠 브라우저 > 콘텐츠 폴더 > "AI" 폴더 생성

    AI 폴더 빈공간 우클릭 > 인공 지능 > 블랙보드 > "BBGWCharacter" 생성 

    다시 한 번 인공지능 > 비헤이비어 트리 > "BTGWCharacter" 생성

  - 비헤이비어 트리 애셋을 더블 클릭 > 에디터 > 작업 공간을 우클릭

    Task 그룹 > Wait 태스크 생성.

    Wait 태스크는 폰에게 지정한 시간 동안 대기하라는 명령을 내림.

  - 태스크는 독립적으로 실행될 수 없고 반드시 컴포짓 노드를 거쳐 실행되어야 함.

    빈공간 우클릭 > Composit 그룹 > Sequence 컴포짓을 생성. 연결된 태스크들이

    False 결과가 나올 때까지 왼쪽에서 오른쪽으로 태스크를 계속 실행하는 컴포짓.

    시퀀스 컴포짓을 루트에 연결한 후 Wait 태스크를 컴포짓에 연결.

  - 비헤이비어 트리 애셋을 저장하고 우리가 생성한 블랙보드와 비헤이비어 트리 애셋을

    GWAIController가 사용하도록 코드를 추가함.

    기존에 만들어둔 GWAIController 클래스의 모든 코드를 비헤이비어 트리

    구동에 맞도록 다시 재작성 해야함.

<hide/>

// GhostWar5.Build.cs

using UnrealBuildTool;

public class GhostWar5 : ModuleRules
{
    public GhostWar5(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
    
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "NavigationSystem", "AIModule" });

        PrivateDependencyModuleNames.AddRange(new string[] {  });

    }
}
<hide/>

// GWAIController.h

#pragma once

#include "CoreMinimal.h"
#include "AIController.h"
#include "GWAIController.generated.h"

UCLASS()
class GHOSTWAR5_API AGWAIController : public AAIController
{
    GENERATED_BODY()

public:
    AGWAIController();

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

private:
    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category=AI, Meta=(AllowPrivateAccess=true))
    class UBlackboardData* BBAsset;

    UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category=AI, Meta=(AllowPrivateAccess=true))
    class UBehaviorTree* BTAsset;

};
<hide/>

// GWAIController.cpp

#include "GWAIController.h"
#include "BehaviorTree/BlackboardData.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"

AGWAIController::AGWAIController()
{
    static ConstructorHelpers::FObjectFinder<UBlackboardData> BBObject(TEXT("BlackboardData'/Game/AI/BBGWCharacter.BBGWCharacter'"));
    if (true == BBObject.Succeeded())
    {
        BBAsset = BBObject.Object;
    }

    static ConstructorHelpers::FObjectFinder<UBehaviorTree> BTObject(TEXT("BehaviorTree'/Game/AI/BTGWCharacter.BTGWCharacter'"));
    if (true == BTObject.Succeeded())
    {
        BTAsset = BTObject.Object;
    }
}

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

    UBlackboardComponent* GWBlackboard = Cast<UBlackboardComponent>(Blackboard);
    if (true == UseBlackboard(BBAsset, GWBlackboard))
    {
        if (true == RunBehaviorTree(BTAsset))
        {
            UE_LOG(LogTemp, Error, TEXT("Behavior tree is now running."));
        }
    }
    // if (true == UseBlackboard(BBAsset, Blackboard) && false == RunBehaviorTree(BTAsset))
    //   같은 소괄호 내에서 어떤 함수를 먼저 판정할지는 정해져 있지 않음. 
    //   따라서 위와 같은 코드는 작성을 지양.
}

 

Note) 에디터 구조 변경

  - 좀 더 넓은 공간 확보를 위해서 왼쪽 상단에 있는 액터 배치 윈도우를 

    디테일 윈도우 옆에 붙히자.

  - 만약 레이아웃이 마음에 안드는 경우,

    창 > 레이아웃 리셋을 클릭하면 최초 레이아웃으로 복구 가능.

  - 비헤이 비어 트리 애셋을 더블 클릭해서 띄운 후에 Alt + P를 누르면 

    뷰포트에서 게임 플레이 시 비헤이비어 트리의 로직 흐름을 함께 볼 수 있음.

 

Note) 순찰 기능 구현

  - 블랙보드에는 특정 유형의 데이터를 저장하고

    이를 비헤이비어 트리가 활용하도록 구성할 수 있음.

  - NPC의 순찰 기능을 구현하려면 두 가지 데이터가 필요함.

    블랙 보드에서 NPC 생성 위치 값 Vector 타입으로 키를 생성. "HomePos" 이름 부여.

    순찰할 위치 정보를 보관할 블랙보드 키도 추가. Vector 타입 키 생성한 후 "PatrolPos"

  - AI 컨트롤러에서 블랙보드의 HomePos 키 값을 지정하는 로직을 구현.

    비헤이비어 트리 구동 전에 준비해두는 것.

  - 블랙보드 키 이름인 "HomePos" 값으로 FName 멤버를 GWAIController에 추가.

    여기에 HomePos라는 값을 할당.

  - 이번 코드에서는 앞으로 관련 키 이름이 절대 변하지 않는다는 가정하에 

    static const를 사용해 변수 초기 값을 지정함. 이렇게 선언하면 향후 다른 코드에서

    관련 값을 참조하기가 편하지만, 하드코딩으로 값을 변경해야하는 단점이 있음.

  - 플레이 버튼을 눌러서 비헤이비어 트리 에디터에서 HomePos 키 값이 블랙보드에

    잘 전달 되었는지 확인 해보자.

<hide/>

// GWAIController.h

...
class GHOSTWAR5_API AGWAIController : public AAIController
{
    ...

public:
    static const FName HomePosKey;
    static const FName PatrolPosKey;

private:
    ...

};
<hide/>

// GWAIController.cpp

...

const FName AGWAIController::HomePosKey(TEXT("HomePos"));
const FName AGWAIController::PatrolPosKey(TEXT("PatrolPos"));

...

void AGWAIController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);
    
    UBlackboardComponent* GWBlackboard = Cast<UBlackboardComponent>(Blackboard);
    if (true == UseBlackboard(BBAsset, GWBlackboard))
    {
        GWBlackboard->SetValueAsVector(HomePosKey, InPawn->GetActorLocation());
        if (true == RunBehaviorTree(BTAsset))
        {
            UE_LOG(LogTemp, Error, TEXT("RunBehaviorTree"));
        }
    }
}

 

Note) 비헤이비어 트리에서 블랙보드 값 갱신

  - GamePlayTasks 모듈 임포트

  - NPC가 이동할 위치인 PatrolPos 데이터를 생성해야 함.

    이는 순찰할 때마다 바뀌므로 태스크를 제작해 비헤이비어 트리에서

    블랙보드에 값을 쓰도록 설계하는 것이 좋음.

  - 파일 > 새로운 C++ 클래스 > BTTask 부모 클래스 > "BTTask_GetPatrolPos"

    이때 UI에서 표현할 때는 BTTask_ 접두사 부분은 자동으로 걸러짐.

<hide/>

// GhostWar5.Build.cs

using UnrealBuildTool;

public class GhostWar5 : ModuleRules
{
    public GhostWar5(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
    
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG", "NavigationSystem", "AIModule", "GameplayTasks" });

        PrivateDependencyModuleNames.AddRange(new string[] {  });

    }
}

 

 

Note) ExecuteTask() 함수

  - Aborted

    태스크 실행 중에 중단되었다. 결과적으로 실패했다.

  - Failed

    태스크를 수행했지만 실패했다.

  - Succeeded

    태스크를 성공적으로 수행했다.

  - InProgress

    태스크를 계속 수행하고 있다.

    태스크의 실행 결과는 향후 알려줄 예정이다.

  ExecuteTask() 함수의 실행 결과에 따라 컴포짓 내에 있는 다음 태스크를

  계속 수행할지, 중단할지가 결정된다. 현재 사용 중인 시퀀스 컴포짓은

  자신에 속한 태스크를 실패할 때까지 계속 실행하는 성질을 가짐.

  ExecuteTask() 함수에서 다음 정찰 지점을 찾는 로직을 구현하고 바로 실행 결과를

  반환하도록 구현한다. 추가로 태스크의 이름을 다른 이름으로 표시하고 싶다면

  NodeName 속성을 다른 값으로 지정하면 됨. 

<hide/>

// BTTask_GetPatrolPos.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_GetPatrolPos.generated.h"

UCLASS()
class GHOSTWAR5_API UBTTask_GetPatrolPos : public UBTTaskNode
{
    GENERATED_BODY()

public:
    UBTTask_GetPatrolPos();

protected:
    virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
    
};
<hide/>

// BTTask_GetPatrolPos.cpp

#include "BTTask_GetPatrolPos.h"
#include "GWAIController.h"
#include "NavigationSystem.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTTask_GetPatrolPos::UBTTask_GetPatrolPos()
{
    NodeName = TEXT("GetPatrolPos");
}

EBTNodeResult::Type UBTTask_GetPatrolPos::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;
    }

    FVector HomePos = OwnerComp.GetBlackboardComponent()->GetValueAsVector(AGWAIController::HomePosKey);
    FNavLocation NextLocation;
    if (true == NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, 500.f, NextLocation))
    {
        OwnerComp.GetBlackboardComponent()->SetValueAsVector(AGWAIController::PatrolPosKey, NextLocation.Location);
        return EBTNodeResult::Succeeded;
    }

    return EBTNodeResult::Type();
}

 

Note) 비헤이비어트리 에디터 수정

  - Wait 태스크 오른쪽에  GetPatrolPos 배치. 

    GetPatrolPos 오른쪽에는 MoveTo 태스크를 추가.

    BlackboardKey를 PatrolPos로 지정

  - 시퀀스 컴포짓에 의해 Wait 태스크가 성공하면 GetPatrolPos 태스크를 수행하고,

    GetPatrolPos 태스크가 성공하면 GetPatrolPos에서 설정한 블랙보드의 

    PatrolPos 키 값을 참고해 MoveTo 태스크가 실행됨.

 

12.3 NPC의 추격 기능 구현

Note) NPC의 플레이어를 추격

  - NPC가 플레이어를 발견할 때 플레이어의 정보를 블랙보드에 저장하도록

    Object 타입으로 "Target" 키를 생성함.

  - Target 키 클릭 -> 블랙보드 디테일 > Key Type > Base Class를 GWCharacter로 변경.

<hide/>

// GWAIController.h

...
class GHOSTWAR5_API AGWAIController : public AAIController
{
    ...

public:
    static const FName HomePosKey;
    static const FName PatrolPosKey;
    static const FName TargetKey;

private:
    ...
    
};
<hide/>

// GWAIController.cpp

...

const FName AGWAIController::HomePosKey(TEXT("HomePos"));
const FName AGWAIController::PatrolPosKey(TEXT("PatrolPos"));
const FName AGWAIController::TargetKey(TEXT("Target"));

...

 

Note) 셀렉터 컴포짓의 활용

  - NPC의 행동 패턴은 플레이어를 발견 했는지,

    발견하지 못했는지에 따라 추격과 정찰로 구분됨.

  - 추격과 정찰 중 하나를 선택해 행동하기 때문에 셀렉터 컴포짓을 사용해

    로직을 확장하는 것이 적합함. 추격에 우선권을 주고 추격 로직은

    블랙보드의 Target을 향해 이동하도록 비헤이비어 트리 설계를 확장해봄.

 

Def) 서비스 노드

  자신이 속한 컴포짓 노드가 활성화 될 경우 주기적으로 TickNode() 함수를 호출함.

  호출하는 주기는 서비스 노드 내부에 설정된 Interval 속성 값으로 지정할 수 있음.

 

Note) 서비스 노드로 반경에 캐릭터가 있는지 감지

  - 새 C++ 클래스 > BTService 부모 클래스 > "BTService_DetectPlayer"

  - TickNode() 함수에는 NPC의 위치를 기준으로 반경 6미터 내에

    캐릭터가 있는지 감지구현.

  - 반경 내에 다른 NPC TUCharacter가 있는 경우도 가정해 반경 내에

    모든 캐릭터를 감지하는 OverlapMultipleByChannel() 함수를 사용함.

  - 반경 내에 감지된 모든 캐릭터 정보는 목록을 관리하는데 적합한  

    자료구조인 TArray로 전달됨.

  - 서비스가 만들어지면 셀렉트 컴포짓을 우클릭 하고 서비스 메뉴에서

    Detect를 선택해서 컴포짓에 부착한다.

<hide/>

// BTService_DetectPlayer.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "BTService_DetectPlayer.generated.h"

UCLASS()
class GHOSTWAR5_API UBTService_DetectPlayer : public UBTService
{
    GENERATED_BODY()

public:
    UBTService_DetectPlayer();

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

};
<hide/>

// BTService_DetectPlayer.cpp

#include "BTService_DetectPlayer.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "GWAIController.h"

UBTService_DetectPlayer::UBTService_DetectPlayer()
{
	NodeName = TEXT("DetectPlayer");
	Interval = 1.f;
}

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

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

    UWorld* GWWorld = ControllingPawn->GetWorld();
    if (nullptr == GWWorld)
    {
        return;
    }

    FVector CenterPos = ControllingPawn->GetActorLocation();
    float DetectRadius = 600.f;
    TArray<FOverlapResult> OverlapResults;
    FCollisionQueryParams CollisionQueryParams(NAME_None, false, ControllingPawn);
    bool bResult = GWWorld->OverlapMultiByChannel(
        OverlapResults,
        CenterPos,
        FQuat::Identity,
        ECollisionChannel::ECC_GameTraceChannel2,
        FCollisionShape::MakeSphere(DetectRadius),
        CollisionQueryParams
    );

    DrawDebugSphere(GWWorld, CenterPos, DetectRadius, 16, FColor::Red, false, 0.2f);
}

 

Note) 플레이어 가려내기

  - NPC가 탐지 영역 내의 캐릭터를 감지한다면,

    그 중에서 우리가 조종하는 캐릭터를 추려내야 함.

  - 캐릭터를 조종하는 컨트롤러가 플레이어 컨트롤러인지 파악 할 수 있도록 

    IsPlayerController() 함수 사용. 플레이어 캐릭터가 감지되면 블랙보드의 

    Target 값을 플레이어 케릭터로 지정하고 그렇지 않으면 nullptr 값을 지정함.

  - 플레이어 캐릭터를 감지하면 녹색 구체를 그리고,

    NPC와 캐릭터까지 연결된 선을 추가로 그려줌.

<hide/>

// BTService_DetectPlayer.cpp

...
#include "BehaviorTree/BlackboardComponent.h"
#include "GWCharacter.h"

...

void UBTService_DetectPlayer::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    ...

    if (true == bResult)
    {
        for (auto const& OverlapResult : OverlapResults)
        {
            AGWCharacter* GWCharacter = Cast<AGWCharacter>(OverlapResult.GetActor());

            if (nullptr != GWCharacter)
            {
                if (true == GWCharacter->GetController()->IsPlayerController())
                {
                    UE_LOG(LogTemp, Error, TEXT("DETECTED"));
                    OwnerComp.GetBlackboardComponent()->SetValueAsObject(AGWAIController::TargetKey, GWCharacter);
                    DrawDebugSphere(GWWorld, CenterPos, DetectRadius, 16, FColor::Green, false, 0.2f);

                    DrawDebugPoint(GWWorld, GWCharacter->GetActorLocation(), 10.f, FColor::Blue, false, 0.2f);
                    DrawDebugLine(GWWorld, ControllingPawn->GetActorLocation(), GWCharacter->GetActorLocation(), FColor::Blue, false, 0.2f);

                    return;
                }
                else
                {
                    OwnerComp.GetBlackboardComponent()->SetValueAsObject(AGWAIController::TargetKey, nullptr);
                    DrawDebugSphere(GWWorld, CenterPos, DetectRadius, 16, FColor::Red, false, 0.2f);
                }
            }
            
        }
        UE_LOG(LogTemp, Error, TEXT("NOT DETECTED"));
    }
}

 

Note) NPC 회전 보정

  - NPC가 이동할 때 회전이 부자연스럽게 꺾이는 것을 볼 수 있음.

    별도로 NPC를 위한 ControlMode를 추가하고

    NPC는 이동방향에 따라 회전하도록 캐릭터 무브먼트 설정을 변경.

  - NPC의 최대 이동 속도를 플레이어보다 낮게 설정해서

    플레이어가 NPC로부터 도망갈 수 있도록 만들어줌.

<hide/>

// GWCharacter.h

...
class GHOSTWAR5_API AGWCharacter : public ACharacter
{
    GENERATED_BODY()

    enum class EControlMode
    {
        GTA,
        Diablo,
        NPC,
        End
    };

public:
    ...

protected:
    ...

    virtual void PossessedBy(AController* NewController) override;

    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstingator, AActor* DamageCauser) override;

private:
    ...

};
<hide/>

// GWCharacter.cpp

...

void AGWCharacter::SetControlMode(EControlMode NewControlMode)
{
    ...

    switch (CurrentControlMode)
    {
    ...

    case EControlMode::NPC:
    {
        bUseControllerRotationYaw = false;
        GetCharacterMovement()->bOrientRotationToMovement = false;
        GetCharacterMovement()->bUseControllerDesiredRotation = true;
        GetCharacterMovement()->RotationRate = FRotator(0.f, 480.f, 0.f);

        break;
    }

    default:
        break;
    }

}

...

void AGWCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);

    if (true == IsPlayerControlled())
    {
        SetControlMode(EControlMode::Diablo);
        GetCharacterMovement()->MaxWalkSpeed = 600.f;
    }
    else
    {
        SetControlMode(EControlMode::NPC);
        GetCharacterMovement()->MaxWalkSpeed = 300.f;
    }
}

...

 

Def) 데코레이터 노드(Decorator Node)

  블랙보드의 값을 기반으로 특정 컴포짓의 실행여부를 결정하는 노드.

 

Note) 데코레이터에 따라 추격과 정찰로 나누기

  - 이제 서비스가 실행된 결과에 따라 셀렉터 데코레이터 왼쪽은 추격,

    오른쪽은 정찰 로직으로 나눠지도록 비헤이비어 트리 로직을 구성하고자 함.

    서비스 결과는 블랙보드의 Target 키에 값이 있는지 없는지로 구분 가능.

  - 왼쪽 시퀀스 컴포짓 우클릭 > 데코레이터 추가 > Blackboard 클릭

    해당 데코레이터를 선택하고 디테일 > 플로우 컨트롤

    해당 키 값의 변경이 감지되면, 현재 컴포짓 노드의 실행을 곧바로 취소하도록

    Notify Observer를 On Value Change로 설정.

    관찰자 중단을 설정하지 않으면 컴포짓에 속한 태스트가

    모두 마무리 될때까지 대기하므로, 플레이어가 시야를 벗어나도 NPC는

    플레이어를 따라 잡을때까지 계속 쫒아옴. 그래서 Self로 지정.

    블랙보드 섹션에서는 Key Query를 Is Set, Blackboard Key에 Target 지정.

  - 오른쪽 시퀀스 컴포짓에도 동일하게 데코레이터를 추가.

    디테일에서 플로우 컨트롤 섹션과 블랙보드 섹션을 왼쪽 데코레이터와 동일 설정.

    다만, 오른쪽 데코레이터에는 반대 조건인 Is Not Set으로만 설정.

 

12.4 NPC의 공격

Note) 추격 후 공격 구현

  - NPC의 행동은 플레이어와의 거리에 따라 추격이나 공격으로 분기되어야 함.

    분기를 위해 왼쪽 노드 그룹에 있던 시퀀스 컴포짓을 셀렉터 컴포짓으로 변경.

  - 셀렉터 컴포짓을 추가하고, 기존의 왼쪽 시퀀스 컴포짓에 설정했던 데코레이터를

    셀렉터 컴포짓에 드래그해 옮김.

 

Note) 플레이어가 공격 범위 내에 있는지 판단하는 데코레이터

  새로운 C++ 클래스 > BTDecorator 부모 클래스 > "BTDecorator_IsInAttackRange"

  데코레이터 클래스는 CalculateRawConditionValue() 함수를 상속받아

  원하는 조건이 달성되었는지를 파악하도록 설계되었음.

  이 함수는 const로 선언되어 데코레이터 클래스의 멤버 변수 값은 변경할 수 없음.

<hide/>

// BTDecorator_IsInAttackRange.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "BTDecorator_IsInAttackRange.generated.h"

UCLASS()
class GHOSTWAR5_API UBTDecorator_IsInAttackRange : public UBTDecorator
{
    GENERATED_BODY()

public:
    UBTDecorator_IsInAttackRange();

protected:
    virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
    
};
<hide/>

// BTDecorator_IsInAttackRange.cpp

#include "BTDecorator_IsInAttackRange.h"
#include "GWAIController.h"
#include "GWCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTDecorator_IsInAttackRange::UBTDecorator_IsInAttackRange()
{
    NodeName = TEXT("IsInAttackRange");
}

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

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

    AGWCharacter* Target = Cast<AGWCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AGWAIController::TargetKey));
    if (nullptr == Target)
    {
        return false;
    }

    bResult = (Target->GetDistanceTo(ControllingPawn) <= 200.f);

    return bResult;
}

 

Note) 공격 관련 비헤이비어 트리 로직 구현

  - IsInAttackRange 데코레이터를 가장 왼쪽에 위치한 시퀀스 컴포짓에 부착.

    새로운 데코레이터의 조건이 참이 되면 공격을 수행해야 함. 

  - 아직 공격기능은 구현되지 않음. 우선 공격이 3초간 수행되었다고 가정한 후

    Wait 태스크를 왼쪽 시퀀스 컴포짓에 부착함. 이렇게 로직을 구성하면

    NPC가 플레이어를 따라잡는 경우 NPC는 3초간 대기하고 다시 플레이어를 추격함.

  - 그 오른쪽 시퀀스 컴포짓에도 IsInAttackRange 데코레이터를 추가하고 

    InverseCondition 속성 값을 체크해 조건을 반대로 설정함.

 

Note) Wait 대신에 공격 태스크를 생성

  - 새로운 C++ 클래스 > BTTaskNode 부모 클래스 > "BTTask_Attack"

  - 공격 태스크는 공격 애니메이션이 끝날 때까지 대기해야 하는 지연 태스크임.

    그래서 ExecuteTask()가 InProgress를 일단 반환하고 공격이 끝났을 때

    태스크가 끝났다고 알려줘야함.

  - 이를 알려주는 함수가 FinishLatentTask() 함수.

    태스크에서 이 함수를 나중에 호출해주지 않으면

    비헤이비어 트리 시스템은 현재 태스크에 계속 머물게 됨.

  - 차후에 FinishLatentTask() 함수를 호출할 수 있도록

    노드의 Tick() 기능을 활성화하고 Tick()에서 조건을 파악한 후

    태스크 종료 명령을 내려줘야 함.

<hide/>

// BTTask_Attack.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_Attack.generated.h"

UCLASS()
class GHOSTWAR_API UBTTask_Attack : public UBTTaskNode
{
    GENERATED_BODY()

public:
    UBTTask_Attack();

protected:
    virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
    virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
    
};
<hide/>

// BTTask_Attack.cpp

#include "BTTask_Attack.h"

UBTTask_Attack::UBTTask_Attack()
{
    bNotifyTick = true;
}

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

    return EBTNodeResult::InProgress;
}

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

    FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}

 

Note) 공격이 끝난 시점에 태스크 종료 구현

  - 먼저 AI 컨트롤러에서도 공격 명령을 내릴 수 있도록

    GWGhost 클래스의 Attack() 함수의 접근 권한을 public으로 변경.

  - 플레이어의 공격이 종료되면 공격 태스크에서 해당 알림을 받을 수 있도록 델리게이트를

    새로 선언하고 공격이 종료될 때 이를 호출하는 로직을 캐릭터에 구현함. 

  - 태스크에서 람다 함수를 해당 델리게이트에 등록. Tick() 함수 로직에서 이를 파악해

    FinishLatentTask() 함수를 호출함으로써 태스크를 종료 구현.

  - 임시로 부착했던 Wait 태스크를 제거 > Attack 태스크로 교체.

<hide/>

// GWCharacter.h

...

DECLARE_MULTICAST_DELEGATE(FOnAttackEndedDelegate)

UCLASS()
class GHOSTWAR5_API AGWCharacter : public ACharacter
{
    ...

public:
    ...

    void Attack();
    FOnAttackEndedDelegate OnAttackEnded;

protected:
    ...
    
private:
    void ChangeView();
    // void Attack();

};
<hide/>

// GWCharacter.cpp

...

void AGWCharacter::OnAttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
    ...
    
    OnAttackEnded.Broadcast();
}

...
<hide/>

// BTTask_Attack.h

...
class GHOSTWAR5_API UBTTask_Attack : public UBTTaskNode
{
    ...
    
private:
    bool IsAttacking;
  
};
<hide/>

// BTTask_Attack.cpp

#include "BTTask_Attack.h"
#include "GWAIController.h"
#include "GWCharacter.h"

UBTTask_Attack::UBTTask_Attack()
    : IsAttacking(false)
{
    bNotifyTick = true;
}

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

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

    GWCharacter->Attack();
    IsAttacking = true;
    GWCharacter->OnAttackEnded.AddLambda([this]() -> void {
        IsAttacking = false;
    });

    return EBTNodeResult::InProgress;
}

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

    if (false == IsAttacking)
    {
        FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
    }
}

 

Note) FMath::RInterpTo 함수를 이용한 회전 구현

  - 플레이어가 NPC 뒤로 돌아가더라도 같은 곳을 공격하고 있음.

    이를 보완하기 위해서 공격함과 동시에 플레이어를 향해 회전하는 기능을 구현.

  - 새 C++ 클래스 > BTTaskNode 부모클래스 > "BTTask_TurnToTarget"

    해당 태스크는 플레이어 폰을 향해 일정한 속도로 회전하도록

    FMath::RInterpTo() 함수를 사용해서 회전시키는 기능을 구현

<hide/>

// BTTask_TurnToTarget.h

#pragma once

#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_TurnToTarget.generated.h"

UCLASS()
class GHOSTWAR5_API UBTTask_TurnToTarget : public UBTTaskNode
{
    GENERATED_BODY()

public:
    UBTTask_TurnToTarget();

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

};
<hide/>

// BTTask_TurnToTarget.cpp

#include "BTTask_TurnToTarget.h"
#include "GWAIController.h"
#include "GWCharacter.h"
#include "GWCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"

UBTTask_TurnToTarget::UBTTask_TurnToTarget()
{
    NodeName = TEXT("TurnToTarget");
}

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

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

    AGWCharacter* TargetCharacter = Cast<AGWCharacter>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(AGWAIController::TargetKey));
    if (nullptr == TargetCharacter)
    {
        return EBTNodeResult::Failed;
    }

    FVector LookVector = TargetCharacter->GetActorLocation() - ControllingCharacter->GetActorLocation();
    LookVector.Z = 0.f;
    FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
    ControllingCharacter->SetActorRotation(FMath::RInterpTo(ControllingCharacter->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), 2.f));

    return EBTNodeResult::Succeeded;

    return Result;
}

 

Note) 심플 패러렐 컴포짓(Simple-Parallel)

  공격 로직에서 사용한 시퀀스 컴포짓을 심플 패러랠 컴포짓으로 대체.

  캐릭터의 공격을 메인 태스크로 회전을 보조 태스크로 지정함.

  심플 패러랠 컴포짓에 의해 캐릭터는 공격과

  캐릭터를 향해 회전하는 태스크를 동시에 실행함.