no image
[ UE, C++ ] 네트워크 환경에서 클라이언트 카메라 전환시키기
데디케이트 서버 기반의 프로젝트를 진행하면서 인게임 로직을 담당하게 되었다.캐릭터가 사망하고 리스폰되었을 때를 처리하는 부분을 설계하다가 단순히 다른 컨트롤러에 빙의되는 것이 아니라 격투게임이니만큼 격투를 관전할 수 있는 관전뷰를 만들어 거기에 플레이어들을 빙의시켰다. 트러블슈팅하다가 서버에서 클라이언트의 플레이어컨트롤러를 관전 카메라에 배치하는건 잘 작동되었다.void ACharacterController::ClientSpectateCamera_Implementation(ACameraActor* SpectatorCam){ if (!SpectatorCam) { UE_LOG(LogTemp, Warning, TEXT("ClientSpectateCamera: SpectatorCam is null")); r..
2025.04.21
no image
[ UE ] TrainingMode 및 UI 구현
트러블 슈팅RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸..  프로젝트 진행 중 다시 알게된 내용GameMode, GameState, Widget 간의 데이터 처리게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.나는 GameState가 실시간으로 데이터를 저장하는 구조라서 데이터 업데이트하는 로직을 여기에서 처리하고 있었다.근데 다시 공부해보니 로직이나 계산 같은 부분은 GameMode에서 처리하고 GameState는 데이터를 저장하는 역할하고 GameMode에서 State의 데이터를 업데이트해주도록 가져가는게 좋을 것 같았다...
2025.04.08
no image
[ UE ] 주말 작업동안의 트러블 슈팅 정리
첫번째 트러블 상황BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.   이유IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.   해결방법 UFUNCTION(BlueprintCallable, Category = "CCFF|UI") FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }IsValid()를 쓰지않고 ≠n..
2025.04.07
no image
[ UE ] 위젯과 타이머 연동하기
위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다. BaseHUD, BaseWidget 설계위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다. 그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고...
2025.04.04
no image
[ UE ] 훈련장모드 베이스 설계
훈련장 모드를 만들기 위해 베이스 구조를 설계해보았따.가장 필요한 GameMode와 GameState를 설계했다. BaseInGameMode#pragma once#include "CoreMinimal.h"#include "GameFramework/GameMode.h"#include "BaseInGameMode.generated.h"UCLASS()class CCFF_API ABaseInGameMode : public AGameMode{ GENERATED_BODY()public: ABaseInGameMode(const FObjectInitializer& ObjectInitializer); virtual void BeginPlay() override; UFUNCTION(BlueprintCallable, Cat..
2025.04.03
no image
[ UE ] 데디케이트 서버 패키징을 해보자
오늘을 데디케이트 서버로 패키징을 하는 방법을 공부해보았다. 패키징 하는 과정먼저 똑같은 프로젝트를 2개를 만들어줘야한다. 하나는 작업용, 하나는 패키징용으로 관리할 것이다.(참고로 두 프로젝트의 경로는 다르게 설정해주는 것이 좋다. 패키징 작업 때 경로 때문에 꼬일 일이 생길 수도 있다고 한다.)그리고 작업용은 어떤 버전으로 해도 상관없으나 패키징용 프로젝트는 언리얼 소스 빌드로 해줘야 문제없이 패키징할 수 있는 환경을 만들 수 있다. 패키징용 프로젝트가 있는 폴더로 들어가서 Source 폴더로 들어간다. 프로젝트이름Editor.Target.cs 파일을 2번 복붙하고 Editor를 각각 Client, Server로 변경해주고 파일 안에서도 이름을 변경해준다. 언리얼 에디터에서 Tools에서 Projec..
2025.03.31
no image
[ UE ] 언리얼의 멀티네트워크
언리얼의 네크워크에 대해 공부를 하다가 내가 이해하기 쉽게 정리해보았다. 멀티플레이 흐름1. 멀티플레이 게임을 실행하면 언리얼 복제 시스템(Replication)이 모든 GameInstance가 동기화되도록 백드라운드에서 실행2. 공유하는 세계를 가져옴(각 PC에서 자체적으로 GetWorld() 함)+서버와 Client는 각각 개별의 UGameEngine을 가짐3. 서버를 부팅하면 NetDriver가 생성되고 원격 프로세스(서버)의 메세지를 수신4. 클라이언트를 부팅하면 클라이언트만의 NetDriver가 생성되고, 서버에 연결 요청을 보냄5. 서버와 클라이언트의 NetDriver가 접촉하면 각 NetDriver 내에 NetConnection이 설정됨+서버에는 연결된 플레이어 각각의 NetConnectio..
2025.03.27
[ UE ] 격투 게임 기획 및 설계
오늘은 앞으로 만들 격투 게임에 대한 기획과 설계를 간단하게 진행해보았다.  인게임 시스템 기능 설계 GameMode(게임 규칙과 전투 흐름 관리할 예정)- 매치 관리 (어떻게 매치할건지 ← 규태님이랑 이야기해보기)- 플레이어 스폰(맵에 움직일 수 있는 랜덤한 위치에 생성할 예정- 게임 시작/종료(하트 3개에 플레이어 1명 남을 때까지, 시간 타이머)- 플레이어 상태 초기화(체력, 스킬 쿨타임 등)- 이벤트 트리거(라운드시작, 종료, 플레이어 사망, 리스폰 등)GameState- GameMode에서 정의한 시작/종료 조건 업데이트- 각 라운드별 결과 및 이벤트 저장- HUD에 필요한 데이터 업데이트(체력, 콤보, 스킬 쿨타임, 남은 시간 등)- GameMode에서 이벤트 트리거 받아와서 UI에 전달- 플..
2025.03.24
no image
[UE, C++] 트러블슈팅, 멀티환경에서 카운트 개별 처리
트러블 슈팅상황호스트와 게스트의 플레이어 입력 카운트가 공유되는 버그 발생 해결GameMode에서 PlayerInputCount를 관리했는데 공부하다보니, 게임모드는 서버만 가지고 있는 인스턴스라 문제가 있었다. 플레이어 컨트롤러에서 카운트를 관리하는 방식으로 변경해주었다.
2025.03.21

 

데디케이트 서버 기반의 프로젝트를 진행하면서 인게임 로직을 담당하게 되었다.

캐릭터가 사망하고 리스폰되었을 때를 처리하는 부분을 설계하다가 단순히 다른 컨트롤러에 빙의되는 것이 아니라 격투게임이니만큼 격투를 관전할 수 있는 관전뷰를 만들어 거기에 플레이어들을 빙의시켰다.

 

트러블슈팅

하다가 서버에서 클라이언트의 플레이어컨트롤러를 관전 카메라에 배치하는건 잘 작동되었다.

void ACharacterController::ClientSpectateCamera_Implementation(ACameraActor* SpectatorCam)
{
	if (!SpectatorCam)
	{
		UE_LOG(LogTemp, Warning, TEXT("ClientSpectateCamera: SpectatorCam is null"));
		return;
	}

	UnPossess();
	ChangeState(NAME_Spectating);
	SetViewTargetWithBlend(SpectatorCam, 0.f);

	UE_LOG(LogTemp, Log, TEXT("ClientSpectateCamera: switched to SpectatorCamera"));
}
void ACharacterController::NotifyPawnDeath()
{
	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!ArenaPlayerState)
	{
		UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: PlayerState not invaild"));
		return;
	}

	if (ArenaPlayerState->MaxLives > 0)
	{
		ArenaPlayerState->MaxLives--;

		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(RespawnTimerHandle, [this]()
			{
				if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
				{
					ArenaGameMode->RespawnPlayer(this);
				}
			}, 3.0f, false);
	}
	else
	{
		if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
		{
			float CurrentRoundTime = ArenaGameState->GetRemainingTime();
			float TotalRountTime = ArenaGameState->GetRoundStartTime();
			ArenaPlayerState->SetSurvivalTime(TotalRountTime - CurrentRoundTime);
			UE_LOG(LogTemp, Log, TEXT("NotifyPawnDeath: Survivla time is %.2f"), CurrentRoundTime);
		}

		if (AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode()))
		{
			if (ACameraActor* SpectatorCam = ArenaGameMode->GetSpectatorCamera())
			{
				ClientSpectateCamera(SpectatorCam);
			}
			else
			{
				UE_LOG(LogTemp, Warning, TEXT("NotifyPawnDeath: SpectatorCamera not invaild"));
			}
		}
	}
}

