목표

잔여 매치시간을 나타내는 타이머를 만들어봅시다. 1분이 주어지면 1초씩 차감하다가 0이 되고 끝나는 그런 타이머를 만들겁니다. 멀티플레이 환경에서 사용할 것이기 때문에 단순히 SetTimer를 사용하는 것을 넘어 동기화 작업이 필요합니다.

멀티플레이 환경에서 동작하는 타이머 설계

왜 동기화가 필요한가?

다행히도 많은 분들이 이 기능을 만들어보셨고, 그만큼 참고할만한 자료도 많습니다. 덕분에 기능 제작에 많은 도움이 되었습니다.

멀티플레이 환경에서 중요하게 고려해야 하는 요소는 ‘전송시간’입니다. 서버와 클라이언트간의 통신에 소요되는 시간이 있기 때문에 이를 보정해야합니다.

서버와 클라이언트 모두 타이머를 1분으로 지정해두고, 전송시간에 2초가 소요된다고 생각해봅시다. 서버의 타이머가 시작됨과 동시에 클라이언트에게 타이머 시작을 알리는겁니다. 그렇다면 2초가 지나서야 클라이언트의 타이머가 시작될 것이고, 서버 타이머가 종료되었을 때 클라이언트는 아직 2초가 남아있는 상태로 끝나게 되는 것입니다.

기능은 이미 있지만 불완전하다 - GetServerWorldTimeSeconds

서버와 클라이언트의 시간 차이 보정은 매우 중요한 요소인 만큼, 언리얼 엔진에서도 해당 기능을 제공합니다. GetServerWorldTimeSeconds는 말 그대로 서버의 시간을 요청하는 함수입니다. 서버와 클라이언트는 일정 시간 간격으로 시간을 동기화하고, 이를 저장하여 사용합니다.

다만 정확하거나 일정하지는 않습니다. 시간이 갱신되는 간격마다 1초가 빨리 갔다가 느리게 갔다가 그러더라고요. 단순히 서버의 시간을 받아오는 형태라 그렇습니다. 해당 글에 잘 정리되어있습니다.

시간 동기화 진행하기

그래서 별도의 시간 동기화를 만들겁니다. 이 글을 참고해서 만들어봅시다. 이전과 다른 점은 클라이언트가 단순히 서버의 현재 시간을 받는 것이 아닌 서버-클라이언트 전송 시간까지 받는다는 것입니다. ‘서버시간’, ‘서버-클라 전송시간’ 이 두 개를 구하는 과정은 아래와 같습니다.

  • (클라→서버) : 클라이언트는 서버 접속 직후 서버에게 현재 시간을 알려 달라고 요청한다
  • (서버→클라) : 서버는 본인 월드 타임을 첨부해서 클라에 전송한다.
  • (클라) : 서버 시간을 알았다! 이제 서버 시간과 클라 시간 차이를 통해서 지연시간을 계산할 수 있다.
    • 현재 서버 시간은 ‘클라가 받은 서버 시간’ + ‘단방향 전송 시간’이다.

물론 서버까지 가는 시간과 돌아오는 시간이 완벽하게 같지는 않겠지만, 한 번 찍고 돌아오는 시간의 절반이면 비슷하게 쓸 수 있다.

void AEPPlayerController::ReceivedPlayer()
{
	Super::ReceivedPlayer();
	if (IsLocalController())
	{
		// 클라이언트는 서버에게 서버 시간을 요청한다. 이 때 클라이언트의 요청 시간 또한 함께 전달한다.
		ServerRequestServerTime(this, GetWorld()->GetTimeSeconds());
	}
}

void AEPPlayerController::ServerRequestServerTime_Implementation(APlayerController* Requestor, float RequestWorldTime)
{
	// 서버는 서버 시간을 구해 클라이언트에게 전달한다. 클라이언트의 요청 시간 또한 함께 전달한다.
	float WorldServerTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds();
	ClientReportServerTime(RequestWorldTime, WorldServerTime);
}

void AEPPlayerController::ClientReportServerTime_Implementation(float RequestWorldTime, float RequestServerTime)
{
	/* 
	* 클라이언트는 본인이 요청한 시간과 서버 시간을 전달받는다.
	* 현재 시간에서 클라이언트의 요청 시간을 뺀 값이 클라이언트-서버-클라이언트로 한 번 왕복하는데 소요된 시간이다.
	*/
	float RoundTripTime = GetWorld()->GetTimeSeconds() - RequestWorldTime;

	/*
	* 왕복 시간의 절반은 단방향 전송에 사용되는 시간으로 간주할 수 있다(완벽하게 같지 않을 수 있다).
	* 전달받은 서버 시간은 서버에서 클라이언트로 전송될 때의 시간이 고려되지 않은 값이다. 
	* 따라서 서버 시간에 단방향 전송 시간을 더해야만 클라이언트와 서버에서 동일한 시간을 얻을 수 있다.
	*/
	float AdjustedTime = RequestServerTime + (RoundTripTime * 0.5f);

	/* 클라이언트는 최종적으로 계산한 시간을 '서버시간'으로 사용한다 */
	ServerTime = AdjustedTime;
}

타이머 UI 만들기

타이머 기본 기능 만들기

