오늘의 구현 계획! 먼저, 저번주에 서버 관련해서 수정하지 못했던 문제를 해결할 것이다. 그리고 이게 끝나면 솔루나 시프트 상세 기획서를 보고, 구현하지 못했던 부분들을 할 수 있는 만큼 구체화할 생각이다.
위 gif를 확인하면 알 수 있듯이, 클라이언트 쪽에서 솔루나 시프트를 사용할 경우 서버에선 아무런 반응이 없고 클라이언트에서도 이동 후에 다시 원래 자리로 돌아가는 문제가 있다. 서버쪽에서 동작하는 것들은 클라이언트로 잘 복제되고 있다.
솔루나 시프트 애니메이션이 출력되지 않는건, 기본 캐릭터 상태를 활 착용중으로 바꾸면서 내가 만들어놨던 애니메이션 상태가 적용되지 않고있기 때문이다. 이 부분은 문제 해결 후에 활 애니메이션을 설정한 친구와 이야기해서 고칠 예정이다.
우선 캐릭터 무브먼트를 상속받은 클래스를 하나 만들어 주었다. 솔루나 시프트와 같은 이동 기능은 무브먼트 컴포넌트에 이관하는게 훨씬 깔끔할 것 같았기 때문이다
// TrapperPlayer.h
ATrapperPlayer(const FObjectInitializer& ObjectInitializer);
// TrapperPlayer.cpp
ATrapperPlayer::ATrapperPlayer(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer.SetDefaultSubobjectClass<UTrapperPlayerMovementComponent>(ACharacter::CharacterMovementComponentName))
{
// 생략
}
기본 생성자를 삭제하고, 기존 기본 생성자에 있던 코드들을 전부 옮겨주었다. 그리고 난 후, 캐릭터에 부착된 캐릭터 무브먼트 컴포넌트를 내가 만든 새 컴포넌트로 변경해주었다. FObjectInitializer
라는 생성자의 인자를 사용하면 기존의 컴포넌트를 생성하지 않고 내가 제작한 컴포넌트를 사용해 생성해준다.
캐릭터쪽에선 자성 기둥을 탐색 후 솔루나 시프트의 발동 여부를 결정하는 역할을 하고, 솔루나 시프트 관련된 로직들을 새로 만든 무브먼트 컴포넌트로 옮겨주기로 했다.
// TrapperPlayer.h
void FindMagneticPillar();
// 플레이어 관련 처리가 필요할 때 사용
void StartManeticMoving();
void FinishMagneticMoving();
이제 플레이어 코드에 자성 이동 관련 코드는 자성 기둥을 탐색하고, 솔루나 시프트가 발동됐을 때와 끝났을 때 호출되는 함수 세개만 존재한다.
이제 솔루나 시프트가 어떻게 발동되는지 과정을 정리해보겠다.
void ATrapperPlayer::FindMagneticPillar()
{
if (Movement->GetMagneticMovingState() || !IsLocallyControlled())
{
return;
}
FVector Location;
FRotator Rotation;
GetController()->GetPlayerViewPoint(Location, Rotation);
FVector TraceDirection = Rotation.Vector();
FVector End = Location + TraceDirection * Movement->MagneticDistance;
FHitResult Result;
bool HasHit = GetWorld()->LineTraceSingleByChannel(
Result,
Location,
End,
ECC_GameTraceChannel1
);
if (HasHit)
{
AActor* Target = Result.GetActor();
// 갖고있는 타겟이 없을때 한번만 호출
if (!Movement->GetTarget())
{
AMagneticPillar* NewTarget = Cast<AMagneticPillar>(Target);
if (NewTarget)
{
Movement->SetTarget(NewTarget);
}
}
}
else
{
Movement->ClearTarget();
}
}
매 틱마다 FindMagneticPillar()
함수를 돌면서, 자성 기둥을 탐색한다. 만약 Hit 결과가 있고 가지고 있는 타겟(자성 기둥)이 없을 때, 무브먼트 컴포넌트의 SetTarget()
함수를 호출한다. 마찬가지로, 자성 기둥에서 포커스가 벗어날 경우 ClearTarget()
함수를 호출해준다.
void UTrapperPlayerMovementComponent::SetTarget(TObjectPtr<AMagneticPillar> Target)
{
bCanMagneticMoving = true;
// 타겟 포지션 계산
TargetMagneticPillar = Target;
TargetPosition = Target->GetActorLocation();
FVector BoxSize = Target->GetComponentsBoundingBox().GetSize();
UCapsuleComponent* CapsuleComponent = GetOwner()->FindComponentByClass<UCapsuleComponent>();
TargetPosition.Z += BoxSize.Z + (CapsuleComponent->GetScaledCapsuleHalfHeight() * 2);
// 효과
TargetMagneticPillar->SetOutline(true);
PlayerRef->StartManeticMoving();
}
void UTrapperPlayerMovementComponent::ClearTarget()
{
// 방금까지 타겟이었던 자성기둥의 아웃라인을 해제해주고 nullptr
if (GetTarget())
{
TargetMagneticPillar->SetOutline(false);
TargetMagneticPillar = nullptr;
bCanMagneticMoving = false;
}
}
SetTarget()
함수에서는 bCanMagneticMoving
변수를 true로 만들어주고, 타겟에 대한 처리를 해준다. ClearTarget()
에서는 타겟의 효과를 해제하고, 타겟을 비운다.
void ATrapperPlayer::Interact(const FInputActionValue& Value)
{
float Data = Value.Get<float>();
if (Data && Movement->CanMagneticMoving())
{
Movement->StartMagneticMove();
}
}
이제 Shift 키를 눌렀을 때 무브먼트 컴포넌트의 StartMagneticMove()
함수를 호출한다.
void UTrapperPlayerMovementComponent::StartMagneticMove()
{
bIsMagneticMoving = true;
bCanMagneticMoving = false;
StartPosition = GetActorLocation();
}
void UTrapperPlayerMovementComponent::StopMagneticMove()
{
bIsMagneticMoving = false;
ElapsedTime = 0.0f;
}
// MagneticMove() 함수 내부의
// 목표 지점에 도달했는지 확인하는 부분
if (Alpha >= 1.0f)
{
StopMagneticMove();
PlayerRef->FinishMagneticMoving();
}
bIsMagneticMoving
변수가 true가 된다. MagneticMove()
의 구현부는 도착했을 때 플레이어의 FinishMagneticMoving()
함수를 호출해주는 것을 제외하고는 이전의 로직과 같다.
// UTrapperPlayerMovementComponent.h
virtual void OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity) override;
// UTrapperPlayerMovementComponent.cpp
void UTrapperPlayerMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity)
{
if (bIsMagneticMoving)
{
MagneticMove();
}
}
OnMovementUpdated()
함수를 오버라이드해 bIsMagneticMoving
변수값에 따라 MagneticMove()
가 호출되도록 만들어 주었다. 이 함수는 PerformMovement()
함수 내부에서 호출된다.
클라이언트에서 이동을 수행함과 동시에, 서버로 클라이언트 플레이어의 타겟 위치를 보내주어야 한다. 먼저 서버 RPC를 이용해 구현해보자.
// TrapperPlayer.cpp
void ATrapperPlayer::Interact(const FInputActionValue& Value)
{
float Data = Value.Get<float>();
if (Data && Movement->CanMagneticMoving())
{
Movement->StartMagneticMove();
}
}
무브먼트 컴포넌트의 StartMagneticMove()
함수를 호출하면,
// TrapperPlayerMovementComponent.cpp
void UTrapperPlayerMovementComponent::StartMagneticMove()
{
bIsMagneticMoving = true;
bCanMagneticMoving = false;
StartPosition = CharacterOwner->GetActorLocation();
if (!CharacterOwner->HasAuthority())
{
CharacterOwner->SetReplicates(false);
ServerRPCMagneticMove(TargetPosition, GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
}
}
자성 이동 가능 상태를 만든 뒤, 서버가 아닐 경우 서버로 움직임을 복제하기 위해 Server RPC를 호출해준다.
bool UTrapperPlayerMovementComponent::ServerRPCMagneticMove_Validate(FVector TargetPos, float MoveStartTime)
{
if (LastMoveStartTime == 0.0f)
{
return true;
}
return (MoveStartTime - LastMoveStartTime) > TotalMoveTime;
}
void UTrapperPlayerMovementComponent::ServerRPCMagneticMove_Implementation(FVector TargetPos, float MoveStartTime)
{
MoveTimeDifference = GetWorld()->GetTimeSeconds() - MoveStartTime;
MoveTimeDifference = FMath::Clamp(MoveTimeDifference, 0.0f, TotalMoveTime - 0.01f);
TotalMoveTime -= MoveTimeDifference;
LastMoveStartTime = MoveStartTime;
TargetPosition = TargetPos;
StartMagneticMove();
}
지연이 있을 것을 고려해서 이렇게 작성해보았다. 아직 이 최적화(?) 코드가 정상적으로 작동하는지는 모르겠다. 아직 기본적인 기능이 제대로 작동하지 않기 때문..
움직임은 잘 되지만 클라이언트쪽에서 지터링 현상이 발생한다. gif에서는 좀 덜 해보이지만, 실제로는 덜덜 떨린다. 내가 예상하건데.. 솔루나 시프트가 작동하는 동안 서버쪽의 클라이언트 캐릭터가 계속 리플리케이트 되어서 발생하는 현상(서버는 조금 더 늦게 솔루나 시프트를 시작하기 때문)인 것 같다.
https://forums.unrealengine.com/t/client-side-jittery-movement/380473/2
이 문제가 맞는 것 같은데... 그렇다고 서버쪽에서 클라이언트 캐릭터를 움직이고 클라이언트가 그 값을 복제하도록 만들면 클라이언트와 서버간에 지연이 발생할 때 클라이언트쪽의 반응성이 굉장히 안좋아질 것 같은 느낌이 들어서 그렇게 짜기가 싫다...^-^
일단은 캐릭터 무브먼트 클래스를 더 공부하고 와야할 것 같다는 생각을 했다.. 사실 몇번 보긴 했는데 아직 이해를 못해서..ㅎㅎ.. 네트워크 너무 어지럽다 ㅠㅠ 공부하고 다시 돌아오도록 하겠습니다,,
p.NetShowCorrections
을 1로 설정하고 나타난 결과. 서버의 화면으로 확인해 보면, 클라이언트가 먼저 빨간색 캡슐을 그리며 이동하고, 그 뒤로 서버의 움직임(초록색 캡슐이) 따라오면서 클라이언트의 위치를 보정해주는 것을 볼 수 있었다.
[Unreal Engine] Setup & Sprinting | Character Movement Component In-Depth
일단은, 인터넷 검색으로 찾아본 영상을 따라해보며 이 문제를 해결해보기로 했다. 영문 강의라 내용이 정확하지 않을 수 있다.
class FTrapperSavedMove_Character : public FSavedMove_Character
{
typedef FSavedMove_Character Super;
uint8 bIsMagneticMoving : 1;
virtual bool CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter, float MaxDelta) const;
virtual void Clear() override;
virtual uint8 GetCompressedFlags() const override;
virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character& ClientData) override;
virtual void PrepMoveFor(ACharacter* C) override;
};
먼저 UTrapperPlayerMovementComponent 클래스 내부에 FSavedMove_Character를 상속받은 새 클래스를 만들어 주었다. 이 구조체는 Autonomous Proxy가 각 틱 도중 움직임을 시작하고 종료한 방식을 기록하는 구조체이다.
먼저, CanCombineWith()
함수는 현재의 움직임과 새로운 움직임을 확인하고, 이 두 동작을 결합할 수 있는 지와 그 이유를 확인한다. 대역폭을 절약하기 위해서라고 한다.
bool UTrapperPlayerMovementComponent::FTrapperSavedMove::CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter, float MaxDelta) const
{
FTrapperSavedMove* NewTrapperMove = static_cast<FTrapperSavedMove*>(NewMove.Get());
if (bIsMagneticMoving != NewTrapperMove->bIsMagneticMoving)
{
return false;
}
return Super::CanCombineWith(NewMove, InCharacter, MaxDelta);
}
if문 안의 두 동작인 동일하지 않으면, 두 동작은 정확히 동일하지 않다는 것을 알 수 있고 따라서 결합할 수 없으므로 false를 반환한다. 만약 두 값이 같다면, 실제로 결합할 수 있는지 여부를 Super 함수에서 처리하도록 한다. 아무튼, 확실히 솔루나 시프트 상태가 서로 다르다면, 결합할 수 없는 움직임일 것이므로 이렇게 구현한 것.
void UTrapperPlayerMovementComponent::FTrapperSavedMove::Clear()
{
Super::Clear();
bIsMagneticMoving = false;
}
Clear()
함수에서는 Super 함수를 먼저 실행해 기존 값들을 초기화해주고, 추가해준 내 변수도 초기화해주자.
캐릭터 움직임 클래스에는 특별한 행동을 판별할 수 있는 플래그 값이 저장되어 있는데, 이 플래그 값을 사용해 클라이언트는 서버가 참고해야 될 중요한 행동정보를 전달할 수 있다.
enum CompressedFlags
{
FLAG_JumpPressed = 0x01, // Jump pressed
FLAG_WantsToCrouch = 0x02, // Wants to crouch
FLAG_Reserved_1 = 0x04, // Reserved for future use
FLAG_Reserved_2 = 0x08, // Reserved for future use
// Remaining bit masks are available for custom flags.
FLAG_Custom_0 = 0x10,
FLAG_Custom_1 = 0x20,
FLAG_Custom_2 = 0x40,
FLAG_Custom_3 = 0x80,
};
GetCompressedFlags()
에는 솔루나 시프트 발동을 알리기 위한 플래그를 새로 추가해 덮어써줘야 한다.
uint8 UTrapperPlayerMovementComponent::FTrapperSavedMove::GetCompressedFlags() const
{
uint8 Result = Super::GetCompressedFlags();
if (bIsMagneticMoving)
{
Result |= FLAG_Custom_0;
}
return Result;
}
솔루나 시프트 플래그는 커스텀 0번에 할당해주자. 우리는 총 4개의 커스텀 압축 플래그를 전달할 수 있다.
void UTrapperPlayerMovementComponent::FTrapperSavedMove::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, FNetworkPredictionData_Client_Character& ClientData)
{
Super::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);
UTrapperPlayerMovementComponent* Movement = Cast<UTrapperPlayerMovementComponent>(C->GetCharacterMovement());
bIsMagneticMoving = Movement->bIsMagneticMoving;
}
SetMoveFor()
함수에서는 움직임 데이터를 저장한다. 여기서 필요한 커스텀 데이터를 설정할 수 있다.
void UTrapperPlayerMovementComponent::FTrapperSavedMove::PrepMoveFor(ACharacter* C)
{
Super::PrepMoveFor(C);
UTrapperPlayerMovementComponent* Movement = Cast<UTrapperPlayerMovementComponent>(C->GetCharacterMovement());
Movement->bIsMagneticMoving = bIsMagneticMoving;
}
PrepMoveFor()
함수는 저장된 움직임의 데이터를 가져와 캐릭터 무브먼트 컴포넌트의 현재 상태에 적용한다.
이제 FNetworkPredictionData_Client_Character
클래스를 알아보자. 이 클래스는 클라이언트에서 캐릭터의 움직임을 예측하고 관리하는 데 사용되는 클래스이다. 이를 통해 클라이언트는 네트워크 지연 중에도 부드러운 캐릭터 움직임을 유지할 수 있다. 클라이언트는 플레이어의 입력에 따라 움직임을 계산하고 이를 서버로 전송하는데, 서버는 이 데이터를 기반으로 최종 위치를 계산하고 클라이언트에 반환하게 된다. 이 과정에서 클라이언트는 자신의 예측과 서버의 계산이 일치하지 않을 경우 이를 보정한다.
class FTrapperNetworkPredictionData_Client : public FNetworkPredictionData_Client_Character
{
typedef FNetworkPredictionData_Client_Character Super;
public:
FTrapperNetworkPredictionData_Client(const UCharacterMovementComponent& ClientMovement);
virtual FSavedMovePtr AllocateNewMove() override;
};
UTrapperPlayerMovementComponent::FTrapperNetworkPredictionData_Client::FTrapperNetworkPredictionData_Client(const UCharacterMovementComponent& ClientMovement)
: Super(ClientMovement)
{
}
FSavedMovePtr UTrapperPlayerMovementComponent::FTrapperNetworkPredictionData_Client::AllocateNewMove()
{
return FSavedMovePtr(new FTrapperSavedMove());
}
생성자에서는 Super 함수만 호출해주면 되고, AllocateNewMove()
함수에서는 기본 값을 호출하는 대신 우리가 새로 만들어준 FTrapperSavedMove를 할당해 줄 것이다.
이제 지금까지 우리가 구현해준 것들을 사용하기 위해, 우리가 만든 캐릭터 무브먼트 컴포넌트에 몇가지 함수를 구현해주자.
virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
FNetworkPredictionData_Client* UTrapperPlayerMovementComponent::GetPredictionData_Client() const
{
if (ClientPredictionData == nullptr)
{
UTrapperPlayerMovementComponent* MutableThis = const_cast<UTrapperPlayerMovementComponent*>(this);
MutableThis->ClientPredictionData = new FTrapperNetworkPredictionData_Client(*this);
}
return ClientPredictionData;
}
GetPredictionData_Client()
함수에서는 방금 우리가만든 예측 데이터 클래스를 생성해준다. 클라이언트 예측 데이터가 nullptr인지 확인하고, 데이터를 생성하지 않았을 경우 우리가 만들어 주고, 생성했을 경우엔 가지고 있는 것을 반환해준다.
virtual void UpdateFromCompressedFlags(uint8 Flags) override;
void UTrapperPlayerMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
Super::UpdateFromCompressedFlags(Flags);
bIsMagneticMoving = (Flags & FSavedMove_Character::FLAG_Custom_0) != 0;
}
이제 서버로 전송된 압축된 움직임 플래그 데이터를 기반으로 캐릭터의 움직임 상태를 업데이트하는 함수를 오버라이드해주자. 클라이언트가 서버에 전송한 데이터와 서버가 보낸 응답 데이터를 기반으로 클라이언트와 서버 간의 움직임을 동기화하는 데 중요하다.
이제 정리해보자. 먼저, 틱에서 모든 이동 로직을 실행하는 PerformMovement()
를 호출한다. 그리고 방금 어떻게 움직였는지에 대한 데이터를 내가 만든 FTrapperSavedMove
를 생성하고 SetMoveFor()
를 통해 저장한다. 그리고 CanCombineWith()
를 통해 결합될 수 있는지 확인한 뒤, GetCompressedFlags()
에서 플래그를 덮어써준다.
서버가 이 움직임을 수신하면 UpdateFromCompressedFlags()
함수를 통해 압축된 플래그를 가져와 변수를 업데이트 해준다. 그 다음, 클라이언트가 수행한 이동을 수행해준다.
이렇게 구현한 후, 기존의 Server RPC에서 StartMagneticMove()
함수의 호출을 없애고 시작 위치와 타겟 위치만 받도록 바꿔주었다.
그럼 이렇게 된다 짜잔..^^.... 덜덜거리는 현상은 사라졌지만 목적지에 도착하기도 전에 솔루나 시프트 상태가 종료되어 버리고, 클라이언트쪽도 위치가 보정되면서 서버의 위치와 동기화된다.
예상가는 문제는, 서버가 이동하고 있는 도중에 클라이언트쪽에서 자성이동을 종료해버리면서 플래그가 동기화되는 것. 변수를 false로 바꾸는 쪽 코드를 주석처리 해보았더니,
서버와 클라이언트가 정확히 같은 곳에서 자성이동을 종료하지 않고 떠있는 모습을 볼 수 있다. 내가 예상한 문제가 맞다는걸 가정하고 해결해본다면 어떻게 할 수 있을까?
서버에서 도착했다고 멀티캐스트 RPC를 날리는건 어떨까? 클라이언트가 서버가 목적지에 도착할때까지 공중에 떠있을게 분명하다..
진짜다
또 이상한건, 이동이나 점프와 같은 부분은 지연 없이 동기화가 완벽하게 일치하는 것처럼 보이는데, 내가 구현한 솔루나 시프트의 경우 분명한 지연현상이 있는 것이다. 이정도면 로직에도 문제가 있는 것 아닐까.... 아무튼, 지연이 있어도 의도대로 움직여야 한다.
아주 살짝 공부한거라 확실하지는 않지만, 내가 애초에 무브먼트 컴포넌트를 확장해야 하는 이유에 대해 제대로 이해하지 못하고 있었던 것 같다.
클라이언트측 예측 기능이 올바르게 작동하려면, 클라이언트는 서버가 수행할 이동을 성공적으로 예측하기 위해 서버와 클라이언트가 정확히 동일한 데이터로 호출하는 이동 함수를 만들어야 한다. 따라서 함수 안에 난수를 가질 수 없으며, 외부 상태 데이터를 갖도록 하면 안된다. 만약 이동 함수가 랜덤 벡터를 호출하면 클라이언트가 해당 벡터로 이동하지만, 서버는 다른 벡터를 얻게 되고 결국 서버와 클라이언트는 다른 위치에 존재하게 되며, 이로 인해 서버가 클라이언트를 수정해야 하고 클라이언트의 예측은 실패하게 된다. 따라서, 함수가 완전히 명확해야 하며 동일한 입력 매개변수에 대해 작동해야 한다.
내가 유튜브에서 들은 내용을 허접하게나마 번역한 글이다. 번역이 완벽하진 않겠지만, 어쨌든 내가 구현하려고 했던 솔루나 시프트는 델타타임을 누적시키고 있고, 누적된 값에 따라 이동 위치가 바뀌며, 심지어 타겟 위치까지 받아와서 사용하고 있다. 이 글을 보고, 내가 절대 원하는 의도대로 움직일 수가 없었겠다는 생각이 들었다. 애초에 무브먼트를 확장하는 이유가, "클라이언트측의 예측"을 통한 네트워크 구현을 위함인 것 같은데, 내 의도와 이걸 구현하는 하는 이유가 애초에 완전히 달랐던 것이다.
일단은 지연을 고려하지 않고 동작하게 해보라는 교수님들의 조언을 따라야겠다는 생각이 들었다. 그 후에 문제가 생기면 그때 고치면 된다.
클라이언트가 이동을 원할 때 서버쪽에서 움직여주고, 클라이언트에서 그걸 복제하는 식으로 다시 구현해보기로 했다.
그리고, 그렇게 구현해봤더니 맨 처음에 있었던 지터링 현상이 또다시 발생했다.. 델타타임을 사용한 메인로직도 이동 거리를 기준으로 구하도록 바꿔보고, 여러 지터링 관련 해결책도 찾아보고 따라해봤는데 도저히 해결이 안됨..ㅠㅠㅠㅠ
그렇게 삽질하던 와중에 외부 교수님의 멘토링 시간이 다가왔따.. 정말 감사하게도 같이 고민해주셨고, 이걸 제대로 동작하게 하려면 캐릭터 무브먼트 컴포넌트를 다시 만들어야 하는 상황까지 갈 수도 있다고(..) 일단은 캐릭터가 움직일 때 사용하는
AddMovementInput
을 사용해서 해결해보는걸 추천해주셨다.
일단 생각해봤을 때 뭔가 느낌이 좋다. 언리얼이 쓰라고 만들어둔 기능을 쓰는거다보니 다 잘 돌아갈 것 같은 느낌.. 게다가 이걸 사용하면 로컬에서도 바로바로 움직일거고, 그럼 고민했던 문제들이 싹 해결된다! 카메라때도 AddControllerInput 관련 함수를 써서 해결했었는데, 왜 이걸 생각을 못했지? 일단 구현해보자!
void UTrapperPlayerMovementComponent::MagneticMove(float DeltaSeconds)
{
FVector Direction = TargetPosition - GetActorLocation();
CharacterOwner->AddMovementInput(Direction, 1.f);
if (GetActorLocation().Equals(TargetPosition, 10.f))
{
StopMagneticMove();
}
}
우선 이렇게만 넣어주고, 솔루나 시프트를 시작할 때 무브먼트 모드를 MOVE_Flying
으로 바꿔주었다. 최대 속력도 기존 MaxFlySpeed
변수를 수정해서 바꿀 수 있었다.
파란색은 타겟의 위치(액터 위치에 바운딩 박스 절반값과 캐릭터 캡슐의 절반을 더한 값), 빨간색은 캐릭터 액터의 위치이다. 떨어지는건 오차 값을 조금 수정해주면 해결할 수 있을 것 같다! 이제 거리에 따른 가속도랑 애니메이션 넣어줘야지 :)
애니메이션도 다시 만져주고, 기획자님이 원하시는 속력으로 변경하고 나니 역시나 문제가 생겼다. 캐릭터의 움직임에 입력을 추가하여 가속도와 중력을 처리하는 함수다보니, 목적지에 도착하고도 타겟 지점에서 훨씬 벗어났다가 다시 돌아오는 문제가 생겼다.
최대 스피드가 됐을 때 멈추면 가려던 위치보다 훨씬 멀어지게 되고, 점프해서 실행하는 순간 타겟위치를 벗어나게 되면서 다시 돌아와버린다(이것도 원하신 속도의 절반으로 줄인 수치). 다행히 네트워크 관련 문제는 없지만.. 이동 속도나 타겟과의 거리에 따른 입력값 등 여러 수치들을 조절해서 해결하는법 밖에 떠오르질 않는다. Walking 무브먼트 모드의 경우에는 입력을 멈추면 딱 끝나던데, Flying 모드는 그게 아닌건가?
무브먼트 컴포넌트 옵션을 살펴보니 BrakingDeceleration라는 감속 가속도(?)라는 개념이 있었다.
MaxFlySpeed = 7000.f;
BrakingDecelerationFlying = MaxFlySpeed;
캐릭터 무브먼트에서 Flying 관련 스피드와 BrakingDecelerationFlying 수치를 조절해주었다. 이렇게 수치를 같게 설정하면, 바로 멈추는게 가능한 것 같다.
Walking에서는 Max Acceleration과 같은 수치로 되어있어서 왜 그런건가 GPT한테 물어보았다.
수학이나 물리같은 과목에 약한게 게임 개발에서 얼마나 힘든건지 매순간 깨닫고 있음.. 수학공부도 열심히 해야지 ㅠㅠ
FVector2D DeleteZCurrentLocation(CurrentLocation.X, CurrentLocation.Y);
FVector2D DeleteZTargetLocation(TargetPosition.X, TargetPosition.Y);
if (DeleteZCurrentLocation.Equals(DeleteZTargetLocation, 100.f))
{
StopMagneticMove();
}
점프하면서 솔루나 시프트를 실행하면 Z축때문에 타겟지점을 벗어나버리는 상황이 생겨서, X와 Y축만 비교하는 식으로 코드를 바꿔주었다.
타겟지점에 도착했을 때 자성 기둥의 콜라이더와 부딪히면서 미끄러지는 문제는
우선 메시의 콜리전을 평평하게 바꿔주고,
스태틱 메시 편집으로 들어가 기존 콜리전을 제거한 뒤 Simple Box로 바꿔주었다.
다음으로는 솔루나 시프트를 시작할 때와 끝나고 떨어질 때 Velocity 값이 캐릭터를 의도하지 않은 곳으로 움직이게 영향을 주는 문제를 수정해주었다. 솔루나 시프트를 시작할 때는 Velocity = FVector::ZeroVector;
를 이용해 방향 계산에 문제가 없도록 해주었고, 솔루나 시프트가 끝났을 때는 Velocity = FVector(0.f, 0.f, Velocity.Z);
를 넣어주어 자연스럽게 수직낙하 하도록 변경해주었다.
드디어.. 5일간의 여정을 마치고 나서야 네트워크에서 멀쩡하게 굴러가는 솔루나 시프트를 구현해냈다.. 아직 관련해서 개발해야 할 컨텐츠가 산더미지만 일단은 너무너무 만족 ㅠ_ㅠ
생각보다 관련 내용이 너무너무 길어져서, 다음 작업들은 다음 포스팅으로 이어가겠다!