시점 고정
시점 고정이라하면 Lock On 기능을 말하는 것이다.
몬스터와 전투중이 아니라면 마우스로 화면을 회전하고 쳐다보는 것이 가능하지만
몬스터와 전투중이라면 카메라는 항상 몬스터를 쳐다보게 할 것이다.
이렇게 설계한 이유는 단순하다.
유저의 편의성이다. 유저가 편하게 몬스터와 전투 경험을 하기 위해서 Lock On 시스템을 도입하고자 한다.
다만, 오늘은 그 Lock On을 구현해서 넣기보다는
Lock On을 했을 경우 플레이어의 움직임을 먼저 구현해보자.
예를 들어, Lock On을 했을 경우 bControllerRotationYaw 값을 true로 해서 항상 카메라가 바라보는 방향을 플레이어가 바라보게 한다는지 말이다.
실제 몬스터와 전투가 아직 진행되지 않았기 때문에 임시로 Tab 키를 누르면 고정이 되도록하자.
EnhancedInput->BindAction(LockOnAction, ETriggerEvent::Triggered, this, &ARASPlayer::LockOn);
void ARASPlayer::LockOn()
{
URASPlayerAnimInstance* MyAnimInstance = Cast<URASPlayerAnimInstance>(GetMesh()->GetAnimInstance());
if (MyAnimInstance == nullptr)
return;
bUseControllerRotationYaw = !bUseControllerRotationYaw;
MyAnimInstance->SetLockOn(bUseControllerRotationYaw);
}
탭키를 누를때마다 bUseControllerRotationYaw를 트리거 해준다.
그런데 이렇게 하면 애니메이션이 제대로 동작하지 않는다.
왜냐하면 그전까지는 bUseControllerRotationYaw가 false일때를 기준으로 애님 블루프린트를 작성했기 때문에 그것에 맞춰서 애니메이션을 블렌딩 해줘야한다.
그렇기 때문에 애님 인스턴스에 SetLockOn 함수를 만들어 애님 블루프린트에 건네주자.
void URASPlayerAnimInstance::SetLockOn(bool InLockOn)
{
bLockOn = InLockOn;
}
애니메이션
항상 플레이어는 카메라가 바라보는 방향을 보고 있고 그에 따른 애니메이션이 필요하다.
기존에는 내가 s를 눌러 뒤로 갈 경우 플레이가 뒤로 회전하며 정면으로 만들고 그 방향으로 전진하는 애니메이션이 작동했다.
하지만 이제는 s눌러 뒤로 갈 경우 뒷걸음질을 하는 애니메이션이 작동해야 올바르다.
따라서, 현재 움직이는 방향을 알 필요가 있다. (그래야 8방향으로 움직이는 것을 체크해 애니메이션을 틀어줄 수 있다.)
void URASPlayerAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
/*
...
*/
if (!Velocity.IsNearlyZero())
{
FVector LocalMovementDirection = Owner->GetActorTransform().InverseTransformVector(Velocity.GetSafeNormal());
MoveDirectionY = LocalMovementDirection.Y;
MoveDirectionX = LocalMovementDirection.X;
}
}
이 방향을 가지고 BlendSpace를 구성해보자.
움직이는 방향에 따라 애니메이션 4개를 정해두고 블렌딩 하면 된다.
이를 애님 블루프린트에서 블렌드 해주자.
Lock On이 True라면 BlendSpace를 활용하고 아니면 일반 앞으로 가기 애니메이션을 동작한다.
이에 따라 Rolling Animation을 재생해 줘야하는데 이 부분은 지난 포스팅에서 간단하게 작성하긴 했다.
void ARASPlayer::Roll(const FInputActionValue& Value)
{
if (RollMontage == nullptr) return;
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
if (AnimInstance == nullptr) return;
if (bIsRolling) return;
bIsRolling = true;
AnimInstance->Montage_Stop(0.1f);
FName RollSection = TEXT("Roll_F"); // 기본 섹션
// 입력값이 없으면 기본값 (정면, (1,0)) 사용
FVector2D RawInput;
if (!LastMoveInput.IsNearlyZero())
{
RawInput = LastMoveInput.GetSafeNormal();
}
else
{
RawInput = FVector2D(1.f, 0.f);
}
float SignedAngle = 0.f; // 계산될 각도 (0~360)
if (bUseControllerRotationYaw)
{
// 카메라 회전 기준: 원시 입력을 카메라 기준으로 월드 방향으로 변환
FRotator CamRot = Controller->GetControlRotation();
FRotator CamYaw(0, CamRot.Yaw, 0);
FVector RollDirection = FRotationMatrix(CamYaw).TransformVector(FVector(RawInput.X, RawInput.Y, 0.f));
RollDirection.Normalize();
// 기준 정면은 카메라의 forward (CamYaw의 X축)
FVector ReferenceForward = FRotationMatrix(CamYaw).GetUnitAxis(EAxis::X);
float Dot = FVector::DotProduct(ReferenceForward, RollDirection);
float AngleDegrees = FMath::Acos(Dot) * (180.f / PI);
FVector Cross = FVector::CrossProduct(ReferenceForward, RollDirection);
float Sign = (Cross.Z >= 0.f) ? 1.f : -1.f;
SignedAngle = AngleDegrees * Sign;
if (SignedAngle < 0.f)
{
SignedAngle += 360.f;
}
}
else
{
// bUseControllerRotationYaw == false: 캐릭터가 직접 회전한 상태.
// 원시 입력은 카메라 기준으로 저장되어 있으므로, 의도한 이동 방향을
// 카메라의 회전(원래 입력 기준)으로부터 구함.
FRotator CamRot = Controller->GetControlRotation();
FRotator CamYaw(0, CamRot.Yaw, 0);
FVector IntendedDirection = FRotationMatrix(CamYaw).TransformVector(FVector(RawInput.X, RawInput.Y, 0.f));
IntendedDirection.Normalize();
// 캐릭터의 현재 정면 (Actor의 forward)
FVector ActorForward = GetActorForwardVector();
ActorForward = ActorForward.GetSafeNormal2D();
float Dot = FVector::DotProduct(ActorForward, IntendedDirection);
float AngleDegrees = FMath::Acos(Dot) * (180.f / PI);
FVector Cross = FVector::CrossProduct(ActorForward, IntendedDirection);
float Sign = (Cross.Z >= 0.f) ? 1.f : -1.f;
SignedAngle = AngleDegrees * Sign;
if (SignedAngle < 0.f)
{
SignedAngle += 360.f;
}
}
// 8방향(45도 단위) 매핑
if ((SignedAngle >= 337.5f && SignedAngle < 360.f) || (SignedAngle >= 0.f && SignedAngle < 22.5f))
{
RollSection = TEXT("Roll_F"); // 정면
}
else if (SignedAngle >= 22.5f && SignedAngle < 67.5f)
{
RollSection = TEXT("Roll_FR"); // 정면 우측 대각
}
else if (SignedAngle >= 67.5f && SignedAngle < 112.5f)
{
RollSection = TEXT("Roll_R"); // 우측
}
else if (SignedAngle >= 112.5f && SignedAngle < 157.5f)
{
RollSection = TEXT("Roll_BR"); // 후면 우측 대각
}
else if (SignedAngle >= 157.5f && SignedAngle < 202.5f)
{
RollSection = TEXT("Roll_B"); // 후면
}
else if (SignedAngle >= 202.5f && SignedAngle < 247.5f)
{
RollSection = TEXT("Roll_BL"); // 후면 좌측 대각
}
else if (SignedAngle >= 247.5f && SignedAngle < 292.5f)
{
RollSection = TEXT("Roll_L"); // 좌측
}
else if (SignedAngle >= 292.5f && SignedAngle < 337.5f)
{
RollSection = TEXT("Roll_FL"); // 정면 좌측 대각
}
AnimInstance->Montage_Play(RollMontage);
AnimInstance->Montage_JumpToSection(RollSection);
FOnMontageEnded MontageEndedDelegate;
MontageEndedDelegate.BindLambda([this](UAnimMontage* Montage, bool bInterrupted)
{
bIsRolling = false;
bIsAttacking = false;
bIsParrying = false;
if (ComboAttack)
{
ComboAttack->EndCombo(false);
}
});
AnimInstance->Montage_SetEndDelegate(MontageEndedDelegate, RollMontage);
}
각 방향에 따라 애니메이션 몽타주를 준비한다.
결과
일반 몬스터
일반 몬스터는 보스와 다르게 간단한 패턴을 가지고있다.
플레이어를 발견하기 전까지는 주변 장소를 패트롤하다가
플레이어를 발견한 순간 추격 상태가 되며
추격을 해 거리가 일정 범위 안에 들어올 경우 공격을 한다.
한번의 공격이 끝나면 랜덤한 딜레이를 가지게 되고
그 뒤에 다시 플레이를 추격한다.
일부분은 지난 포스팅에서 다룬 내용이고 주의 깊게 봐야할 점은 Detect와 Attack 이다.
주변 플레이어를 감지하는 과정에서
생각해보면 눈이 귀에 달려있지 않기에 플레이어가 뒤에있다면 플레이어를 감지하지 못하는 것이 정상이다.
그래서 내적으로 이용해
몬스터와 범위내 감지된 플레이어의 location을 빼서 방향 벡터로 만들고 현재 몬스터가 바로보고 있는 방향 벡터를 내적해서 그 값이 0보다 작을경우 뒤로 판별 0보다 클 경우 앞으로 판별한다.
이유는 삼각함수를 공부하면 자연스레 알게 될것이다.
#include "AI/Service/BTService_FindTarget.h"
#include "AIController.h"
#include "Interface/Monster/RASMonsterInfoInterface.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "DrawDebugHelpers.h"
#include "Engine/OverlapResult.h"
#include "Utils/RASCollisionChannels.h"
#include "Utils/RASBlackBoardKey.h"
#include "Character/RASCharacterBase.h"
UBTService_FindTarget::UBTService_FindTarget()
{
NodeName = TEXT("Detect");
Interval = 0.5f;
}
void UBTService_FindTarget::TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
if (nullptr == ControllingPawn)
{
return;
}
FVector Center = ControllingPawn->GetActorLocation();
UWorld* World = ControllingPawn->GetWorld();
if (nullptr == World)
{
return;
}
IRASMonsterInfoInterface* MonsterInfo = Cast<IRASMonsterInfoInterface>(ControllingPawn);
if (MonsterInfo == nullptr)
return;
ARASCharacterBase* Target = MonsterInfo->GetTarget();
if (Target != nullptr)
{
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBTarget, Target);
return;
}
float DetectRadius = 500.f;
TArray<FOverlapResult> OverlapResults;
FCollisionQueryParams CollisionQueryParam(SCENE_QUERY_STAT(Detect), false, ControllingPawn);
bool bResult = World->OverlapMultiByChannel(
OverlapResults,
Center,
FQuat::Identity,
ECC_GameTraceChannel1,
FCollisionShape::MakeSphere(DetectRadius),
CollisionQueryParam
);
if (bResult)
{
for (auto const& OverlapResult : OverlapResults)
{
ARASCharacterBase* Pawn = Cast<ARASCharacterBase>(OverlapResult.GetActor());
if (Pawn && Pawn->GetController()->IsPlayerController())
{
FVector MonsterToPlayer = Pawn->GetActorLocation() - ControllingPawn->GetActorLocation();
MonsterToPlayer.Normalize();
FVector ForwardVector = ControllingPawn->GetActorForwardVector();
ForwardVector.Normalize();
float DotResult = MonsterToPlayer.Dot(ForwardVector);
if (DotResult > 0)
{
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBTarget, Pawn);
}
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Green, false, 0.2f);
DrawDebugPoint(World, Pawn->GetActorLocation(), 10.0f, FColor::Green, false, 0.2f);
DrawDebugLine(World, ControllingPawn->GetActorLocation(), Pawn->GetActorLocation(), FColor::Green, false, 0.27f);
return;
}
}
}
OwnerComp.GetBlackboardComponent()->SetValueAsObject(BBTarget, nullptr);
DrawDebugSphere(World, Center, DetectRadius, 16, FColor::Red, false, 0.2f);
}
공격의 경우는 공격이 완벽히 완료 될 때 까지 기다렸다가 다 진행됐을 경우에만 Sucecced를 반환해야 하기에 다음과 같이 작성한다.
#include "AI/Task/BTTask_CommonMonsterAttack.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Interface/RASBattleInterface.h"
#include "AIController.h"
#include "Utils/RASBlackBoardKey.h"
#include "Character/RASCharacterBase.h"
UBTTask_CommonMonsterAttack::UBTTask_CommonMonsterAttack()
{
}
EBTNodeResult::Type UBTTask_CommonMonsterAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
APawn* ControllingPawn = Cast<APawn>(OwnerComp.GetAIOwner()->GetPawn());
if (ControllingPawn == nullptr)
return EBTNodeResult::Failed;
IRASBattleInterface* BattleInterface = Cast<IRASBattleInterface>(ControllingPawn);
if (BattleInterface == nullptr)
return EBTNodeResult::Failed;
ARASCharacterBase* Target = Cast<ARASCharacterBase>(OwnerComp.GetBlackboardComponent()->GetValueAsObject(BBTarget));
if (nullptr == Target)
return EBTNodeResult::Failed;
FCharacterAttackFinished OnAttackFinished;
OnAttackFinished.BindLambda(
[&]()
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
);
OwnerComp.GetBlackboardComponent()->SetValueAsBool(BBCooldown, true);
FVector LookVector = Target->GetActorLocation() - ControllingPawn->GetActorLocation();
LookVector.Z = 0.0f;
FRotator TargetRot = FRotationMatrix::MakeFromX(LookVector).Rotator();
ControllingPawn->SetActorRotation(FMath::RInterpTo(ControllingPawn->GetActorRotation(), TargetRot, GetWorld()->GetDeltaSeconds(), 100.f));
BattleInterface->SetAttackFinishedDelegate(OnAttackFinished);
BattleInterface->StartAttackMontage();
return EBTNodeResult::InProgress;
}
공격하기 직전 플레이어가 바라보는 방향으로 회전하게 작성한다.
결과
'Unreal Engine 5 > ProjectRAS' 카테고리의 다른 글
[UE5] ProjectRAS - 패링으로 적 밀격 하기, UI 만들기 (0) | 2025.03.20 |
---|---|
[UE5] ProjectRAS - 전투 공격 판정 과 패링, 적 타겟팅 시스템(락온) (0) | 2025.03.12 |
[UE5] ProjectRAS - 콤보 공격 과 Rolling (0) | 2025.02.19 |
[UE5] ProjectRAS - 몬스터 AI 제작하기 (0) | 2025.02.06 |
[UE5] ProjectRAS - 스탯 구성하기 (0) | 2025.01.21 |