Ch4 팀프로젝트(12) - 관전자 처리

yys·2026년 7월 17일

TIL

목록 보기
74/86

관전자(Spectator) 처리


이번에는 플레이를 마친 생존자가 남아 있는 팀원의 화면을 볼 수 있도록 멀티플레이 관전자 시스템을 구현했다.

구현 목표는 다음과 같았다.

  • 다음·이전 플레이어로 관전 대상을 전환한다
  • 대상의 이동은 따라가되, 카메라 Look은 관전자에게 맡긴다
  • 플레이할 때와 관전할 때의 Look 감각을 동일하게 유지한다
  • 관전 대상이 사망하거나 탈출하면 자동으로 다음 대상을 찾는다

처음에는 PlayerController 내부에 구현했지만, 입력 처리부터 대상 검증, 카메라 계산까지 코드가 빠르게 늘어났다.

결국 관전자 기능은 별도의 USPSpectatorComponent로 분리하고, PlayerController는 외부에서 사용할 진입점과 이벤트만 제공하도록 정리했다.

컴포넌트 분리


관전자 기능에는 생각보다 많은 책임이 들어가기 때문에, 따로 컴포넌트로 분리하여 책임을 분리해주었다.

ASPPlayerController::ASPPlayerController()
{
	SpectatorComponent = CreateDefaultSubobject<USPSpectatorComponent>(TEXT("SpectatorComponent"));
}

PlayerController는 컴포넌트의 기능을 그대로 전달하는 진입로의 역할만 맡는다.

void ASPPlayerController::EnterSpectatorMode(float InitialViewDelay)
{
	SpectatorComponent->EnterSpectatorMode(InitialViewDelay);
}

bool ASPPlayerController::IsSpectating() const
{
	return SpectatorComponent->IsSpectating();
}

ASurvivorCharacter* ASPPlayerController::GetSpectateTarget() const
{
	return SpectatorComponent->GetSpectateTarget();
}

대상 변경이나 대상 소진 이벤트도 컴포넌트에서 받은 뒤 다시 외부로 전달한다.

void ASPPlayerController::HandleSpectateTargetChanged(
	ASurvivorCharacter* NewTarget)
{
	OnSpectateTargetChanged.Broadcast(NewTarget);
}

void ASPPlayerController::HandleSpectateTargetsExhausted()
{
	OnSpectateTargetsExhausted.Broadcast();
}

덕분에 결과 화면이나 HUD는 PlayerController만 바라보면 되고, 내부 카메라 구현까지 알 필요가 없어졌다.

관전자 모드 진입


관전자 진입은 서버에서만 시작하도록 제한했다.

void USPSpectatorComponent::EnterSpectatorMode(float InitialViewDelay)
{
	APlayerController* PlayerController = GetPlayerController();

	if (!PlayerController || !PlayerController->HasAuthority() 
    	|| bIsSpectating || !GetWorld())
	{
		return;
	}

	bIsSpectating = true;

	if (InitialViewDelay > 0.f)
	{
		GetWorld()->GetTimerManager().SetTimer(
			SpectatorStartTimerHandle,
			this,&ThisClass::BeginTeammateSpectate,InitialViewDelay,false);
	}
	else
	{
		GetWorld()->GetTimerManager().SetTimerForNextTick(
			this, &ThisClass::BeginTeammateSpectate);
	}
}

InitialViewDelay를 받도록 한 이유는 플레이 종료와 동시에 카메라가 즉시 전환되는 대신, 필요한 경우 기존 시점을 잠깐 유지할 수 있게 만들었다.
즉, 죽자마자 바로 스위칭이 되는 것이 아닌 약간의 텀을 주고 스위칭되도록 만들기 위해서이다.

그 다음으로는 서버에서는 기존 Pawn을 UnPossess하고 관전 대상을 찾는다.

void USPSpectatorComponent::BeginTeammateSpectate()
{
	APlayerController* PlayerController = GetPlayerController();

	if (!PlayerController || !PlayerController->HasAuthority()
		|| !bIsSpectating || !GetWorld())
	{
		return;
	}

	if (PlayerController->GetPawn())
	{
		PlayerController->UnPossess();
	}

	Client_BeginSpectate();
	FocusSpectateTarget(0);

	if (bIsSpectating)
	{
		GetWorld()->GetTimerManager().SetTimer(
			SpectatorValidateTimerHandle,
			this,&ThisClass::ValidateSpectateTarget,
			SpectatorValidateInterval,true);
	}
}