이건 처음에 작성했던 코드인데, 클라이언트 RPC로 구현했다.

그런데 저 카메라 전환에 대해 0.2초정도는 원하는 카메라로 이동되었다가 갑자기 이상한 뷰로 전환되는 것이 아닌가!

솔직히 로직상으로는 뭐가 문제인지 잘 찾을 수 없었던 것 같다.

 

하지만 찾아내야했다..

 

카메라는 블루프린트로 만들어진 카메라 액터였고, 컨트롤러에 Tick 함수에 어떤 카메라가 빙의되는지 찾아봤다.

근데 내 관전카메라로 떴다가 컨트롤러로 뜨는 것이 아닌가..

아마 캐릭터쪽에서 Pawn Destroy()를 타이머로 관리하고, 바로 컨트롤러의 죽음 처리 함수를 호출한다.

컨트롤러 죽음 함수에서 카메라 전환을 위해 컨트롤러를 UnPossess하고, SetViewTarget을 해주게되는데 카메라 빙의 함수 호출 순서가 꼬여서 그런 것 같다.

(디버깅에만 4시간 쓴 것 같다...)

 

void ACharacterController::NotifyPawnDeath()
{
	if (!HasAuthority()) { return; }

	AArenaPlayerState* ArenaPlayerState = GetPlayerState<AArenaPlayerState>();
	if (!IsValid(ArenaPlayerState)) { return; }

	AArenaGameMode* ArenaGameMode = Cast<AArenaGameMode>(GetWorld()->GetAuthGameMode());
	if (!IsValid(ArenaGameMode)) { return; }

	UnPossess();
	DisableInput(this);
	FlushPressedKeys();
	SetIgnoreMoveInput(true);
	SetIgnoreLookInput(true);

	ArenaPlayerState->MaxLives--;

	// Posses Spectator Camera
	if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
	{
		FTimerHandle SpectateHandle;
		ACameraActor* SpectatorCam = ArenaGameState->GetSpectatorCamera();
		if (IsValid(SpectatorCam))
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}

		if (IsLocalController())
		{
			GetWorld()->GetTimerManager().SetTimer(SpectateHandle, [this, SpectatorCam]()
				{
					FViewTargetTransitionParams Params;
					Params.BlendTime = 0.f;
					Cast<APlayerController>(this)
						->ClientSetViewTarget(SpectatorCam, Params);
				}, 0.2f, false);
		}
	}

	// Respawn
	AArenaPlayerState* PS = GetPlayerState<AArenaPlayerState>();
	const bool bHasLives = PS && PS->MaxLives > 0;

	if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::DeathMatch)
	{
		ClientStartRespawnCountdown();
		FTimerHandle RespawnTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(
			RespawnTimerHandle,
			[this, ArenaGameMode]()
			{
				ArenaGameMode->SpawnPlayer(this);
			},
			5.0f,
			false
		);
	}
	else if (ArenaGameMode->SelectedArenaSubMode == EArenaSubMode::Elimination)
	{
		if (bHasLives)
		{
			ClientStartRespawnCountdown();
			FTimerHandle RespawnTimerHandle;
			GetWorld()->GetTimerManager().SetTimer(
				RespawnTimerHandle,
				[this, ArenaGameMode]()
				{
					ArenaGameMode->SpawnPlayer(this);
				},
				5.0f,
				false
			);
		}
		else
		{
			if (AArenaGameState* ArenaGameState = Cast<AArenaGameState>(GetWorld()->GetGameState()))
			{
				float SurvivalTime = ArenaGameState->GetRoundStartTime() - ArenaGameState->GetRemainingTime();
				ArenaPlayerState->SetSurvivalTime(SurvivalTime);
				ClientShowDieMessage();
			}
		}
	}
}

