12.17 - TIL

김혁·2025년 12월 17일

TIL

목록 보기
76/84

오늘의 코드카타

  • 에어컨
    • 승객이 탑승했을 때, 에어컨을 통해 실내온도를 특정 범위 안에 설정해야 할 때 필요한 최소 소비전력을 구하는 문제
    • 에어컨을 끈 경우는 실외온도랑 같아지는 방향으로 1도 변화하고, 에어컨을 켠 경우에서 희망 온도와 다른 경우는 a만큼, 같은 경우는 b만큼 전력이 소비된다. 항상 희망온도를 딱 맞추는 것이 최적이라는 보장이 없기 때문에 dp를 통해 문제를 해결했다.
    • 온도의 범위가 -10도에서 40도이기 때문에 전체에 +10을 해서 0도에서 50도 범위로 제한을 바꿨다. 그 이유는 dp를 통해 문제를 해결할 때, 배열의 인덱스 기반으로 구현했는데 음수를 제외하고자 10만큼 더했다.
    • https://school.programmers.co.kr/learn/courses/30/lessons/214289

팀 프로젝트 진행 내용

1. 클라에서 관전할 시에 플레이어 카메라로 안 보이는 오류 수정

  • 클라에서 관전할 시에 플레이하는 화면으로 보이는 것이 아니라 디폴트 카메라처럼 보이는 오류가 있었다.
  • Character에서는 CalcCamera() 함수를 오버라이딩해서 SetViewTarget()으로 타겟을 지정했을 때, 나올 화면을 PlayerCameraManager에게 보내는 방식으로 코드를 구현했었다.
  • 해당 증상을 확인하기 위해 아래와 같은 코드를 추가해서 제대로 ViewTarget이 설정한 카메라로 가는것인가 확인하고, 카메라 계산이 정상적으로 되는지 확인해보고자 했다.
// AAO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::ClientRPC_SetSpectateTarget_Implementation(APawn* NewTarget, int32 NewPlayerIndex)
{
	...
    
	DebugPrintSpectateCamera(TEXT("SpectateRPC-BeforeSetViewTarget"));
	
	const float BlendTime = 0.4f;
	SetViewTargetWithBlend(NewTarget, BlendTime);
    
	DebugPrintSpectateCamera(TEXT("SpectateRPC-AfterSetViewTarget"));

	for (int32 i = 1; i <= 10; ++i)
	{
		FTimerHandle Temp;
		GetWorldTimerManager().SetTimer(Temp, FTimerDelegate::CreateWeakLambda(this, [this, i]()
		{
			DebugPrintSpectateCamera(*FString::Printf(TEXT("SpectateRPC-After %d"), i));
		}), i * 0.1f, false);
	}
}

void AAO_PlayerController_Stage::DebugPrintSpectateCamera(const TCHAR* Where)
{
	const AActor* VT = GetViewTarget();
	const APawn* MyPawn = GetPawn();

	FVector CamLoc = FVector::ZeroVector;
	FRotator CamRot = FRotator::ZeroRotator;
	if (PlayerCameraManager)
	{
		CamLoc = PlayerCameraManager->GetCameraLocation();
		CamRot = PlayerCameraManager->GetCameraRotation();
	}

	AO_LOG(LogKH, Log,
		TEXT("[%s] Local=%d Pawn=%s ViewTarget=%s SpectateTarget=%s CamLoc=%s CamRot=%s"),
		Where,
		IsLocalController() ? 1 : 0,
		MyPawn ? *MyPawn->GetName() : TEXT("None"),
		VT ? *VT->GetName() : TEXT("None"),
		CurrentSpectateTarget ? *CurrentSpectateTarget->GetName() : TEXT("None"),
		*CamLoc.ToString(),
		*CamRot.ToString());
}
  • 클라에서 관전을 시도하니까 아래와 같은 로그가 출력되었다. 관전을 하니까 정상적으로 다른 캐릭터로 ViewTarget이 이동하는 것을 알 수 있었다.
  • 하지만 이상하게 Camera의 Rotation 값이 전부 0이었다.
  • 이를 통해 알 수 있는 점은 클라에서 관전 대상 캐릭터의 카메라 계산이 유효하게 갱신되지 않고, 회전 값을 고정으로 0으로 가져간다는 점이다.
