Unreal Project Acero#34(Delegate를 이용한 스코어 업데이트)

공팡팡·2025년 5월 8일

Unreal - Project Acero

목록 보기
8/10

ScoreBoard

현재 스코어보드의 UI구성이다.

해당 수치들의 변화 조건은 당연하게도 플레이어가 제거될때다. 기존에 만들어두었던 PlayerEliminated함수에서 Attacker와 Victim의 Controller, State를 가져올 수 있다. 이때 PS->GoldAdd나 PS->DeathAdd를 구현할 수 있다.

이 때, 동기화를 위해 Gold나 Death에 OnRep_Function을 만드는게 필요하다. 기존에 활용하였던 set형태로 구현하여도 되나, Delegate를 이용하면 좀더 깔끔한 형태가 가능할 듯 하여 아래와 같은 형태로 구현하였다.

OnRep을 통한 Delegate

//PS
    UPROPERTY(BlueprintAssignable, Category = "Score")
    FOnScoreUpdated OnScoreUpdated;
    UPROPERTY(BlueprintAssignable, Category = "Gold")
    FOnGoldUpdated OnGoldUpdated;

protected:
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    
    UFUNCTION()
    void OnRep_Team();
    UFUNCTION()
    void OnRep_Gold();
    UFUNCTION()
    void OnRep_Kills();
    UFUNCTION()
    void OnRep_Deaths();

private:
    UPROPERTY(ReplicatedUsing = OnRep_Team, VisibleAnywhere, Category = "Player Stats")
    ETeam Team = ETeam::ET_NoTeam;
    UPROPERTY(Replicated, VisibleAnywhere, Category = "Player Stats")
    FString Name;
    UPROPERTY(ReplicatedUsing = OnRep_Kills, VisibleAnywhere, Category = "Player Stats")
    int32 Kills = 0;
    UPROPERTY(ReplicatedUsing = OnRep_Deaths, VisibleAnywhere, Category = "Player Stats")
    int32 Deaths = 0;
    UPROPERTY(ReplicatedUsing = OnRep_Gold, EditAnywhere, Category = "Player Stats")
    int32 Gold = 0;

만약 PS의 킬이나 데스가 변경될 경우 Player의 Row에게 Notify하는 형식으로 구성하였다. Kill이나 Death등의 변화가 일어나면 자동으로 해당 Rep함수로 넘어가는 형식이다. 킬데스와 달리 골드의 경우 플레이어의 UI에서 즉각적으로 변하기에 따로 함수공간을 만들어주었다.

OnRep의 예시로 골드를 갱신하는 Delegate함수다. 함수가 두개로 나뉘는데, 전자의 경우 인자를 필요로 하지 않는 ScoreUpdate함수, 후자는 인자를 필요로 하는 GoldUpdate함수다. 이처럼 인자가 필요한 경우 뒤의 공간에 자료형과 변수명을 ,로 구현하는 과정이 필요하다.

//In PS
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnScoreUpdated);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnGoldUpdated, int32, NewGold);

UPROPERTY(BlueprintAssignable, Category = "Score")
    FOnScoreUpdated OnScoreUpdated;

선언으로 끝나는게 아닌, 이를 이용하기 위해선 델리게이트의 인스턴스를 만들어줘야 한다.

void ACDPlayerState::OnRep_Gold()
{
	//UE_LOG(LogTemp, Display, TEXT("Gold Updated: %d"), Gold);
	OnGoldUpdated.Broadcast(Gold);
}

최종적으로 해당 인스턴스를 Broadcast하면 비로소 Gold가 업데이트 된다.

Delegate를 사용하는 이유

델리게이트를 이용해 얻을 수 있는 가장 큰 장점이 무엇인가 하면 PS를 순회할 필요가 없다는 것이다.

for (UPlayerRow* PlayerRow : PlayerRows)
    {
        if (PlayerRow)
        {
            PlayerRow->UpdateRow();
        }
    }

위의 코드방식과 달리, playerstate의 변경사항을 자동으로 감지하기에, 각각의 playerstate가 어떤 playerrow를 담당하고 있는지 신경 쓰지 않아도 된다. broadcast가 호출되면 각 playerrow는 자신이 바인딩 되어 있는 delegate함수를 자동으로 호출하게 되기에, 직접적인 호출을 건너띄게 된다.

PS는 playerrow의 객체 구조를 알 필요가 없다. 따라서 그 만큼의 캐스팅도 필요없고, 캐스팅에 실패해서 null을 참조할때의 크래시 발생과 같은 문제들이 일어나지 않게 된다.

각 열에대한 설정을 마치면, 이 PlayerRow 배열을 담고 있는 UI에 해당함수를 순회하며 AddChild를 하기만 하면된다.

