[UE5] Character Movement Component Series : Setup & Sprinting

연하·2024년 9월 22일
0

Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요!
https://youtu.be/17D4SzewYZ0?si=aBG-uYJkNECFkftK

Create Custom CMC

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "NyongCharacterMovementComponent.generated.h"

/**
 * 
 */
UCLASS()
class MOVEMENTSTUDY_API UNyongCharacterMovementComponent : public UCharacterMovementComponent
{
	GENERATED_BODY()
	
};

먼저 캐릭터 무브먼트 컴포넌트를 상속받은 커스텀 무브먼트를 만들어주자. 그 다음, bool Safe_bWantsToSprint 라는 변수를 선언한다. 클라이언트가 Shift 키를 누를 때마다 플래그가 설정될 것이다. 클라이언트 업데이트가 호출되면 모든 것이 복제되고 적절하게 준비된다. (rubber 현상같은 것이 일어나지 않을 것이라는 뜻) 이제 우리가 만들어야 하는 두 개의 도우미 클래스가 있다.

Saved Move Class

UCLASS()
class MOVEMENTSTUDY_API UNyongCharacterMovementComponent : public UCharacterMovementComponent
{
	GENERATED_BODY()
	
	class FSavedMove_Nyong : public FSavedMove_Character
	{
		typedef FSavedMove_Character Super;

		uint8 Saved_bWantsToSprints : 1;

		virtual bool CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter, float MaxDelta) const override;
		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;
	};

	bool Safe_bWantsToSprint;
};

첫번째 클래스는 Saved Move이다. 무브먼트 컴포넌트의 모든 상태 데이터 스냅샷을 저장한다. 이 스냅샷은 단일 프레임에 대한 이동을 생성하는데 필요하다. 그런 다음, 서버가 정확한 동작을 수행할 수 있도록 압축된 버전을 서버에 복제한다.

Safe_bWantsToSprint 변수는 우리가 스프린트를 원하는지의 여부를 나타낸다. 이 변수는 중요하기 때문에 saved move에 저장해야 한다. 기본 클래스를 재정의해야 하며, 여기에 추가해야 할 것은 저장 될 단일 변수 뿐이다.

Safe_bWantsToSprint 는 작동 변수이다. 우리가 설정하고 확인할 것이며, 이 변수의 값을 기반으로 논리를 적용할 것이다.

Saved_bWantsToSprintsSafe_bWantsToSprint 값으로 자동으로 업데이트 된다. new saved move를 생성하고, safe move를 실행해야 할 때마다 변수가 복사된다.

이제 오버라이드한 함수를 구현하면서, 어떻게 동작하는지 살펴보자.

CanCombineWith

bool UNyongCharacterMovementComponent::FSavedMove_Nyong::CanCombineWith(const FSavedMovePtr& NewMove, ACharacter* InCharacter, float MaxDelta) const
{
	FSavedMove_Nyong* NewNyongMove = static_cast<FSavedMove_Nyong*>(NewMove.Get());

	if (Saved_bWantsToSprints != NewNyongMove->Saved_bWantsToSprints)
	{
		return false;
	}

	return FSavedMove_Character::CanCombineWith(NewMove, InCharacter, MaxDelta);
}

기본적으로 두 개의 동작을 확인하는 함수이다. 현재의 움직임과 새로운 움직임을 확인하고, 결합할 수 있는지 확인한다. 대역폭을 절약하기 위해서이다.

이 방법의 기본 아이디어는 saved move의 모든 데이터가 거의 동일한지의 여부를 기반으로, 동일하다면 true를 반환한다. 두 번의 움직임을 보낼 필요 없이, 서버에게 두번 실행하라고 요청한다.

먼저 NewMove를 캐스팅하여 커스텀 클래스에 저장한 다음, 현재 Saved_bWantsToSprints 변수가 NewMove 안의 변수와 동일하지 않은지 확인한다. 만약 다르다면, 동작은 정확히 동일하지 않으며 결합할 수 없으므로 false를 리턴한다. 두 값이 동일하다면, Super 함수에서 결합할 수 있는지를 확인하도록 한다.

Clear

void UNyongCharacterMovementComponent::FSavedMove_Nyong::Clear()
{
	FSavedMove_Character::Clear();

	Saved_bWantsToSprints = 0;
}

Clear() 함수에서는 Super 함수를 먼저 실행해 기존 값들을 초기화해주고, 추가해준 변수도 초기화해주자.

GetCompressedFlags

uint8 UNyongCharacterMovementComponent::FSavedMove_Nyong::GetCompressedFlags() const
{
	uint8 Result = Super::GetCompressedFlags();

	if (Saved_bWantsToSprints)
	{
		Result |= FLAG_Custom_0;
	}

	return Result;
}