여기 함수를 보면 ClientSetViewTarget을 호출하는데 이건 언리얼 내장 RPC 함수이고 호출될 때 지연시간이 있다고 한다.

그래서 충분히 UnPossess되고, 빙의되고 이런 과정을들 기다렸다가 호출하기 때문에 순서가 꼬이지 않아 정상적으로 카메라 전환이 되었던 것 같다.

또는 컨트롤러에서 빙의해제가 되기 전에  RPC 함수가 호출되면서 컨트롤러의 빙의 처리와 SetViewTarget의 호출시점이 꼬여서 발생했을 수도 있을 것 같다,

 

 

 

마무리말

아직 언리얼 내장 함수들이 어떻게 동작하는지 명확하게 이해하지 못한 것 같다.

결론도 혼자 함수와 코드를 따라가보면서 유추한것뿐이라, 나중에 시간을 따로 내어서 내장 함수를 깊에 들여다보는 시간을 가져야겠다.

 

트러블 슈팅

RoundTime을 0으로 초기화해줬는데 갑자기 이상한 값이 뜸..

 

 

프로젝트 진행 중 다시 알게된 내용

GameMode, GameState, Widget 간의 데이터 처리

게임 프레임워크를 설계하고 구현하다보니 구조적인 설계에 대해 많이 고민하고 성장하게 되었다.

GameMode, GameState, Widget 간의 데이터 처리에 부분에서 흐름이 어떻게 흘러가는지 이해하게되었다.

나는 GameState가 실시간으로 데이터를 저장하는 구조라서 데이터 업데이트하는 로직을 여기에서 처리하고 있었다.

근데 다시 공부해보니 로직이나 계산 같은 부분은 GameMode에서 처리하고 GameState는 데이터를 저장하는 역할하고 GameMode에서 State의 데이터를 업데이트해주도록 가져가는게 좋을 것 같았다.

