2023.04.10 ~ 2023.04.23 개발일지
이번 기간동안은 도끼를 던지는 것을 구현하였다
먼저 조준 자세를 취하도록 만들어주었고, 이는 Timeline으로 Spring Arm 의 길이를 조절하는 방식으로 구현하였다
이런 형태의 Timeline 을 만들어 둔 후, Aim이 시작할 때 Play, Aim이 끝났을때 Reverse 시켜줌으로 부드러운 연출을 구현하였다
IFCharacter.cpp
void AIFCharacter::PostInitializeComponents()
{
Super::PostInitializeComponents();
...
OnAimTimelineFunction.BindDynamic(this, &AIFCharacter::UpdateAimCamera);
AimTimeline->AddInterpFloat(AimCurveFloat, OnAimTimelineFunction);
}
...
void AIFCharacter::UpdateAimCamera(float NewArmLength)
{
SpringArm->TargetArmLength = NewArmLength;
}
다음은 조준할때 조준점이 보이도록 구현했다
이때, 도끼는 던지면 중력에 의해 밑으로 점점 떨어지게 되니, 조준점이 표시하는 곳을 정확히 닿을 수 있을 정도의 거리라면
추가적인 UI가 표시되도록 구현하였다
이는 이렇게 생긴 UI를 만들고, 양쪽의 날개는 상황에 따라 켜졌다 꺼졌다를 해주는 방식으로 구현하였다
IFAimWidget.cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "IFAimWidget.h"
#include "Components/Image.h"
void UIFAimWidget::NativeConstruct()
{
Aim = Cast<UImage>(GetWidgetFromName(TEXT("AimPoint")));
AimTargetL = Cast<UImage>(GetWidgetFromName(TEXT("AimOnTarget_L")));
AimTargetR = Cast<UImage>(GetWidgetFromName(TEXT("AimOnTarget_R")));
}
void UIFAimWidget::SetAimTargetUI(const bool& bIsTarget)
{
if (bIsTarget)
{
AimTargetL->SetVisibility(ESlateVisibility::Visible);
AimTargetR->SetVisibility(ESlateVisibility::Visible);
}
else
{
AimTargetL->SetVisibility(ESlateVisibility::Hidden);
AimTargetR->SetVisibility(ESlateVisibility::Hidden);
}
}
const bool UIFAimWidget::GetAimTargetUI() const
{
return (AimTargetL->GetVisibility() == ESlateVisibility::Hidden);
}
이 UI를 캐릭터에 연결하고, 캐릭터가 조준을 취할때 Line Trace로 검출하며 추가적인 UI의 On/Off를 제어하였다
IFCharacter.cpp
void AIFCharacter::AimStart()
{
RotateToCamera();
if (GetCharacterMovement()->MaxWalkSpeed != 200.0f)
{
AimHUD->AddToViewport();
AnimInstance->SetAimState(true);
GetCharacterMovement()->MaxWalkSpeed = 200.0f;
AimTimeline->Play();
AimTimeline->SetPlayRate(1.4f);
}
FVector StartLocation = Camera->GetComponentLocation() + Camera->GetComponentRotation().Vector() * 100;
FVector EndLocation = Camera->GetComponentLocation() + Camera->GetComponentRotation().Vector() * 3000;
FHitResult HitResult;
bool bResult = GetWorld()->LineTraceSingleByChannel(HitResult, StartLocation, EndLocation, ECC_Visibility);
if (bResult)
{
if (AimHUD->GetAimTargetUI())
AimHUD->SetAimTargetUI(true);
}
else
{
if (!AimHUD->GetAimTargetUI())
AimHUD->SetAimTargetUI(false);
}
}
void AIFCharacter::AimEnd()
{
if(GetVelocity().Size() == 0) RotateDefault();
AimHUD->RemoveFromParent();
AnimInstance->SetAimState(false);
GetCharacterMovement()->MaxWalkSpeed = 400.0f;
AimTimeline->Reverse();
AimTimeline->SetPlayRate(0.9f);
}
던지기는 먼저, Aim 상태일때만 던질 수 있도록 Inhanced Input의 Chorded Action
을 활용하였다
도끼가 던져지는 부분은 Notify 를 활용해서 보이도록 구현하였다
IFCharacterAnimInstance.cpp
void UIFCharacterAnimInstance::AnimNotify_ThrowPoint()
{
bIsDrawState = false;
bIsAxeHolding = false;
bCanDoNextAction = true;
OnThrow.ExecuteIfBound();
}
OnThrow
함수는 Character에서 생성한 Axe
를 통해 함수를 바인딩 해주었다
IFCharacter.cpp
void AIFCharacter::PostInitializeComponents()
{
Super::PostInitializeComponents();
...
AnimInstance->OnThrow.BindUObject(Axe, &AIFAxe::Throw);
}
도끼에서 던지기를 구현하기 전, 먼저 저번 시간에서 도끼의 구조를 바꿔주었다
제일 상위에 Root
, 회전을 맡을 부분인 Pivot
, 땅에 박힐 날 부분에 Lodge
, 마지막으로 매쉬가 존재하는 방식으로 구현하였다
또, 도끼가 투사체의 모습으로 날아가는 방식은 Projectile Movement
를 사용해서 구현해 주었다
여기에 회전을 하는 Timeline 을 만들어 도끼가 회전하면서 나아가는 방식으로 연출해보았다
IFAxe.cpp
void AIFAxe::UpdateRotate(float InRotate)
{
Pivot->SetRelativeRotation(FRotator(InRotate * -360.0f, 0.0f, 0.0f));
}
회전은 이런 방식으로 구현하였다
처음에는 RotateComponent 를 사용해 구현하였는데, 나중에 도끼를 돌아오게 하는 기능을 만들며 Timeline으로 바꾸게 되었다
그 내용은 밑에 추가하도록 하겠다
도끼를 던지는 것이 완성되었는데, 개인적으로 너무 힘이 없게 나아간다고 느껴졌다
그래서 이번에는 도끼가 받는 중력을 보정해주었다
다만 중력 보정 또한 선형적으로 구현하지 않고, 처음에는 중력이 없다가 추후 떨어지도록 구현하였다
커브는 이런식으로 두고, 이 값을 그대로 중력으로 대입해주는 방식을 사용하였다
추가로 중력을 제어해주는 이 커브 함수에서, 충돌에 대한 부분도 체킹을 해주었다
던지기까지의 상태는 이러하다
IFAxe.cpp
// Called when the game starts or when spawned
void AIFAxe::BeginPlay()
{
Super::BeginPlay();
ProjectileMovement->Deactivate();
Character = Cast<AIFCharacter>(UGameplayStatics::GetPlayerCharacter(this, 0));
SetAxeState(EAxeState::Idle);
}
void AIFAxe::Throw()
{
SetAxeState(EAxeState::Flying);
DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
// Get the current forward direction of the Axe
FVector Direction = Character->GetCamera()->GetForwardVector();
FVector CameraLocation = Character->GetCamera()->GetComponentLocation();
CameraRotation = Character->GetCamera()->GetComponentRotation();
SetActorLocationAndRotation((Direction * 200.0f + CameraLocation) - Pivot->GetRelativeLocation(), CameraRotation);
ProjectileMovement->Activate();
AxeGravityTimeline->PlayFromStart();
// Set the velocity of the ProjectileMovement component
ProjectileMovement->Velocity = Direction * ProjectileMovement->InitialSpeed;
AxeRotateTimeline->PlayFromStart();
AxeRotateTimeline->SetPlayRate(2.5f);
}
void AIFAxe::UpdateAxeGravity(float InGravity)
{
ProjectileMovement->ProjectileGravityScale = InGravity;
FHitResult OutHit;
bool bResult = GetWorld()->LineTraceSingleByChannel
(
OutHit,
GetActorLocation(),
// Axe's total length = 110.0f
(GetVelocity().GetSafeNormal()) * 55.0f + GetActorLocation(),
ECollisionChannel::ECC_Visibility
);
if (bResult)
{
LodgePosition(OutHit);
}
}
아까보다 훨씬 힘있게 나아가도록 구현이 되었다
도끼를 날리는 것까지는 완벽했으니, 이제는 벽에 날 부분이 박히도록 구현하였다
날이 항상 박히게 하지 않으면 이런 불상사들이 발생하기 때문이다
날이 언제나 박히는 구현을 위해 아까 전 lodge point
를 굳이 만드는 수고를 하였다
기본적인 원리는 이러하다
이렇게 하면 메쉬는 lodge point
의 자식으로 있기 때문에 벽에 박힌 모습으로 보이게 된다
코드는 이러하다
IFAxe.cpp
void AIFAxe::LodgePosition(const FHitResult& InHit)
{
SetAxeState(EAxeState::Lodged);
// Stopping the movement
ProjectileMovement->Deactivate();
AxeGravityTimeline->Stop();
AxeRotateTimeline ->Stop();
//resetting the rotation
Pivot->SetRelativeRotation(FRotator::ZeroRotator);
SetActorRotation(CameraRotation);
float AdjustRoll = FMath::FRandRange( -3.0f, -8.0f);
float InclineSurfaceRange = FMath::FRandRange(-30.0f, -55.0f);
float RegularSurfaceRange = FMath::FRandRange( -5.0f, -25.0f);
// setting the rotation by using ImpactNormal position
FMatrix RotateMatrix(InHit.ImpactNormal.GetSafeNormal(), FVector::ZeroVector, FVector::ZeroVector, FVector::ZeroVector);
float MatrixPitch = RotateMatrix.Rotator().Pitch;
float AdjustPitch = MatrixPitch > 0 ? InclineSurfaceRange - MatrixPitch : RegularSurfaceRange - MatrixPitch;
// setting the rotation of lodge
Lodge->SetRelativeRotation(FRotator(AdjustPitch, 0.0f, AdjustRoll));
float AdjustZ = MatrixPitch > 0 ? (90 - MatrixPitch) / 9 : 10;
FVector AdjustLocation = InHit.ImpactPoint + FVector(0.0f, 0.0f, AdjustZ) + (GetActorLocation() - Lodge->GetComponentLocation());
SetActorLocation(AdjustLocation);
}
개선 후의 모습이다
날라간 도끼를 돌아오게 하는 것을 구현하였다
이때 날라가는 중과 땅에 박혔을때의 상태를 구분하며, 추가로 도끼의 상태를 정리하기 위해 enum class 를 통해 정리하였다
UENUM()
enum class EAxeState
{
Idle,
Flying,
Lodged,
Returning,
};
캐릭터와 함께 있다면 Idle, 던져서 날아가는중이라면 Flying, 박혔다면 lodged, 리콜을 시작하면 Returning 으로 설정 했다
리콜은 연속적으로 여러가지 Timeline을 활용해 자연스러운면서도 역동적인 모션을 만들었다
총 4+1 가지의 Timeline을 활용하였는데 이는 이와 같다
각 각 어떻게 구현되었는지 확인해보겠다
이는 간단하게 lodge point 의 roll에 Timeline 값을 적용시켜주었다
void AIFAxe::UpdateWiggle(float InWigglePosition)
{
float WiggleRoll = Lodge->GetRelativeRotation().Roll + (InWigglePosition * 12.0f);
Lodge->SetRelativeRotation(FRotator(Lodge->GetRelativeRotation().Pitch, Lodge->GetRelativeRotation().Yaw, WiggleRoll));
}
또, Wiggle Timeline이 끝나고 리콜이 시작되도록 OnTimelineFinishedFunc
를 통해 리콜 함수를 추가해주었다
나머지 4개의 Timeline은 서로가 유기적으로 변수를 공유하며 움직임을 구성하기 때문에 코드를 전체적으로 올리고자 한다
IFAxe.cpp
void AIFAxe::RecallMovement()
{
SetAxeState(EAxeState::Returning);
// Initialize components for recall
DistanceFromCharacter = FMath::Clamp((GetActorLocation() - Character->GetMesh()->GetSocketLocation(TEXT("Weapon_R"))).Size(), 0, 3000);
float TimelinePlayRate = FMath::Clamp(1400 / DistanceFromCharacter, 0.4f, 5.0f);
ReturnStartLocation = GetActorLocation();
ReturnStartRotation = GetActorRotation();
ReturnStartCameraRotation = Character->GetCamera()->GetComponentRotation();
Lodge->SetRelativeRotation(FRotator::ZeroRotator);
// playing the timelines
RightVectorTimeline->PlayFromStart();
RightVectorTimeline->SetPlayRate(TimelinePlayRate);
ReturnSpeedTimeline->PlayFromStart();
ReturnSpeedTimeline->SetPlayRate(TimelinePlayRate);
ReturnTiltStartTimeline->PlayFromStart();
ReturnTiltStartTimeline->SetPlayRate(TimelinePlayRate);
ReturnTiltEndTimeline->PlayFromStart();
ReturnTiltEndTimeline->SetPlayRate(TimelinePlayRate);
CalculateSpin(TimelinePlayRate);
}
void AIFAxe::UpdateRightVector(float InVector)
{
// start position is designated, but the end position is dynamic
ReturnRightVector = ((DistanceFromCharacter * InVector) * Character->GetCamera()->GetRightVector()) + Character->GetMesh()->GetSocketLocation(TEXT("Weapon_R"));
}
void AIFAxe::UpdateReturnLocation(float InSpeed)
{
// setting the axe location by lerp
ReturnLocation = FMath::Lerp(ReturnStartLocation, ReturnRightVector, InSpeed);
SetActorLocation(ReturnLocation);
}
void AIFAxe::UpdateTiltStart(float InValue)
{
FRotator TiltRotation = FRotator(ReturnStartCameraRotation.Pitch, ReturnStartCameraRotation.Yaw, ReturnStartCameraRotation.Roll + 60.0f);
TiltingRotation = FMath::Lerp(ReturnStartRotation, TiltRotation, InValue);
}
void AIFAxe::UpdateTiltEnd(float InValue)
{
FRotator TargetRotation = FMath::Lerp(TiltingRotation, Character->GetMesh()->GetSocketRotation(TEXT("Weapon_R")), InValue);
SetActorRotation(TargetRotation);
}
완성된 모습은 이러하다
(벽에 박혀있을때)
(날라가는 도중일때)
(리콜하며 움직일때)
여기까지만 해도 충분히 멋있지만, 날라갈때 회전하며 날라갔기에 돌아올때도 회전하며 돌아와야한다고 생각했다
그래서 원래는 loop로 걸어두었던 회전을 1회 시행할때마다 다시 할 지 체크 하는 방식으로 변경하고,
리콜할때 거리를 계산해 최대 회전 가능 횟수만큼 회전하고 손에 잡히도록 구현하였다
IFAxe.cpp
void AIFAxe::CalculateSpin(float InTimelinePlayRate)
{
float AdjustRate = 1 / InTimelinePlayRate;
SpinCount = FMath::RoundToInt(AdjustRate / 0.35);
float SpinRate = 1 / (AdjustRate / SpinCount);
AxeRotateTimeline->SetPlayRate(SpinRate);
AxeRotateTimeline->ReverseFromEnd();
}
void AIFAxe::SpinStop()
{
if (SpinCount == 1)
{
AxeRotateTimeline->Stop();
SpinCount = 0;
}
else if (SpinCount <= 0)
{
AxeRotateTimeline->PlayFromStart();
SpinCount--;
}
else
{
AxeRotateTimeline->ReverseFromEnd();
SpinCount--;
}
}
이때 SpinStop은 Rotate Timeline의 OnTimelineEndedFunc
에 추가하여 매 회전 이후 검사하도록 로직을 구성하였다
완성된 모습은 다음과 같다
(벽에 박혔을때)
(공중에서)
Blueprint를 보니, 타임라인 하나에 여러가지 Track을 넣어 사용할 수 있었다
다만, Code로 구현할 때는 언리얼에서 만들어둔 타임라인 델리게이트는 모두 Single delegate였다
그래서 어쩔 수 없이 델리게이트를 여러개 만들고, 그 델리게이트에 엮는 타임라인 또한 여러개를 만드는 방식을 사용하였는데,
가독성 측면에서 굉장히 안좋다고 느껴진다
Timeline-MultiCast가 없는 것인지, 내가 못 찾는것인지 모르겠다
추가로 가끔씩 리콜이 종료되었을때 손과 도끼의 위치가 맞지 않는 현상이 발생한다
다시 던지고 리콜하면 손에 알맞게 들어오긴 하는데... 한번 어떤 방법이 있을지 체크해봐야겠다