항상 제일 오래걸리는 위젯 만들기.. 너무너무 어렵다.. 이번에도 어떻게 얼레벌레 만들어냈는데, 나중에 위젯 관련된 강의가 없는지 찾아봐야겠다. 알고 만들고 싶어..
아무튼, 이번에는 기존에 만들어두었던 안내창의 Vertical Layout에 SizeBox를 추가하고 자식으로 Overlay를 추가한 뒤, 이미지와 텍스트, Radial Slider를 사용해 UI를 제작해주었다.
1P에 해당하는 붉은색 바는 Value가 증가하면 시계방향으로 차도록 설정해주었고,
2P에 해당하는 파란색 바는 Value가 감소하면 차도록 설정해주었다. 이렇게 구현한 이유는 별거없다.. 아무리 옵션을 이것저것 만져봐도 반시계 방향으로 차오르는걸 구현할 수가 없어서.. ㅎㅎ
스킵이 가능할 때만 보여져야 하므로, 가장 최상위 패널인 SizeBox와 Value를 조작해야 하는 Radial Slider 두개만 변수로 승격해주었다.
기존에는 F키가 신전 상호작용에만 한번 누르는 정도로만 사용하고 있었어서, Input Action을 변경해주었다.
Trigger는 Hold And Release로, Hold Time Threshold는 2.0초로 해주자. (Threshold에 지정한 시간이 지나야 Complete 처리가 된다!)
// ATrapperPlayer.h
void FClickStarted(const FInputActionValue& Value);
void FClickOngoing(const FInputActionValue& Value);
void FClickCanceled(const FInputActionValue& Value);
void FClickCompleted(const FInputActionValue& Value);
UPROPERTY(ReplicatedUsing = OnRep_SkipGauge)
float SkipGauge;
float SkipAccumulatelTime;
float SkipTime = 2.f;
UFUNCTION() void OnRep_SkipGauge();
void CalculateSkipGauge();
bool SkipCurrentStage();
UFUNCTION(Server, Unreliable)
void ServerRPCSkipGaugeChange(float Value);
UFUNCTION(Server, Reliable)
void ServerRPCSkip();
스킵 기능을 위해 추가로 구현한 함수와 변수들이다. 막상 이렇게 적어두니 뭔가 엄청 많아보이네(..)
UIC->BindAction(FClickAction, ETriggerEvent::Started, this, &ATrapperPlayer::FClickStarted);
UIC->BindAction(FClickAction, ETriggerEvent::Ongoing, this, &ATrapperPlayer::FClickOngoing);
UIC->BindAction(FClickAction, ETriggerEvent::Canceled, this, &ATrapperPlayer::FClickCanceled);
UIC->BindAction(FClickAction, ETriggerEvent::Completed, this, &ATrapperPlayer::FClickCompleted);
먼저, 트리거 이벤트에 맞추어 만든 함수들을 바인딩해주었다. 처음에 눌렀을 때, 누르고 있을 때, 누르다 뗐을 때 성공했을 경우와 실패했을 경우 이렇게 네가지 조건이 필요했다.
void ATrapperPlayer::FClickStarted(const FInputActionValue& Value)
{
if (!IsLocallyControlled()) return;
if (SkipGauge >= 1.f)
{
if (HasAuthority())
{
SkipGauge = 0.f;
OnRep_SkipGauge();
}
else
{
ServerRPCSkipGaugeChange(0.f);
}
}
// 생략..
}
F키를 처음 눌렀을 때 실행되는 함수인 FClickStarted
에서는, 스킵판정 처리가 난 후 F키를 한번 더 누르면 초기화해주는 코드를 넣어두었다.
void ATrapperPlayer::ServerRPCSkipGaugeChange_Implementation(float Value)
{
SkipGauge = Value;
OnRep_SkipGauge();
}
서버에서 바뀌는건 게이지 값이 리플리케이트되고 있지만, 클라이언트에서 바뀌는 값은 서버에서 알 수 없으므로 Server RPC를 통해 변경해준다.
void ATrapperPlayer::FClickOngoing(const FInputActionValue& Value)
{
SkipAccumulatelTime += GetWorld()->GetDeltaSeconds();
float Gauge = SkipAccumulatelTime / SkipTime;
Gauge = FMath::Clamp(Gauge, Gauge, 1);
if (HasAuthority())
{
SkipGauge = Gauge;
OnRep_SkipGauge();
}
else
{
ServerRPCSkipGaugeChange(Gauge);
}
}
F키가 눌린 상태에서 호출되는 FClickOngoing
함수에서는 SkipAccumulatelTime 변수에 델타타임을 누적해주고, UI 게이지를 계산해 SkipGauge 변수에 넣어준다. SkipTime은 스킵 판정이 이루어지기까지 필요로 하는 시간이며, 지금은 기획자분들이 지정해두신 2.0초를 사용하고 있다. UI에 넣어주어야 하는 값이기 때문에, 누적된 시간 값을 0~1 사이로 노말라이징해 사용하고 있다.
void ATrapperPlayer::FClickCanceled(const FInputActionValue& Value)
{
SkipAccumulatelTime = 0.f;
if (HasAuthority())
{
SkipGauge = 0.f;
OnRep_SkipGauge();
}
else
{
ServerRPCSkipGaugeChange(0.f);
}
}
버튼을 누르고 2초가 지나지 않고 뗐을 경우 호출되는 함수이다. SkipAccumulatelTime 변수를 초기화해주고, 게이지를 0으로 되돌린다.
void ATrapperPlayer::FClickCompleted(const FInputActionValue& Value)
{
SkipAccumulatelTime = 0.f;
if (HasAuthority())
{
SkipGauge = 1.f;
if (SkipCurrentStage())
{
SkipGauge = 0.f;
}
OnRep_SkipGauge();
}
else
{
ServerRPCSkip();
}
}
void ATrapperPlayer::ServerRPCSkip_Implementation()
{
SkipCurrentStage();
}
2초가 지난 후 버튼을 떼면 스킵이 가능한 상태가 된다. 게이지 값을 1로 고정시킨다.
bool ATrapperPlayer::SkipCurrentStage()
{
TArray<AActor*> OutActors;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATrapperPlayer::StaticClass(), OutActors);
ATrapperPlayer* FirstPlayer = nullptr;
ATrapperPlayer* SecondPlayer = nullptr;
for (const auto& Pawn : OutActors)
{
ATrapperPlayer* TrapperPlayer = Cast<ATrapperPlayer>(Pawn);
if (!TrapperPlayer) continue;
if (TrapperPlayer->IsLocallyControlled())
{
FirstPlayer = TrapperPlayer;
}
else
{
SecondPlayer = TrapperPlayer;
}
}
if(!IsValid(FirstPlayer) || !IsValid(SecondPlayer))
{
return false;
}
if ((FirstPlayer->SkipGauge < 1.f) || (SecondPlayer->SkipGauge < 1.f))
{
return false;
UE_LOG(LogTemp, Warning, TEXT("Skip Failed"));
}
FirstPlayer->SkipGauge = 0.f;
SecondPlayer->SkipGauge = 0.f;
AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
ATrapperGameMode* MyGameMode = Cast<ATrapperGameMode>(GameMode);
if(!MyGameMode) return false;
EGameProgress CurrentStage = MyGameMode->CurrentGameProgress;
switch (CurrentStage)
{
case EGameProgress::Tutorial:
MyGameMode->SkipTutorial();
break;
case EGameProgress::Maintenance:
MyGameMode->SkipMaintenanace();
break;
default:
break;
}
return true;
}
FClickCompleted
함수 내에서 SkipCurrentStage
이라는 함수를 호출하고 있다. 이 함수는 두 플레이어를 받아와 게이지를 검사한 뒤, 두 플레이어 모두 스킵이 가능한 상태라면 게임모드의 Skip 함수를 호출해준다.
void ATrapperGameMode::SkipTutorial()
{
InitialItemSetting();
ActiveQuestActor(QuestManager->TutorialQuestEndNumber);
QuestManager->QuestEffect->MulticastRPCSetQuestEffect(false, FVector::ZeroVector);
SetGameProgress(EGameProgress::Maintenance);
}
void ATrapperGameMode::SkipMaintenanace()
{
bSkipMaintenanace = true;
bMaintenanceInProgress = false;
SetSkipIcon(false);
Wave++;
SetGameProgress(EGameProgress::Wave);
}
SkipTutorial()
함수 내부에서는 처음에 정비에 필요한 아이템 지급, 퀘스트 액터 비활성화 처리, 퀘스트 이펙트 비활성화, 게임 상태를 정비로 넘겨주는 역할을 한다.
SkipMaintenanace()
함수 내부에서는 정비를 종료하는 플래그를 설정해주고, 웨이브 단계에서는 스킵을 사용하지 않으므로 Skip UI를 안보이게 바꿔준 뒤 웨이브를 진행시킨다.
void ATrapperPlayer::OnRep_SkipGauge()
{
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
ATrapperPlayerController* PC = Cast<ATrapperPlayerController>(Iterator->Get());
if (PC)
{
PC->SetSkipGauge();
}
}
}
void ATrapperPlayerController::SetSkipGauge()
{
if (IsLocalController() && PlayerHudRef)
{
PlayerHudRef->SetSkipGauge();
}
}
OnRep 함수에서는 플레이어 컨트롤러의 함수를 호출해 UI를 변경해주고 있다.
void UPlayerHUD::SetSkipGauge()
{
FindPlayer();
if (Player->HasAuthority())
{
SkipGaugeFirstPlayer->SetValue(Player->SkipGauge);
if (TeamPlayer)
{
SkipGaugeSecondPlayer->SetValue(1 - TeamPlayer->SkipGauge);
}
else
{
SkipGaugeSecondPlayer->SetValue(1.f);
}
}
else
{
SkipGaugeSecondPlayer->SetValue(1 - Player->SkipGauge);
SkipGaugeFirstPlayer->SetValue(TeamPlayer->SkipGauge);
}
}
기존 플레이어들의 체력 값을 받아오는 로직을 그대로 사용했다. FindPlayer()
함수를 사용해 플레이어를 받아오고, 스킵 게이지를 받아와 UI에 설정해준다.
이제 튜토리얼과 정비시간을 스킵할 수 있게됐다 :)
간단하긴 하지만, 테스트에 유용하게 쓰일 것 같아서 테스트하기 쉽도록 간단하게 정리해 기획자, 아트분들께 전달도 해드렸다.
위젯은 다른 팀원이 만들어주어서, 빠르게 구현만 진행할 수 있었다.
지금까지는 계속 게임모드에 모든 게임의 상태와 데이터를 관리하고 있었는데, 게임 스테이트라는 것을 알게 되었다... 여기에 구현했으면 조금 더 쉬웠을 텐데... 아무튼, 나중에 옮기더라도 구현이 우선이므로, 이거라도 게임 스테이트를 사용해보기로 했다.
게임 스테이트는 서버와 클라이언트 모두가 가지고 있으므로, 동시에 관리해야할 데이터를 가지고 있기 좋은 곳이었다.
// ATrapperGameState.h
public:
void ChangeMonsterCount(int32 Count);
void ChangeRemainingMonsterCount(int32 Count);
UPROPERTY(ReplicatedUsing = OnRep_ChangeCurrentMonster)
int32 CurrentMonsterCount = 0;
UFUNCTION()
void OnRep_ChangeCurrentMonster();
UPROPERTY(ReplicatedUsing = OnRep_ChangeRemainingMonsterCount)
int32 RemainingMonsterCount = 0;
UFUNCTION()
void OnRep_ChangeRemainingMonsterCount();
void ATrapperGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ATrapperGameState, CurrentMonsterCount);
DOREPLIFETIME(ATrapperGameState, RemainingMonsterCount);
}
현재 몬스터 수와, 앞으로 나올 몬스터의 수를 설정해주기 위하여 변수를 선언하고, 리플리케이트를 설정해주었다.
void ATrapperGameState::ChangeMonsterCount(int32 Count)
{
if (HasAuthority())
{
CurrentMonsterCount += Count;
OnRep_ChangeCurrentMonster();
}
}
void ATrapperGameState::OnRep_ChangeCurrentMonster()
{
if (PlayerHUDRef)
{
PlayerHUDRef->SetCurrentMonsterCount(CurrentMonsterCount);
}
}
몬스터가 생성되거나 삭제될 때 호출되는 함수들이다. 몬스터의 생명주기 관련 처리는 팀원 친구가 해놨으므로 패스하고, 웨이브별 남은 몬스터를 띄우는 부분만 정리하겠다.
void ATrapperGameState::ChangeRemainingMonsterCount(int32 Count)
{
if (HasAuthority())
{
RemainingMonsterCount += Count;
OnRep_ChangeRemainingMonsterCount();
}
}
void ATrapperGameState::OnRep_ChangeRemainingMonsterCount()
{
if (PlayerHUDRef)
{
PlayerHUDRef->SetRemainingMonsterCount(RemainingMonsterCount);
}
}
ChangeRemainingMonsterCount
함수를 호출하면 앞으로 나올 몬스터 수에 가감되고 UI에 표시된다.
void ATrapperGameMode::GetThisWaveRemainingMonsterCount()
{
for (uint32 i = Wave + 1; i < Wave + 6; i++)
{
for (uint32 k = 1; k < 6; k++)
{
FString WaveText = TEXT("Wave") + FString::FromInt(i) + TEXT("_") + FString::FromInt(k);
FWaveInfo* Data = WaveData->FindRow<FWaveInfo>(*WaveText, FString());
if (!Data || !Data->UseThisWave) continue;
int32 TotalMonster = 0;
TotalMonster += Data->Skeleton;
TotalMonster += Data->Mummy;
TotalMonster += Data->Zombie;
TotalMonster += Data->Debuffer;
TrapperGameState->ChangeRemainingMonsterCount(TotalMonster);
//UE_LOG(LogTemp, Warning, TEXT("This Wave %d-%d, Total Spawn Monster %d"), i, k, TotalMonster);
}
}
}
게임모드에 GetThisWaveRemainingMonsterCount
함수를 추가해 정비시간마다 호출해주었다. 이 함수 안에서 앞으로 5웨이브동안 나올 몬스터 수를 계산해준다.
void ATrapperGameMode::SpawnMonster(struct FWaveInfo& OutData)
{
/// *********** Create Monster ***********
if (!bSpawnMonster) return;
UE_LOG(LogGameLoop, Warning, TEXT("Create Monster - Skeleton %d / Mummy %d / Zombie %d / Debuffer %d"),
OutData.Skeleton, OutData.Mummy, OutData.Zombie, OutData.Debuffer);
int32 DeleteMonsterCount = 0;
DeleteMonsterCount += OutData.Skeleton;
DeleteMonsterCount += OutData.Mummy;
DeleteMonsterCount += OutData.Zombie;
DeleteMonsterCount += OutData.Debuffer;
TrapperGameState->ChangeRemainingMonsterCount(-DeleteMonsterCount);
if (IsValid(Spanwer))
{
Spanwer->SpawnMonsters(OutData.Skeleton, OutData.Mummy, OutData.Zombie, OutData.Debuffer);
//UE_LOG(LogTemp, Warning, TEXT("Check"));
}
}
SpawnMonster
함수 내에서는 한 서브웨이브가 진행될 때마다 나오는 몬스터 수를 빼주도록 했다.