Widget은 GameMode에서 State의 값을 가져오거나 바로 State에서 가져가는 구조로 설계했는데 지금 프로젝트 규모는 크지 않기때문에 바로 State에서 값을 가져가도록 설계했다.

 

 

결과

 

타이머 연동 ? OK !

Start, Reset 버튼 온클릭 함수 ? OK !

TotalDamage, DPS 연동 ? OK !

 

잘 작동한다. 구조도 싸그리 대공사를 했기때문에 나중에 유지보수하기 더 쉬울 것 같다.

 

 

 

 

 

 

첫번째 트러블 상황

BaseInGameHUD에서 정의한 GetTogglePauseWidget을 PlayerController에서 가져오기 위해 IsValid로 값체크를 하는 과정에서 발생.

 

 

 

이유

IsValid()는 UObject* 타입을 요구하는데 GetTogglePauseWidget()은 const 포인터를 반환하고 있음. IsValid()는 주로 non-const 버전을 검사하게 구현되어있어서 생기는 문제.

 

 

 

해결방법
	UFUNCTION(BlueprintCallable, Category = "CCFF|UI")
	FORCEINLINE UTogglePauseWidget* GetTogglePauseWidget() const { return TogglePauseWidget; }


IsValid()를 쓰지않고 ≠nullptr로 바꿔줘서 해결

 

 

 

두번째 트러블 상황

void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	UpdateTimerText(FormattedTime);
}

이런 구조로 가는데 Base의 TimerText가 업데이트되지 않았다.

 

 

 

이유 및 해결 방법
void UTrainingWidget::UpdateTimer()
{

	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (APlayerController* PC = GetOwningPlayer())
	{
		if (ABaseInGameHUD* HUD = Cast<ABaseInGameHUD>(PC->GetHUD()))
		{
			if (UBaseInGameWidget* BaseWidget = HUD->GetBaseInGameWidget())
			{
				BaseWidget->UpdateTimerText(FormattedTime);
			}
		}
	}
}


TrainingWidget에서는 직접 TimerText를 갖지 않고, 대신 현재 HUD에 있는 BaseInGameWidget의 UpdateTimerText() 함수를 호출해서 타이머 텍스트를 갱신하는 게 내 예상 결과값이었다.

즉, TrainingWidget 내부에서 "this->UpdateTimerText(...)"를 호출하면 TrainingWidget 자체의 (없거나 null인) TimerText에 업데이트가 적용된다고 한다. 그래서 대신, HUD에 있는 BaseInGameWidget 인스턴스를 찾아서 그 인스턴스의 UpdateTimerText()를 호출해주는 방식으로 수정해주었다.

 

 

 

마지막 말

오늘 타이머 작업을 하다가 아무래도 위젯클래스에서 로직을 타는게 좀 이상하다고 생각이 들었다..

그리고 GameMode와 GameState의 역할? 분리를 어떻게 해야할지 감을 못잡고 작업하는 상태여서 계속 하면서 이상하다는 생각을 했다..

아니나다를까.. 이상한 구조로 작업하고 있어서 아예 구조를 전면변경하려고 한다..

눈에서 무슨 액체가 흐른다..

위젯에 텍스트로 타이머 기능을 넣는 부분을 진행했다.

 

BaseHUD, BaseWidget 설계

위젯을 쓰려고하니 모드마다 공통으로 들어가는 위젯들이 있어서 Base 위젯을 만들면 좋겠다고 생각했다.

베이스 HUD와 Widget의 구조를 잡는데 오늘 대부분의 시간을 썼었던 것 같다.

HUD와 Widget의 역할을 잘 몰랐었는데 HUD는 UI를 담는 컨테이너 같은 역할이고 그 안에 실질적으로 유저가 보는 것들이 UI라고 이해했다.

 

그래서 일단 BaseHUD를 설계했고, 공통된 부분만 묶어서 정의해주었다.

새로운 HUD가 추가될 때마다 이 BaseHUD를 상속받아서 적용할 수 있게 설계했고, 위젯도 비슷한 방식을 적용했다.

근데 위젯은 어떤걸 묶을지 아직 감이 안잡히는 것 같다.. Base가 굳이 필요한가 싶기도하고.. 주말에 좀 더 정리를 해봐야겠다.

 

 

Text 위젯과 타이머 연동

타이머 연동은

00:00 이런 식으로 하려고 설계했고, 하나의 Text를 사용해서 만들어주었다.

 

이렇게 중간에 시간을 넣어주었고 위젯을 상속받은 C++을 기반으로 만들어진 위젯블루프린트이기때문에, 특정 위젯을 가져야만 오류가 나지 않도록 처리해주었다.

베이스 UI를 이렇게 만들어주었고, 이제 이 베이스 UI를 상속받은 훈련장 UI를 만들어주었다.

 