LogKH: [SpectateRPC-BeforeSetViewTarget] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_0 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=-155.247 Y=821.873 Z=331.189 CamRot=P=-25.899999 Y=-88.700005 R=-0.000000
LogKH: [SpectateRPC-AfterSetViewTarget] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=-155.247 Y=821.873 Z=331.189 CamRot=P=-25.899999 Y=-88.700005 R=-0.000000
LogKH: [SpectateRPC-After 1] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=10.482 Y=486.258 Z=268.812 CamRot=P=-19.001817 Y=-65.075726 R=-0.000000
LogKH: [SpectateRPC-After 2] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=161.563 Y=180.304 Z=211.947 CamRot=P=-12.713304 Y=-43.539389 R=-0.000000
LogKH: [SpectateRPC-After 3] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=314.661 Y=-129.733 Z=154.324 CamRot=P=-6.340859 Y=-21.715609 R=-0.000000
LogKH: [SpectateRPC-After 4] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 5] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 6] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 7] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 8] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 9] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
LogKH: [SpectateRPC-After 10] Local=1 Pawn=BP_PlayerCharacter_C_0 ViewTarget=BP_PlayerCharacter_C_1 SpectateTarget=BP_PlayerCharacter_C_1 CamLoc=X=467.000 Y=-438.233 Z=96.987 CamRot=P=0.000000 Y=0.000000 R=0.000000
  • 위와 같은 로그를 확인해보니 문제점을 깨달았다. 플레이어 컨트롤러는 복제되지 않기 때문에 로컬에서 보여지는 화면은 서버로 복제가 되지 않는다는 점이다.
  • 그렇다면 서버에서는 왜 정상적으로 다른 플레이어의 화면이 보였을까? 그건 리슨 서버로 구현이 되어있어서 서버에서는 다른 플레이어의 컨트롤러 값을 받아와서 의도한 대로 보여준 것이 아니였을까 싶다.
  • 따라서 위와 같은 문제를 해결하고, 정상적으로 플레이어의 화면이 보이게끔 하고자 복제될 카메라 뷰를 지정하고, CalcCamera()에서 복제될 카메라 뷰를 선택하면 될 것으로 보인다.

2. 다른 클라에게 보여줄 관전 화면에 대한 정보 연결하기

  • 다른 클라이언트에게 보여줄 화면에 대한 정보를 저장하는 구조체를 선언했다.
USTRUCT()
struct FRepCameraView
{
	GENERATED_BODY()

	UPROPERTY()
	FVector_NetQuantize10 Location = FVector::ZeroVector;

	UPROPERTY()
	FRotator Rotation = FRotator::ZeroRotator;

	UPROPERTY()
	float FOV = 90.f;
};
  • 매 Tick마다 데이터를 전송하는 것은 부하가 심할 것으로 보여서 0.05초마다 전송하게끔 타이머로 구현했다.
  • 0.05초마다 서버에 플레이어의 스프링암을 통해 계산된 카메라의 위치, 각도, FOV를 보내게끔 했다. 서버 RCP의 경우는 스트리밍이기 때문에 Unreliable로 속도를 중요시했다.
// AAO_PlayerCharacter.cpp

void AAO_PlayerCharacter::BeginPlay()
{
	...
    
	if (IsLocallyControlled())
	{
    	...
        
		GetWorldTimerManager().SetTimer(
			TimerHandle_CameraViewSync,
			this,
			&AAO_PlayerCharacter::SendCameraViewToServer,
			0.05f,
			true);
	}
 }

void AAO_PlayerCharacter::SendCameraViewToServer()
{
	if (!IsLocallyControlled())
	{
		return;
	}

	if (APlayerController* PC = Cast<APlayerController>(GetController()))
	{
		if (PC->PlayerCameraManager)
		{
			FRepCameraView V;
			V.Location = PC->PlayerCameraManager->GetCameraLocation();
			V.Rotation = PC->PlayerCameraManager->GetCameraRotation();
			V.FOV = PC->PlayerCameraManager->GetFOVAngle();

			ServerRPC_UpdateCameraView(V);
		}
	}
}

void AAO_PlayerCharacter::ServerRPC_UpdateCameraView_Implementation(const FRepCameraView& NewView)
{
	RepCameraView = NewView;
}
  • 위와 같이 구현하니까 클라에서도 서버 플레이어가 플레이하는 화면도 정상적으로 보였다.