이제 타이머를 만들어봅시다. 타이머는 1초마다 지정 시간을 차감하여 UI를 갱신합니다. 그러기 위해서는 이번 매치에 지정된 시간(GameState에 존재)과 서버 시간(PlayerController)을 가져와서 타이머 초기에 띄울 시간을 지정하는 과정이 필요합니다.

void UEPTimerWidget::NativeConstruct()
{
	Super::NativeConstruct();

	float ServerTime = 0.0f;
	float LimitTime = 0.0f;

	ULocalPlayer* LocalPlayer = GetOwningLocalPlayer();
	if (LocalPlayer)
	{
		// 플레이어 컨트롤러에서 현재 서버 시간 가져오기
		EPPlayerController = Cast<AEPPlayerController>(LocalPlayer->PlayerController);
		if (EPPlayerController)
		{
			ServerTime = EPPlayerController->GetServerTime();

			// 서버로부터 플레이어 컨트롤러에 매치 종료가 전달되면 타이머 진행을 멈춘다.
			EPPlayerController->OnSetupEndMatch.AddUObject(this, &UEPTimerWidget::StopTimeDisplay);
		}

		//게임 스테이트에서 매치에 배정된 시간을 가져온다.
		EPGameState = GetWorld()->GetGameState<AEPGameState>();
		if (EPGameState)
		{
			LimitTime = EPGameState->GetMatchTimeLimit();
		}
	}
	// 매치 시간에서 서버-클라이언트 지연시간을 제외하여 서버와 시간 동기화
	InitialTime = LimitTime - ServerTime;

	DisplayTimerDelegate.BindUObject(this, &UEPTimerWidget::UpdateTimeDisplay);
}

1초씩 차감하는건 SetTimer를 사용하여 구현할 수 있습니다. 시간은 00:00 형태로 남은 시간을 분, 초로 표시합니다. 만약 게임이 종료된다면 그대로 타이머를 중지합니다.

void UEPTimerWidget::StartTimeDisplay()
{
	UpdateTimeDisplay(); // 최초 시간 출력

	// 1초마다 시간 업데이트
	GetWorld()->GetTimerManager().SetTimer(DisplayTimerHandle, DisplayTimerDelegate, 1.0f, true, -1.0f);
}

void UEPTimerWidget::UpdateTimeDisplay()
{
	InitialTime -= 1.0f;
	Minutes = int(InitialTime) / 60;
	Seconds = int(InitialTime) % 60;
	Minutes = FMath::Clamp(Minutes, 0.0f, Minutes);
	Seconds = FMath::Clamp(Seconds, 0.0f, Seconds);
	TimeDisplay->SetText(FText::FromString(FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds)));

	if (EPGameState->GetIsSetupEndMatch() || InitialTime <= 0)
	{
		StopTimeDisplay();
	}
}

void UEPTimerWidget::StopTimeDisplay()
{
	GetWorld()->GetTimerManager().ClearTimer(DisplayTimerHandle);
}

타이머는 같이 시작해야한다

누군가 먼저 타이머를 시작해서는 안됩니다! 적어도 이번에 구현한 방식에서는 그렇습니다. 서버에서 신호를 보내면 클라이언트에서는 ‘매치 지정 시간 - 전송 시간’의 형태로 미리 시간을 차감해서 진행하기 때문에 그렇습니다.

이 방식에는 문제가 있습니다. 같이 시작하고 끝나기는 하지만 서버는 1분이 주어지고, 클라이언트는 58초가 주어지는 형태이기 때문입니다. 이를 개선한다면 서버에서 각 플레이어들의 전송 시간을 조사하여 가장 느린 전송 시간과 플레이어를 구하고, 이를 기준으로 게임을 시작하도록 전달하는 방식을 사용할 것입니다. 모두에게 1분을!

준비되지 않은 정보에 접근하지 않도록 하자

UI 준비 과정에서 아직 서버로부터 전달받지 않은 정보에 접근하지 않도록 주의가 필요합니다. 충분한 시간이 흐르거나 모두 준비가 완료되었음을 서버로부터 확인받은 이후 접근합시다.

2초의 차이가 발생한다면

분명 동기화를 맞췄는데도 2초의 차이가 발생한다면 그건 SetTimer를 잘못 썼을 수도 있다. 내가 그랬다.

SetTimer에서 1초마다 UI갱신 함수를 호출한다면, 첫 번째 호출 시점에는 이미 1초가 지나있을 것이다. 그러니까 시간차감→UI 출력 순서로 작동해야 이 흐름이 적용된다. 출력→시간차감으로 진행하면 마지막에 남아있는 1초를 보게 될 것이다.

매치가 종료되어 UI업데이트가 끝나는 시점도 1초에 영향을 미친다. 그러니까 1→0으로 전환될 때, 0이 되었을 때 바로 종료할 것인가? 아니면 0에서 1초가 지나서야 종료할 것인가? 이걸 결정해야한다. 0이 되었을 때 바로 종료한다고 하면 0초로 UI를 업데이트 하기 전에 타이머가 종료되어서 마지막에 1초가 또 남을 수도 있다.

사소하지만 이런 실수가 있을 수 있으니 주의하자.

결론

결과물

Untitled

참고자료