우선 내가 개선해야 할 UI들은 사진과 같다. <신전의 HP, 아이템 갯수, 플레이어 HP, 퀘스트 안내창, 남은 몬스터 수> 각각의 위젯마다 어떻게 동작하는지 도식화 해보기로 했다.
아직 남은 몬스터 수와 관련되어 처리해준 것은 없기 때문에 제외하고 분석해보았다. 퀘스트 UI를 제외하고는 모두 개선이 필요하다고 느꼈고, 이번 포스팅에서는 이걸 개선해나가는 과정을 적어보려고 한다.
UI 관련 코드들을 변경하기 전에 TakeDamage
구현부부터 변경해주기로 했다.
float ATrapperPlayer::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
if (IsDead) return Damage;
Damage = FMath::Min(CurrentHP, Damage);
if (HasAuthority())
{
CurrentHP -= Damage;
MulticastCharacterDamaged(DamageCauser);
}
else if (IsLocallyControlled())
{
ServerRPCCharacterDamaged(DamageCauser, Damage);
}
return Damage;
}
void ATrapperPlayer::ServerRPCCharacterDamaged_Implementation(AActor* DamageCauser, float Damage)
{
CurrentHP -= Damage;
MulticastCharacterDamaged(DamageCauser);
}
void ATrapperPlayer::MulticastCharacterDamaged_Implementation(AActor* DamageCauser)
{
OnRep_CurrentHP();
if (HasAuthority() && CurrentHP <= 0)
{
MulticastCharacterDeath();
return;
}
Alive(DamageCauser);
}
기존 코드를 살펴보면, TakeDamage가 호출될 경우 서버에서는 CurrentHP에서 데미지만큼 체력을 깎고 Multicast RPC를 날려준다. 클라이언트일 경우 Server RPC를 날린다. 그러고 난 뒤 또 Multicast RPC를 날려준다.
타고타고 내려와서 Multicast에서 사망 혹은 생존판정을 내리게 되는데, 사망판정은 심지어 또 다른 Multicast RPC를 호출한다. 이 과정이 너무 복잡하다고 느꼈다.
float ATrapperPlayer::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
if (IsDead) return Damage;
Damage = FMath::Min(CurrentHP, Damage);
if (HasAuthority())
{
ServerTakeDamage(DamageCauser, Damage);
}
else if (IsLocallyControlled())
{
ServerRPCCharacterDamaged(DamageCauser, Damage);
}
return Damage;
}
void ATrapperPlayer::ServerTakeDamage(AActor* DamageCauser, float Damage)
{
CurrentHP -= Damage;
OnRep_CurrentHP();
if (CurrentHP <= 0)
{
MulticastRPCCharacterDeath();
return;
}
MulticastRPCCharacterAlive(DamageCauser);
}
TakeDamage() 구현부는 이렇게 바꿔주었다. 권한을 가질 경우 ServerTakeDamage
함수를 호출해주고, 아닐 경우 Server RPC를 통해 서버에서 ServerTakeDamage
함수를 호출해준다.
그리고 ServerTakeDamage
함수 내부에서 체력 상태를 확인해 Death, 혹은 Alive Multicast RPC를 날려준다.
우선 HP Change 델리게이트와 관련된 코드들을 삭제해주었다. 이제 OnRep_CurrentHP() 함수를 수정해줄 것이다.
void ATrapperPlayer::OnRep_CurrentHP()
{
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
ATrapperPlayerController* PC = Cast<ATrapperPlayerController>(Iterator->Get());
if (PC)
{
PC->SetPlayerHPBar();
}
}
}
void ATrapperPlayerController::SetPlayerHPBar()
{
if (IsLocalController() && PlayerHudRef)
{
PlayerHudRef->SetHPBar();
}
}
월드에 있는 플레이어 컨트롤러를 가져와, 각각의 SetPlayerHPBar();
함수를 호출하도록 했다. 그럼 정상적으로 HP UI를 바꿔준다 :)
void UPlayerHealthBar::SetHealthUI()
{
FindPlayer();
// ------------------------------------------------------------------------
// Player
float Health = Player->CurrentHP;
float MaxHealth = Player->MaxHP;
HealthText->SetText(FText::FromString(FString::FromInt(Health) + TEXT(" / ") + FString::FromInt(MaxHealth)));
float HealthPercent = Health / MaxHealth;
if (HealthPercent <= 0.5)
{
HealthText->SetColorAndOpacity(FSlateColor(FColor::Red));
}
else
{
HealthText->SetColorAndOpacity(FSlateColor(FColor::White));
}
HealthBar->SetPercent(HealthPercent - 0.04);
// ------------------------------------------------------------------------
// Team Player
if (!TeamPlayer)
{
TeamInfoCanvas->SetVisibility(ESlateVisibility::Collapsed);
return;
}
TeamInfoCanvas->SetVisibility(ESlateVisibility::Visible);
float TeamHealth = TeamPlayer->CurrentHP;
float MaxTeamHealth = TeamPlayer->MaxHP;
TeamHealthText->SetText(FText::FromString(FString::FromInt(TeamHealth) + TEXT(" / ") + FString::FromInt(MaxTeamHealth)));
float TeamHealthPercent = TeamHealth / MaxTeamHealth;
if (TeamHealthPercent <= 0.5)
{
TeamHealthText->SetColorAndOpacity(FSlateColor(FColor::Red));
}
else
{
TeamHealthText->SetColorAndOpacity(FSlateColor(FColor::White));
}
TeamHealthBar->SetPercent(TeamHealthPercent - 0.04);
}
게임을 시작했을 때 HP의 Max값을 받아와 변경해주기 위해 SetHealthUI()
구현부를 변경해주었다.
초기 설정을 확인하기 위해 기본 텍스트를 999/999로 바꾸어주자 :) 플레이어 컨트롤러의 BeginPlay()
함수 안에 있는 InitializeHUD()
함수에서 초기설정을 진행해주었다.
// MainHUD
if (IsValid(PlayerHudClass))
{
PlayerHudRef = CreateWidget<UPlayerHUD>(this, PlayerHudClass);
if (PlayerHudRef)
{
PlayerHudRef->AddToViewport();
PlayerHudRef->SetVisibility(ESlateVisibility::Visible);
PlayerHudRef->GetItemCountChanged(0);
PlayerHudRef->SetHPBar();
ATrapperGameState* gs = Cast<ATrapperGameState>(GetWorld()->GetGameState());
if (gs)
{
gs->SetPlayerHUDRef(PlayerHudRef);
}
}
}
클라이언트는 정상적으로 세팅이 완료되었지만, 서버쪽의 팀 플레이어 HP 정보가 반영되지 않은것을 볼 수 있었다. 클라이언트가 접속한 다음 서버쪽에서도 다시 호출해주어야 한다.
UFUNCTION(Server, Reliable)
void ServerRPCAfterJoinClientSetting();
void ATrapperPlayerController::ServerRPCAfterJoinClientSetting_Implementation()
{
// 클라이언트 접속 이후 서버의 로컬 캐릭터에서 처리해야할 것
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
ATrapperPlayerController* PC = Cast<ATrapperPlayerController>(Iterator->Get());
if (PC && PC->IsLocalController())
{
PC->SetPlayerHPBar();
}
}
}
클라이언트의 초기화 이후, 서버쪽에 클라이언트의 정보를 반영해주는 Server RPC를 만들어주었다.
클라이언트가 접속했을 때, 정상적으로 서버쪽에서 클라이언트의 정보가 동기화되어 UI가 생기는 것을 확인할 수 있다.
마찬가지로, 게임모드에 있는 신전의 정보들과 델리게이트 관련 코드들을 모두 삭제해주었다.
// PlayerHUD.h
void SetTowerInfo(float TowerHealth, float MaxTowerHealth, int32 CurrentWave, int32 MaxWave);
void SetTowerHealth(float TowerHealth, float MaxTowerHealth);
void SetWaveInfo(int32 CurrentWave, int32 MaxWave);
void UPlayerHUD::SetTowerHealth(float TowerHealth, float MaxTowerHealth)
{
FString TowerHealthString = FString::FromInt(TowerHealth) + TEXT(" / ") + FString::FromInt(MaxTowerHealth);
TowerHealthText->SetText(FText::FromString(TowerHealthString));
float HealthPercent = TowerHealth / MaxTowerHealth;
if (HealthPercent < 0.5)
{
TowerHealthText->SetColorAndOpacity(FColor::Black);
}
TowerHealthBar->SetPercent(HealthPercent - 0.04);
}
void UPlayerHUD::SetWaveInfo(int32 CurrentWave, int32 MaxWave)
{
FString TowerWaveString = TEXT("Wave ") + FString::FromInt(CurrentWave) + TEXT(" / ") + FString::FromInt(MaxWave);
TowerWaveText->SetText(FText::FromString(TowerWaveString));
}
원래 신전의 HP 정보를 변경할 때 Wave 정보도 함께 바꿔주었다. 게임모드와 신전 각각에서 접근해 변경할 수 있도록 두개의 함수로 분리해줬다.
// Tower.h
UPROPERTY(ReplicatedUsing = OnRep_HpChanged)
float CurrentHP;
UFUNCTION()
void OnRep_HpChanged();
// Tower.cpp
void ATower::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ATower, CurrentHP);
}
먼저, Tower의 HP 변수를 리플리케이트 해줬다.
float ATower::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float Damage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
Damage = FMath::Min(CurrentHP, Damage);
if (HasAuthority())
{
CurrentHP -= Damage;
OnRep_HpChanged();
UE_LOG(LogTemp, Warning, TEXT("Tower Health : %f"), CurrentHP);
}
return Damage;
}
TakeDamage 함수가 호출되었을 때 서버에서만 현재 HP를 깎아주도록 했고, 서버에서는 OnRep_HpChanged 함수가 호출되지 않으므로 명시적으로 호출해주었다.
void ATower::OnRep_HpChanged()
{
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
ATrapperPlayerController* PlayerController = Cast<ATrapperPlayerController>(Iterator->Get());
if (PlayerController && PlayerController->IsLocalController())
{
PlayerController->SetTowerHPBar(CurrentHP, MaxHP);
}
}
}
void ATrapperPlayerController::SetTowerHPBar(float CurrentHP, float MaxHP)
{
if (IsLocalController() && PlayerHudRef)
{
PlayerHudRef->SetTowerHealth(CurrentHP, MaxHP);
}
}
void UPlayerHUD::SetTowerHealth(float TowerHealth, float MaxTowerHealth)
{
FString TowerHealthString = FString::FromInt(TowerHealth) + TEXT(" / ") + FString::FromInt(MaxTowerHealth);
TowerHealthText->SetText(FText::FromString(TowerHealthString));
float HealthPercent = TowerHealth / MaxTowerHealth;
if (HealthPercent < 0.5)
{
TowerHealthText->SetColorAndOpacity(FColor::Black);
}
TowerHealthBar->SetPercent(HealthPercent - 0.04);
}
OnRep_HpChanged() 함수가 실행되면, 레벨에 있는 플레이어 컨트롤러의 SetTowerHPBar 함수를 호출해준다.
void ATrapperPlayerController::InitializeWorldInfoHUD()
{
// Tower Initialize
TArray<AActor*> OutActors;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATower::StaticClass(), OutActors);
for (const auto& Actor : OutActors)
{
ATower* Tower = Cast<ATower>(Actor);
if (Tower)
{
SetTowerHPBar(Tower->CurrentHP, Tower->MaxHP);
}
}
초기 세팅은 아까와 같이 플레이어 컨트롤러의 InitializeHUD() 함수에서 InitializeWorldInfoHUD() 함수를 호출하고, 그 함수 안에서는 타워 정보를 받아와 최초의 신전 HP를 세팅해준다.
void ATrapperGameMode::SetWaveUI()
{
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
ATrapperPlayerController* PlayerController = Cast<ATrapperPlayerController>(Iterator->Get());
if (PlayerController)
{
PlayerController->MulticastRPCWaveSetting(Wave, MaxWave);
}
}
}
현재 웨이브 정보는 퀘스트처럼 게임모드가 관리하므로, 퀘스트의 방법과 비슷하게 코드를 짜기로 했다.
// Quest & Wave Initialize
if (HasAuthority())
{
SetWorldInfo();
}
else
{
ServerRPCSetWorldInfo();
}
void ATrapperPlayerController::SetWorldInfo()
{
ATrapperGameMode* GameMode = Cast<ATrapperGameMode>(UGameplayStatics::GetGameMode(GetWorld()));
GameMode->QuestManager->RequireSetQuestUI();
GameMode->SetWaveUI();
}
void ATrapperPlayerController::ServerRPCSetWorldInfo_Implementation()
{
SetWorldInfo();
}
초기설정도 퀘스트와 마찬가지로, 플레이어 컨트롤러의 InitializeWorldInfoHUD()
함수 안에서 진행한다.
이번에도 확인을 위해 기본 텍스트를 9999/9999로 바꾸어주었다.
Max HP와 웨이브가 동기화 하는 모습, 몬스터에 의해 신전의 체력이 닳는 모습까지 잘 나오는 것을 확인했다 :)
마찬가지로 아이템과 관련된 델리게이트를 모두 지워주었고, 브로드캐스트 하는 쪽을 PlayerController->SetItemCount(BoneItemBox);
로 모두 바꿔주었다.
void ATrapperPlayerController::SetItemCount(int32 Value)
{
if (IsLocalController() && PlayerHudRef)
{
PlayerHudRef->SetItemCount(Value);
}
}
void UPlayerHUD::SetItemCount(int32 Count)
{
ItemText->SetText(FText::FromString(FString::FromInt(Count)));
}
이 순서로 호출되어 UI를 바꿔주고, 초기 세팅은 HP Bar를 세팅하는 쪽에 함께 적어주었다.
// MainHUD
if (IsValid(PlayerHudClass))
{
PlayerHudRef = CreateWidget<UPlayerHUD>(this, PlayerHudClass);
if (PlayerHudRef)
{
PlayerHudRef->AddToViewport();
PlayerHudRef->SetVisibility(ESlateVisibility::Visible);
PlayerHudRef->SetItemCount(0); // 여기
PlayerHudRef->SetHPBar();
ATrapperGameState* gs = Cast<ATrapperGameState>(GetWorld()->GetGameState());
if (gs)
{
gs->SetPlayerHUDRef(PlayerHudRef);
}
}
}
아이템도 잘 먹어지는 것을 확인했다 :)
오늘은 계속 마음에 안들었던 UI 구조들을 개편할 수 있는 시간이었다. 여전히 어떤 구조가 좋은 코드인지는 잘 모르겠지만, 중구난방하게 사용되고 있던 코드들을 하나의 규칙을 잡고 정리한 것 같아 꽤나 만족스러운 하루였다.
내일은 아직 구현하지 않은 남은 몬스터 수 UI와 튜토리얼 / 정비시간 스킵 기능을 제작할 예정이다 :)