멀티플레이 게임에서 UI를 개발함에 있어서 고려할 만한 점이 있다.
Owner설정
- UI의 Owner
- UI에 띄울 정보를 가질 Owner
보이는 곳
- Owner에게만 보여줄 것인가
- 다른 유저들에게도 보여줄 것인가
위젯에서 표시할 값이 어디에서 변하는가
- 서버에서 변경
- 클라이언트에서 변경
1. Owner 설정과 보여지는 곳
예를 들어, 우리가 자신의 체력 바를 만든다고 했을 때를 생각해 보자
- UI의 Owner는 나 자신이고, 띄울 정보의 Owner도 나 자신이다.
- 보여지는 곳은 나 자신이 될 것이다.
- 이 경우는 크게 생각할 문제가 없다.
그러나 우리가 다른 플레이어의 체력 바를 또한 띄우고 싶다면
- UI의 Owner는 나 자신이지만, 띄울 정보는 다른 플레이어의 정보이다.
- 보여지는 곳은 여전히 나 자신이 된다.
- 이 경우 해당 Widget을 초기화 함에 있어서 다른 플레이어의 정보를 통해서 초기화 해주어야 한다.
아래의 코드를 보면
- 위젯 이름 설명
- HP_BoardWidget은 우리 팀원의 체력을 붙여서 띄워줄 VertexBox를 가지고 있는 클래스이다.
- HP_Widget의 경우는 각 팀원들의 체력을 나타내는 클래스이다.
- 코드 설명
- 각각의 HP_Widget의 정보의 Owner는 나를 제외한 다른 플레이어이다.
- 그렇게 때문에 GameState의 PlayerArray를 통해 모든 플레이어의 정보 순환하면서 Owner를 각 플레이어로 지정해주고 부모 Widget인 HP_BoardWidget에 붙여준다.
void UGS_HPBoardWidget::InitBoardWidget()
{
if (!IsValid(HPWidgetClass) || !IsValid(HPWidgetList))
{
return;
}
HPWidgetList->ClearChildren();
AGameStateBase* GS = UGameplayStatics::GetGameState(this);
if (IsValid(GS))
{
TArray<APlayerState*> PSA = GS->PlayerArray;
for (APlayerState* PS : PSA)
{
AGS_PlayerState* GSPS = Cast<AGS_PlayerState>(PS);
if (IsValid(GSPS))
{
UGS_HPWidget* HPWidget = CreateWidget<UGS_HPWidget>(this, HPWidgetClass);
if (IsValid(HPWidget))
{
AGS_Character* Character = Cast<AGS_Character>(GSPS->GetPawn());
//자기 자신 제외
if (IsValid(Character) && Character != OwningCharacter)
{
//가디언 제외
if (GSPS->CurrentPlayerRole == EPlayerRole::PR_Guardian)
{
continue;
}
HPWidget->SetOwningActor(Character);
HPWidget->InitializeHPWidget(Character->GetStatComp());
HPWidgetList->AddChildToVerticalBox(HPWidget);
}
}
}
}
}
}
- 부모 Widget인 HP_BoardWidget의 Owner는 나 자신이기 때문에 아래와 같이 생성자에서 OwningActor를 GetOwningPlayer()를 통해 지정해준다.
void UGS_HPBoardWidget::NativeConstruct()
{
Super::NativeConstruct();
if (IsValid(GetOwningPlayer()->GetPawn()))
{
OwningCharacter = Cast<AGS_Character>(GetOwningPlayer()->GetPawn());
}
...
}
2. 어디에서 값이 변경되는가
두가지 경우가 있을 수 있다.
- 값이 클라이언트에서 변경
- 혹은 서버에서 변경
값이 클라이언트에서 변하고, 위젯 Owner만 값을 보는 경우는 크게 생각할 것이 없다.
그러나, 값이 서버에서 변경되었을 때 모든 클라이언트가 해당 값을 보고 싶은 경우가 많다.
- 보스 몬스터의 체력과 피버 게이지
- 다른 플레이어의 체력 등...
현재 진행하는 프로젝트를 예를 들면, 보스 몬스터의 체력과 피버 게이지를 생각해 볼 수 있다.
2 - 1. 보스 몬스터의 체력
- 플레이어의 체력은 변수 자체가 replicated된 상태이다.
- 주의할 점은 체력의 변경은 주요 로직이므로 "서버"에서 진행된다.
- 그렇기 때문에, 변경된 순간 클라이언트 쪽에서도 값을 업데이트 해줘야한다.
- 이 경우 ReplicatedUsing 키워드를 통해서 클라이언트로 값을 알려주었다.
- 또한 해당 체력을 가지고 있는 Stat 컴포넌트가 변수는 delegate를 통해 연동되어서 값이 바뀔 때마다 위젯의 함수를 호출하여 체력 게이지의 값을 변경해준다.
- 로직
- StatComp와 HPWidget을 Delegate 바인딩 하기
- StatComp에 존재하는 CurrentHealth 변수가 SetCurrentHealth 를 통해 변경되면 BroadCast를 통해 위젯의 업데이트 함수가 call된다.
- 이때 OnRep 함수를 통해 클라이언트에서도 BroadCast하게 되면서 모든 클라이언트 위젯이 업데이트 된다.
- 이때 주의할 점은 OnRep을 통해서 클라이언트에 변경된 값을 알려주지 않는다면, 위젯이 업데이트되지 않는다
//GS_StatComp.h
DECLARE_MULTICAST_DELEGATE_OneParam(FOnCurrentHPChangedDelegate, UGS_StatComp*);
UPROPERTY(ReplicatedUsing = OnRep_CurrentHealth)
float CurrentHealth;
//GS_StatComp.cpp
void UGS_StatComp::OnRep_CurrentHealth()
{
OnCurrentHPChanged.Broadcast(this);
}
void UGS_StatComp::SetCurrentHealth(float InHealth, bool bIsHealing)
{
...
CurrentHealth = InHealth;
OnCurrentHPChanged.Broadcast(this);
...
}
//GS_Character.cpp
void AGS_Character::SetHPBarWidget(UGS_HPWidget* InHPBarWidget)
{
UGS_HPWidget* HPBarWidget = Cast<UGS_HPWidget>(InHPBarWidget);
if (IsValid(HPBarWidget))
{
HPBarWidget->InitializeHPWidget(GetStatComp());
StatComp->OnCurrentHPChanged.AddUObject(HPBarWidget, &UGS_HPWidget::OnCurrentHPBarChanged);
}
}
//GS_HPWidget.cpp
void UGS_HPWidget::NativeConstruct()
{
Super::NativeConstruct();
if (!IsValid(OwningCharacter))
{
OwningCharacter = Cast<AGS_Character>(GetOwningPlayer()->GetPawn());
}
if (IsValid(OwningCharacter))
{
OwningCharacter->SetHPBarWidget(this);
}
}
void UGS_HPWidget::OnCurrentHPBarChanged(UGS_StatComp* InStatComp)
{
HPBarWidget->SetPercent(InStatComp->GetCurrentHealth()/InStatComp->GetMaxHealth());
}
2 - 2. 보스 몬스터의 피버 게이지
- 피버 게이지의 경우 변수를 리플리케이트하지 않았다.
- 그렇기 때문에, 이 값들을 다른 클라이언트에게 모두 알려주기 위해서 Multicast를 사용하였다.
- 체력과 거의 유사한 구조로 구성되었지만, 값을 변경해주는 함수를 Multicast로 만든 것이 차이점이다.
//GS_Drakhar.h
DECLARE_MULTICAST_DELEGATE_OneParam(FOnCurrentFeverGageChangedDelegate, float);
float CurrentFeverGage;
//GS_Drakhar.cpp
void AGS_Drakhar::SetFeverGageWidget(UGS_DrakharFeverGauge* InDrakharFeverGageWidget)
{
UGS_DrakharFeverGauge* DrakharFeverGaugeWidget = Cast<UGS_DrakharFeverGauge>(InDrakharFeverGageWidget);
if (IsValid(DrakharFeverGaugeWidget))
{
//client
DrakharFeverGaugeWidget->InitializeGage(GetCurrentFeverGage());
OnCurrentFeverGageChanged.AddUObject(DrakharFeverGaugeWidget, &UGS_DrakharFeverGauge::OnCurrentFeverGageChanged);
}
}
void AGS_Drakhar::MulticastRPCSetFeverGauge_Implementation(float InValue)
{
CurrentFeverGage += InValue;
...
OnCurrentFeverGageChanged.Broadcast(CurrentFeverGage);
}
void AGS_Guardian::ApplyDamageToDetectedPlayer(const TSet<AGS_Character*>& DamagedCharacters, float PlusDamge)
{
for (auto const& DamagedCharacter : DamagedCharacters)
{
...
AGS_Drakhar* Drakhar = Cast<AGS_Drakhar>(this);
if (!Drakhar->GetIsFeverMode())
{
Drakhar->MulticastRPCSetFeverGauge(10.f);
}
}
}
}
2 - 3. 어떤 방식이 더 좋은가?
- 멀티캐스트 하는 방식에는 가장 큰 문제점이 존재한다.
- 만약 다른 플레이어가 게임 도중에 들어온 경우, 피버 게이지의 값이 동기화 되지 않는 문제점이 있다.
- 아래 사진과 같은 문제
변수를 리플리케이트 되도록 수정한 버전
- 체력과 마찬가지로 OnRep을 통해 클라이언트에게 알려주도록 구현
//Drakhar.h
UPROPERTY(ReplicatedUsing=OnRep_FeverGauge)
float CurrentFeverGauge;
UFUNCTION()
void OnRep_FeverGauge();
//Drakhar.cpp
void AGS_Drakhar::OnRep_FeverGauge()
{
OnCurrentFeverGageChanged.Broadcast(CurrentFeverGauge);
}
void AGS_Drakhar::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
...
DOREPLIFETIME(ThisClass, CurrentFeverGauge);
}
결과 모습
'Unreal Engine' 카테고리의 다른 글
UnrealEngine - HitStop (0) | 2025.06.17 |
---|---|
UE5 Issues - 서버 & 클라이언트 로직 (0) | 2025.06.15 |
Unreal Engine - 데디케이티드 서버 11 (콤보 공격 최적화) (0) | 2025.06.02 |
Unreal Engine - 데디케이티드 서버 10 (콤보 공격) (0) | 2025.05.30 |
UE5 Issues - Dash Skill (데디케이티드 서버) (0) | 2025.05.27 |