void UKDOverlay::SetupScoreboard()
{
	if (!GetWorld() || !PlayerRowClass) return;

	ClearScoreboard();

	AGameStateBase* GameState = UGameplayStatics::GetGameState(this);
	if (!GameState) return;

	const TArray<APlayerState*>& PlayerArray = GameState->PlayerArray;

	for (APlayerState* PS : PlayerArray)
	{
		ACDPlayerState* CDPS = Cast<ACDPlayerState>(PS);
		if (CDPS)
		{
			UPlayerRow* NewPlayerRow = CreateWidget<UPlayerRow>(this, PlayerRowClass);
			if (NewPlayerRow)
			{
				NewPlayerRow->Setup(CDPS); 
				PlayerRows.Add(NewPlayerRow);
				if (CDPS->GetTeam() == ETeam::ET_RedTeam)
				{
					RedTeamBox->AddChild(NewPlayerRow);
				}
				else if (CDPS->GetTeam() == ETeam::ET_BlueTeam)
				{
					BlueTeamBox->AddChild(NewPlayerRow);
				}
			}
		}
	}
}


GS에서 플레이어들을 가져오고, 이를 순회하여 PlayerRow에 담는다. 기존에 만들어두었던 Setup함수를 통해 각각의 Row에 정보들을 배치 및 추가하기만 하면, 정보가 바뀔때마다 Update되는 UI를 최종적으로 만들 수 있다.

Round 증가함수

Round증가함수를 구현하려던 기존 방식이다.

void ADemolitionGameMode::PlayerEliminated(class ACDPlayerController* VictimController,
	ACDPlayerController* AttackerController)
{
	Super::PlayerEliminated(VictimController, AttackerController);
	ACDGameState* BGameState=Cast<ACDGameState>(UGameplayStatics::GetGameState(this));
	ACDPlayerState* AttackerPlayerState=AttackerController?Cast<ACDPlayerState>(AttackerController->PlayerState):nullptr;
	ACDPlayerState* VictimPlayerState=VictimController?Cast<ACDPlayerState>(VictimController->PlayerState):nullptr;
	
	if (BGameState&&AttackerController)
	{
		AttackerPlayerState->AddGold(200);
		if (AttackerPlayerState->GetTeam()==ETeam::ET_RedTeam)
		{
			BGameState->AliveBlueTeam.Remove(VictimPlayerState);
			if (BGameState->AliveBlueTeam.Num()==0)
			{
				BGameState->RedTeamScoreAdd();
				CooldownStartTime = GetWorld()->GetTimeSeconds();
				SetCurMatchState(ECurMatchState::EMS_CoolDown);
			}
			
		}
		else if (AttackerPlayerState->GetTeam()==ETeam::ET_BlueTeam)
		{
			BGameState->AliveRedTeam.Remove(VictimPlayerState);
			if (BGameState->AliveRedTeam.Num()==0)
			{
				BGameState->BlueTeamScoreAdd();
				CooldownStartTime = GetWorld()->GetTimeSeconds();
				SetCurMatchState(ECurMatchState::EMS_CoolDown);
			}
		}
	}
}

기존의 코드는 모드에서 플레이어가 죽을때마다 조건을 검사해서, GameState의 Score를 증가하는 방식이다. 그러나 이는 책임 분리 원칙에 어긋난다. GameMode의 역할은 게임의 흐름과 규칙을 관리하는 역할, 그리고 GameState는 게임의 현재상태를 관리하는 역할이다.

즉 GameMode는 사망처리만을 담당하고, 팀 생존 여부와 점수 증가 관리는 일반적으로 State에서 관리하여야 한다. 또한 문제는 데이터의 일관성에도 생긴다.

GameMode는 서버에만 존재하나, State는 서버와 클라이언트 모두 동기화 된다. 즉 GameMode에서 점수를 증가한다면 클라이언트에 반영되지 않거나 타이밍이 엇갈리면 업데이트 되지 않거나 크래시가 날 가능성이 있다. 따라서 State에서 관리해야만 한다.

따라서 플레이어가 전부 사망한 시점만을 확인하여 다음 라운드로 넘어가는 함수만 그대로 둔채, 구현부로 넘어간다.

PlayerEliminated함수에서 플레이어가 없어질때마다 승리조건을 확인하면 된다.

//In PlayerEliminated
BGameState->CheckTeamElimination();
void ACDGameState::UpdateTeamScore(bool bIsRedTeam)
{
	if (bIsRedTeam)
	{
		RedTeamScore++;
		OnRep_RedTeamScore();
	}
	else
	{
		BlueTeamScore++;
		OnRep_BlueTeamScore();
	}
}

void ACDGameState::CheckTeamElimination()
{
	bool bRedTeamEliminated = AliveRedTeam.Num() == 0;
	bool bBlueTeamEliminated = AliveBlueTeam.Num() == 0;

	if (bRedTeamEliminated)
	{
		UpdateTeamScore(false);
	}
	if (bBlueTeamEliminated)
	{
		UpdateTeamScore(true);
	
	}
}

적고보니 매우 당연한 사실인데, Elimininated에서 관리했던 내 자신이 바보같다.

profile
공부 정리 노트

0개의 댓글