압축 플래그는 실제로 클라이언트가 이동 데이터를 복제하는 방법이다. 디폴트 SavedMove 안에는 수많은 변수가 있는데, 매번 이 모든 것을 서버에 복제하는 것은 분명히 비쌀 것이다. 우리는 매 프레임마다 이동을 수행하므로, 모두 보내는 대신 매우 최소한으로 압축된 버전을 보내게 된다.

압축된 플래그는 기본적으로 8개이므로 uint8을 반환하고, 압축된 플래그를 사용하여 원하는 모든 것을 할 수 있지만, 일반적으로 각 플래그는 키 누름과 관련되어 있다. 실제로 기본 값을 보면, 점프 키를 누르면 점프 플래그를 플립하는 식이다.

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,
};

우리는 Custom 이라고 적힌 4가지의 플래그를 이용할 수 있다. 압축된 플래그는 클라이언트에서 서버로 전송된다.

SetMoveFor

void UNyongCharacterMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, FNetworkPredictionData_Client_Character& ClientData)
{
	FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);

	UNyongCharacterMovementComponent* CharacterMovement = Cast<UNyongCharacterMovementComponent>(C->GetCharacterMovement());
	Saved_bWantsToSprints = CharacterMovement->Safe_bWantsToSprint;
}

CMC의 현재 스냅샷에 저장된 이동을 설정한다. Super 함수를 실행하고, 현재 캐릭터의 무브먼트 컴포넌트를 가져온 뒤, Saved_bWantsToSprints 변수를 Safe_bWantsToSprint 값과 동일하게 설정해준다.

PrepMoveFor

void UNyongCharacterMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
	Super::PrepMoveFor(C);
	UNyongCharacterMovementComponent* CharacterMovement = Cast<UNyongCharacterMovementComponent>(C->GetCharacterMovement());
	CharacterMovement->Safe_bWantsToSprint = Saved_bWantsToSprints;
}

PrepMove는 방금 한 것의 정확히 반대 기능을 한다. Saved Move의 데이터를 가져와 CMC의 현재 상태에 적용한다.

Client Predicition Data

class FNyongNetworkPredictionData_Client : public FNetworkPredictionData_Client_Character
{
	typedef FNetworkPredictionData_Client_Character Super;
public:
	FNyongNetworkPredictionData_Client(const UCharacterMovementComponent& ClientMovement);
	virtual FSavedMovePtr AllocateNewMove() override;
};

이제 Default Character Move를 사용하지 않을 것임을 캐릭터 컴포넌트에 알려야 한다. 커스텀 Save move를 사용하고 이를 수행하는 방법은 network prediction data client 라는 다른 클래스를 통해 이루어지며 이전과 마찬가지로 커스텀 버전으로 만들어야 한다.

UNyongCharacterMovementComponent::FNyongNetworkPredictionData_Client::FNyongNetworkPredictionData_Client(const UCharacterMovementComponent& ClientMovement)
	: Super(ClientMovement)
{
}

FSavedMovePtr UNyongCharacterMovementComponent::FNyongNetworkPredictionData_Client::AllocateNewMove()
{
	return FSavedMovePtr(new FSavedMove_Nyong());
}

생성자에서는 Super 함수를 호출하기만 하면 되고, AllocateNewMove() 안에서는 새로 만든 Saved Move를 할당하면 된다.

이제 CMC에서 방금 만든 클래스를 사용하고 싶다는 것을 알리기 위해 실제 캐릭터 무브먼트 컴포넌트에 몇 가지 함수를 구현해야 한다.

virtual FNetworkPredictionData_Client* GetPredictionData_Client() const override;
FNetworkPredictionData_Client* UNyongCharacterMovementComponent::GetPredictionData_Client() const
{
	check(PawnOwner != nullptr)

	if (ClientPredictionData == nullptr)
	{
		UNyongCharacterMovementComponent* MutableThis = const_cast<UNyongCharacterMovementComponent*>(this);

		MutableThis->ClientPredictionData = new FNyongNetworkPredictionData_Client(*this);
		MutableThis->ClientPredictionData->MaxSmoothNetUpdateDist = 92.f;
		MutableThis->ClientPredictionData->NoSmoothNetUpdateDist = 140.f;
	}

	return ClientPredictionData;
}

먼저 ClientPredictionData 가 null인지 확인하고, 없다면 만들어서 반환하고 있다면 이미 가지고 있는 것을 반환한다. 이 값은 클라이언트 예측의 캐시된 값이다. GetPredictionData_Client() 함수는 기본적으로 Getter 이기 때문에 const 함수이다. 하지만 클라이언트 예측 데이터가 null 이라면 우리는 그것을 만들어야 하므로, const_cast를 해준다.
새로운 FNyongNetworkPredictionData_Client 를 만들어 주자. 밑의 두 줄은, 클라이언트 예측 데이터에 대한 두 개의 매개변수일 뿐이다.

protected:
	virtual void UpdateFromCompressedFlags(uint8 Flags) override;
void UNyongCharacterMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
	Super::UpdateFromCompressedFlags(Flags);

	Safe_bWantsToSprint = (Flags & FSavedMove_Character::FLAG_Custom_0) != 0;
}

해당 플래그를 기반으로 CMC의 상태를 설정한다.

Recap

이 모든 설정들이 movement safe한 변수를 생성하고 활용하는 데 필요한 모든 종류의 인프라이므로, 앞으로 일어날 일에 대한 파이프라인을 살펴보자.

먼저, 틱에서 모든 이동 로직을 실행하는 Perform Move를 호출하고, Safe_bWantsToSprint 변수를 활용한다는 내용을 읽고, 안전한 saved move를 생성한 뒤, Set Move를 통해 변수를 읽어낸 후, 이를 Saved_bWantsToSprints 에 저장한다. 그 다음, CanCombineWith 함수를 사용하여 서버로 전송될 대기 중인 이동이 있는지 확인하고 필요하면 결합한 다음, GetCompressedFlags 를 호출하여 저장된 이동을 서버에 보낼 수 있는 작은 네트워크 가능 패킷으로 줄인 다음 서버에게 전송한다.

서버가 이를 수신하면, UpdateFromCompressedFlags 를 호출하여 값을 읽어와 Safe_bWantsToSprint 변수 값을 갱신한 후, 클라이언트가 수행한 이동을 수행한다.

Sprint Implementation

public:
	UFUNCTION(BlueprintCallable) void SprintPressed();
	UFUNCTION(BlueprintCallable) void SprintReleased();

스프린팅을 구현하기 위해 정의한 이 두가지 함수는, safe move flag를 토글하기만 한다. 무브먼트 컴포넌트의 non-safe 한 컨텍스트에서 호출되고 있는데, 이게 안전한 이동의 속성을 손상시키지 않을까? 여기서 핵심은
클라이언트에서만 이러한 함수를 호출할 수 있다는 것이다. 클라이언트의 non-safe movement 함수에서 safe 변수를 바꿀 수 있다는 것이 혼란스러울 수 있다. non-safe movement 함수에서 safe 변수를 절대 활용할 수 없으며, 서버에서 safe 변수를 변경하는 non-safe movement 함수를 호출할 수 없다.

이제 완전히 움직임에 안전하며, 클라이언트는 스프린트 pressed 또는 release를 호출하여 이 변수의 값을 토글할 수 있다.

virtual void OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity) override;

OnMovementUpdated 함수는 모든 이동 수행이 끝날 때 자동으로 호출되는 편리한 함수이며, 어떤 이동 모드에 관계 없이 실행되는 일부 이동 로직을 작성할 수 있게 한다.

UPROPERTY(EditDefaultsOnly) float Sprint_MaxWalkSpeed;
UPROPERTY(EditDefaultsOnly) float Walk_MaxWalkSpeed;

블루프린트에서 편집할 수 있는 두가지 변수를 선언해주자. 스프린트를 사용했을 때 캐릭터가 얼마나 빨리 움직이는지를 결정한다.

void UNyongCharacterMovementComponent::OnMovementUpdated(float DeltaSeconds, const FVector& OldLocation, const FVector& OldVelocity)
{
	Super::OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);

	if (MovementMode == MOVE_Walking)
	{
		if (Safe_bWantsToSprint)
		{
			MaxWalkSpeed = Sprint_MaxWalkSpeed;
		}
		else
		{
			MaxWalkSpeed = Walk_MaxWalkSpeed;
		}
	}
}

이렇게 OnMovementUpdated 함수를 구현해주면, 우리가 MOVE_Walking 에 있는지 확인하고, Safe_bWantsToSprint 변수의 값에 따라 최대값을 설정하는 역할을 한다.

Setup Character for new CMC

protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = Movement)
	class UNyongCharacterMovementComponent* NyongMovement;
    
public:
	AMovementStudyCharacter(const FObjectInitializer& ObejctInitializer);

이제 캐릭터 클래스에 커스텀 무브먼트 변수를 추가해주고, 기본 생성자를 변경해주자. FObjectInitializer 를 사용하면, 커스텀 CMC를 재정의 할 수 있다.

AMovementStudyCharacter::AMovementStudyCharacter(const FObjectInitializer& ObejctInitializer)
	: Super(ObejctInitializer.SetDefaultSubobjectClass<UNyongCharacterMovementComponent>(ACharacter::CharacterMovementComponentName))
{
	NyongMovement = Cast<UNyongCharacterMovementComponent>(GetCharacterMovement());
    
// 생략

그리고 이렇게 구현해주면 된다.

Testing

캐릭터 블루프린트로 이동해 이렇게 구현해주고, 스프린트 스피드를 설정해주고 테스트해보자.

PktLag 를 사용하여 극단적인 지연을 테스트해볼 수 있다.

0개의 댓글