지난주 목, 금 여행 일정으로 인해 TIL은 작성하지 못했습니다. 이번 팀 프로젝트에서 AI 구현을 맡게 되어서 과거 '이득우의 언리얼 C++ 게임 개발의 정석' 책을 읽고 정리한 내용을 작성해 보았습니다.
AI 컨트롤러와 비헤이비어 트리
AIController와 내비게이션 시스템
NPC에 인공지능을 추가해 스스로 영역을 정찰하고 플레이어를 감지하면 쫓아가서 공격하도록 만들 수 있다.
AIController을 부모클래스로 하는 ABAIController 클래스 생성하고
ABCharacter.cpp
#include "ABAIController.h"
AABCharacter::AABCharacter()
{
...
AIControllerClass = AABAIController::StaticClass();
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
// world에 배치되거나 스폰되면 AIController 자동으로 빙의
}
ABCharacter마다 ABAIController 액터가 생성되고 플레이어가 조종하는 캐릭터를 제외한 모든 캐릭터는 이 ABAIController의 지배를 받는다.
NPC가 스스로 움직일 수 있는 환경 구축
- 내비게이션 메시 영역 생성
- ABAIController에 빙의한 폰에게 목적지 알려줘 스스로 움직이도록 명령 추가
- GetRandomPointInNavigableRadius : 이동 가능한 목적지를 랜덤으로 가져옴
- AIController에 타이머 설치해 3초마다 폰에게 목적지로 이동하는 명령
- SimpleMoveToLocation : 목적지로 폰 이동시킴
비헤이비어 트리 시스템
NPC의 복잡한 행동 패턴을 체계적으로 설계
- 블랙보드 : 인공지능의 판단에 사용하는 데이터 집합
- 비헤이비어 트리: 블랙보드 데이터에 기반해 설계한 비헤이비어 트리 정보 저장
태스크를 통해 동작 명령을 내린다. 단, 컴포짓 노드를 거친다
컴포짓 노드 : 왼쪽부터 오른쪽으로 태스크 실행
- 셀렉터: 태스크 중 하나가 성공하면 실행 중단
- 시퀀스: 태스크 중 하나가 실패하면 실행 중단
ABAIController.cpp
#include "ABAIController.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardData.h"
AABAIController::AABAIController()
{
static ConstructorHelpers::FObjectFinder<UBlackboardData> BBObject(TEXT("/Game/Book/AI/BB_ABCharacter.BB_ABCharacter"));
if (BBObject.Succeeded())
{
BBAsset = BBObject.Object; // 블랙보드
}
static ConstructorHelpers::FObjectFinder<UBehaviorTree> BTObject(TEXT("/Game/Book/AI/BT_ABCharacter.BT_ABCharacter"));
if (BTObject.Succeeded())
{
BTAsset = BTObject.Object; // 비헤이비어 트리
}
}
void AABAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
if (UseBlackboard(BBAsset, Blackboard)) // 블랙보드 사용, 비헤이비어 트리 실행
{
if (!RunBehaviorTree(BTAsset))
{
ABLOG(Error, TEXT("AIController coudn't run behavior tree!"));
}
}
}
ABAIController 가동 시 비헤이비어 트리 애셋과 같은 폴더에 위치한 블랙보드 애셋, 비헤이비어 트리가 함께 동작
비헤이비어 트리는 태스크를 실행할 때 태스크 클래스의 ExecuteTask 멤버 함수를 실행한다.
ExecuteTask 함수 반환값
- Aborted : 태스크 실행 중 중단(결과적으로 실패)
- Failed : 태스크 수행 실패
- Succeeded : 태스크 수행 성공
- InProgress : 태스크 계속 수행(실행 결과 향후 알려줄 예정)
BTTask_FindPatrolPos.cpp
#include "BTTask_FindPatrolPos.h"
#include "ABAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
UBTTask_FindPatrolPos::UBTTask_FindPatrolPos()
{
NodeName = TEXT("FindPatrolPos"); // 해당 이름으로 노드 표시
}
EBTNodeResult::Type UBTTask_FindPatrolPos::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
auto ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn) return EBTNodeResult::Failed;
// 월드의 내비게이션 시스템 가져오기
UNavigationSystemV1* NavSystem = UNavigationSystemV1::GetNavigationSystem(ControllingPawn->GetWorld());
if (nullptr == NavSystem) return EBTNodeResult::Failed;
FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(AABAIController::HomePosKey);
FNavLocation NextPatrol;
// 월드의 내비게이션 범위 중 Origin을 기준으로 반지름만큼의 범위를 한정지어 랜덤한 좌표를 가져온다
if (NavSystem->GetRandomPointInNavigableRadius(FVector::ZeroVector, 500.0f, NextPatrol))
{
// 해당 액터를 NextPatrol(랜덤좌표)으로 이동
OwnerComp.GetBlackboardComponent()->SetValueAsVector(AABAIController::PatrolPosKey, NextPatrol.Location);
return EBTNodeResult::Succeeded;
}
return EBTNodeResult::Failed;
}
NPC 추격 기능 구현
- 블랙보드에 Object 타입으로 Target 변수 생성
- NPC가 플레이어 발견 시 플레이어의 정보 저장
- 셀렉터 컴포짓을 활용해 로직 확장
- NPC가 추격과 정찰 중 하나를 선택해 행동하도록 명령
- 추격에 우선권 : Target을 향해 이동하도록
- 서비스 노드 클래스(부모클래스 BTService) 생성
- 서비스 노드 : 독립적으로 동작 X , 컴포짓 노드에 부착되는 노드, 반복적인 작업 실행 적합
- 연결된 컴포짓 노드가 활성화되면 주기적으로 TickNode 함수 호출
- TickNode 함수에 탐지 영역 구현
- 비헤이비어 트리 에디터에서 컴포짓에 Detect 서비스 부착
- ABAIController에 Target 키 등록
- IsPlayerController 함수 사용
- 캐릭터가 감지되면 Target을 플레이어 캐릭터로 지정
- 플레이어 캐릭터 감지 시 녹색 구체와 연결선 그림
BTService_Detect.cpp
#include "BTService_Detect.h"
#include "ABAIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"
UBTService_Detect::UBTService_Detect()
{
NodeName = TEXT("Detect");
Interval = 1.0f; // TickNode 주기 설정
}
void UBTService_Detect::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn) return;
UWorld* World = ControllingPawn->GetWorld();
FVector Center = ControllingPawn->GetActorLocation();
float DetectRadius = 600.0f; // 반경 6미터 주위 탐지
if (nullptr == World) return;
TArray<FOverlapResult> OverlapResults;
// 월드에 충돌체로 생성해서 충돌된 오브젝트들을 첫번째 인자에 넣어준다
// 무시할 액터(여기서는 자기자신)를 세번째 인자에
FCollisionQueryParams CollisionQueryParam(NAME_None, false, ControllingPawn); // 자기 자신을 무시하겠다
bool bResult = World->OverlapMultiByChannel(
OverlapResults,
Center,
FQuat::Identity,
ECollisionChannel::ECC_GameTraceChannel2,
FCollisionShape::MakeSphere(DetectRadius),
CollisionQueryParam
);
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}
- 데코레이터 노드 삽입
- 데코레이터 노드 : 블랙보드 값을 기반으로 특정 컴포짓의 실행 여부 결정
- Target 키 값 유무로 추격, 정찰 구분
- 데코레이터 속성 지정
- 노티파이 옵저버 값을 On Value Change로 변경
- 키 값의 변경이 감지되면 현재 컴포짓 노드 실행 취소
- 관찰자 중단 값 설정(Self)
- 블랙보드 키값 설정(추격->Is Set, 정찰->Is Not Set)
- 노티파이 옵저버 값을 On Value Change로 변경
NPC의 공격
플레이어가 범위 이내에 있는지 판단하도록 데코레이터 클래스 생성
공격 로직
- FinishiLatentTask 함수 선언
- ExecuteTask의 결과값을 InProgress로 반환한 후 공격이 끝나면 태스크 종료 알림 제공(공격 태스크가 공격 애니메이션이 끝날 때까지 대기해야 하는 지연 태스크이므로)
- Tick 기능 활성화
- FinishLatentTask 호출할 수 있도록
- Tick에서 조건을 파악한 후 태스크 종료 명령 내림
- ABController 클래스의 Attack 함수 접근 권한을 public으로 변경
- AI컨트롤러에서도 공격 명령을 내리기 위함
- 공격 종료 알림을 받도록 델리게이트 선언
- 공격 종료 시 호출할 로직을 캐릭터에 구현
- 태스크에서 람다 함수를 델리게이트에 등록
- 비헤이비어 트리에 Attack 태스크 삽입
BTTask_Attack.cpp
#include "BTTask_Attack.h"
#include "ABAIController.h"
#include "ABCharacter.h"
UBTTask_Attack::UBTTask_Attack()
{
bNotifyTick = true; // 기본은 false, true 설정시 TickTask 실행 -> Finish를 계속 체크
IsAttacking = false; // 아직 공격중이 아니다
}
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
auto ABCharacter = Cast<AABCharacter>(OwnerComp.GetAIOwner()->GetPawn());
if (nullptr != ABCharacter) return EBTNodeResult::Failed;
ABCharacter->Attack();
IsAttacking = true;
// 람다식. ABCharacter이 AttackEnd Delegate를 호출하면 IsAttacking을 false로
ABCharacter->OnAttackEnd.AddLambda([this]()->void {
IsAttacking = false;
});
// 일단 InProgress에 머물게한다. 공격 끝나기전까지 계속 지연시켜줌
return EBTNodeResult::InProgress;
}
// 매 Tick마다 공격태스크 끝났는지 체크
void UBTTask_Attack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickTask(OwnerComp, NodeMemory, DeltaSeconds);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded());
if (!IsAttacking) // 공격이 끝났으면 작업의 끝을 알린다
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
NPC가 공격과 동시에 플레이어를 향해 회전하는 기능을 추가
출처 : 이득우의 언리얼 C++ 게임 개발의 정석
'내일배움캠프 > TIL' 카테고리의 다른 글
[내일배움캠프 Day42] WIL (5) | 2025.02.18 |
---|---|
[내일배움캠프 Day42] 알고리즘 수업 Wrap-Up (0) | 2025.02.18 |
[내일배움캠프 Day38] 8주차 과제 진행 (0) | 2025.02.12 |
[내일배움캠프 Day37] TWeakObjectPtr (0) | 2025.02.11 |
[내일배움캠프 Day35] 알고리즘 수업 1주차 과제 (1) | 2025.02.07 |