● NPC가 정찰하다가 플레이어 발견 시, 플레이어를 추격하는 기능은 발견할 때 플레이어의 정보를 블랙보드에 저장하도록 Object 타입으로 변수를 생성하면 된다.
Object 타입에는 베이스 클래스(Base Class)를 지정할 수 있는데, 이때, 기반 클래스를 ABCharacter로 지정하면 된다.
● NPC의 비헤이비어 트리의 행동 패턴은 플레이어를 발견 했는지 / 못했는 지에 따라 추격과 정찰로 나누어진다.
추격 / 정찰 중 하나의 상태를 선택하여 행동하므로, 시퀀스가 아닌 셀렉터(Selector) 컴포짓을 사용하여 로직을 구성해야한다.
두 상태 중 추격을 더 우선순위를 높게하고, 추격 상태가 되면 블랙보드의 Target 오브젝트를 향해 이동하도록 한다.
● 다음으로, 정찰하다가 플레이어가 일정 반경 이내로 들어오면 감지하여 추격하는 기능을 추가해야한다. 이때 사용되는 것이 '서비스 노드'이다.
● '서비스 노드'는 독립적으로 동작하지 않고, 컴포짓 노드에 부착되는 노드이다. 또한 서비스 노드는 해당 컴포짓에 속한 태스크들이 실행되는 동안 반복적인 작업을 실행하는 데 적합하다.
플레이어를 감지하는 서비스 노드를 새로 생성하고, 이를 셀렉터 컴포짓에 추가하면 비헤이비어 트리는 플레이어를 감지하는 루틴을 계속 반복한다.
● 위 서비스 노드를 만들기 위해서 BTService 클래스를 부모로하는 클래스를 생성해야한다.
● 비헤이비어 트리의 서비스 노드는 자신이 속한 컴포짓 노드가 활성화될 경우, 주기적으로 TickNode 함수를 호출한다. 호출 주기는 서비스 노드 내부에 설정된 Interval 속성 값으로 지정할 수 있다.
● NPC 기준으로 600cm 이내에 캐릭터가 있는지 감지하도록 코드를 작성하는데, 만약 반경 내 다른 NPC도 있다면, 반경 내 모든 캐릭터를 감지하는 OverlapMultiByChannel() 함수를 사용한다.
반경 내 감지된 모든 캐릭터 정보는 목록을 관리하는 데 적합한 자료구조 'TArray'로 전달이 된다.
그리고 구현부를 작성하면 다음과 같다.
BTService_Detect.h
#include "../ArenaBattle.h"
#include "BehaviorTree/BTService.h"
#include "BTS_Detect.generated.h"
/**
*
*/
UCLASS()
class ARENABATTLE_API UBTS_Detect : public UBTService
{
GENERATED_BODY()
public:
UBTS_Detect();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
};
BTService_Detect.h
// Fill out your copyright notice in the Description page of Project Settings.
#include "BTS_Detect.h"
#include "ABAIController.h"
#include "ABCharacter.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"
UBTS_Detect::UBTS_Detect()
{
NodeName = TEXT("Detect");
Interval = 1.0f;
}
void UBTS_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 = 500.f;
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
);
//검색전 타겟은 초기화를 해줘야 없어졌을때 패트롤 모드로 간다. 아직도 복수의 타겟 처리가 안되었다.
OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, nullptr);
if (bResult)
{ // 디텍팅된 플레이어가 없어졌을 경우의 처리가 없음
for (auto const& OverlapResult : OverlapResults)
{
AABCharacter* ABCharacter = Cast<AABCharacter>(OverlapResult.GetActor());
if (ABCharacter && ABCharacter->GetController()->IsPlayerController())
{
//DrawDebugString(GetWorld(), ABCharacter->GetActorLocation(), FString::Printf(TEXT("%s"), *ABCharacter->GetName()), 0, FColor::White, 1, false);
OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, ABCharacter);
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.1f);
DrawDebugPoint(World, ABCharacter->GetActorLocation(), 10.0f, FColor::Blue, false, 0.2f);
DrawDebugLine(World, ControllingPawn->GetActorLocation(), ABCharacter->GetActorLocation(), FColor::Blue, false, 1.0f);
DrawDebugSphere(World, ABCharacter->GetActorLocation(), 60.f, 8, FColor::Green, false, 1.0f);
return;
}
}
}
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.1f);
}
여기서 각 매개변수들이 의미하는 것을 정리하면
우선 OverlapMultiByChannel() 함수의 기존 형태와 매개 변수에는 들어가는 것이 의미하는 것은 다음과 같다.
bool OverlapMultiByChannel
(
TArray< struct FOverlapResult > & OutOverlaps,
const FVector & Pos,
const FQuat & Rot,
ECollisionChannel TraceChannel,
const FCollisionShape & CollisionShape,
const FCollisionQueryParams & Params,
const FCollisionResponseParams & ResponseParam
)
bool bResult = world->OverlapMultiByChannel(
OverlapResults, // 탐색된 모든 객체를 담는 TArray
Center, // 현재 위치에서 탐색 시작
FQuat::Identity, // 충돌 검사 수행 전, 충돌 형상에 적용할 회전으로, 충돌 검사에 회전은 적용하지 않음
ECollisionChannel::ECC_GameTraceChannel2, // 충돌 검사에 사용할 트레이스 채널 선택
FCollisionShape::MakeSphere(DetectRadius), // 충돌 검사를 확인할 영역을 정의하는 충돌 모양 및 범위
CollisionQueryParam // 단순 충돌 모양만 고려
);
|
다음으로 DrawDebugSphere() 함수의 기존 형태와 매개 변수에 들어가는 값이 의미하는 것은 다음과 같다.
void DrawDebugSphere
(
const UWorld * InWorld, // 월드에서
FVector const & Center, // 원을 그릴 기준 좌표
float Radius, // 원의 반경(= 반지름)
int32 Segments, // 원의 각(8, 16, 32... 등이 있으며 값이 클 수록 더 둥근 구체 생성)
FColor const & Color, // 원의 색상
bool bPersistentLines, // 영구 선
float LifeTime, // 원이 보여지는 시간
uint8 DepthPriority, //
float Thickness //
)
위 매개 변수를 참고하여 작성한 내 코드를 해석하면 다음과 같다.
// 월드에서, AI 폰의 현재 좌표를 기준으로 6미터 이내 16개의 각을 가진 빨간색 원 0.2초간 생성
DrawDebugSphere(world, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
|
● 작성이 완료되면 C++로 만든 서비스 노드 BTService_Detect를 정찰의 시퀀스 컴포짓에 추가해준다.
● 그러면 플레이 시, 자동으로 돌아다니는 AI 캐릭터의 현재 좌표 기준으로 원이 생기는 것을 확인할 수 있다.
● 이제 NPC가 탐지 영역에서 캐릭터를 감지하면, 감지된 캐릭터 중에서 현재 플레이 중인 내 캐릭터를 추려내야한다. 캐릭터를 조종하는 컨트롤러가 플레이어 컨트롤러인지 파악할 수 있도록 IsPlayerController() 함수를 사용한다.
그리고 플레이어를 찾아내면 블랙보드의 Target 값을 플레이어 캐릭터로 지정하고, 그렇지 않으면 nullptr 값으로 지정하면 된다. 그리고 플레이어 감지 성공 시, 탐지하는 원 색을 녹색으로 변경하고, NPC와 캐릭터까지 연결되는 선을 그린다.
그 코드는 다음과 같다.
위코드는 형변환된 player가 없다면 nullptr이 담기고 이게 자연히 blackboard target키에 전달되는데 개정된책에서는 아래 코드는if (ABCharacter && ABCharacter->GetController()->IsPlayerController())가 추가되어 nullptr이 전달이 안되어 초기화를 시켜주었다.
//검색전 타겟은 초기화를 해줘야 없어졌을때 패트롤 모드로 간다. 아직도 복수의 타겟 처리가 안되었다.
OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, nullptr);
if (bResult)
{ // 디텍팅된 플레이어가 없어졌을 경우의 처리가 없음
for (auto const& OverlapResult : OverlapResults)
{
AABCharacter* ABCharacter = Cast<AABCharacter>(OverlapResult.GetActor());
if (ABCharacter && ABCharacter->GetController()->IsPlayerController())
{
//DrawDebugString(GetWorld(), ABCharacter->GetActorLocation(), FString::Printf(TEXT("%s"), *ABCharacter->GetName()), 0, FColor::White, 1, false);
OwnerComp.GetBlackboardComponent()->SetValueAsObject(AABAIController::TargetKey, ABCharacter);
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.1f);
DrawDebugPoint(World, ABCharacter->GetActorLocation(), 10.0f, FColor::Blue, false, 0.2f);
DrawDebugLine(World, ControllingPawn->GetActorLocation(), ABCharacter->GetActorLocation(), FColor::Blue, false, 1.0f);
DrawDebugSphere(World, ABCharacter->GetActorLocation(), 60.f, 8, FColor::Green, false, 1.0f);
return;
}
}
}
DrawDebugPoint(), DrawDebugLine() 함수를 사용하여 파란색 선으로 AI폰과 플레이어 캐릭터를 연결해주는 것이다.
● NPC가 이동 시, 회전이 부자연스럽게 휙휙 돌아간다. 이 문제를 해결하기 위해서 NPC를 위한 ControlMode를 추가하고, NPC 이동 방향에 따라 회전하도록 캐릭터 무브번트 설정을 변경한다. 그리고 NPC는 플레이어보다 속도를 느리게 하여 플레이어가 도망칠 수 있게 한다.
헤더에 PossessedBy()함수 선
추가한 NPC 모드일 때의 회전 및 속도를 구현하고, 게임이 시작될 때, 플레이어와 NPC의 모드를 다르게 설정해준다.
// Called every frame
void AABCharacter::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
SpringArm->TargetArmLength = FMath::FInterpTo(SpringArm->TargetArmLength, ArmLengthTo, DeltaTime, ArmLengthSpeed);
switch (CurrentControlMode)
{
case EControlMode::DIABLO:
SpringArm->SetRelativeRotation(FMath::RInterpTo(SpringArm->GetRelativeRotation(), ArmRotationTo, DeltaTime, ArmRotationSpeed));
break;
}
switch (CurrentControlMode)
{
case EControlMode::DIABLO:
if (DirectionToMove.SizeSquared() > 0.0f)
{
GetController()->SetControlRotation(FRotationMatrix::MakeFromX(DirectionToMove).Rotator());
AddMovementInput(DirectionToMove);
}
break;
case EControlMode::NPC:
bUseControllerRotationYaw = false;
GetCharacterMovement()->bUseControllerDesiredRotation = false;
GetCharacterMovement()->bOrientRotationToMovement = true;
GetCharacterMovement()->RotationRate = FRotator(0, 480, 0);
break;
}
DrawDebugString(GetWorld(), FVector(0, 0, 0), CurrentWeapon?FString::Printf(TEXT("%s"), *CurrentWeapon->GetActorNameOrLabel()):FString::Printf(TEXT("Null")), this, FColor::Yellow, 0);
}
void AABCharacter::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController);
if (IsPlayerControlled())
{
SetControlMode(EControlMode::DIABLO);
GetCharacterMovement()->MaxWalkSpeed = 600.0f;
}
else
{
SetControlMode(EControlMode::NPC);
GetCharacterMovement()->MaxWalkSpeed = 300.0f;
}
}
● 위처럼 설정하고 플레이를 해보면 속도가 다르게 설정된 것을 확인할 수 있다.
● 이렇게 구분을 하고, 서비스 노드의 실행된 결과에 따라서 셀레터 데코레이터 추격 / 정찰로 나누어지도록 비헤이비어 트리 로직을 구성한다. 서비스 노드의 결과는 블랙보드의 Target 키에 값이 있는지 없는지로 구분한다.
이를 위해서 블랙보드의 값을 기반으로 특정 컴포짓의 실행 여부를 결정하는 데코레이터 노드를 사용하는 것이 좋다. 이 데코레이터 노드로인해서 Target이 nullptr 상태이면, 정찰 / Target이 ABCharacter 이면, 추격을 하도록 한다.
그리고 추가한 데코레이터 노드를 디테일 탭에서 아래와 같이 설정해준다.
각 설정의 의미는
- 관찰자 노티파이 : 노티파이 옵저버 값을 On Value Chnage로 설정해주면, 해당 키 값(Target)이 변경이 감지되면 현재 컴포짓 노드의 실행을 곧바로 취소한다. 즉, Target이 ABCharacter와 거리가 멀어져 nullptr이되면 추격 컴포짓 노드의 실행을 멈추어 추격을 멈춘다는 것이다.
- 관찰자 중단 : 관찰자 중단 항목 값을 설정하지 않으면(= None), 컴포짓에 속한 태스크가 모두 마무리될 때까지 대기하므로 플레이어가 탐지 범위를 벗어나도 NPC는 플레이어를 따라잡을 때까지 계속 쫓아온다. Self로 설정하여 중단할 수 있도록 해준다.
● 오른쪽 정찰 시퀀스 컴포짓에도 동일하게 데코레이터를 추가한다. 오른쪽 데코레이터에 반대 조건으로 Is Not Set으로 설정하고, 동일하게 관잘차 중단 옵션도 설정한다. 그러면 NPC는 정찰 중 플레이어 감지 시, 정찰을 멈추고 바로 플레이어를 추격하게 된다.
● 추격 로직에서 플레이어를 따라 잡으면 공격하는 기능을 추가한다. NPC 행동은 거리에 따라 추격과 공격으로 나누기 때문에 왼쪽 추격 로직을 두 갈래로 나누어야한다.
따라서, Sequence 컴포짓 위에 셀렉터 컴포짓을 추가한다. 그 후, 이 전에 설정했던 데코레이터를 셀렉터 컴포짓으로 옮겨준다.
● 다음으로, 블랙보드의 값을 사용하지 않고, 추격 중에 플레이어가 공격 범위 내에 있는지 판단하는 데코레이터를 생성 및 사용하여 공격할 지 / 말지 판단하게 할 수 있다.
이는 BTDecorator 클래스를 부모 클래스로 하는 클래스를 생성해야한다.
● 이 데코레이터 클래스는 CalculateRawConditionValue() 함수를 상속받아서 원하는 조건이 달성됐는지 파악하도록 설계되었다. 이 함수는 const로 선언되어 데코레이터 클래스의 멤버 변수 값은 변경할 수 없다.
그리고 CalculateRawConditionValue() 함수를 구현하면 다음과 같다.
코드에서 볼 수 있듯이 NPC와 플레이어 캐릭터 사이의 거리가 200cm이하면 공격하도록 한다.
● 그리고, 비헤이비어 트리로 들어가서 공격을 의미하는 시퀀스 컴포짓에 위에서 만든 데코레이터를 추가해준다.
그리고, 아래 동작할 행동으로 공격 태스크를 넣어야하는데 아직 구현하지 않았으므로, Wait 태스크를 넣어준다.
그리고 반대쪽 시퀀스 컴포짓에도 동일한 데코레이터를 추가하고 InverseCondition 속성 값을 체크해 조건을 반대로 설정해준다. 즉, NPC와 플레이어 캐릭터 거리가 200을 넘어가면 false가 넘어오므로, inverse로 true가 되어 추격 태스크를 진행한다.
그러면 로직이 다음과 같이 완성된다.
● 실행해보면 NPC가 플레이어와 200cm 이내로 근접해지면 공격 시퀀스 컴포짓이 동작하도록 되는 것을 확인할 수 있다.
● 이제 공격 태스크를 만들면 된다.
태스크 노드는 이전에 만들었던 FindPatrolPos처럼 BTTaskNode를 부모 클래스로하는 클래스를 만들면 된다.
● 공격 태스크는 공격 애니메이션이 끝날 때까지 대기해야 하는 지연 태스크이므로, ExecuteTask의 결과 값을 InProgress로 일단 반환하고, 공격이 끝났을 때 태스크가 끝났다고 알려줘야한다.
이를 알려주는 함수가 FinishLatenTask() 함수이다. 태스크에서 이 함수를 나중에 호출해주지 않으면 비헤이비어 트리 시스템은 현재 태스크에 계속 머문다.
차후 FinishLatenTask() 함수를 호출하기 위해 Tick 함수를 사용해야한다. Tick에서 조건을 파악하고, 태스크 종료 명령을 내리면 된다.
코드는 다음과 같이 작성된다.
BTTask_Attack.h
BTTask_Attack.cpp
● 우선, 위 코드대로 작성한 후, 공격 애니메이션이 종료되면 FinishLatenTask() 함수가 호출되도록 하여 태스크를 종료하도록 한다.
먼저 AI 컨트롤러에서도 공격 명령을 내릴 수 있도록, ABCharacter 클래스의 Attack() 함수의 접근 권한을 Public으로 변경한다. 그리고 플레이어 공격이 종료되면 공격 태스크에서 해당 알림을 받을 수 있도록 델리게이트도 생성해준다.
● 공격 애님에ㅣ션이 끝남을 알리는 델리게이트는 공격 몽타주가 끝났다는 것을 알려주는 OnAttackMontageEnded() 함수에 같이 써주면 된다.
● 그리고 이제 다시 BTTask_Attack 클래스에서 공격을 하고, 공격이 끝나서 isAttacking이 false가 되면 FinishLatenTask() 함수가 호출되도록 코드를 작성해주면 된다.
그리고 공격을 하는 애니메이션도 실행되어야 하므로, NPC라는 AI Controller로 빙의된 폰을 가져와서, 해당 폰이 ABCharacter의 Attack() 함수를 호출하도록 한다.
ABCharacter의 Attack() 함수
그러면 NPC가 공격을 하고, isAttacking = true;로 바꿔준다.
그러다가 공격 몽타주가 끝남을 알려주는 ABCharacter의 OnAttackMontageEnded()함수가 호출되면서 ABCharacter에서 작성됐던 델리게이트 OnAttackEnd를 호출한다.
그러면 isAttacking = false가 되고, TickTask에서 공격이 끝났다는 것을 확ㅇ니하고, 태스크가 끝났다는 것을 알려주는 FinishLatenTask() 함수를 호출해준다.
코드가 호출되는 순서를 정리하여 적어보면
1. 플레이어와 NPC 거리가 200cm이내면 BTTask_Attack 태스크가 실행된다. (= ExecuteTask() 함수 실행)
∨
2. AI Controller에 빙의된 ABCharacter(= NPC)를 폰으로 가져옴
∨
3. 이 캐릭터가 ABCharacter의 Attack() 함수 호출(공격 몽타주 재생)
∨
4. 공격 몽타주가 끝나면 ABCharacter의 OnAttackMontageEnded() 함수가 호출되면서, 그 안에 선언되어있던 OnAttackEnd 델리게이트도 호출됨.
∨
5. NPC의 OnAttackEnd() 델리게이트에 연결된 함수 실행(isAttacking = false)
∨
6. 태스크를 계속 수행하고 있다고 알려줌
∨
7. 공격 중이었다가 OnAttackEnd() 델리게이트가 호출돼서 isAttacking = false가 되면, 태스크가 끝났다고 알려주는 FinishLatenTask() 함수 호출
∨
8. 공격 태스크 종료
|
아래는 BTTask_Attack.cpp의 전체 코드이다.
● 위에서 만든 Attack 태스크를 이전에 대체로 넣어뒀던 Wait 태스크 위치에 넣어준다.
이렇게 하면 탐지 범위 내에 들어오면 NPC가 플레이어를 쫓아오고, 200cm 이내로 근접해지면 공격하여 플레이어에게 대미지를 준다.
● 그런데, NPC가 플레이어를 공격할 때, 제자리에 정지하기 때문에 플레이어가 NPC 등쪽으로 이동해도 계속 같은 곳을 공격한다. (200cm 이내에서 돌아다니면 처음 공격했던 곳만 공격함)
뒤에 있지만 처음에 공격하고 있던 그 위치를 공격
● 이 문제를 해결하기 위해서 공격하며 동시에 플레이어를 향해 회전하는 기능을 추가해야한다. 블랙보드에 만들었던 Target으로 회전하는 태스크를 추가하면 된다.
그 태스크를 또 만들어야 하므로, BTTaskNode를 부모 클래스로 하는 태스크를 만든다.
BTTask_TurnToTarget.h
BTTask_TurnToTarget.cpp
아래 코드는 위 전체 코드의 일부 코드로 목표 회전 값까지 자연스럽게 회전하기 위해 작성된 코드이다.
● 태스크가 완성됐으면 공격 로직에서 사용한 시퀀스 컴포짓을 심플 패러럴(Simple Paralle) 컴포짓으로 변경해준다.
● 심플 패러럴 컴포짓은 메인 태스크가 성공하면, 리턴하는 동안에는 보조 태스크가 동시에 실행되는 컴포짓이다. 즉, 공격을 하게되면, 공격하면서 플레이어를 바라보도록 회전하는 것이다.
● 여기서 왼쪽은 메인 태스크, 오른쪽은 보조 태스크를 의미한다. 메인 태스크를 공격 태스크로 하고, 보조 태스크를 회전 태스크로 설정해준다.
심플 패러럴 컴포짓에 의해 캐릭터는 공격과 캐릭터를 향해 회전하는 태스크를 동시에 실행한다.
이렇게 비헤이비어 트리의 설계는 다음과 같이 이뤄진다.
플레이 해보면 아래 사진처럼 플레이어가 움직이는 방향을 쫓아 공격을 한다.
'언리얼C++게임개발 > 12.AI컨트롤러와 비헤이비어 트리' 카테고리의 다른 글
AIController와 내비게이션 시스템 (1) | 2024.02.19 |
---|