이런 느낌으로 초를 입력하면 화면 중앙 상단의 타이머까지 연동할 수 있게 하고 싶었다.

시작 버튼을 누르면 설정한 초만큼 시작이되고, 리셋을 누르면 타이머가 0초로 돌아가게 설정해주려고 한다.

 

#pragma once

#include "CoreMinimal.h"
#include "Framework/UI/BaseInGameWidget.h"
#include "TrainingWidget.generated.h"

UCLASS()
class CCFF_API UTrainingWidget : public UBaseInGameWidget
{
	GENERATED_BODY()
	
	UFUNCTION()
	void OnStartButtonClicked();

	UFUNCTION()
	void OnResetButtonClicked();

	UFUNCTION()
	void UpdateTimer();

protected:
	virtual void NativeConstruct() override;

	float CurrentTime = 0.0f;

	FTimerHandle TimerHandle;

	UPROPERTY(meta = (BindWidget))
	class UTextBlock* TimerText;
	
	UPROPERTY(meta = (BindWidget))
	class UEditableTextBox* TimeInputBox;

	UPROPERTY(meta = (BindWidget))
	class UButton* StartButton;

	UPROPERTY(meta = (BindWidget))
	class UButton* ResetButton;

};
#include "Framework/UI/TrainingWidget.h"
#include "Components/Button.h"
#include "Components/EditableTextBox.h"
#include "Components/TextBlock.h"
#include "TimeManagementClasses.h"


void UTrainingWidget::NativeConstruct()
{
	Super::NativeConstruct();

	if (IsValid(StartButton))
	{
		StartButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnStartButtonClicked);
	}

	if (IsValid(ResetButton))
	{
		ResetButton->OnClicked.AddDynamic(this, &UTrainingWidget::OnResetButtonClicked);
	}

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}
}


void UTrainingWidget::OnStartButtonClicked()
{
	if (IsValid(TimeInputBox))
	{
		const FString InputStr = TimeInputBox->GetText().ToString();
		float EnterTime = FCString::Atof(*InputStr);

		if (EnterTime < 0.f)
			EnterTime = 0.f;

		CurrentTime = EnterTime;

		int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
		int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;

		FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

		if (IsValid(TimerText))
		{
			TimerText->SetText(FText::FromString(FormattedTime));
		}

		// initialize timer
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);

		GetWorld()->GetTimerManager().SetTimer(TimerHandle, this, &UTrainingWidget::UpdateTimer, 1.0f, true);
	}
}

void UTrainingWidget::OnResetButtonClicked()
{
	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(TEXT("00:00")));
	}

	GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
}

void UTrainingWidget::UpdateTimer()
{
	CurrentTime -= 1.0f;

	if (CurrentTime <= 0.0f)
	{
		CurrentTime = 0.0f;
		GetWorld()->GetTimerManager().ClearTimer(TimerHandle);
	}

	int32 Minutes = FMath::FloorToInt(CurrentTime / 60.0f);
	int32 Seconds = FMath::FloorToInt(CurrentTime) % 60;
	FString FormattedTime = FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds);

	if (IsValid(TimerText))
	{
		TimerText->SetText(FText::FromString(FormattedTime));
	}
}

 

Editable Text Box에서 입력된 텍스트를 가져와서 형변환을 해주고, 분과 초를 나눠서 타이머에 적용시켜주었다.

 

블루프린트로도 버튼에 대한 온클릭 함수를 구현할 수 있었지만 이번에 진행하는 프로젝트는 UI가 많아지고 Base 위젯을 상속받아 사용하기 때문에 혼동될 수도 있어서 안전하게 코드로 온클릭 함수를 연결해주었다.

 

 

결과

이렇게 입력한 값은 항상 초로 인식하고 라운드 시계에서는 저렇게 분과 초로 나눠서 타이머를 구현해보았다.

 

 

마지막 말

초반에 역할 분리가 애매하게 된 상황에서 클래스를 구현하다보니 내 담당이 아닌 부분까지 함수를 만들어뒀었다.

다른 팀원의 혼동을 막기 위해 주말에 쓸데없는 함수는 다 과감하게 없애버리고, 중복되는 코드들을 줄여볼 생각이다.

훈련장 모드를 완성하고, 멀티 서버 공부를 더 해봐야겠다.

프로젝트에서 내가 담당한 인게임의 구조를 다시 한 번 틀을 잡아가는 단계를 거쳐야할 것 같다.

코드를 짜면서 구조 설계가 미흡해서 자꾸 딴 길로 새면서 기능 하나를 구현하는데 쓸데없이 많은 시간을 쓰고 있는 것 같다.

훈련장 모드를 만들기 위해 베이스 구조를 설계해보았따.

가장 필요한 GameMode와 GameState를 설계했다.

 

