보스 패턴을 제작함에 있어 많은 고민과 시행착오가 있었다.
원래는 보스의 패턴을 플레이어와의 거리 및 현재 남은 체력을 고려해 행동트리를 제작하려고 했다.
다만 이렇게 하자니 플레이 하는 입장에서 어떤 패턴을 쓸지 예측이 가능해진다는 점이
플레이 하는 입장에서 단조롭게 느껴질 수 있다고 판단했다.
그래서 Utility System AI를 이용하기로 결정했다.
거창하지만 단순하게 얘기하자면 현재 모든 상황을 고려했을 때 가장 좋은 행동을 하자! 가 아이디어의 주제이다.
가중치
Utility System을 이용하기 위해선 정해진 가중치가 존재해야한다.
가령 체력이 낮을땐 어떤 기술의 가중치가 높고, 거리가 멀거나 가까울 땐 특정 스킬의 가중치가 높아 그 기술을 선택하게끔 하는 것이다.
그렇다면 고려해야될 가중치를 데이터 에셋으로 저장해 언제든 간편하게 수정할 수 있게 해보자.
USTRUCT(BlueprintType)
struct FSkillScoreData
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
FName SkillName;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float Cooldown = 5.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float IdealRange = 100.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float BaseWeight = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float DistanceWeight = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float HpWeight = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float StaminaWeight = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Skill")
float SkillDelay = 5.f;
float LastUsedTime = -999.f;
};
/**
*
*/
UCLASS()
class PROJECTRAS_API URASBossScoreData : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
URASBossScoreData();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "SkillScoreData")
TMap<int32, FSkillScoreData> SkillScoreDataMap;
};
보스의 스킬이 가지고 있어야하는 정보들이다.
Cooldown : 재사용 대기시간
IdealRange : 스킬 사용 범위, 이 안에 들어왔을 때 스킬 시전
BaseWeight : 기본 가중치, 높으면 높을 수록 자주 사용
DistanceWeight : 거리 가중치, 플레이어와 멀리 떨어져 있을 수록 자주 사용
HpWeight : Hp 가중치, Hp가 낮을 수록 자주 사용
StaminaWeight : 스테미나 가중치 , 스테미나가 낮을 수록 자주 사용
SkillDelay : 스킬 사용 후 경직 시간
이렇게만 간단히 만들었지만 추가적으로 고려해야하는 가중치가 생길 경우 손 쉽게 추가할 수 있다.

