-
[UE5 | FPS] 멀티플레이에서 작동하는 달리기 구현 과정언리얼엔진/FPS 프로젝트 2024. 6. 14. 22:43
캐릭터 무브먼트 컴포넌트를 이용해서 달리기를 구현할때는
MaxWalkSpeed
를 변경하는 방식을 생각할 수 있다.MaxWalkSpeed
를 사용자의 입력에 따라 변경해야하는데, 이 값을 어떻게 변경할지를 결정하기 위해서는 캐릭터 무브먼트 컴포넌트에서MaxWalkSpeed
를 언제, 어떻게 사용하는지를 알아야한다.캐릭터 무브먼트 컴포넌트의 cpp파일에서
MaxWalkSpeed
를 검색해보면GetMaxSpeed()
함수를 찾을 수 있다.GetMaxSpeed()
함수는MovementMode
에 따라서 최대 이동 속도를 리턴해준다.이 함수가 사용되는곳을 찾아 올라가다보면 결국엔
TickComponent()
함수가 나온다.따라서 매번
GetMaxSpeed()
함수를 통해 현재 상태에서의 최대 속도를 구하는걸 알 수 있다.그렇다면 이 함수를 오버라이드해서 달리고있는 상태인지 확인하고, 달리는 중일땐 달리기 속도를 리턴해주면 된다.
다음으로 해야하는 것은 사용자의 입력이나 상황에 따라서 달리기 상태를 변경하는건데 이건 캐릭터의 앉기 상태를 어떻게 변경하는지를 보면 힌트를 쉽게 얻을 수 있다.
앉기는 플레이어 컨트롤러에서도 요청할 수 있고, 캐릭터 무브먼트 컴포넌트까지 전달되어서 앉기 상태를 변경하게 된다.
그리고 사용자가 입력을 했어도 앉기 상태를 변경할 수 있는 상황에만 변경한다.
달리기도 앉기와 같은 방식으로 진행되어야 한다.
따라서 앉기 상태를 변경하는 과정을 그대로 따라가면 달리기도 구현 가능하다.
이렇게 하면 스탠드얼론에서 작동하는 달리기를 구현할 수 있다.
문제는 멀티플레이에서 발생한다.
스탠드얼론에서는 아래처럼 잘 작동하는 달리기가
멀티플레이의 클라이언트에서는 이전 위치로 되돌려져서 걷기 속도와 같아진다.
입력은 클라이언트에서 처리하므로 서버에서는 달리는 중이라는걸 모르고있고, 걷기 속도로 움직인다고 판단하고있다.
그리고 클라이언트에서는 서버가 생각하는 올바른 위치로 되돌려지는 상황이다.
따라서 서버에게 달리는 상태임을 알려줘야한다.
이럴때에는 RPC를 사용할 수 있겠지만, 캐릭터 무브먼트 컴포넌트는 예측과 리플레이를 통해서 위치를 검증하고 보정하는 과정이 있으므로 간단한 RPC로는 위와 동일한 문제가 일시적으로 발생할 가능성이 있다.
대신에 캐릭터 무브먼트 컴포넌트는 자체적으로 서버에 이동 관련 정보를 보내므로 이 방법을 이용하면 기존의 로직에 크게 개입하지 않으면서 원하는 정보를 전달할 수 있다.
서버로 이동 관련 정보를 보내는것도 앉기 상태를 서버로 전달하는 과정을 따라가보면 이해할 수 있다.
그 전에 캐릭터 무브먼트 컴포넌트에서는 이동이 어떤 순서로 처리되는지 알고있으면 더 쉬워진다.
이전에 나름 정리해본 글이 있다.
https://mstone8370.tistory.com/42
[UE5] 캐릭터 무브먼트 컴포넌트 작동 순서 정리
캐릭터의 움직임이 처리되는 순서를 간단하게 정리한것.피직스 시뮬레이션이나 루트 모션 관련은 제외했음.TickComponent()| 서버에 접속한 클라이언트인 경우 서버에서 받은 정보를 통해 위치 업
mstone8370.tistory.com
캐릭터 무브먼트 컴포넌트에서 이동을 처리하는 함수는
ControlledCharacterMove()
함수에서부터 시작된다.이 함수는 매 틱마다 호출되며, 다음으로 호출해야하는
PerformMovement()
를 호출할때까지의 과정을 상황에 따라 다른 방식으로 처리한다.만약 Authority를 가지고있다면 바로
PerformMovement()
를 호출한다.Authority는 없지만 이 캐릭터가 서버에 접속한 클라이언트고 플레이어가 조종하고 있는 캐릭터인 경우에는
ReplicateMoveToServer()
함수를 호출한다.ReplicateMoveToServer()
함수에서도PerformMovement()
를 호출하는데 추가로 이동 정보를 서버에 보내는 작업을 한다.달리고 있는 상황임을 서버에 보낼 때에는 이 과정을 보면 된다.
ReplicateMoveToServer()
함수에서PerformMovement()
를 호출하기 전을 보면FSavedMove_Character
타입의NewMove
라는 변수를 볼 수 있다.FSavedMove_Character
는 예측에 필요한 움직임과 관련된 정보를 모아놓은 클래스다.이 클래스의 값을 통해 서버로 어떤 데이터를 보내야하는지가 결정된다.
그리고 이 클래스에도 앉기와 관련된
bWantsToCrouch
변수를 저장하는걸 확인할 수 있다.앉기 상태를 변경할때는
bWantsToCrouch
의 값이 중요한 요소이므로 클라이언트에서 결정되는 이 값이 서버로 전달된다.bWantsToCrouch
를 통해 앉기 상태를 변경하는 과정은PerformMovement()
함수 내부에서 상태 업데이트를 통해서 적용되니 서버와 클라이언트 두 곳에서 작동하므로 이 값만 전달해주면 된다.참고로 상태 업데이트는
PerformMovement()
함수 내부의UpdateCharacterStateBeforeMovement()
함수에서 진행된다.달리기를 구현할때도
bWantsToSprint
라는 변수를 만들어서 사용했다면FSavedMove_Character
에bWantsToSprint
를 저장하고 전달해주면 서버에서PerformMovement()
함수가 작동하는 동안 상태 업데이트를 통해 달리기 상태를 전환하게 된다.따라서
FSavedMove_Character
의 자식 클래스를 생성해서bWantsToSprint
를 저장하게 했다.// NLCharacterMovementComponent.h /** * 새로운 상태를 추가한 커스텀 SavedMove 클래스 * 이걸 기반으로 한 정보가 서버로 전달됨. */ class FSavedMove_NLCharacter : public FSavedMove_Character { public: typedef FSavedMove_Character Super; virtual void Clear() override; virtual void SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, class FNetworkPredictionData_Client_Character& ClientData) override; uint8 GetCompressedFlags() const override; uint32 bWantsToSprint : 1; };
// NLCharacterMovementComponent.cpp void FSavedMove_NLCharacter::Clear() { Super::Clear(); bWantsToSprint = false; } void FSavedMove_NLCharacter::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, FNetworkPredictionData_Client_Character& ClientData) { Super::SetMoveFor(C, InDeltaTime, NewAccel, ClientData); if (UNLCharacterMovementComponent* NLCMC = Cast<UNLCharacterMovementComponent>(C->GetCharacterMovement())) { bWantsToSprint = NLCMC->bWantsToSprint; } } uint8 FSavedMove_NLCharacter::GetCompressedFlags() const { uint8 Result = Super::GetCompressedFlags(); if (bWantsToSprint) { Result |= FLAG_Custom_0; } return Result; }
오버라이드 해야하는 함수들이 있는데 이건
bWantsToCrouch
변수가 어디서 사용되는지를 찾아보면 알수있다.간단하게 설명하자면
Clear()
함수는 이 구조체를 재사용 하기 위해 값을 초기화하는 함수다.SetMoveFor()
함수는 이동 상태 정보로 클래스 멤버 변수들의 값을 설정하는 함수다.GetCompressedFlags()
함수는 몇몇 이동 정보를 압축된 값으로 리턴해주는 함수다.bWantsToCrouch
값이 여기에서 비트값으로 압축되어서 전달된 뒤에 서버에서 해석하므로bWantsToSprint
값도 여기에서 압축해서 전달해주기 위해 오버라이드한다.이 Flag에 사용할 수 있는 정보는 아래와 같다.
// Bit masks used by GetCompressedFlags() to encode movement information. 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, };
FLAG_Custom_0
에bWantsToSprint
값을 사용했다.이렇게 전달된 Flag를 통해 캐릭터 무브먼트 컴포넌트에서 값을 업데이트해야한다.
이러한 작업을 하는 함수는 캐릭터 무브먼트 컴포넌트의
UpdateFromCompressedFlags()
함수다.따라서 이 함수도 오버라이드해서
bWantsToSprint
값을 설정하게 한다.// NLCharacterMovementComponent.h class NL_GAS_API UNLCharacterMovementComponent : public UCharacterMovementComponent { // ... protected: // 클라이언트에서 받은 Flag를 통해 상태 업데이트 virtual void UpdateFromCompressedFlags(uint8 Flags) override; } // NLCharacterMovementComponent.cpp void UNLCharacterMovementComponent::UpdateFromCompressedFlags(uint8 Flags) { Super::UpdateFromCompressedFlags(Flags); bWantsToSprint = ((Flags & FSavedMove_Character::FLAG_Custom_0) != 0); }
이렇게 구현된
FSavedMove_NLCharacter
를 사용하기 위해선ReplicateMoveToServer()
함수를 다시 봐서NewMove
변수가 어디에서 왔는지를 봐야한다.// CharacterMovementComponent.cpp void UCharacterMovementComponent::ReplicateMoveToServer(float DeltaTime, const FVector& NewAcceleration) { // ... FNetworkPredictionData_Client_Character* ClientData = GetPredictionData_Client_Character(); if (!ClientData) { return; } // ... // Get a SavedMove object to store the movement in. FSavedMovePtr NewMovePtr = ClientData->CreateSavedMove(); FSavedMove_Character* const NewMove = NewMovePtr.Get(); if (NewMove == nullptr) { return; } NewMove->SetMoveFor(CharacterOwner, DeltaTime, NewAcceleration, *ClientData); // ... }
ClientData
라는FNetworkPredictionData_Client_Character
클래스에서 생성하는걸 확인할 수 있다.그리고 이
ClientData
는 캐릭터 무브먼트 컴포넌트의 함수인GetPredictionData_Client_Character()
함수를 통해 받아온다.따라서
FSavedMove_NLCharacter
를 리턴해주기 위해FNetworkPredictionData_Client_Character
의 자식 클래스를 생성해야하며, 이걸 받아오기 위해서 캐릭터 무브먼트 컴포넌트의GetPredictionData_Client_Character()
함수도 오버라이드 해야한다.하지만
GetPredictionData_Client_Character()
함수는 virtual 함수가 아닌데, 이 함수의 코드를 보면 새로운FNetworkPredictionData_Client_Character
인스턴스를 생성하는 함수인GetPredictionData_Client()
함수가 따로 있는걸 확인할 수 있다.GetPredictionData_Client()
함수가 virtual이므로 이 함수를 오버라이드해서 사용하면 된다.// NLCharacterMovementComponent.h class NL_GAS_API UNLCharacterMovementComponent : public UCharacterMovementComponent { // ... public: // 커스텀 PredictionData를 리턴하도록 수정함 virtual class FNetworkPredictionData_Client* GetPredictionData_Client() const override; } /** * 클라이언트에서 사용되는 Prediction 데이터 * 여기에서 SavedMove를 관리함 */ class FNetworkPredictionData_Client_NLCharacter : public FNetworkPredictionData_Client_Character { public: typedef FNetworkPredictionData_Client_Character Super; FNetworkPredictionData_Client_NLCharacter(const UCharacterMovementComponent& ClientMovement); virtual FSavedMovePtr AllocateNewMove() override; };
// NLCharacterMovementComponent.cpp FNetworkPredictionData_Client* UNLCharacterMovementComponent::GetPredictionData_Client() const { if (ClientPredictionData == nullptr) { UNLCharacterMovementComponent* MutableThis = const_cast<UNLCharacterMovementComponent*>(this); MutableThis->ClientPredictionData = new FNetworkPredictionData_Client_NLCharacter(*this); } return ClientPredictionData; } FNetworkPredictionData_Client_NLCharacter::FNetworkPredictionData_Client_NLCharacter(const UCharacterMovementComponent& ClientMovement) : Super(ClientMovement) {} FSavedMovePtr FNetworkPredictionData_Client_NLCharacter::AllocateNewMove() { return FSavedMovePtr(new FSavedMove_NLCharacter()); }
이제 예측 데이터로 여기에서 구현한
FSavedMove_NLCharacter
가 사용되고, 클라이언트의 입력으로 결정된bWantsToSprint
값이 Flag로 저장되어서 서버로 전달된다.서버에서는 Flag를 통해 값을 업데이트하고,
PerformMovement()
함수 내부의UpdateCharacterStateBeforeMovement()
함수를 통해 서버에서도 달리기 상태가 결정된다.
이렇게 해서 멀티플레이에서도 작동하는 달리기를 구현했다.
뭔가 복사 붙혀넣기로 가득한 과정이지만, 캐릭터 무브먼트 컴포넌트의 전체적인 흐름을 이해하는데에 의미가 있을듯 하다.
추가로 네트워크 에뮬레이션으로 상태가 안좋은 서버를 시뮬레이션해서 테스트해봤다.
총을 발사한게 늦게 적용되는걸 확인할 수 있다.
이런 환경에서도 기본으로 제공되는 예측 시스템 덕분에 조작감의 불편함이 최소화 된다.
'언리얼엔진 > FPS 프로젝트' 카테고리의 다른 글
[UE5 | FPS] 서버와 클라이언트의 동기화를 위한 시행착오 (0) 2024.08.13 [UE5 | FPS] 진행 사항 정리 (0) 2024.08.12 [UE5 | FPS] FGameplayEffectContext의 자식 구조체로 데미지 관련 정보 전달 (2) 2024.06.05 [UE5 | FPS] 히트 박스를 관리하기 위한 에디터 유틸리티 (1) 2024.06.02 [UE5 | FPS] 수평 FOV와 수직 FOV, 그리고 뷰모델 (0) 2024.05.08