BaseInGameMode

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameMode.h"
#include "BaseInGameMode.generated.h"

UCLASS()
class CCFF_API ABaseInGameMode : public AGameMode
{
	GENERATED_BODY()

public:
	ABaseInGameMode(const FObjectInitializer& ObjectInitializer);

	virtual void BeginPlay() override;

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void StartRound();

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void EndRound();

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameMode|Round")
	virtual void CheckGameConditions();

protected:
	UPROPERTY(EditAnywhere, Category = "CCFF|GameMode|Round")
	float RoundDuration;

	UPROPERTY(EditAnywhere, Category = "CCFF|GameMode|Round")
	int32 MaxHP;

	FTimerHandle GameTimerHandle;

};
#include "Framework/GameMode/BaseInGameMode.h"
#include "Framework/GameState/BaseInGameState.h"

ABaseInGameMode::ABaseInGameMode(const FObjectInitializer & ObjectInitializer) : Super(ObjectInitializer)
{
	GameStateClass = ABaseInGameState::StaticClass();

	RoundDuration = 60.0f;
	MaxHP = 3;
}

void ABaseInGameMode::BeginPlay()
{
	Super::BeginPlay();

	StartRound();
}

void ABaseInGameMode::StartRound()
{
	UE_LOG(LogTemp, Log, TEXT("Start Round!"));

	GetWorldTimerManager().SetTimer(
		GameTimerHandle, this, &ABaseInGameMode::CheckGameConditions, 1.0f, true
		);
}

void ABaseInGameMode::EndRound()
{
	UE_LOG(LogTemp, Log, TEXT("End Round!"));

	GetWorldTimerManager().ClearTimer(GameTimerHandle);
}

void ABaseInGameMode::CheckGameConditions()
{
}

 

이렇게 게임 모드가 확장될 때마다 사용할 수 있게 필요한 부분들만 정의해주었다.

그리고 인게임에서는 항상 타이머가 작동되기때문에 타이머를 넣어주었다.

 

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "BaseInGameState.generated.h"


UENUM(BlueprintType)
enum class ERoundProgress : uint8
{
	NotStarted UMETA(DisplayName = "Not Started"),
	InProgress UMETA(DisplayName = "In Progress"),
	Ended UMETA(DisplayName = "Ended")
};


UCLASS()
class CCFF_API ABaseInGameState : public AGameState
{
	GENERATED_BODY()

public:
	ABaseInGameState();

	virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void UpdateHUDData();
	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void SetRemainingTime(float NewTime);
	UFUNCTION(BlueprintCallable, Category = "CCFF|GameState|HUD")
	virtual void SetRoundProgress(ERoundProgress NewProgress);

	UPROPERTY(BlueprintReadOnly, Replicated, Category = "CCFF|GameState|HUD")
	float RemainingTime;

	UPROPERTY(BlueprintReadOnly, Replicated, Category = "CCFF|GameState|HUD")
	ERoundProgress RoundProgress;

};
#include "Framework/GameState/BaseInGameState.h"
#include "Net/UnrealNetwork.h"

ABaseInGameState::ABaseInGameState()
{
	RemainingTime = 0.0f;
	RoundProgress = ERoundProgress::NotStarted;
}

void ABaseInGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, RemainingTime);
	DOREPLIFETIME(ThisClass, RoundProgress);
}

void ABaseInGameState::UpdateHUDData()
{
	UE_LOG(LogTemp, Log, TEXT("HUD Updated: RemainingTime: %f, RoundProgress: %d"), RemainingTime, (uint8)RoundProgress);
}

void ABaseInGameState::SetRemainingTime(float NewTime)
{
	RemainingTime = NewTime;
	UpdateHUDData();
}

void ABaseInGameState::SetRoundProgress(ERoundProgress NewProgress)
{
	RoundProgress = NewProgress;
	UpdateHUDData();
}

 

그리고 GameState에서는 HUD 정보들과 필요한 정보들을 업데이트해주고, 복제할 수 있게 설정해주었다.

 

훈련장 배치는 이렇게 허수아비를 하나 두고, 시작하려고 설계했다.

 

UI  구조는 다음과 같이 설정했따.

네모박스는 BaseWidget으로 설정할려고 한다. 모든 모드에서 사용할 부분이라..

그리고 오른쪽 하단 부분은 훈련장에서만 필요한 부분들을 모아서 작업하려고 한다.

 

 

내일은 UI 위젯을 본격적으로 만들고 기능을 붙일 생각이다.

아자아자

오늘을 데디케이트 서버로 패키징을 하는 방법을 공부해보았다.

 

패키징 하는 과정