3. 사망, 관전 기능 컴포넌트로 분리 및 관전 최적화

  • 사망 및 관전 기능을 컴포넌트로 분리해서 캐릭터 cpp 코드에 너무 많은 기능을 넣는 것을 방지하고자 했다.
  • 기존의 사망 기능과 관전 기능을 컴포넌트로 넘기는 과정을 진행했다. 기존의 카메라를 계산하는 과정은 아래와 같이 컴포넌트에서 데이터를 받아와서 처리하는 과정으로 수정했다.
// AO_PlayerCharacter.cpp

void AAO_PlayerCharacter::CalcCamera(float DeltaTime, struct FMinimalViewInfo& OutResult)
{
	...
    
	// OutResult.Location = RepCameraView.Location;
	// OutResult.Rotation = RepCameraView.Rotation;
	// OutResult.FOV	   = RepCameraView.FOV;
    
    if (DeathSpectateComponent)
	{
		FRepCameraView V;
		DeathSpectateComponent->GetRepCameraView(V);
		OutResult.Location = V.Location;
		OutResult.Rotation = V.Rotation;
		OutResult.FOV	   = V.FOV;
		return;
	}
}
  • 추가적으로 위에서 구현한 관전 방식은 플레이어가 관전자의 유무에 관계없이 타이머가 항상 돌아가서 데이터를 넘겨주는 방식이었는데, 이는 상당히 비효율적이라고 생각해서 관전자가 있을 때만 플레이어의 화면 데이터를 넘겨주는 방식으로 최적화를 진행했다.
  • 플레이어 컨트롤러에서 관전자를 추가하고 제거하는 함수를 선언했고, 관전자가 있다면 bStreamEnabled를 true로 전환하고, StartCameraSyncTimer()를 실행하는 방식으로 구현했다.
  • 또한 관전자를 SpectatorSet에 저장해서 이후에 대상이 사망했을 때, 자동으로 관전자는 다른 플레이어를 관전하게끔 하고자 한다.
// AO_DeathSpectateComponent.h

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class AO_API UAO_DeathSpectateComponent : public UActorComponent
{
    ...
    
public:
    void AddSpectator(APlayerController* SpectatorPC);
	void RemoveSpectator(APlayerController* SpectatorPC);
    
private:
	void StartCameraSyncTimer();
	void StopCameraSyncTimer();
    
    UPROPERTY(ReplicatedUsing=OnRep_StreamEnabled)
	bool bStreamEnabled = false;

	UFUNCTION()
	void OnRep_StreamEnabled();

	UPROPERTY()
	TSet<TWeakObjectPtr<APlayerController>> SpectatorSet;
    
    FTimerHandle TimerHandle_CameraSync;
}
  • 플레이어 컨트롤러에서 관전자 설정을 할 때 호출할 함수로 AddSpectator()와 관전자 변경, 해제를 할 때 호출할 함수로 RemoveSpectator()를 구현했다. 두 함수 모두 서버에서 불릴 것을 전제로 구현했다.
  • 관전자 Set의 수에 따라 bStreamEnabled 변수를 변경하게끔 구현했다. 즉각적인 업데이트가 필요하기 때문에 ForceNetUpdate() 함수를 호출해서 바로 적용되게끔 요청했다.
  • 해당 경우를 구현할 때, 클라에서 서버를 관전할 때 정상적으로 출력되지 않는 경우가 있었는데, 서버에서 bStreamEnabled 값을 변경하면 클라에서 OnRep_StreamEnabled() 함수가 호출될 것을 예상하고 설계를 했는데, 서버에서는 해당 함수가 호출되지 않는다는 점이 그와 같은 오류를 발생시켰다.
    -> 따라서 서버이면서 클라이언트인 경우에는 별도로 OnRep_StreamEnabled() 호출해서 문제를 해결했다.
// AO_DeathSpectateComponent.cpp

void UAO_DeathSpectateComponent::AddSpectator(APlayerController* SpectatorPC)
{
	if (!ensure(SpectatorPC))
	{
		return;
	}

	check(OwnerCharacter);
	
	if (!OwnerCharacter->HasAuthority())
	{
		return;
	}

	const int32 PrevNum = SpectatorSet.Num();
	SpectatorSet.Add(SpectatorPC);

	if (PrevNum == 0 && SpectatorSet.Num() > 0)
	{
		bStreamEnabled = true;
		OwnerActor->ForceNetUpdate();

		// 중요 : 리슨서버는 서버 플레이어는 서버이기 때문에 별도의 OnRep 함수 호출이 필요하다.
		if (OwnerCharacter->IsLocallyControlled())
		{
			OnRep_StreamEnabled();
		}
	}
}