이런 식으로 값을 채워놓았다.
선택
가장 최고의 선택을 하기 위해선
위의 가중치를 이용해서 판단해야한다.
다만 이렇게 해도 패턴이 반복 될 수 있기에
이전에 사용한 스킬이라면 가중치를 확 낮추고
상위 25퍼센트의 스킬중 하나를 랜덤하게 뽑도록 하자.
// Fill out your copyright notice in the Description page of Project Settings.
#include "AI/Task/BTTask_BestActionSelect.h"
#include "Interface/Monster/Boss/RASBossInfoInterface.h"
#include "Character/RASCharacterBase.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Utils/RASBlackBoardKey.h"
#include "Data/RASBossScoreData.h"
#include "AIController.h"
UBTTask_BestActionSelect::UBTTask_BestActionSelect()
{
}
EBTNodeResult::Type UBTTask_BestActionSelect::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
UBlackboardComponent* BlackboardComp = OwnerComp.GetBlackboardComponent();
if (!BlackboardComp) return EBTNodeResult::Failed;
ARASCharacterBase* Boss = Cast<ARASCharacterBase>(OwnerComp.GetAIOwner()->GetPawn());
if (!Boss) return EBTNodeResult::Failed;
ARASCharacterBase* Target = Cast<ARASCharacterBase>(BlackboardComp->GetValueAsObject(BBTarget));
if (!Target) return EBTNodeResult::Failed;
IRASBossInfoInterface* BossInfo = Cast<IRASBossInfoInterface>(Boss);
if (!BossInfo) return EBTNodeResult::Failed;
int32 MaxIndex = BossInfo->GetSkillScoreDataCount();
const float Now = Boss->GetWorld()->GetTimeSeconds();
const float BossHpPct = BossInfo->GetHealthPercent();
const float BossStaminaPct = BossInfo->GetStaminaPercent();
const float Distance = FVector::Distance(Boss->GetActorLocation(), Target->GetActorLocation());
int32 BestIdx = BlackboardComp->GetValueAsInt(BBBestSkillIndex);
if (BestIdx != -1) return EBTNodeResult::Failed;
TArray<FCandidate> Candidates;
float MaxScore = -FLT_MAX;
for (int32 i = 1; i <= MaxIndex; ++i)
{
FSkillScoreData& Skill = BossInfo->GetSkillScoreData(i);
if (Now - Skill.LastUsedTime < Skill.Cooldown) continue;
const float DistScore = 1.f - FMath::Abs(Distance - Skill.IdealRange) / Skill.IdealRange;
const float HPScore = 1.f - BossHpPct;
const float StaminaScore = 1.f - BossStaminaPct;
const float Noise = FMath::FRandRange(0.f, 0.7f);
float Score = Skill.BaseWeight
+ Skill.DistanceWeight * DistScore
+ Skill.HpWeight * HPScore
+ Skill.StaminaWeight * StaminaScore
+ Noise;
if (LastSkillIndex == i) Score -= RepeatPenalty;
MaxScore = FMath::Max(MaxScore, Score);
Candidates.Add({ i, Score });
}
// 상위 25퍼 필터링
const float Threshold = MaxScore * 0.75f;
float TotalWeight = 0.f;
for (const FCandidate& C : Candidates)
{
if (C.Score >= Threshold)
{
TotalWeight += C.Score;
UE_LOG(LogTemp, Log, TEXT("상위 25퍼에 든 스킬 %d"), C.Index);
}
}
// Pick
float Pick = FMath::FRandRange(0.f, TotalWeight);
for (const FCandidate& C : Candidates)
{
if (C.Score >= Threshold)
{
Pick -= C.Score;
if (Pick <= 0.f)
{
BestIdx = C.Index;
break;
}
}
}
BlackboardComp->SetValueAsInt(BBBestSkillIndex, BestIdx);
LastSkillIndex = BestIdx;
UE_LOG(LogTemp, Log, TEXT("%d 선택"), BestIdx);
return Result;
}
이렇게 최고의 스킬을 선택했다면 BestIdx를 블랙보드에 저장해주자
스킬 사용
보스의 패턴이 정해졌다면
스킬을 사용해야한다.
// Fill out your copyright notice in the Description page of Project Settings.
#include "AI/Task/BTTask_BossActions.h"
#include "Interface/Monster/Boss/RASBossInfoInterface.h"
#include "Character/RASCharacterBase.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Utils/RASBlackBoardKey.h"
#include "Data/RASBossScoreData.h"
#include "AIController.h"
#include "Character/Monster/Boss/RASBossMonster.h"
#include "Interface/RASBattleInterface.h"
#include "Navigation/PathFollowingComponent.h"
#include "Animation/Monster/Boss/RASBossAnimInstance.h"
UBTTask_BossActions::UBTTask_BossActions()
{
}
EBTNodeResult::Type UBTTask_BossActions::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
CachedOwnerComp = &OwnerComp;
AI = OwnerComp.GetAIOwner();
Boss = Cast<ARASBossMonster>(AI ? AI->GetPawn() : nullptr);
Battle = Cast<IRASBattleInterface>(Boss);
BossInfo = Cast<IRASBossInfoInterface>(Boss);
Target = Cast<ARASCharacterBase>(BB->GetValueAsObject(BBTarget));
if (!Boss || !Battle || !BossInfo || !Target) return EBTNodeResult::Failed;
Boss->OnStopAttack.RemoveAll(this);
Boss->OnStopAttack.AddUObject(this, &UBTTask_BossActions::OnAttackFinished);
Idx = BB->GetValueAsInt(BBBestSkillIndex);
if(Idx <= 0) return EBTNodeResult::Failed;
FSkillScoreData& Skill = BossInfo->GetSkillScoreData(Idx);
BB->SetValueAsFloat(BBWaitTime, Skill.SkillDelay);
return StartAttack();
}
EBTNodeResult::Type UBTTask_BossActions::StartAttack()
{
FCharacterAttackFinished OnAttackFinished;
OnAttackFinished.BindLambda(
[&]()
{
BB->SetValueAsInt(BBBestSkillIndex, -1);
FinishLatentTask(*CachedOwnerComp, EBTNodeResult::Succeeded);
}
);
FVector LookVector = Target->GetActorLocation() - Boss->GetActorLocation();
LookVector.Z = 0.0f;
FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
Boss->SetActorRotation(FMath::RInterpTo(Boss->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), 200.f));
Battle->SetAttackFinishedDelegate(OnAttackFinished);
Battle->StartAttackMontage(Idx);
return EBTNodeResult::InProgress;
}
void UBTTask_BossActions::OnAttackFinished()
{
if (URASBossAnimInstance* Anim = Cast<URASBossAnimInstance>(Boss->GetMesh()->GetAnimInstance()))
{
Anim->SetHideWeapon(false);
Boss->SetWeaponOn(false);
}
BB->SetValueAsInt(BBBestSkillIndex, -1);
FinishLatentTask(*CachedOwnerComp, EBTNodeResult::Succeeded);
}
간단하게 몽타주를 실행하고 몽타주가 끝났을 때 다시 선택할 수 있게 BestIdx를 -1로 바꿔준다.
블랙보드 와 행동트리
이렇게 구상하게 되면 행동트리가 정말정말 간단하게 구성된다.

순서는 최고의 선택을 하고
타겟 위치까지 이동한 뒤
선택된 액션을 하며
딜레이를 기다리는 형태로 진행된다.
결과
'Unreal Engine 5 > ProjectRAS' 카테고리의 다른 글
| [UE5] ProjectRAS - 몬스터 스폰하기, Clothes 적용하기 (0) | 2025.05.28 |
|---|---|
| [UE5] ProjectRAS - UI 스킬 쿨타임 과 포션 마시기 (0) | 2025.05.21 |
| [UE5] ProjectRAS - Map Generator (0) | 2025.04.23 |
| [UE5] ProjectRAS - 맵 디자인 및 블프화 (0) | 2025.04.16 |
| [UE5] ProjectRAS - 스테미나 추가, 패링 성공 시 Blur, 캐릭터 죽음 (0) | 2025.04.02 |