먼저 똑같은 프로젝트를 2개를 만들어줘야한다. 하나는 작업용, 하나는 패키징용으로 관리할 것이다.
(참고로 두 프로젝트의 경로는 다르게 설정해주는 것이 좋다. 패키징 작업 때 경로 때문에 꼬일 일이 생길 수도 있다고 한다.)

그리고 작업용은 어떤 버전으로 해도 상관없으나 패키징용 프로젝트는 언리얼 소스 빌드로 해줘야 문제없이 패키징할 수 있는 환경을 만들 수 있다.

 

패키징용 프로젝트가 있는 폴더로 들어가서 Source 폴더로 들어간다.

 

프로젝트이름Editor.Target.cs 파일을 2번 복붙하고 Editor를 각각 Client, Server로 변경해주고 파일 안에서도 이름을 변경해준다.

 

언리얼 에디터에서 Tools에서 Project Launcher를 켜준다.

 

 

만약 이렇게 네모부분이 비어있다면 언리얼 5.5버전으로 빌드했기때문!!!!!

***앞에서 언급했듯이 제대로 패키징을 하기 위해서는 언리얼 엔진을 git으로 받고 빌드가 성공적으로 된 것을 확인한 후 Source로 빌드해주는 과정이 필요하다!!!!

 

제대로 환경을 구축했다면 이렇게 Build Target 옵션이 떠야한다.

 

데디케이트 서버 패키징을 하기 위해서는 클라이언트와 서버 둘 다 패키징 작업을 해줘야한다.

 

먼저 클라이언트를 작업해주려고 한다.

 

Build Target - 프로젝트이름Client
Varient - WindowClient
Data Build - By the book

으로 옵션을 설정해주고 오른쪽의 게임기 버튼을 클릭해서 패키징을 시작해준다. (만약 이 옵션들이 보이지않고 Variant만 보인다면 Show Advanced를 클릭해주면 옵션이 뜬다)

 

서버도 동일한 방법으로 Server 옵션으로 바꿔주고 패키징해준다.

 

내 컴퓨터 환경으로는 클라이언트는 1시간 30분, 서버는 30분의 시간이 소요되었다.

 

패키징을 모두 완료하면 Saved/StageBuilds/WindowClient or WindowServer 폴더가 생성된 것을 볼 수 있다.

이 폴더들 안에 들어가보면 각각 exe파일이 생겼을 것이다.

실행해본 후 정상적으로 동작한다면 패키징 끝!!

 

아 참고로 에디터에서 패키징하면서 오류가 있다면 오류 로그가 뜨는데 어디에서 오류가 떴는지 확인해볼 수 있다.

그래서 패키징을 한다면 매일 하는 것을 추천한다.(오류가 쌓이게되면 추적하기 힘들기때문..)

 

언리얼의 네크워크에 대해 공부를 하다가 내가 이해하기 쉽게 정리해보았다.

 

멀티플레이 흐름

1. 멀티플레이 게임을 실행하면 언리얼 복제 시스템(Replication)이 모든 GameInstance가 동기화되도록 백드라운드에서 실행

2. 공유하는 세계를 가져옴(각 PC에서 자체적으로 GetWorld() 함)

+서버와 Client는 각각 개별의 UGameEngine을 가짐

3. 서버를 부팅하면 NetDriver가 생성되고 원격 프로세스(서버)의 메세지를 수신

4. 클라이언트를 부팅하면 클라이언트만의 NetDriver가 생성되고, 서버에 연결 요청을 보냄

5. 서버와 클라이언트의 NetDriver가 접촉하면 각 NetDriver 내에 NetConnection이 설정됨

+서버에는 연결된 플레이어 각각의 NetConnection을 가지고 있음

+각 클라이언트는 서버와의 연결을 나타내는 단일 NetConnection을 가지고 있음

 

6. 각 NetConnection에는 다양한 채널이 연결됨

일반적으로 Control Channel, VoiceChannel, 일련의 Actor Channel을 가지는데 해당 연결을 통해 복제되고 있는 각 액터에 대해 하나씩 있음

 

 

+네트워크 상에서 동기화 상태를 유지해야하는 경우 해당 액터를 복제하고, 이 채널들로 서버와 클라이언트가 해당 액테에 대한 정보를 교환하는 것.