서버에서 관전 대상 선정


관전 대상은 클라이언트가 임의로 결정하지 않고 서버에서 선정한다.
현재 월드의 생존자를 순회하면서 다음 조건을 검사했다.

  • 유효한 생존자인가
  • 자신의 캐릭터가 아닌가
  • 사망 또는 탈출 상태가 아닌가
USPSpectatorComponent::GatherSpectatableSurvivors() const
{
	TArray<ASurvivorCharacter*> Result;

	const APlayerController* PlayerController = GetPlayerController();
	UWorld* World = GetWorld();

	if (!PlayerController || !World)
	{
		return Result;
	}

	for (TActorIterator<ASurvivorCharacter> It(World); It; ++It)
	{
		ASurvivorCharacter* Survivor = *It;

		if (!IsValid(Survivor)
			|| Survivor->GetController() == PlayerController)
		{
			continue;
		}

		const ESurvivorState State = Survivor->GetSurvivorState();

		if (State == ESurvivorState::Dead
			|| State == ESurvivorState::Escaped) // 대상이 죽었거나 탈출했다면
		{
			continue;
		}

		Result.Add(Survivor); // 그 외에는 관전 대상에 포함
	}

	Result.Sort([](const ASurvivorCharacter& A, const ASurvivorCharacter& B)
	{
		return A.GetName() < B.GetName();
	}); // 닉네임 기준으로 sort

	return Result;
}

마지막에 이름 기준으로 정렬해서 다음·이전 대상 전환 순서가 매번 달라지지 않도록 했다.

대상 전환은 현재 인덱스에 방향 값을 더한 뒤 나머지 연산으로 순환시켰다.

// USPSpectatorComponent::FocusSpectateTarget() 내부

int32 NextIndex = 0;

if (Direction != 0 && CurrentSpectateTarget.IsValid())
{
	const int32 CurrentIndex =
		Candidates.IndexOfByKey(CurrentSpectateTarget.Get());

	if (CurrentIndex != INDEX_NONE)
	{
		NextIndex = (CurrentIndex + Direction + Candidates.Num()) % Candidates.Num();
	}
}

ASurvivorCharacter* NewTarget = Candidates[NextIndex];

CurrentSpectateTarget = NewTarget;
PlayerController->SetViewTarget(NewTarget);
Client_SetSpectateView(NewTarget);

마지막 대상을 보고 있을 때 다음 입력을 누르면 첫 번째 대상으로 돌아가고, 첫 번째 대상에서 이전 입력을 누르면 마지막 대상으로 이동한다.

관전자 전용 입력


관전자 상태에서는 캐릭터 이동 입력은 막아야 하지만 Look과 대상 전환 입력은 계속 받아주는 것이 UX 관점에서 제일 좋다고 판단했다.

그래서 이를 위해 InputConfig에 관전자용 Mapping Context와 Input Action을 추가했다.

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input|Context")
TObjectPtr<UInputMappingContext> SpectatorMappingContext;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input|Action")
TObjectPtr<UInputAction> SpectateNextAction;

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Input|Action")
TObjectPtr<UInputAction> SpectatePreviousAction;

컴포넌트에서는 다음·이전 입력과 기존 LookAction을 바인딩한다.

void USPSpectatorComponent::BindInputActions(UInputComponent* InputComponent)
{
	ValidateInputConfig(); // InputConfig가 있는지 검토하고 없으면 크래시

	if (UEnhancedInputComponent* EnhancedInput = Cast<UEnhancedInputComponent>(InputComponent))
	{
		if (InputConfig->SpectateNextAction)
		{
			EnhancedInput->BindAction(
				InputConfig->SpectateNextAction,
				ETriggerEvent::Started, this, &ThisClass::SpectateNext);
		}

		if (InputConfig->SpectatePreviousAction)
		{
			EnhancedInput->BindAction(
				InputConfig->SpectatePreviousAction,
				ETriggerEvent::Started,	this, &ThisClass::SpectatePrevious);
		}

		if (InputConfig->LookAction)
		{
			EnhancedInput->BindAction(
				InputConfig->LookAction,
				ETriggerEvent::Triggered, this, &ThisClass::SpectateLook);
		}
	}
}

