BehaivorTree와 Blackboard를 이용해서 몬스터를 만들어보자.
ProjectRAS에 들어갈 몬스터를 위해 연습 제작해보자.
1. 근접 정찰병, 스폰된 위치에서 일정 범위를 랜덤하게 이동하다가 피격 당할 시 타겟을 쫒으며 공격
2. 마법병, 스폰된 위치에서 일정 범위를 랜덤하게 이동하다가 근처에 타겟이 있을 경우 선 공격
3. 다양한 패턴의 보스
그 중 1번을 제작해보자.
몬스터 헥터
헥터라는 몬스터는 다음과 같이 생겼다.
이 몬스터는 총 4가지의 공격 방식을 구사할 생각이다.
1. 팔을 휘둘러 가까운 거리를 내려친다.
2. 팔을 휘둘러 가까운 거리를 올려친다.
3. 팔을 휘둘러 2번의 연속 공격을 한다.
4. 타겟이 멀리 있을 경우 점프해 타겟 위치로 날라오며 내려찍기를 한다.
이는 애니메이션 몽타주에서 각각 섹션 Attack{Number}와 같다.
또한 근접 몬스터이기 때문에 너무 먼 거리에서 공격을 받을 경우 모든 공격을 전부 패링할 것이다.
행동 트리 구성하기
AI를 지시하는건 BehaivorTree가 하게 되는데 이때 먼저 패턴을 구성해보자.
일단, Root에서 시작할 수 있는 기본적인 전제 조건은 3가지와 같을 것이다.
- 타겟이 존재하고, 방금 공격을 한 경우
- 타겟이 존재하고, 공격을 하지 않은 경우
- 타겟이 존재하지 않는 경우
이렇게 큰 틀에서 각각의 역할을 정의해보자.
나는 몬스터가 플레이어를 공격할 때, 공격한 후 딜레이가 있었으면 좋겠다고 생각했다.(계속 플레이어를 따라오고 공격하면 클리어하지 못하니까)
그렇기에 1번 조건이 존재한다. 이는 최 우선으로 선택하게 될 것이다.
1번은 몬스터가 플레이어를 타겟으로 인지했지만, 방금 공격을 시도한 후 이기 때문에 "기다림" 이라는 역할을 할 것이다. 기다림이 끝나면 다시 본 상태로 돌아가야 할 것이다.
2번은 몬스터가 플레이어를 타겟으로 인지하고 공격을 시도하지 않았기에 공격을 해야한다.
다만 여기서도 나뉘게 된다.
위에서 설계한대로 플레이어와의 거리를 측정해 4번 공격(점프 내려찍기)를 할 것인지 선택을 해야하고, 만약 아니라면 1번부터 3번까지의 공격중 랜덤하게 하나를 선택해야할 것이다.
또한, 그렇게 정해진 공격을 그냥 하는 것이 아니라 플레이어를 추격해 공격 범위 안에 까지 들어와야하며, 공격 범위에 들어올 경우 정해진 공격을 해야한다.
3번은 몬스터가 가지고 있는 타겟이 존재하지 않기 때문에 정해진 시간마다 주변을 패트롤 해야한다. 만약 그러다 타겟이 생길 경우 모든 행동을 취소하고 다시 Root로 가야만 한다.
위의 말로 구성된 내용을 실제로 구성하면 다음과 같다.
필요한 데코레이터나 서비스는 전부 C++ 클래스로 만들어준다.
데코레이터와 서비스, 테스크
FindPatrolPos
가장 오른쪽에 있는 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;
}
FVector Origin = OwnerComp.GetBlackboardComponent()->GetValueAsVector(BBKEY_HOMEPOS);
FNavLocation NextPatrolPos;
if (NavSystem->GetRandomPointInNavigableRadius(Origin, 500.f, NextPatrolPos))
{
OwnerComp.GetBlackboardComponent()->SetValueAsVector(BBKEY_PATROLPOS, NextPatrolPos.Location);
return EBTNodeResult::Succeeded;
}
return EBTNodeResult::Failed;
}
간단하게 NavSystem을 통해서 내가 스폰된 장소를 기준으로 500 거리 만큼의 랜덤한 장소를 결정해 움직이게 한다.
정확히는 움직일 장소를 정해주면 Move To 테스크를 통해 움직이게된다.
Set Target
피격이 당했을 경우 공격받은 대상을 Target으로 설정해주어야한다. 블랙보드에 있는 타겟으로 설정해주기 위해 서비스를 통해 0.1초 마다 Target을 갱신해준다.
void UBTService_SetTarget::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();
if (nullptr == World)
{
return;
}
IPMMonsterInfoInterface* MonsterInfo = Cast<IPMMonsterInfoInterface>(ControllingPawn);
if (MonsterInfo == nullptr)
return;
AActor* Target = MonsterInfo->GetTarget();
if (Target == nullptr)
{
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, nullptr);
return;
}
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBKEY_TARGET, Target);
return;
}
CheckAttackRange
이 데코레이터는 점프 공격을 하기 위해 타겟이 충분한 범위안에 있는지 확인하는 데코레이터 이다.
bool UBTDecorator_CheckAttackRange::CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
bool bResult = Super::CalculateRawConditionValue(OwnerComp, NodeMemory);
int AttackNumber = OwnerComp.GetBlackboardComponent()->GetValueAsInt(BBKEY_ATTACKNUMBER);
APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
if (ControllingPawn == nullptr)
return false;
IPMMonsterInfoInterface* InfoInterface = Cast<IPMMonsterInfoInterface>(ControllingPawn);
if (InfoInterface == nullptr)
return false;
FMonsterInfo MonsterInfo = InfoInterface->GetMonsterInfo();
APawn* Target = Cast<APawn>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBKEY_TARGET));
if (nullptr == Target)
{
return false;
}
// 거리가 완전 먼지
if (AttackNumber == 0)
{
AttackNumber = 4;
FAttackRange AttackRange = MonsterInfo.AttackRange[AttackNumber];
float DistanceToTarget = ControllingPawn->GetDistanceTo(Target);
if (DistanceToTarget <= AttackRange.Depth)
{
OwnerComp.GetBlackboardComponent()->SetValueAsInt(BBKEY_ATTACKNUMBER, 4);
return true;
}
}
return false;
}
현재 정해진 공격이 없고 거리가 주어진 양보다 가까울 경우 공격을 4로 설정하는 역할이다.
Attack
이 테스크는 정해진 공격을 통해 실제로 공격을 요청한다.
EBTNodeResult::Type UBTTask_HectorAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
int AttackNumber = OwnerComp.GetBlackboardComponent()->GetValueAsInt(BBKEY_ATTACKNUMBER);
UE_LOG(LogTemp, Log, TEXT("%d"), AttackNumber);
APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
if (ControllingPawn == nullptr)
return EBTNodeResult::Failed;
IPMMonsterAttackInterface* AttackInterface = Cast<IPMMonsterAttackInterface>(ControllingPawn);
if (AttackInterface == nullptr)
return EBTNodeResult::Failed;
FAICharacterAttackFinished OnAttackFinished;
OnAttackFinished.BindLambda(
[&]()
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
);
AttackInterface->SetAIAttackDelegate(OnAttackFinished);
AttackInterface->StartAttack(AttackNumber);
OwnerComp.GetBlackboardComponent()->SetValueAsBool(BBKEY_JUSTATTACK, true);
OwnerComp.GetBlackboardComponent()->SetValueAsBool(BBKEY_ENABLEATTACK, false);
return EBTNodeResult::InProgress;
}
다만 이때, 공격을 시도한 것이지 코드가 끝났을 때, 공격이 끝난 것이 아니다. 실제로 공격이 끝났을 때는 우리가 알 수 없기 때문에 델리게이트를 만들어 건네주어서 올바르게 공격이 끝났을 때 Succeeded를 반환한다.
Select Attack
이 테스크는 여러 공격중 하나를 랜덤하게 결정해준다.
EBTNodeResult::Type UBTTask_HectorSelectAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
int AttackNumber = FMath::RandRange(0, 3);
OwnerComp.GetBlackboardComponent()->SetValueAsInt(BBKEY_ATTACKNUMBER, AttackNumber);
UE_LOG(LogTemp, Log, TEXT("Select : %d"), AttackNumber);
return EBTNodeResult::Succeeded;
}
EnableAttack
이 서비스는 플레이어를 추격하는 동시에 공격범위 안에 들어와 있는지 확인한다.
void UBTService_SetEnableAttack::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn)
{
return;
}
UWorld* World = ControllingPawn->GetWorld();
if (nullptr == World)
{
return;
}
IPMMonsterInfoInterface* MonsterInfo = Cast<IPMMonsterInfoInterface>(ControllingPawn);
if (MonsterInfo == nullptr)
return;
int AttackNumber = OwnerComp.GetBlackboardComponent()->GetValueAsInt(BBKEY_ATTACKNUMBER);
if (AttackNumber == 0)
return;
FAttackRange AttackRange = MonsterInfo->GetMonsterInfo().AttackRange[AttackNumber];
APawn* Target = Cast<APawn>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBKEY_TARGET));
if (Target == nullptr) return;
float DistanceToTarget = ControllingPawn->GetDistanceTo(Target);
if (DistanceToTarget <= AttackRange.Depth)
{
OwnerComp.GetBlackboardComponent()->SetValueAsBool(BBKEY_ENABLEATTACK, true);
}
else
{
OwnerComp.GetBlackboardComponent()->SetValueAsBool(BBKEY_ENABLEATTACK, false);
}
return;
}
공격 범위 등 공격에 대한 설정
공격에 대한 정보는 몬스터마다 각자 다르게 설정되어 있기 때문에 DataAsset을 통해서 결정한 후 이를 싱글톤 객체가 들고 있다가 몬스터가 스폰 되는 동시에 접근해서 가져오게된다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "PMMonsterInfoDataAsset.generated.h"
USTRUCT(BlueprintType)
struct FAttackRange
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AttackRange")
float Width;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AttackRange")
float Height;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AttackRange")
float Depth;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AttackRange")
float Center;
};
USTRUCT(BlueprintType)
struct FMonsterInfo
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AttackRangeMap")
TMap<int, FAttackRange> AttackRange;
};
/**
*
*/
UCLASS()
class PROJECTM_API UPMMonsterInfoDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPMMonsterInfoDataAsset();
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Info")
TMap<FName, FMonsterInfo> Infos;
};
공격
공격은 1~3번 까지는 위의 정보를 이용해 박스를 만들고 박스 안 플레이어가 포함되어 있는지 확인한 후 데미지를 주게 되고 4번 공격은 점프 후 도착 지점에 원 모양의 콜리전 오버랩을 통해 공격하게 된다.
void APMHector::StartAttack(const int32 InAttackNum)
{
Super::StartAttack(InAttackNum);
USkeletalMeshComponent* SkeletalMesh = GetMesh();
if (SkeletalMesh == nullptr) return;
UAnimInstance* AnimInstance = SkeletalMesh->GetAnimInstance();
if (AnimInstance == nullptr) return;
// 공격 몽타주 실행 (섹션 AttackX로 점프)
FName AttackName = FName(*FString::Printf(TEXT("Attack%d"), InAttackNum));
AnimInstance->Montage_Play(AttackMontage);
AnimInstance->Montage_JumpToSection(AttackName);
// AttackNum이 4인 경우 점프 공격 수행
if (InAttackNum == 4 && Target)
{
// 현재 위치와 목표(플레이어) 위치 계산
FVector StartLocation = GetActorLocation();
FVector TargetLocation = Target->GetActorLocation();
// 시작 위치와 목표 위치의 차이 계산
FVector Diff = TargetLocation - StartLocation;
float HorizontalDistance = Diff.Size2D(); // XY 평면 상 거리
float VerticalDistance = Diff.Z; // 높이 차이
// [1] 점프 궤적 계산:
// 최소 최고 높이(DesiredApexHeight)를 설정하고, 만약 목표가 더 높은 경우에는 그에 맞게 최고 높이를 조정
float DesiredApexHeight = 30.f; // 최소 최고 높이 (예: 30cm)
// 목표가 현재보다 높다면, 목표에 도달하기 위해 더 높은 최고 높이가 필요함.
float ApexHeight = FMath::Max(DesiredApexHeight, VerticalDistance + DesiredApexHeight);
// 월드의 중력(양의 값)
float Gravity = FMath::Abs(GetWorld()->GetGravityZ());
// 최고 높이에 도달하는 시간: t_up = sqrt(2*h/g)
float TimeToApex = FMath::Sqrt(2.f * ApexHeight / Gravity);
// 최고 높이에서 목표까지 떨어지는 시간: t_down = sqrt(2*(h - VerticalDistance)/g)
float TimeFromApex = FMath::Sqrt(2.f * (ApexHeight - VerticalDistance) / Gravity);
float TotalTime = TimeToApex + TimeFromApex;
// 초기 수직 속도: v_z = sqrt(2*g*h)
float InitialVerticalVelocity = FMath::Sqrt(2.f * Gravity * ApexHeight);
// 수평 속도: 목표까지의 수평 거리를 총 비행 시간으로 나눔
float HorizontalSpeed = HorizontalDistance / TotalTime;
// 수평 방향 벡터 계산 (Z 성분은 제거)
FVector HorizontalDirection = Diff;
HorizontalDirection.Z = 0.f;
HorizontalDirection.Normalize();
// 최종 발사 속도 벡터 구성
FVector LaunchVelocity = HorizontalDirection * HorizontalSpeed;
LaunchVelocity.Z = InitialVerticalVelocity;
// 공격 방향으로 캐릭터 회전
FRotator NewRotation = Diff.Rotation();
SetActorRotation(NewRotation);
// LaunchCharacter를 사용하여 점프 공격 실행
LaunchCharacter(LaunchVelocity, true, true);
}
}
void APMHector::AttackToPlayer(const int32 InAttackNum)
{
Super::AttackToPlayer(InAttackNum);
UE_LOG(LogTemp, Log, TEXT("플레이어 공격"));
if (InAttackNum == 4)
{
FVector Center = GetActorLocation();
const float SphereRadius = 200.f;
FCollisionShape CollisionSphere = FCollisionShape::MakeSphere(SphereRadius);
FCollisionQueryParams Params(FName(TEXT("Attack")), false, this);
Params.MobilityType = EQueryMobilityType::Any;
Params.AddIgnoredActor(this); // 자기 자신은 무시
TArray<FOverlapResult> Overlaps;
DrawDebugSphere(GetWorld(), Center, SphereRadius, 12, FColor::Green, false, 1.0f, 0, 2.0f);
bool bIsHit = GetWorld()->OverlapMultiByChannel(
Overlaps, // 결과를 저장할 배열
Center, // 중심점
FQuat::Identity, // 회전
ECC_PMAttack, // 채널
CollisionSphere, // 콜리전 형태
Params // 추가 파라미터
);
if (bIsHit)
{
for (auto& Overlap : Overlaps)
{
AActor* HitActor = Overlap.GetActor();
IPMHitInterface* HitInterface = Cast<IPMHitInterface>(HitActor);
if (HitInterface && HitActor == Target)
{
UE_LOG(LogTemp, Log, TEXT("HitActor: %s"), *HitActor->GetName());
HitInterface->HitFromActor(this, 0);
}
}
}
}
else
{
FAttackRange AttackRange = MyInfo.AttackRange[InAttackNum];
FVector Center = GetActorLocation() + (GetActorForwardVector() * AttackRange.Center);
float Width = AttackRange.Width;
float Height = AttackRange.Height;
float Depth = AttackRange.Depth;
FVector HalfExtents(Depth * 0.5f, Width * 0.5f, Height * 0.5f);
FRotator BoxRotation = GetActorRotation();
FQuat BoxOrientation = BoxRotation.Quaternion();
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionParams(FName(TEXT("Attack")), false, this);;
CollisionParams.AddIgnoredActor(this); // 자기 자신은 무시
DrawDebugBox(
GetWorld(),
Center,
HalfExtents,
BoxOrientation,
FColor::Red,
false, // 지속 시간 (true면 영구 표시)
2.0f // 2초 동안 표시
);
bool bIsHit = GetWorld()->OverlapMultiByChannel(
OverlapResults,
Center,
BoxOrientation,
ECC_PMAttack,
FCollisionShape::MakeBox(HalfExtents),
CollisionParams
);
if (bIsHit)
{
for (auto& Overlap : OverlapResults)
{
AActor* HitActor = Overlap.GetActor();
IPMHitInterface* HitInterface = Cast<IPMHitInterface>(HitActor);
if (HitInterface && HitActor == Target)
{
UE_LOG(LogTemp, Log, TEXT("HitActor: %s"), *HitActor->GetName());
HitInterface->HitFromActor(this, 0);
}
}
}
}
}
결과
'Unreal Engine 5 > ProjectRAS' 카테고리의 다른 글
[UE5] ProjectRAS - 시점 고정과 일반 몬스터 (0) | 2025.03.06 |
---|---|
[UE5] ProjectRAS - 콤보 공격 과 Rolling (0) | 2025.02.19 |
[UE5] ProjectRAS - 스탯 구성하기 (0) | 2025.01.21 |
[UE5] ProjectRAS - 스킬 디자인 구성2 (0) | 2025.01.15 |
[UE5] ProjectRAS - 스킬 디자인 구성 (1) | 2025.01.08 |