+액터가 클라이언트에 복사될 때

  • LifeTime
    1. 액터의 수명은 서버와 클라이언트 사이에서 동기화됨
    2. 서버에서 복제할 액터를 생성하면 클라이언트에 알림을 줘서 클라이언트에서 자체적으로 사본을 생성할 수 있게 함
    3. 서버에서 액터를 파괴하면 클라이언트에서도 파괴됨
  • Property Replication
    1. 복제를 위한 속성(플래그)이 액터에 포함되어있는 경우에 해당 속성이 서버에서 변경되면, 새 값이 클라이언트로 전송되어서 클라이언트도 동기화됨
    2. 서버에서 클라이언트로 단방향만 복제가능
  • RPC(Remote Procedure Call)
    1. 함수를 멀티캐스트 RPC로 설정하면 서버에서 해당 함수를 호출했을 때, 서버는 현재 액터가 복제되고 있는 모든 클라이언트에 메세지 전송 → 클라이언트는 알림을 받고 해당 함수를 호출해야함.
    2. 서버에서 클라이언트, 클라이언트에서 서버의 함수를 호출할 수 있게 하는 이벤트
      1. Server RPC : 클라이언트에서 서버의 함수를 호출(플레이어의 입력이나 행동을 서버로 전달할 때)
      2. Client RPC : 서버에서 클라이언트의 함수를 호출(서버가 게임 상태나 특정 명령을 클라이언트로 전달할 때)
      3. Multicast RPC : 서버에서 모든 클라이언트의 함수를 호출(전체 플레이어에게 동일한 이벤트나 업데이트를 전파할 때)

 

Pawn 클래스로 만들어진 액터여도 각 플레이어 컨트롤러는 Pawn을 기본적으로 상속받기 때문에 알고 있음. 따라서 서버는 각 PC의 가장 하단에 생성된 액터를 통해서도(클라이언트가 소유한 액터를 거슬러 올라가면서) 소유자가 누구인지 알 수 있음

 

  • 액터 Replication
    • 액터가 복제 대상으로 고려되려면 bReplicates = true여야함 (C++은 일반적으로 생성자에서 이 값을 설정, 블루프린트는 Detail 패널에서 Replicate 체크)
  • 내가 이해한 복제된 액터를 동기화하는 내용 결론
    • 서버는 각 UNetConnection에 연결된 Channel을 통해 ActorChannel에 접근하고, 이 때 Replicate 옵션을 확인. → 복사한다고 되어있으면 해당 액터를 클라이언트에 복제함

 

 

마지막 말

 

내일은 GameMode, GameInstance, PlayerController 들의 Replicate 과정을 공부해볼 예정이다.

오늘은 앞으로 만들 격투 게임에 대한 기획과 설계를 간단하게 진행해보았다.

 

인게임 시스템 기능 설계

GameMode(게임 규칙과 전투 흐름 관리할 예정)

- 매치 관리 (어떻게 매치할건지 ← 규태님이랑 이야기해보기)
- 플레이어 스폰(맵에 움직일 수 있는 랜덤한 위치에 생성할 예정
- 게임 시작/종료(하트 3개에 플레이어 1명 남을 때까지, 시간 타이머)
- 플레이어 상태 초기화(체력, 스킬 쿨타임 등)
- 이벤트 트리거(라운드시작, 종료, 플레이어 사망, 리스폰 등)


GameState

- GameMode에서 정의한 시작/종료 조건 업데이트
- 각 라운드별 결과 및 이벤트 저장
- HUD에 필요한 데이터 업데이트(체력, 콤보, 스킬 쿨타임, 남은 시간 등)
- GameMode에서 이벤트 트리거 받아와서 UI에 전달
- 플레이어 사망했을 때 관전모드
- Rating 값 아웃게임에 넘겨주기?
- 데미지, 준 피해량 저장하고 Rating 계산할 때 쓰기

GameInstance

- 선택한 캐릭터 저장(아웃게임에서 받아오기)
- BattleManager(캐릭터랑 이야기) → 캐릭터에서 데이터 받아와서 판정하고 클라에 다시 뿌려주기
- Attack
- TakeDamage
- DI

 

인게임 UI

- 라운드 시작 UI(전투 시작 전 카운트로 표출)
- 타이머
- 카메라 액션 - 피격당하거나 궁극기같은거 쓸 때 쉐이크 같은 것들(플레이어에서 하면 좋을 것 같음)
- 최종 결과 UI

 

HUD

- 체력바(거리에 따라 UI 동기화할지 말지 추가하면 좋을듯)
- 스킬 쿨타임

 

+PlayerController

 

+(후순위) 튜토리얼 진행

 

이런 식으로 설계해보았다.

전반적인 게임모드와 UI는 인게임, 아웃게임으로 분리해서 진행하였고 나는 인게임을 담당하기로 했다.

언리얼 프로젝트를 진행하면서 게임의 전반적인 흐름을 설계해보고 싶었는데 이번 기회에 잘 녹여내보아야겠다.

 

트러블 슈팅

상황
호스트와 게스트의 플레이어 입력 카운트가 공유되는 버그 발생

 

해결
GameMode에서 PlayerInputCount를 관리했는데 공부하다보니, 게임모드는 서버만 가지고 있는 인스턴스라 문제가 있었다. 플레이어 컨트롤러에서 카운트를 관리하는 방식으로 변경해주었다.