관전이 시작되면 높은 우선순위로 관전자 Mapping Context를 추가한다.

PlayerController->SetInputMode(FInputModeGameOnly());
PlayerController->bShowMouseCursor = false;

PlayerController->SetIgnoreMoveInput(true);
PlayerController->ResetIgnoreLookInput();

InputSubsystem->AddMappingContext(InputConfig->SpectatorMappingContext,	SpectatorMappingPriority);

대상 전환 입력은 클라이언트에서 바로 대상을 변경하지 않고 서버 RPC로 전달한다.

void USPSpectatorComponent::SpectateNext()
{
	if (bIsSpectating)
	{
		Server_CycleSpectatorTarget(true);
	}
}

void USPSpectatorComponent::SpectatePrevious()
{
	if (bIsSpectating)
	{
		Server_CycleSpectatorTarget(false);
	}
}

void USPSpectatorComponent::
Server_CycleSpectatorTarget_Implementation(bool bForward)
{
	FocusSpectateTarget(bForward ? 1 : -1);
}

입력은 로컬에서 발생하지만, 최종 대상 선정은 서버가 담당하는 구조다.

관전자 Look 처리


플레이 중 Look은 다음과 같이 PlayerController의 Control Rotation 경로를 사용한다.

void ACharacterBase::Look(const FInputActionValue& Value)
{
	const FVector2D LookVector = Value.Get<FVector2D>();

	AddControllerYawInput(LookVector.X);
	AddControllerPitchInput(LookVector.Y);
}

관전자 Look도 같은 경로를 사용해야 사용자 입장에서 편하다고 느낄 것이라 판단하여 동일한 Look 구조를 사용하였다.

void USPSpectatorComponent::SpectateLook(const FInputActionValue& Value)
{
	APlayerController* PlayerController = GetPlayerController();

	if (!PlayerController || !bIsSpectating	|| !ClientSpectateTarget.IsValid())
	{
		return;
	}

	const FVector2D LookVector = Value.Get<FVector2D>();

	PlayerController->AddYawInput(LookVector.X);
	PlayerController->AddPitchInput(LookVector.Y);
}

로컬 관전자 카메라


관전 대상의 카메라를 그대로 사용하면 관전자의 Look을 자유롭게 조절하기 어렵다.

그래서 관전 대상은 위치 기준점으로만 사용하고, 실제 화면은 클라이언트마다 생성하는 별도의 CameraActor가 담당하도록 했다.

SpectatorCameraActor =
	GetWorld()->SpawnActor<ACameraActor>(ACameraActor::StaticClass(),
		FVector::ZeroVector, FRotator::ZeroRotator,	SpawnParameters);

이 카메라는 화면 표현용이므로 네트워크에 복제할 필요가 없다.

SpectatorCameraActor->SetReplicates(false);
SpectatorCameraActor->SetActorEnableCollision(false);

관전 대상은 서버에서 결정하지만 카메라 위치와 회전은 각 관전자 클라이언트에서 계산한다.

따라서 같은 대상을 관전하더라도 각 플레이어가 원하는 방향을 자유롭게 바라볼 수 있다.

멀티플레이 카메라 떨림 완화


멀티플레이에서는 다른 캐릭터의 위치가 네트워크를 통해 일정 간격으로 갱신된다.

관전자 카메라를 매 프레임 대상의 ActorLocation에 정확히 붙이면, 위치 보정이 들어올 때마다 카메라도 작은 폭으로 튀게 된다.

먼저 대상 위치를 카메라에 바로 사용하지 않고 Pivot을 보간했다.

SmoothedSpectatorPivot = FMath::VInterpTo(
	SmoothedSpectatorPivot, TargetPivotLocation, DeltaTime, SpectatorCameraFollowInterpSpeed);

대상이 처음 지정되거나 대상을 전환했을 때는 보간 때문에 이전 위치에서 날아오지 않도록 즉시 스냅한다.

if (!bSpectatorCameraPivotInitialized || bSnapToTarget || SpectatorCameraFollowInterpSpeed <= 0.f)
{
	SmoothedSpectatorPivot = TargetPivotLocation;
	bSpectatorCameraPivotInitialized = true;
}

카메라 갱신 순서도 중요했다.

