점수 시스템 및 리플리케이션
멀티플레이어 환경에서 작동하는 점수 시스템 만들기
목표
킬, 데스, 점수를 나타내는 간단한 점수시스템(스코어보드)을 만들어보자. 1킬당 10점씩 제공하며, 가장 점수가 높은 플레이어가 지정 점수에 도달하면 게임을 종료한다. 점수 현황은 Tab 버튼을 눌러 확인할 수 있으며, 점수가 높은 순서대로 정렬하여 표시한다.
점수 시스템 설계
기본 설계
위의 목표를 달성하기 위해서는 만들어야 하는 것들이 몇 가지 있다.
- 사망 및 부활 기능(데미지 처리 흐름)
- 점수 저장 및 동기화
- UI 연동
데미지 처리 흐름
데미지 처리 흐름은 위 이미지와 같다. 플레이어가 폭탄을 던지면 서버에서 폭발 및 데미지 처리를 진행하고, 그 결과에 따라서 사망 여부를 판단한다. 사망한 플레이어는 대리자를 통해 이를 게임모드에 알린다. 게임모드는 각 플레이어의 킬, 데스, 스코어를 갱신하고 그 결과를 모든 클라이언트와 공유한다. 클라이언트는 이를 전달받아 스코어보드 UI를 갱신한다. 게임모드는 마지막으로 게임 종료 조건을 판단하는 것으로 마무리. 이 과정에서 각각은 스탯 및 UI를 갱신하거나 애니메이션을 처리하는 등의 부가 행동을 수행한다.
GameMode, GameState, PlayerState
어디에 어떻게 점수를 저장하고 처리해야할까? 이를 위해서는 위의 3가지 클래스를 알아야한다. 자세한 설명은 공식문서에 다 나와있다. 여기서는 몇 가지 정보만 정리해보자.
- GameMode
- 점수를 올리는 로직과 게임 종료 조건 판단을 진행한다.
- 서버에만 존재한다! 클라이언트에서 게임모드를 찾아봤자 존재하지 않는다. 그래서 캐릭터(클라)→캐릭터(서버)→게임모드(서버) 순서대로 전달을 진행했다.
- GameMode와 GameModeBase가 존재한다. 예전에 멀티플레이어 슈팅 게임의 로직 처리 목적으로 만든 것이 GameMode이며, 이를 경량화하여 범용으로 쓸 수 있도록 만든것이 GameModeBase이다.
- GameState
- 게임 진행에 필요한 정보들을 담당한다. 플레이어 목록이 여기 있으며 이외에도 매치의 시작과 끝 여부, 매치 진행 시간 등의 정보도 관리한다.
- 스코어보드를 여기에 저장하고 관리한다.
- 리플리케이트된다! 모든 플레이어는 GameState에 접근 가능하다. 따라서 관리중인 스코어보드에 접근하여 정보를 가져갈 수 있다.
- GameState도 GameStateBase가 있다! 반드시 GameMode-GameState, GameModeBase-GameStateBase 짝을 맞춰서 사용해야한다.
- PlayerState
- 플레이어 개개인의 정보를 담당한다. 핑, 점수, 관전 등의 정보가 있다. 나는 킬, 데스 정보를 여기에서 관리한다.
- GameState는 팀 점수를, PlayerState는 개인 점수를 담당한다고 생각하면 좋다.
- 매치 내내 유지되어야하는 정보는 PlayerState에, 부활마다 달라지는 정보는 Pawn에 두는 것이 좋다.
- 리플리케이트된다! 모든 플레이어는 PlayerState에 접근 가능하다. 따라서 관리중인 스코어보드에 접근하여 정보를 가져갈 수 있다.
PlayerState
는 개인의 Kill, Death 정보를 가지고,GameState
는 PlayerState를 기반으로 계산한 모든 플레이어의 Score 목록을 가지며,GameMode
는 사망 정보를 전달받아 PlayerState와 GameState 내용 갱신을 진행한다.
PlayerState에 Score를 저장하지 않고 굳이 GameState에 Score를 저장하는 ScoreBoard를 만든 이유는 매번 모든 PlayerState에 접근하지 않고 갱신 후 정렬된 컨테이너를 유지하기 위함이다.
점수 저장 방식
// EPPlayerState.h
// Replicate
public:
/* KillCount가 업데이트 되면 로컬 컨트롤러의 스코어보드 업데이트*/
UFUNCTION()
void OnRep_UpdateKillCount();
/* DeathCount가 업데이트 되면 로컬 컨트롤러의 스코어보드 업데이트*/
UFUNCTION()
void OnRep_UpdateDeathCount();
private:
UPROPERTY(ReplicatedUsing = OnRep_UpdateKillCount)
int32 KillCount;
UPROPERTY(ReplicatedUsing = OnRep_UpdateDeathCount)
int32 DeathCount;
// Get & Set
public:
int32 GetKillCount() { return KillCount; };
int32 GetDeathCount() { return DeathCount; };
void AddKillCount() { ++KillCount; };
void AddDeathCount() { ++DeathCount; };
앞서 말했듯 PlayerState에서 Kill, Death를 저장, 관리한다. 프로퍼티 리플리케이션을 사용하기에 각 플레이어는 모든 플레이어의 PlayerState 정보를 동일하게 유지할 수 있다.
// EPGameState.h
USTRUCT()
struct FScoreBoard
{
GENERATED_BODY()
public:
UPROPERTY()
TObjectPtr<AEPPlayerState> PlayerState;
UPROPERTY()
int32 Score = 0;
bool operator==(const FScoreBoard& Other) const
{
return (PlayerState == Other.PlayerState) && (Score == Other.Score);
}
};
...
/*
* 각 플레이어의 상태와 점수를 저장하고 있는 배열
* TMap이 아닌 구조체 TArray인 이유는 Replicate 때문이다.
*/
UPROPERTY(ReplicatedUsing = OnRep_PostUpdateScoreBoard)
TArray<FScoreBoard> ScoreBoard;
GameState에는 PlayerState 포인터와 Score를 멤버로 가지는 구조체가 정의되어있다. 스코어보드는 해당 구조체를 담은 배열이다. 자동 정렬되는 key-value 컨테이너를 사용하지 않는 이유는 아래 리플리케이션 항목에서 설명한다.
점수 처리는 어떻게 하지?
// EPDeathMatchGameMode.cpp
void AEPDeathMatchGameMode::OnPlayerKilled(AController* KillerPlayer, AController* KilledPlayer, AActor* DamageCauser)
{
AEPGameState* EPGameState = GetGameState<AEPGameState>();
AEPPlayerState* KillerPlayerState = KillerPlayer->GetPlayerState<AEPPlayerState>();
AEPPlayerState* KilledPlayerState = KilledPlayer->GetPlayerState<AEPPlayerState>();
if(!EPGameState || !KillerPlayerState || !KilledPlayerState)
{
return;
}
// 킬이 발생한 경우 킬러의 킬카운트 증가. 자살인 경우는 제외한다.
if (KillerPlayer != KilledPlayer)
{
KillerPlayerState->AddKillCount();
}
// 데스카운트는 자살을 포함하여 공통으로 증가한다.
KilledPlayerState->AddDeathCount();
// 스코어보드의 내용을 업데이트한다.
EPGameState->UpdateScoreBoard(KillerPlayerState);
// 서버 플레이어의 스코어보드 UI 업데이트를 호출한다. 클라이언트는 GameState의 OnRep_PostUpdateScoreBoard에서 이를 호출한다.
GetGameState<AEPGameState>()->OnRep_PostUpdateScoreBoard();
// 게임 종료 여부 확인
if (CheckEndMatchCondition())
{
SetTheEndMatch();
}
}
플레이어가 사망했다는 정보가 게임모드에 전달되면, 게임모드는 Kill 플레이어와 Death 플레이어 각각의 PlayerState에 접근하여 AddKillCount, AddDeathCount를 호출한다. PlayerState의 Kill, Death 프로퍼티는 리플리케이션 되며, 값 변경 시 플레이어 컨트롤러의 UpdateScoreBoard함수를 호출한다. 로컬 플레이어는 이에 따라 스코어보드 UI를 갱신한다.
// EPGameState.cpp
void AEPGameState::UpdateScoreBoard(TObjectPtr<AEPPlayerState> KillerGameState)
{
// 점수 변경이 필요한 플레이어를 찾아서 점수를 업데이트하고 점수가 높은 순서대로 정렬한다.
for (auto& PlyserScoreStruct : ScoreBoard)
{
if (PlyserScoreStruct.PlayerState == KillerGameState)
{
int32 KillCount = KillerGameState->GetKillCount();
PlyserScoreStruct.Score = KillCount * KillScoreMultiplier;
break;
}
}
ScoreBoard.Sort([](FScoreBoard A, FScoreBoard B) { return A.Score > B.Score; });
}
스코어보드는 별도로 업데이트한다. 킬당 점수 가중치 정보를 가지고 있으므로 갱신이 필요한 플레이어를 찾아 Score 정보를 갱신한다. 그리고 내림차순으로 정렬! 이 정렬 때문에 별도의 ScoreBoard를 유지한다고 봐도 된다.
리플리케이션
왜 Key-Value 형식의 컨테이너를 사용하지 않았나?
리플리케이션이 막혀있기 때문이다. 처음에는 TMap을 사용하여 Score를 기반으로 자동 정렬되는 컨테이너를 사용하고자했다. 그러나 곧 언리얼 엔진에서 TMap, TSet의 리플리케이트를 막아두었다는걸 알 수 있었다.
생각해보면 TMap이 TArray의 복제보다 신경쓸 일이 많기는 하다. 요소의 삽입, 삭제, 수정에 따라서 복제할 양도 다르고. 그냥 TArray 복제로 못쓸 것도 아니다.
에디터 콘솔 명령어에 cmd.ContainerRepTest.AddToMapAndSet
을 입력하면 저들을 사용할 수 있지만, 굳이 막아둔걸 뚫어서 쓰기보다는 TArray와 구조체를 결합하여 사용하는 방안으로 진행했다.
정보가 동기화를 위한 시간이 필요하다
게임을 시작하고 바로 ScoreBoard에 접근했다가는 문제가 발생할 수 있다. 아직 각종 정보들이 리플리케이트 되지 않았기 때문이다. 한 컴퓨터에서 PIE 모드로 테스트 할 때는 정상 작동하다가 패키징 이후에 다른 컴퓨터로 나눠서 테스트하니 그제서야 문제가 드러났다. 항상 정보 동기화까지는 지연시간이 있다는 것을 명심하자.
정보 갱신 시점
정보가 전달 된 후에 UI를 갱신한다. 당연하지만 이를 고려하지 않아서 조금 문제가 있었다. 하나의 UI에 프로퍼티 리플리케이션으로 전달해야 하는 정보는 3개였기 때문이다.
처음에는 한 번의 UI 갱신만 호출했었다. 그러자 어떤 정보는 갱신되는데 다른 정보는 갱신이 되지 않는 문제가 나타났다. 스코어보드에서 Kill은 업데이트 되었는데 Score는 그대로인 그런 문제말이다.
그래서 3개의 정보가 모두 도착하면 한 번 UI를 갱신하는 방식으로 변경해보았다. 하지만 좋은 방식은 아니었다. 정보가 4개가 된다면? 더 늘어난다면? 매번 갱신 여부를 전부 확인할 것인가?
결국은 각 정보가 전달 될 때 마다 UI를 갱신하는 것을 선택했다. 전달되는 정보는 3개, UI 갱신도 3번.
추가 고려 사항
게임 도중에 다른 플레이어가 참가할 수 있는지, 갑자기 플레이어가 퇴장하거나 연결이 끊기면 어떻게 처리할 것인지 생각해두자. 플레이어가 퇴장하면 GameState의 플레이어 리스트에서 삭제될테니 이를 활용하는 방안으로 진행했다.
UI 구성
VerticalBox 내부에 각 플레이어의 정보를 표시하는 블럭을 추가하거나 제거하는 방식이다. 각 블럭은 단순한 텍스트 출력 기능만을 가진다.
// EPScoreBoardWidget.cpp
void UEPScoreBoardWidget::UpdateScoreBoard()
{
// GameState의 ScoreBoard에는 각 플레이어의 PlayerState와 Score가 있으며, Score가 높은 순서대로 정렬되어있다.
TArray<FScoreBoard> &SB = GetWorld()->GetGameState<AEPGameState>()->ScoreBoard;
// 정보를 담을 위젯들을 플레이어 수에 맞게 조정한다.
ResizeScoreBoard(SB.Num());
for (int32 i = 0; i < SB.Num(); ++i)
{
FString PlayerName = SB[i].PlayerState->GetPlayerName();
int32 KillCount = SB[i].PlayerState->GetKillCount();
int32 DeathCount = SB[i].PlayerState->GetDeathCount();
int32 Score = SB[i].Score;
FText PlayerScoreText = FText::Format(
NSLOCTEXT("ScoreBoard", "PlayerScoreText", "{0} / Kills: {1} / Deaths: {2} / Score: {3}"),
FText::FromString(PlayerName),
FText::AsNumber(KillCount),
FText::AsNumber(DeathCount),
FText::AsNumber(Score)
);
PlayerScoreWidgets[i]->SetText(PlayerScoreText);
}
}
매번 정보를 업데이트 하기 전에 플레이어의 수와 스코어보드의 블럭 갯수를 비교하여 스코어보드를 조정한다.
참고자료
- https://wizardcell.com/unreal/multiplayer-tips-and-tricks/
- https://ikrima.dev/ue4guide/networking/networking-overview/
- https://www.youtube.com/watch?v=4US1mHsGcs4&ab_channel=enigmatutorials
- https://www.youtube.com/watch?v=5_gu-UxjxgE&ab_channel=NiceShadow
- https://www.youtube.com/watch?v=Hsr6mbNKBLU&ab_channel=Kekdot
- https://dev.epicgames.com/documentation/ko-kr/unreal-engine/map-containers-in-unreal-engine?application_version=5.3