void UAO_DeathSpectateComponent::RemoveSpectator(APlayerController* SpectatorPC)
{
	if (!ensure(SpectatorPC))
	{
		return;
	}

	check(OwnerCharacter);
	
	if (!OwnerCharacter->HasAuthority())
	{
		return;
	}

	SpectatorSet.Remove(SpectatorPC);

	if (SpectatorSet.Num() == 0)
	{
		bStreamEnabled = false;
		OwnerActor->ForceNetUpdate();

		if (OwnerCharacter->IsLocallyControlled())
		{
			OnRep_StreamEnabled();
		}
	}
}
  • Replication 설정 시에 bStreamEnabled는 Owner한테만 복제되게끔 설정해서 권위 설정을 확실히 했다.
  • bStreamEnabled가 true가 되면 화면을 보여줄 데이터를 캐릭터에서 업데이트하게끔 했고, false가 되면 Timer를 멈춰서 업데이트가 멈추게끔 했다.
// AO_DeathSpectateComponent.cpp

void UAO_DeathSpectateComponent::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(UAO_DeathSpectateComponent, RepCameraView);

	DOREPLIFETIME_CONDITION(UAO_DeathSpectateComponent, bStreamEnabled, COND_OwnerOnly);
}

void UAO_DeathSpectateComponent::OnRep_StreamEnabled()
{
	check(OwnerCharacter);

	if (!OwnerCharacter->IsLocallyControlled())
	{
		return;
	}

	if (bStreamEnabled)
	{
		StartCameraSyncTimer();
	}
	else
	{
		StopCameraSyncTimer();
	}
}
  • 아래는 관전용 카메라를 업데이트하기 위한 코드로 기존의 타이머 방식을 그대로 가져와서 사용했다.
  • 업데이트가 필요없을 때는 해당 타이머를 제거해줬다.
// AO_DeathSpectateComponent.cpp

void UAO_DeathSpectateComponent::StartCameraSyncTimer()
{
	check(OwnerCharacter);

	if (!OwnerCharacter->IsLocallyControlled())
	{
		return;
	}

	UWorld* World = GetWorld();
	checkf(World, TEXT("Failed to get World"));

	if (World->GetTimerManager().IsTimerActive(TimerHandle_CameraSync))
	{
		return;
	}

	World->GetTimerManager().SetTimer(
		TimerHandle_CameraSync,
		this,
		&UAO_DeathSpectateComponent::SendCameraViewToServer,
		0.05f,
		true);
}

void UAO_DeathSpectateComponent::StopCameraSyncTimer()
{
	UWorld* World = GetWorld();
	checkf(World, TEXT("Failed to get World"));

	World->GetTimerManager().ClearTimer(TimerHandle_CameraSync);
}

  • 플레이어 컨트롤러에서 관전을 시작하거나 관전 변경 시에 관전자에게 요청하는 함수를 구현했다.
  • 추가적으로 관전자 제거를 위해 이전 관전하고 있던 폰을 저장하는 변수도 선언했다.
// AO_PlayerController_Stage.h

UCLASS()
class AO_API AAO_PlayerController_Stage : public AAO_PlayerController_InGameBase
{
	...
    
    UFUNCTION(Server, Reliable)
	void ServerRPC_SetSpectateTarget(APawn* NewTarget);

	UPROPERTY()
	TObjectPtr<APawn> PrevSpectateTarget;
}
  • 변경 시에 관전하던 플레이어의 관전자 목록에서 해당 컨트롤러를 제외하고, 새로 관전할 플레이어의 관전자 목록을 추가하는 함수를 Server RPC로 추가했다.
  • 해당 함수는 기존에 관전할 대상을 계산한 후에 관전하기 전에 호출했다.
// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::ServerRPC_SetSpectateTarget_Implementation(APawn* NewTarget)
{
	if (!HasAuthority())
	{
		return;
	}

	if (PrevSpectateTarget)
	{
		if (AAO_PlayerCharacter* OldChar = Cast<AAO_PlayerCharacter>(PrevSpectateTarget))
		{
			if (UAO_DeathSpectateComponent* Comp = OldChar->FindComponentByClass<UAO_DeathSpectateComponent>())
			{
				Comp->RemoveSpectator_Server(this);
			}
		}
	}

	PrevSpectateTarget = NewTarget;

	if (PrevSpectateTarget)
	{
		if (AAO_PlayerCharacter* NewChar = Cast<AAO_PlayerCharacter>(PrevSpectateTarget))
		{
			if (UAO_DeathSpectateComponent* Comp = NewChar->FindComponentByClass<UAO_DeathSpectateComponent>())
			{
				Comp->AddSpectator_Server(this);
			}
		}
	}
}

void AAO_PlayerController_Stage::ServerRPC_RequestSpectateNext_Implementation(bool bForward)
{
	...
    
	if (NewTarget)
	{
		ServerRPC_SetSpectateTarget(NewTarget);
		
		...
	}
}

4. 관전 기능 개선

  • 위와 같이 구현하면 관전하고 있던 플레이어가 사망 시에 그대로 똑같이 관전하게 되는 현상이 발생한다. 즉, A가 죽어서 B를 관전하고 있는데, B가 사망해서 C를 관전하게 되면 A가 B를 통해 C를 보게 되서 UI 연동에서 오류가 발생한다.
  • 따라서 관전자 정보를 가지고 있기 때문에 사망하게 되면 관전자 정보를 통해 다른 관전자로 전환하게끔 관전 기능을 개선했다.

  • 사망 시에 관전자에게 다른 플레이어를 관전하라고 알리는 함수를 아래와 같이 구현했다.
  • 관전자 중에서 다른 플레이어를 관전하게 되서 RemoveSpectator() 함수가 불려서 관전자 Set이 변경될 수 있기 때문에 스냅샷을 통해 미리 TArray에 저장을 해서 모두 안전하게 해당 동작을 할 수 있게 구현했다.
// AO_DeathSpectateComponent.cpp

void UAO_DeathSpectateComponent::NotifySpectators_TargetInvalidated()
{
	check(OwnerCharacter);

	if (!OwnerCharacter->HasAuthority())
	{
		return;
	}

	// 스냅샷 (순회 중에 Set에서 제거되어서 변경될 수 있기 때문)
	TArray<TWeakObjectPtr<APlayerController>> Spectators;
	Spectators.Reserve(SpectatorSet.Num());
	for (const TWeakObjectPtr<APlayerController>& PC : SpectatorSet)
	{
		if (PC.IsValid())
		{
			Spectators.Add(PC);
		}
	}

	for (const TWeakObjectPtr<APlayerController>& PC : SpectatorSet)
	{
		AAO_PlayerController_Stage* SpectatorPC = Cast<AAO_PlayerController_Stage>(PC.Get());
		if (!SpectatorPC)
		{
			continue;
		}

		SpectatorPC->ForceReselectSpectateTarget(OwnerCharacter);
	}
}
  • 플레이어 컨트롤러에서 다른 관전자로 재지정하는 함수는 기존에 있는 다음 관전자를 고르는 로직과 같게 구현했다.
  • 서버에서만 불리기 때문에 Server RPC를 통해서 구현하지는 않았다.
// AO_PlayerController_Stage.cpp

void AAO_PlayerController_Stage::ForceReselectSpectateTarget(APawn* InvalidTarget)
{
	if (!HasAuthority())
	{
		return;
	}

	if (CurrentSpectateTarget != InvalidTarget)
	{
		return;
	}

	int32 NewIndex = INDEX_NONE;
	TObjectPtr<APawn> NewTarget = FindNextSpectateTarget(true, NewIndex);

	if (!NewTarget)
	{
		ServerRPC_SetSpectateTarget(nullptr);

		CurrentSpectateTarget = nullptr;
		CurrentSpectatePlayerIndex = INDEX_NONE;
		return;
	}

	ServerRPC_SetSpectateTarget(NewTarget);
	
	CurrentSpectateTarget = NewTarget;
	CurrentSpectatePlayerIndex = NewIndex;
	
	ClientRPC_SetSpectateTarget(NewTarget, NewIndex);
}
profile
게임 개발자를 향해..

0개의 댓글