Character Movement Component In-Depth 강의 시리즈를 공부하면서 한글로 정리한 포스트입니다. 의역과 오역이 난무하니 주의해주세요! https://youtu.be/j45CUV9lWTA?si=nsePEHZSnSmg8hMK
UENUM(BlueprintType)
enum ECustomMovementMode
{
CMOVE_None UMETA(Hidden),
CMOVE_Slide UMETA(DisplayName = "Slide"),
CMOVE_Prone UMETA(DisplayName = "Prone"),
CMOVE_MAX UMETA(Hidden),
};
새로운 Custom Movement Mode를 추가해주고,
/// Prone
UPROPERTY(EditDefaultsOnly) float ProneEnterHoldDuration = .2f;
UPROPERTY(EditDefaultsOnly) float ProneSlideEnterImpulse = 300.f;
UPROPERTY(EditDefaultsOnly) float MaxProneSpeed = 300.f;
UPROPERTY(EditDefaultsOnly) float BrakingDecelerationProning = 2500.f;
// Prone
private:
void EnterProne(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode);
void ExitProne();
bool CanProne() const;
void PhysProne(float deltaTime, int32 Iterations);
Prone과 관련된 변수와 함수를 선언해주자.
전체 구현부는 저자의 깃허브에서 확인하실 수 있습니다!
https://github.com/delgoodie/Zippy
PhysProne
의 경우, PhysWalking
의 코드와 굉장히 유사하게 구현했다. Walking과 Prone은 기본적으로 이동 속도의 변화일 뿐이고, 동작 자체가 매우 유사하다. 이번 튜토리얼에서는 서브 스테핑이 어떻게 작동하는지에 중점을 둘 예정이다.
우리는 서브 스테핑을 할 것이며, 서브 스테핑이 의미하는 것은 한 프레임에서 여러번 반복하는 것이다. 따라서 프레임이 주어지고, 프레임은 특정 델타 타임으로 구성되어 있다. 한 프레임 안에서 여러번 반복하며, 더 정확하고 움직임에 대해 더 높은 해상도의 시뮬레이션을 할 수 있다.
float remainingTime = deltaTime;
while문에 들어가기 전, remainingTime에 deltaTime을 넣어준다.
while ((remainingTime >= MIN_TICK_TIME) &&
(Iterations < MaxSimulationIterations) &&
CharacterOwner && (CharacterOwner->Controller || bRunPhysicsWithNoController || (CharacterOwner->GetLocalRole() == ROLE_SimulatedProxy)))
서브 스테핑에 들어가기 위한 조건문이다. 최소 틱 시간보다 남은 시간이 더 많은지 확인하고, 반복이 최대 시뮬레이션 반복보다 적을때(무한 루프에 들어가는 것을 방지하기 위함), 그리고 밑의 조건들을 만족할 때 서브 스테핑을 실행한다.
Iterations++;
bJustTeleported = false;
const float timeTick = GetSimulationTimeStep(remainingTime, Iterations);
remainingTime -= timeTick;
텔레포트를 사용하지 않기 때문에 bJustTeleported
를 false로 설정하고, GetSimulationTimeStep
함수를 이용해 틱 시간을 얻는다.
// Save current values
UPrimitiveComponent * const OldBase = GetMovementBase();
const FVector PreviousBaseLocation = (OldBase != NULL) ? OldBase->GetComponentLocation() : FVector::ZeroVector;
const FVector OldLocation = UpdatedComponent->GetComponentLocation();
const FFindFloorResult OldFloor = CurrentFloor;
모든 현재 값에 대해 저장된 값을 만들어준다. 이렇게 하는 이유는, 방금 계산한 이동을 되돌려야 할 수도 있기 때문이다.
// Ensure velocity is horizontal.
MaintainHorizontalGroundVelocity();
const FVector OldVelocity = Velocity;
Acceleration.Z = 0.f;
// Apply acceleration
CalcVelocity(timeTick, GroundFriction, false, GetMaxBrakingDeceleration());
MaintainHorizontalGroundVelocity
는 속도를 수평으로 유지하는 데 매우 유용한 함수이다. 우리는 지면에 있을 수도 있고, 경사면에 있을 수도, 평평하지 않은 평면에 있을 수도 있지만 속도는 평면에 수평으로 유지된다. 주로 지상 이동 모드에서 도움이 되는 함수이다. Acceleration
은 입력 벡터라는 것을 잊지 말자. 아무튼, Z값을 0으로 변경하여 위 또는 아래로 가속할 수 없게 한다.
마찰은 항상 캡슐에 적용되므로, 캡슐이 움직일 때 마찰이 가해지며 속도가 느려진다. 그렇기 때문에 무한히 가속할 수 없다. 우리는 캐릭터가 걸어다니다가 키를 놓았을 때 곧바로 멈추기를 원한다. 하지만, 이것을 위해 마찰을 높이게 되면 캐릭터의 이동속도가 정말 느려질 것이고, 슬라이드와 같은 이동 모드가 제대로 구현되지 않을 수 있다. 따라서 존재하는 것이 BrakingDeceleration
인데, 이것은 키를 놓았을때와 같이 어떤 움직임도 적용하지 않을 때 적용되는 값이다. 이 값을 이용해 빠르게 멈출 수 있다.
커스텀 무브먼트 모드의 경우 GetMaxBrakingDeceleration()
함수를 사용하면 0을 반환하므로, 함수를 오버라이드 하여 해당 이동 모드의 감속도를 반환해주어야 한다.
// Compute move parameters
const FVector MoveVelocity = Velocity;
const FVector Delta = timeTick * MoveVelocity; // dx = v * dt
const bool bZeroDelta = Delta.IsNearlyZero();
FStepDownResult StepDownResult;
if ( bZeroDelta )
{
remainingTime = 0.f;
}
else
{
MoveAlongFloor(MoveVelocity, timeTick, &StepDownResult);
if ( IsFalling() )
{
// pawn decided to jump up
const float DesiredDist = Delta.Size();
if (DesiredDist > KINDA_SMALL_NUMBER)
{
const float ActualDist = (UpdatedComponent->GetComponentLocation() - OldLocation).Size2D();
remainingTime += timeTick * (1.f - FMath::Min(1.f,ActualDist/DesiredDist));
}
StartNewPhysics(remainingTime,Iterations);
return;
}
else if ( IsSwimming() ) //just entered water
{
StartSwimming(OldLocation, OldVelocity, timeTick, remainingTime, Iterations);
return;
}
}
만약 속도가 0이면 캡슐이 어디로도 움직이지 않으므로 서브스테핑이 필요하지 않게된다. 따라서 시뮬레이션을 서두르기 위해 남은 시간을 즉시 0으로 설정한다.
속도가 0이 아니라면, MoveAlongFloor
함수를 이용해 바닥을 따라 이동한다. 이 함수 또한 매우 유용한 헬퍼 함수인데, 이 함수는 컴포넌트를 안전하게 움직여준다. 이름에서 알 수 있듯 바닥을 따라 움직이며, 이것은 정말 도움이 된다. 다양한 각도의 경사면에서 움직일 수 있고, 각각의 속도와 시간 틱을 가질 수 있으며, StepDownResult 이라는 결과값이 반환되기 때문이다. 현재 있는 표면과 이동중인 표면을 아는 것은 정말 중요하다. 캡슐이 계단과 같은 표면에서 움직인다고 상상해보자. 그 표면은 항상 변하기 때문에, 우리는 그에 따라 처리해주어야 한다. StepDownResult를 통해 변화된 표면을 알 수 있다.
이 줄 자체가 실제로 움직임이 일어나는 곳이며, 나머지는 움직임을 정리하는 코드들이다. 첫번째는 움직이다가 떨어졌을 때의 경우이다. 움직이고 있다가 바닥이 없는 곳으로 떨어지면, 우리는 기본적으로 falling 모드에 들어가게 될 것이다. 여기서 볼 수 있는 멋진 것 중 하나는, 남은 시간과 반복을 사용하여 StartNewPhysics
를 실행한다는 것이다. 새로운 물리 시뮬레이션을 전체 델타 타임이 아니라 우리가 가지고 있는 남은 델타 타임을 사용하여 시뮬레이션 중간에 이동 모드를 전환할 수 있게 된다. StartSwimming
의 경우에도, 같은 경우를 설정한다. 이러한 코드들을 사용해, 걷다가 다른 이동 모드로 전환되는 경우를 처리할 수 있게 된다.
// Update floor.
// StepUp might have already done it for us.
if (StepDownResult.bComputedFloor)
{
CurrentFloor = StepDownResult.FloorResult;
}
else
{
FindFloor(UpdatedComponent->GetComponentLocation(), CurrentFloor, bZeroDelta, NULL);
}
StepDownResult.bComputedFloor
가 true라면, 캐릭터가 아래로 이동할 때 이미 바닥 정보를 계산했다는 뜻이므로 CurrentFloor
에 현재 바닥 정보를 대입해주고,
false 라면 새로운 바닥 정보를 계산한다.
// check for ledges here
const bool bCheckLedges = !CanWalkOffLedges();
if ( bCheckLedges && !CurrentFloor.IsWalkableFloor() )
{
// calculate possible alternate movement
const FVector GravDir = FVector(0.f,0.f,-1.f);
const FVector NewDelta = bTriedLedgeMove ? FVector::ZeroVector : GetLedgeMove(OldLocation, Delta, GravDir);
if ( !NewDelta.IsZero() )
{
// first revert this move
RevertMove(OldLocation, OldBase, PreviousBaseLocation, OldFloor, false);
// avoid repeated ledge moves if the first one fails
bTriedLedgeMove = true;
// Try new movement direction
Velocity = NewDelta/timeTick; // v = dx/dt
remainingTime += timeTick;
continue;
}
else
{
// see if it is OK to jump
// @todo collision : only thing that can be problem is that oldbase has world collision on
bool bMustJump = bZeroDelta || (OldBase == NULL || (!OldBase->IsQueryCollisionEnabled() && MovementBaseUtility::IsDynamicBase(OldBase)));
if ( (bMustJump || !bCheckedFall) && CheckFall(OldFloor, CurrentFloor.HitResult, Delta, OldLocation, remainingTime, timeTick, Iterations, bMustJump) )
{
return;
}
bCheckedFall = true;
// revert this move
RevertMove(OldLocation, OldBase, PreviousBaseLocation, OldFloor, true);
remainingTime = 0.f;
break;
}
}
else
{
// Validate the floor check
if (CurrentFloor.IsWalkableFloor())
{
AdjustFloorHeight();
SetBase(CurrentFloor.HitResult.Component.Get(), CurrentFloor.HitResult.BoneName);
}
else if (CurrentFloor.HitResult.bStartPenetrating && remainingTime <= 0.f)
{
// The floor check failed because it started in penetration
// We do not want to try to move downward because the downward sweep failed, rather we'd like to try to pop out of the floor.
FHitResult Hit(CurrentFloor.HitResult);
Hit.TraceEnd = Hit.TraceStart + FVector(0.f, 0.f, MAX_FLOOR_DIST);
const FVector RequestedAdjustment = GetPenetrationAdjustment(Hit);
ResolvePenetration(RequestedAdjustment, Hit, UpdatedComponent->GetComponentQuat());
bForceNextFloorCheck = true;
}
// check if just entered water
if ( IsSwimming() )
{
StartSwimming(OldLocation, Velocity, timeTick, remainingTime, Iterations);
return;
}
// See if we need to start falling.
if (!CurrentFloor.IsWalkableFloor() && !CurrentFloor.HitResult.bStartPenetrating)
{
const bool bMustJump = bJustTeleported || bZeroDelta || (OldBase == NULL || (!OldBase->IsQueryCollisionEnabled() && MovementBaseUtility::IsDynamicBase(OldBase)));
if ((bMustJump || !bCheckedFall) && CheckFall(OldFloor, CurrentFloor.HitResult, Delta, OldLocation, remainingTime, timeTick, Iterations, bMustJump) )
{
return;
}
bCheckedFall = true;
}
}
[*TODO] 32:41 - 42:02 Ledges와 관련된 구현 로직은 추후에 정리하겠습니다 :)
여기서 가장 중요한 부분은 서브 스테핑이다. 더 작은 델타 타임으로 반복을 수행하면 더 정확한 시뮬레이션을 얻을 수 있다는 것을 기억하자.
bool UNyongMovementComponent::CanProne() const
{
return IsCustomMovementMode(CMOVE_Slide) || IsMovementMode(MOVE_Walking) && IsCrouching();
}
슬라이드하고 있거나, 웅크리고 있을 때 Prone 모드에 들어갈 수 있다.
void UNyongMovementComponent::EnterProne(EMovementMode PrevMode, ECustomMovementMode PrevCustomMode)
{
bWantsToCrouch = true;
if (PrevMode == MOVE_Custom && PrevCustomMode == CMOVE_Slide)
{
Velocity += Velocity.GetSafeNormal2D() * ProneSlideEnterImpulse;
}
FindFloor(UpdatedComponent->GetComponentLocation(), CurrentFloor, true, NULL);
}
여기서 FindFloor
를 호출하는 이유는 Enter
함수 이후에 MoveAlongFloor
함수가 바로 호출되기 때문이다. MoveAlongFloor
의 가장 첫 줄을 보면, 현재 바닥이 걸을 수 있는지의 여부를 확인하고 그렇지 않다면 return 해버린다. 만약 갱신하지 않고 무브먼트 모드를 변경한다면, 첫번째 서브 스테핑 단계에서 이동이 실패하고 바닥이 갱신되므로, 첫번째 서브스테핑을 성공시키기 위해 함수를 호출한 것이다.
bool UNyongMovementComponent::IsMovingOnGround() const
{
return Super::IsMovingOnGround() || IsCustomMovementMode(CMOVE_Slide) || IsCustomMovementMode(CMOVE_Prone);
}
Prone도 지상에서 움직이는 이동 모드이므로, IsMovingOnGround()
함수에 추가해준다.
void UNyongMovementComponent::PhysCustom(float deltaTime, int32 Iterations)
{
Super::PhysCustom(deltaTime, deltaTime);
switch (CustomMovementMode)
{
case CMOVE_Slide:
PhysSlide(deltaTime, Iterations);
break;
case CMOVE_Prone:
PhysProne(deltaTime, Iterations);
break;
default:
UE_LOG(LogTemp, Fatal, TEXT("Invalid Movement Mode"));
}
}
PhysCustom
함수에 PhysProne
을 호출해주는 케이스문을 추가해준다.
virtual void OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode) override;
void UNyongMovementComponent::OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode)
{
Super::OnMovementModeChanged(PreviousMovementMode, PreviousCustomMode);
if (PreviousMovementMode == MOVE_Custom && PreviousCustomMode == CMOVE_Slide) ExitSlide();
if (PreviousMovementMode == MOVE_Custom && PreviousCustomMode == CMOVE_Prone) ExitProne();
if (IsCustomMovementMode(CMOVE_Slide)) EnterSlide(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
if (IsCustomMovementMode(CMOVE_Prone)) EnterProne(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
}
OnMovementModeChanged
함수를 오버라이드하고 구현해준다. 이부분은 강의 이후 수정된 내용이므로, 뒷부분에서 설명한다.
virtual float GetMaxSpeed() const override;
virtual float GetMaxBrakingDeceleration() const override;
float UNyongMovementComponent::GetMaxSpeed() const
{
if (IsMovementMode(MOVE_Walking) && Safe_bWantsToSprint && !IsCrouching()) return MaxSprintSpeed;
if (MovementMode != MOVE_Custom) return Super::GetMaxSpeed();
switch (CustomMovementMode)
{
case CMOVE_Slide:
return MaxSlideSpeed;
case CMOVE_Prone:
return MaxProneSpeed;
default:
UE_LOG(LogTemp, Fatal, TEXT("Invalid Movement Mode"))
return -1.f;
}
}
float UNyongMovementComponent::GetMaxBrakingDeceleration() const
{
if (MovementMode != MOVE_Custom) return Super::GetMaxBrakingDeceleration();
switch (CustomMovementMode)
{
case CMOVE_Slide:
return BrakingDecelerationSliding;
case CMOVE_Prone:
return BrakingDecelerationProning;
default:
UE_LOG(LogTemp, Fatal, TEXT("Invalid Movement Mode"))
return -1.f;
}
}
두 개의 함수를 오버라이드 해주자.
이전 강의에서는 compressed flag를 사용했지만, prone의 경우는 sprint와 다르게 일회성 이벤트이기 때문에 prone을 하고 있는지 여부를 서버에 매 프레임마다 알릴 필요가 없기 때문에 사용할 필요가 없다. 이번 강의에서는 RPC를 이용해 트리거할 예정이다.
uint8 Saved_bWantsToProne : 1;
bool Safe_bWantsToProne;
커스텀 SavedMove 구조체에 Saved 변수를 선언해주고, 무브먼트 컴포넌트 안에 Safe 변수를 선언해주자.
void UNyongMovementComponent::FSavedMove_Nyong::SetMoveFor(ACharacter* C, float InDeltaTime, FVector const& NewAccel, FNetworkPredictionData_Client_Character& ClientData)
{
FSavedMove_Character::SetMoveFor(C, InDeltaTime, NewAccel, ClientData);
UNyongMovementComponent* CharacterMovement = Cast<UNyongMovementComponent>(C->GetCharacterMovement());
Saved_bWantsToSprints = CharacterMovement->Safe_bWantsToSprint;
Saved_bPrevWantsToCrouch = CharacterMovement->Safe_bPrevWantsToCrouch;
Saved_bWantsToProne = CharacterMovement->Safe_bWantsToProne;
}
void UNyongMovementComponent::FSavedMove_Nyong::PrepMoveFor(ACharacter* C)
{
Super::PrepMoveFor(C);
UNyongMovementComponent* CharacterMovement = Cast<UNyongMovementComponent>(C->GetCharacterMovement());
CharacterMovement->Safe_bWantsToSprint = Saved_bWantsToSprints;
CharacterMovement->Safe_bPrevWantsToCrouch = Saved_bPrevWantsToCrouch;
CharacterMovement->Safe_bWantsToProne = Saved_bWantsToProne;
}
SetMoveFor
, PrepMoveFor
함수를 구현해주고,
void TryEnterProne() { Safe_bWantsToProne = true; }
UFUNCTION(Server, Reliable) void Server_EnterProne();
void UNyongMovementComponent::Server_EnterProne()
{
Safe_bWantsToProne = true;
}
두 함수를 선언 및 구현해준다. 두 함수 모두 Safe_bWantsToProne
변수를 true 처리하는 것 뿐이라는걸 알 수 있다.
Server RPC
의 경우, 호출될 때 항상 현재 프레임 movement RPC가 도착하기 전에 도착한다. 따라서 이런 RPC가 호출될 때, 서버의 프레임이 클라이언트의 프레임보다 항상 먼저 실행될 수 있다.
클라이언트가 프레임 100에서 움직임 관련 작업을 수행하고 RPC를 서버에 보내면, 서버는 클라이언트가 보낸 RPC를 처리한 후 프레임 100을 실행한다. 따라서 서버는 프레임을 실행하기 전에 클라이언트가 보낸 움직임 정보로 상태 변수를 미리 업데이트하는 것이 중요하다.
FTimerHandle TimerHandle_EnterProne;
void UNyongMovementComponent::CrouchPressed()
{
bWantsToCrouch = ~bWantsToCrouch;
GetWorld()->GetTimerManager().SetTimer(TimerHandle_EnterProne, this, &UNyongMovementComponent::TryEnterProne, ProneEnterHoldDuration);
}
void UNyongMovementComponent::CrouchReleased()
{
GetWorld()->GetTimerManager().ClearTimer(TimerHandle_EnterProne);
}
특정 시간 동안 C키를 누른 후 prone을 시작하므로, 타이머 핸들이 필요하다. 타이머 핸들은 서버와 클라이언트에서 다른 속도로 실행되기 때문에 movement safe하지 않다. 하지만 클라이언트만이 Enter Prone을 호출할 것이기 때문에 괜찮다. 클라이언트만이 C키를 실제로 누르고 뗀걸 알 수 있기 때문이다.
void UNyongMovementComponent::UpdateCharacterStateBeforeMovement(float DeltaSeconds)
{
// 생략
if (Safe_bWantsToProne)
{
if (CanProne())
{
SetMovementMode(MOVE_Custom, CMOVE_Prone);
if(!CharacterOwner->HasAuthority()) Server_EnterProne();
}
Safe_bWantsToProne = false;
}
if (IsCustomMovementMode(CMOVE_Prone) && !bWantsToCrouch)
{
SetMovementMode(MOVE_Walking);
}
Super::UpdateCharacterStateBeforeMovement(DeltaSeconds);
}
Prone으로 들어갈 때, 서버가 아닌 경우 Server RPC를 호출한다.
void UNyongMovementComponent::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;
}
}
Safe_bPrevWantsToCrouch = bWantsToCrouch;
}
원래는 OnMovementUpdated
함수 내에서 Max Walk Speed를 변경하고 있었다. 이부분을 지우고,
float UNyongMovementComponent::GetMaxSpeed() const
{
if (IsMovementMode(MOVE_Walking) && Safe_bWantsToSprint && !IsCrouching()) return MaxSprintSpeed;
// 생략
}
이 코드를 작성해준다. 무브먼트 모드가 Walking이고, Sprint를 원하고, Crouch 상태가 아니라면 MaxSprintSpeed
를 반환해주자. 만약 Safe_bWantsToSprint
상태가 아니라면 Super 함수에서 MaxWalkSpeed를 반환할 것이다. 동일한 이동 모드이지만 다른 최대 속도를 갖을 뿐이기 때문에, 좀 더 깔끔하게 구현할 수 있다.
먼저 슬라이드 구현 로직이 일부 변경 및 추가되었다. 자세한 코드는 깃허브를 참고하자.
void UNyongMovementComponent::EnterSlide()
{
UE_LOG(LogTemp, Warning, TEXT("Enter Slide"));
bWantsToCrouch = true;
Velocity += Velocity.GetSafeNormal2D() * Slide_EnterImpulse;
SetMovementMode(MOVE_Custom, CMOVE_Slide);
}
원래 EnterSlide()
함수 내에서 무브먼트 모드가 변경됐지만, 이제는 OnMovementModeChanged()
함수에서 변경된다.
void UNyongMovementComponent::OnMovementModeChanged(EMovementMode PreviousMovementMode, uint8 PreviousCustomMode)
{
Super::OnMovementModeChanged(PreviousMovementMode, PreviousCustomMode);
if (PreviousMovementMode == MOVE_Custom && PreviousCustomMode == CMOVE_Slide) ExitSlide();
if (PreviousMovementMode == MOVE_Custom && PreviousCustomMode == CMOVE_Prone) ExitProne();
if (IsCustomMovementMode(CMOVE_Slide)) EnterSlide(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
if (IsCustomMovementMode(CMOVE_Prone)) EnterProne(PreviousMovementMode, (ECustomMovementMode)PreviousCustomMode);
}
이 방법으로 구현하면, 이미 기본 함수인 Set Movement Mode 함수만 호출하면 된다. 따로 EnterSlide 함수를 호출할 필요가 없다. 이유는 두 가지가 있는데, 하나는 우선 깔끔하다. 블루프린트에서 Set Movement mode를 호출할 때 Enter 과 Exit 함수가 자동으로 호출되므로, 두 함수를 블루프린트에 노출하지 않아도 자동으로 불러질 것이다. 두번째로, 슬라이드를 종료하고 falling 모드나 Walking, Swimming 모드에 들어갈 수도 있으므로 Exit Slide 함수가 Movement Mode를 설정하는 것을 원하지 않기 때문이다.
enum CompressedFlags
{
FLAG_Sprint = 0x10,
FLAG_Custom_1 = 0x20,
FLAG_Custom_2 = 0x40,
FLAG_Custom_3 = 0x80,
};
커스텀 Saved Move 구조체 안에 CompressedFlags 만들어 커스텀 플래그를 만들어주었다. 기존 커스텀 플래그와 값이 같다는 것에 주의하자.
void UNyongMovementComponent::UpdateFromCompressedFlags(uint8 Flags)
{
Super::UpdateFromCompressedFlags(Flags);
Safe_bWantsToSprint = (Flags & FSavedMove_Nyong::FLAG_Sprint) != 0;
}
이런식으로 사용할 수 있다. 가독성 말고는 크게 좋은 점은 없지만, 완성도를 높이기 위해 이렇게 했다.