AI가 플레이어를 공격하고 데미지를 입고 미니맵에 적용시켜보도록하겠습니다
AI의 Behavior Tree의 구조를 변경하겠습니다
(변경전 BT)
AI의 Patrol은 필요없다고 판단해서 누락시키고 AI가 스폰할때와 죽을때의 처리분기를 추가해주겠습니다
state의 이너머레이터를 OutOfSight, Chase, Attack, Die, Spawn,Damaged으로 수정
EnumClass c++ 수정
서비스 타겟이 없을때 수정을 해주었습니다
Dissolve Material을 사용한 AI의 자연스러운 스폰과 죽음처리를 위해 미리 상태분기를 수정시켜주었습니다
(Behavior Tree의 우선순위는 왼쪽,위로 갈수록 우선순위가 높습니다)
우선 AI가 공격할수있도록 무기를 들려주었습니다
WeaponSocket을 만들어서 프리뷰메시를 달아준뒤 AI에게 맞춰주었습니다
sword = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("Sword"));
sword->SetupAttachment(GetMesh(), TEXT("WeaponSocket"));
swordCollision = CreateDefaultSubobject<UCapsuleComponent>(TEXT("SwordCollision"));
swordCollision->SetupAttachment(GetMesh(), TEXT("WeaponSocket"));
swordCollision->SetRelativeLocation(FVector(0.0f, 0.0f, 57.0f));
swordCollision->SetWorldScale3D(FVector(0.5f, 1.0f, 1.25f));
플레이어와 상호작용할 스피어 콜리전도 달아주었습니다
적을 공격할때 애니메이션 몽타쥬를 실행시키는 함수도 만들어줍니다
사용할 공격 애니메이션을 리타게팅해주고 애니메이션 몽타쥬로 만들어줍니다
RandRagne로 1,3까지 랜덤으로 attack01 ~ 03 중에 랜덤으로 한개를 실행시킵니다
void ABasicSkeletonEnemy::PlayAttackAnimation()
{
int32 randomAttack = FMath::RandRange(1, 3);
switch (randomAttack)
{
case 1:
PlayAnimMontage(attack01);
break;
case 2:
PlayAnimMontage(attack02);
break;
case 3:
PlayAnimMontage(attack03);
break;
default:
break;
}
}
플레이어가 데미지를 받을수있는 데미지받는 함수를 만들어줍니다
void ASwordCharacter::GetDamage(float damage)
{
playerCurrentHp -= damage;
playerCurrentHp = FMath::Clamp(playerCurrentHp, 0.0f, playerMaxHp);
}
다시 AI에서 필요한 헤더를 추가해줬습니다
#include "Kismet/GameplayStatics.h"
Overlap처리를 추가해줍니다
void ABasicSkeletonEnemy::BeginPlay()
{
Super::BeginPlay();
swordCollision->OnComponentBeginOverlap.AddDynamic(this, &ABasicSkeletonEnemy::OnBeginOverlapSwordCollision);
swordCharacter = Cast<ASwordCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
}
void ABasicSkeletonEnemy::OnBeginOverlapSwordCollision(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (OtherActor == swordCharacter)
{
swordCharacter->GetDamage(200.0f);
}
}
플레이어를 캐스팅한뒤 오버랩한대상 (OtherActor) 이 swordCharacter 즉 플레이어면
플레이어의 데미지받는 커스텀함수를 콜합니다
Attack State를 만들어보겠습니다
해당 애니메이션이 재생되고 공격을 담당할 태스크를 생성합니다
USTRUCT()
struct FTaskSkeletonAttackMemory
{
GENERATED_BODY()
float structCurrentTime = 0.0f;
float randomEndTime;
};
UCLASS()
class BLASTERDREAM_API UTask_SkeletonAttack : public UBTTask_BlackboardBase
{
GENERATED_BODY()
public:
UTask_SkeletonAttack();
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds) override;
EAIStateEnum currentState;
bool onceAttackFlag;
};
사용할 기본 ExecuteTask , TickTask를 선언
사용하고있는 컨트롤러와 사용하고있는 폰인 BasicSkeletaonEnemy 그리고 tick에서 사용할
메모리변수 structCurrentTime선언
UTask_SkeletonAttack::UTask_SkeletonAttack()
{
NodeName = TEXT("Attack");
bNotifyTick = true;
}
EBTNodeResult::Type UTask_SkeletonAttack::ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory)
{
Super::ExecuteTask(ownerComp, nodeMemory);;
onceAttackFlag = false;
FTaskSkeletonAttackMemory* taskMemory = reinterpret_cast<FTaskSkeletonAttackMemory*>(nodeMemory);
taskMemory->structCurrentTime = 0.0f;
return EBTNodeResult::InProgress;
}
void UTask_SkeletonAttack::TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds)
{
Super::TickTask(ownerComp, nodeMemory, deltaSeconds);
FTaskSkeletonAttackMemory* taskMemory = reinterpret_cast<FTaskSkeletonAttackMemory*>(nodeMemory);
taskMemory->structCurrentTime += deltaSeconds;
ASkeletonAIController* skeletonController = Cast<ASkeletonAIController>(ownerComp.GetAIOwner());
UBlackboardComponent* blackBoard = ownerComp.GetBlackboardComponent();
ABasicSkeletonEnemy* skeleton = Cast<ABasicSkeletonEnemy>(ownerComp.GetAIOwner()->GetPawn());
if (skeleton->bDamaged)
{
currentState = EAIStateEnum::Damaged;
blackBoard->SetValueAsEnum("AIState", static_cast<uint8>(currentState));
FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
}
if (!onceAttackFlag)
{
if (taskMemory->structCurrentTime > 0.0f)
{
skeleton->PlayAttackAnimation();
onceAttackFlag = true;
taskMemory->randomEndTime = FMath::FRandRange(2.0f, 3.5f);
}
}
if (taskMemory->structCurrentTime > taskMemory->randomEndTime)
{
onceAttackFlag = false;
FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
}
}
해당 노드가 실행이 되면 ticktask에서 structCurrentTime이 deltasSeconds에 축적이되어 시간이 측정이되며
structCurrentTime 이 0초면 skeleton의 애니메이션실행시키는 함수를 호출
2.0초가 되면 노드가 끝납니다
어색한점이 몇개 보입니다
Dist 조건만 충족하면 허공에 공격해버리는 형상이 일어납니다
이렇게 뒤에 있으면 Dist조건이 충족되어 계속 attack state인데 뒤에있어도 플레이어를 바라보지않습니다
애니메이션이 실행될때 모션워핑을 추가해주겠습니다
모션워핑 적용법은 전글을 참고해주시면 되겠습니다
2025.04.12 - [Unreal5 프로젝트 다이어리] - Unreal - 모션워핑(Motion Warping)
Unreal - 모션워핑(Motion Warping)
현재 공격하거나 스킬을 사용하면 애니메이션이 나오는데애니메이션 도중 방향을 꺾을수가없다그래서 적이 지나갔는데도 애니메이션은 계속 나오니조작감도 별로고 너무 딱딱한느낌이 든다.
lucodev.tistory.com
플레이어방향으로 회전시키는 모션워핑 함수를 짜줍니다
void ABasicSkeletonEnemy::MotionWarpingPlayer()
{
FVector playerLocation = blackboardComp->GetValueAsVector(TEXT("PlayerLocation"));
if (motionWarpComponent)
{
FVector enemyLocation = GetActorLocation();
FVector playerDirection = playerLocation - enemyLocation;
FRotator playerRotation = playerDirection.Rotation();
FTransform playerTransform(playerRotation, playerLocation);
motionWarpComponent->AddOrUpdateWarpTargetFromTransform(TEXT("PlayerTarget"), playerTransform);
}
}
애니메이션 실행함수에 모션워핑함수를 달아줍니다
모션워핑 노티파이와 워프 타깃을 설정해주었습니다
또한 애니메이션을 inplace 애니메이션에서 root 애니메이션으로 변경해주었습니다
플레이어를 잘 조준합니다
지금 칼콜리전에 플레이어가 닿기만 해도 플레이어가 hp가 닳는현상이 있습니다
애니메이션 노티파이로 콜리전의 설정을 온 오프 해주도록 하겠습니다
콜리전을 온 오프해주는 함수를 만들어주겠습니다
void ABasicSkeletonEnemy::CollisionOff()
{
swordCollision->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
void ABasicSkeletonEnemy::CollisionOn()
{
swordCollision->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
}
애님인스턴스에서 ai를 캐스팅후에
온오프함수를 호출하는 노티파이를 생성해주었습니다
void USkeletonEnemyAnimInstance::NativeInitializeAnimation()
{
Super::NativeInitializeAnimation();
skeletonEnemy = Cast<ABasicSkeletonEnemy>(TryGetPawnOwner());
}
void USkeletonEnemyAnimInstance::AnimNotify_CollisionOn()
{
skeletonEnemy->CollisionOn();
}
void USkeletonEnemyAnimInstance::AnimNotify_CollisionOff()
{
skeletonEnemy->CollisionOff();
}
노티파이를 찍어줍니다
그리고 오버랩이벤트가 한번만 발생할수있도록 해주겠습니다
BHit 플래그 변수로 한번만 맞는 오버랩 처리를 해주었습니다
캐릭터의 메시는 반드시 블록 설정 Pawn 그리고 quert and physics 채널설정이 되어있어야합니다
--코드수정--
SwordActor의 BoxTraceSingle을 생성하는 주기가 오버랩될때의 아주 잠깐밖에없어서
조건체크가 어색했습니다 tick에 옮겨주었습니다
void ASwordActor::OnBeginOverlapSwordCollision(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (!bSwordAttackCollisionActivate || !OtherActor || ignoreActors.Contains(OtherActor))
{
return;
}
if (IHitInterface* hitInterface = Cast<IHitInterface>(OtherActor))
{
FVector hitLocation = SweepResult.ImpactPoint;
float damage = 0.f;
if (swordCharacter->skillName == "Q")
{
damage = statGI->qAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "QF")
{
damage = statGI->qAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "W")
{
damage = statGI->wAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "WF")
{
damage = statGI->wAttackDamage + FMath::RandRange(5, 20) + 50;
}
else if (swordCharacter->skillName == "E")
{
damage = statGI->eAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "EF")
{
damage = statGI->eAttackDamage + FMath::RandRange(5, 20) + 50;
}
else if (swordCharacter->skillName == "R")
{
damage = statGI->rAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "RF")
{
damage = statGI->rAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "Normal")
{
damage = statGI->normalAttackDamage + FMath::RandRange(5, 20);
}
else if (swordCharacter->skillName == "NormalF")
{
damage = statGI->normalAttackDamage + FMath::RandRange(5, 20) + 30;
}
if (OtherActor->GetName().Contains("Crow"))
{
crow = Cast<AHitTestCrow>(OtherActor);
if(crow)
{
crow->ShowHud(damage);
}
}
else if (OtherActor->GetName().Contains("BasicSkeleton"))
{
skeleton = Cast<ABasicSkeletonEnemy>(OtherActor);
if (skeleton)
{
skeleton->ShowHud(damage);
}
}
hitInterface->GetHit(hitLocation);
}
ignoreActors.AddUnique(OtherActor);
}
오버랩처리의 코드를 깔끔하게 정리해주었습니다
또한 skeleton도 코드를 추가해주었습니다
나머지 광역공격 폭발공격도 skeleton의 조건을 추가해주었습니다
서로다른 객체를 때릴때 다른객체의 ui가 겹치는 현상이 일어납니다
iterator로 객체를 찾아 없애줍니다
for (TActorIterator<AHitTestCrow> it(GetWorld()); it; ++it)
{
AHitTestCrow* crows = *it;
if (crows)
{
if (crows->enemyUIDisplayWidgetInstance && ::IsValid(crows->enemyUIDisplayWidgetInstance))
{
if (crows->enemyUIDisplayWidgetInstance->IsInViewport())
{
crows->enemyUIDisplayWidgetInstance->RemoveFromParent();
crows->enemyUIDisplayWidgetInstance = nullptr;
crows->currentWidgetOwner = nullptr;
}
}
}
}
ui를 띄우는 전체 코드
void ABasicSkeletonEnemy::SkeletonBeAttacked()
{
if (enemyUIDisplayWidgetInstance && ::IsValid(enemyUIDisplayWidgetInstance))
{
if (enemyUIDisplayWidgetInstance->IsInViewport())
{
enemyUIDisplayWidgetInstance->RemoveFromParent();
}
}
currentWidgetOwner = nullptr;
GetWorld()->GetTimerManager().ClearTimer(th_HideUIAfterDelay);
enemyUIDisplayWidgetInstance = CreateWidget<UEnemyUIDisplayWidget>(GetWorld(), enemyDisplayWidget);
if (IsValid(enemyUIDisplayWidgetInstance))
{
enemyUIDisplayWidgetInstance->AddToViewport();
GetWorld()->GetTimerManager().SetTimerForNextTick([this]()
{
if (IsValid(enemyUIDisplayWidgetInstance))
{
enemyUIDisplayWidgetInstance->updateHpBar(monsterName, currentHp, maxHp);
}
});
}
currentWidgetOwner = this;
GetWorld()->GetTimerManager().SetTimer(th_HideUIAfterDelay, [this]()
{
if (this == currentWidgetOwner && enemyUIDisplayWidgetInstance && enemyUIDisplayWidgetInstance->IsInViewport())
{
enemyUIDisplayWidgetInstance->RemoveFromParent();
enemyUIDisplayWidgetInstance = nullptr;
currentWidgetOwner = nullptr;
}
}, 3.0f, false);
for (TActorIterator<AHitTestCrow> it(GetWorld()); it; ++it)
{
AHitTestCrow* crows = *it;
if (crows)
{
if (crows->enemyUIDisplayWidgetInstance && ::IsValid(crows->enemyUIDisplayWidgetInstance))
{
if (crows->enemyUIDisplayWidgetInstance->IsInViewport())
{
crows->enemyUIDisplayWidgetInstance->RemoveFromParent();
crows->enemyUIDisplayWidgetInstance = nullptr;
crows->currentWidgetOwner = nullptr;
}
}
}
}
}
ui도 뜨는것을 확인할수있습니다
이제 state의 Damaged 즉 데미지를 받았을때의 처리를 해보겠습니다
플레이어를 처다보면서 뒤로 밀리면 됩니다
블랙보드에 넣을 blackboardbase c++클래스를 추가하고 코드를 추가해주겠습니다
ai가 데미지를 받는곳에 bDamaged = true; 를 추가
일단 서비스노드에서 AI가 데미지를 받으면 Damaged 스테이트로 넘기는 조건부터 추가하겠습니다
if (skeleton->bDamaged)
{
currentState = EAIStateEnum::Damaged;
blackBoard->SetValueAsEnum("AIState", static_cast<uint8>(currentState));
}
데미지를 받는 태스크의 코드입니다
DamagedMontagePlay를 통해 데미지를 받는 몽타쥬를 플레이시키고
모션워핑으로 플레이어를 처다보면서 뒤로 밀립니다
캐릭터가 받은 데미지가 여러번일수도있기떄문에 그 횟수를 카운팅하기위한 횟수카운팅 전용 변수
PendingDamageCount변수를 선언하고 카운팅도 해줍니다
데미지를 받는곳에서 누적시켜줍니다
UTask_SkeletonBeDamaged::UTask_SkeletonBeDamaged()
{
NodeName = TEXT("BeDamaged");
bNotifyTick = true;
}
EBTNodeResult::Type UTask_SkeletonBeDamaged::ExecuteTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory)
{
Super::ExecuteTask(ownerComp, nodeMemory);
ASkeletonAIController* skeletonController = Cast<ASkeletonAIController>(ownerComp.GetAIOwner());
skeleton = Cast<ABasicSkeletonEnemy>(ownerComp.GetAIOwner()->GetPawn());
FTaskSkeletonBeDamageMemory* taskMemory = reinterpret_cast<FTaskSkeletonBeDamageMemory*>(nodeMemory);
taskMemory->structCurrentTime = 0.0f;
return EBTNodeResult::InProgress;
}
void UTask_SkeletonBeDamaged::TickTask(UBehaviorTreeComponent& ownerComp, uint8* nodeMemory, float deltaSeconds)
{
Super::TickTask(ownerComp, nodeMemory, deltaSeconds);
currentTime += deltaSeconds;
FTaskSkeletonBeDamageMemory* taskMemory = reinterpret_cast<FTaskSkeletonBeDamageMemory*>(nodeMemory);
taskMemory->structCurrentTime += deltaSeconds;
UBlackboardComponent* blackBoard = ownerComp.GetBlackboardComponent();
if (skeleton->bDamaged)
{
currentTime = 0.0f;
onceAttackFlag = false;
}
if (skeleton->currentHp <= 0.0f)
{
FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
}
if (skeleton->pendingDamageCount > 0 && !onceAttackFlag)
{
skeleton->MotionWarpingPlayer();
skeleton->DamagedMontagePlay();
onceAttackFlag = true;
skeleton->pendingDamageCount--;
if (skeleton->pendingDamageCount == 0)
skeleton->bDamaged = false;
}
if (taskMemory->structCurrentTime > 0.9f)
{
onceAttackFlag = false;
FinishLatentTask(ownerComp, EBTNodeResult::Succeeded);
}
}
데미지를 받으면 신호가 Damaged 로 넘어가고 ai가 데미지를 받아 뒤로 밀립니다
미니맵에 표시될 Paper Sprite도 달아주었습니다
미니맵에 표시가 잘 됩니다
결과물
--2025.05.09 추가--
AI가 여러마리면 공격주기가 이상해지는 오류가있습니다
2025.05.09 - [Unreal 디버깅] - BehaviorTree의 currentTime 디버깅
BehaviorTree의 currentTime 디버깅
언리얼엔진에서 만약 BehaviorTree AI를 사용했을때그냥 currentTime을 선언한후 DeltaSeconds에 누적하여 그 currentTime에 따라 task의 실행을 좌우했엇다 하지만 이런경우 AI가 여러마리인 경우 CurrentTime의
lucodev.tistory.com
이글을 참고해주세요
Struct로 Memory안의 변수를 선언해서 사용하면해결됩니다
'Unreal5 프로젝트 다이어리' 카테고리의 다른 글
Unreal - Dissolve Material (0) | 2025.05.08 |
---|---|
Unreal - Behavior Tree(4) AI Perception Team ID (1) | 2025.05.04 |
Unreal - Behavior Tree(2) 플레이어 따라가기 (0) | 2025.04.29 |
Unreal - BehaviorTree(1) AI Perception (0) | 2025.04.27 |
Unreal - 8방향 블렌드스페이스(BlendSpace) (0) | 2025.04.27 |