카메라가 PlayerController의 회전 입력 처리보다 먼저 움직이면 한 프레임 이전 회전을 사용할 수 있다.

void USPSpectatorComponent::BeginPlay()
{
	Super::BeginPlay();

	if (APlayerController* PlayerController = GetPlayerController())
	{
		AddTickPrerequisiteActor(PlayerController);
	}
}

관전 대상의 Character Movement보다 카메라가 먼저 갱신되는 경우에도 이전 위치를 읽을 수 있다.

그래서 대상이 변경될 때 Character Movement를 Tick 선행 조건으로 등록했다.

if (NewViewTarget)
{
	AddTickPrerequisiteComponent(NewViewTarget->GetCharacterMovement());
}

대상이 바뀌거나 카메라를 해제할 때는 기존 선행 조건도 제거한다.

if (ASurvivorCharacter* PreviousTarget = ClientSpectateTarget.Get())
{
	RemoveTickPrerequisiteComponent(PreviousTarget->GetCharacterMovement());
}

다음과 같이 생존자가 달릴 때 끊김 현상이 없음을 확인할 수 있다.

관전 대상이 0명일 때


관전 가능한 생존자가 한 명도 없다면 계속 빈 목록을 검사할 필요가 없다.

서버에서는 관전자 상태를 종료하고 시작·검증 타이머를 모두 정리한다.

// USPSpectatorComponent::FocusSpectateTarget() 내부
if (Candidates.IsEmpty())
{
	bIsSpectating = false;
	CurrentSpectateTarget.Reset();

	GetWorld()->GetTimerManager().ClearTimer(SpectatorStartTimerHandle);

	GetWorld()->GetTimerManager().ClearTimer(SpectatorValidateTimerHandle);

	Client_EndSpectateNoTargets();
	return;
}
void USPSpectatorComponent::Client_EndSpectateNoTargets_Implementation()
{
	APlayerController* PlayerController = GetPlayerController();

	if (!PlayerController)
	{
		return;
	}

	bIsSpectating = false;
	SetComponentTickEnabled(false);

	PlayerController->bAutoManageActiveCameraTarget = true;

	InputSubsystem->RemoveMappingContext(InputConfig->SpectatorMappingContext);

	PlayerController->ResetIgnoreMoveInput();

	ReleaseSpectatorCamera();

	OnSpectateTargetChanged.Broadcast(nullptr);
	OnSpectateTargetsExhausted.Broadcast();
}

여기서 결과 화면을 직접 생성하지는 않았는데, 결과 화면을 생성하는 부분은 다른 팀원분이 해주시기 때문에 혹시 모를 더 이상 관전할 대상이 없다는 훅을 제공했다.

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FSPSpectateTargetsExhaustedSignature);

UPROPERTY(BlueprintAssignable, Category = "Spectator")
FSPSpectateTargetsExhaustedSignature OnSpectateTargetsExhausted;

카메라 정리


관전자 카메라는 로컬에서 생성한 임시 액터이기 때문에 관전 종료나 EndPlay에서 반드시 제거해야 한다.

void USPSpectatorComponent::ReleaseSpectatorCamera()
{
	if (ASurvivorCharacter* PreviousTarget = ClientSpectateTarget.Get())
	{
		RemoveTickPrerequisiteComponent(PreviousTarget->GetCharacterMovement());
	}

	if (IsValid(SpectatorCameraActor))
	{
		SpectatorCameraActor->Destroy();
	}

	SpectatorCameraActor = nullptr;
	ClientSpectateTarget.Reset();

	SmoothedSpectatorPivot = FVector::ZeroVector;
	bSpectatorCameraPivotInitialized = false;
}

컴포넌트가 종료될 때 타이머도 함께 해제했다.

void USPSpectatorComponent::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
	if (GetWorld())
	{
		GetWorld()->GetTimerManager().ClearTimer(SpectatorStartTimerHandle);

		GetWorld()->GetTimerManager().ClearTimer(SpectatorValidateTimerHandle);
	}

	ReleaseSpectatorCamera();
	Super::EndPlay(EndPlayReason);
}

관전자 모드에 다시 진입하더라도 이전 대상이나 보간 위치가 남지 않도록 로컬 상태까지 초기화했다.

profile
게임 개발 지망생

0개의 댓글