언리얼엔진 학습 및 과제

소울라이크 팀 프로젝트 Desecration : 시작부터 MVP까지 (1)

park94128 2026. 3. 18. 23:54

● MVP란 무엇인가

스포츠에서 말하는 Most Valuable Player도 아니다. 백화점 같은 곳에서 중요 고객을 칭하는 VIP의 일종 같은것도 아니다. 당연히 메이플스토리의 MVP 시스템도 아니다.

 

여기서 말하는 MVP는 Minimum Viable Product의 약자로, '최소한의 기능만을 갖춘 상품'이라는 의미다. 어떻게 생각해보면 프로토타입과 비슷하다고 볼 수 있다. 게임이라고 불릴 수 있도록 핵심적인 기능만 구현해둔 상태를 말할 수 있다.

이 MVP라는 것은 완성을 위한 초석이라고 할 수 있는데, 만드려고 하는 게임을 MVP를 거치지 않고 한꺼번에 모든 것을 구현하려고 하면 막대한 분량, 막연한 방향에 의해 진행이 막혀버리고 말 것이다. 하지만 MVP를 먼저 완성하면 이후 추가적인 기능은 MVP에서 붙이거나 수정하는 식으로 구현하면 되기 때문에 만들고자 하는 방향이 명확해지고 많은 분량을 정리할 수 있어서 제작 분량을 수시로 가늠할 수 있다.

 

이러한 이유로 나와 팀원은 당장에 만들 것, 지금은 만들지 않아도 되는 것을 구분하여 각자 파트에서 필수 기능만을 만들기로 했다.

 

● 내가 담당할 범위

팀원끼리 설계를 했을때 내가 하기로 한 것은 게임시스템이다. 본 게임을 시작하기전 일어나는 일(새 게임 시작, 이어하기, 종료 등)과 화면에 직접 보이지 않는 기능(진행 상황 저장, 게임 설정 등)을 담당하기로 했다.

 

● 타이틀 화면을 위한 클래스

내가 생각한 구조를 그려보자면 위와 같다. 레벨내 클래스 구조는 그냥 기본적인 언리얼 엔진의 레벨 구성에 폰(캐릭터)만 빠졌다. UI만 필요할 것이니 폰은 쓸 일이 없고 레벨에 따로 배치될 액터도 없다.

 

게임 인스턴스는 타이틀 레벨 뿐만이 아니라 전역으로 사용할 것이다. 다만 게임 인스턴스의 Init함수를 통한 초기화 외에도 타이틀 레벨에서 진행할 초기화가 추가된다.

 

타이틀 레벨에 필요한 기능들은 게임 모드에서 주로 구현할 것이며 일부는 게임 인스턴스를 이용할 생각이다. 플레이어 컨트롤러는 UI 생성 목적으로 사용할 생각이며 UI를 통해 타이틀 화면의 기능을 유저에게 제공하도록 만들 것이다.

 

일단 이렇게 생각하며 만들고 추가로 필요한 클래스가 생긴다면 그때가서 만들어 본다.

 

● SaveGame

추가로 필요한 클래스가 바로 생겼다. 나는 게임 데이터를 게임 인스턴스에서 보관하기로 했다. 이 게임 데이터는 게임 구성을 담고 있으며 이 데이터를 저장하도록 만들 것이다. 그리고 게임 불러오기를 하면 이 데이터를 생성하도록 한다.

그러기 위해 필요한 것이 SaveGame이라는 클래스다. 언리얼 엔진은 생각보다 싱글 게임을 위한 기능이 많이 있는데 게임 저장 및 불러오기에 필요한 기능을 이 SaveGame 클래스를 통해 제공하고 있다.

뭔가 설명은 긴데 지금 미리 말하자면 저 설명대로 하기엔 다소 귀찮으니 간단하게 처리할 수 있는 함수를 사용할 것이다.

 

UCLASS()
class DESECRATION_API UT3SaveGame : public USaveGame
{
	GENERATED_BODY()
}

지금은 게임 데이터의 구성을 생각하지 않았으니 일단 클래스를 만들기만 하고 다음에 내용을 채운다.

 

● 게임 인스턴스

UCLASS()
class DESECRATION_API UT3GameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:
	virtual void Init() override;

	/**
	 * 게임 저장하기
	 * @return true : 저장 성공
	 */
	bool SaveGame();
	
	/**
	 * 저장된 게임 불러오기
	 * @return true : 불러오기 성공
	 */
	bool LoadGame();
	
	//저장된 게임
	FORCEINLINE TObjectPtr<UT3SaveGame> GetSavedGameData() { return SavedGameData; }
	
private:
	//현재 설정
	UPROPERTY()
	TObjectPtr<UT3SaveUserSettings> CurrentSettings;
	
	//저장, 불러오기에 사용할 슬롯 이름
	const FString SAVE_GAME_NAME = TEXT("SaveSlot1");
}

말한대로, 게임 데이터를 게임 인스턴스에 보관한다. 그리고 게임 인스턴스에서 게임 데이터를 가지고 있으니 게임 불러오기 및 저장도 게임 인스턴스에서 구현한다.

 

void UT3GameInstance::Init()
{
	Super::Init();
	
	//게임 데이터
	LoadGame();
}

게임 인스턴스의 시작시 자동으로 실행하는 Init 함수에서 곧바로 저장되 게임 데이터를 가져오는 함수를 실행한다 타이틀 화면에서 불러온 데이터가 있으면 이어하기를 할 수 있고 아니면 할 수 없게 만들도록 구현할 생각이다.

 

bool UT3GameInstance::SaveGame()
{
	return UGameplayStatics::SaveGameToSlot(SavedGameData, SAVE_GAME_NAME, 0);
}

bool UT3GameInstance::LoadGame()
{
	TObjectPtr<UT3SaveGame> SavedData = Cast<UT3SaveGame>(UGameplayStatics::LoadGameFromSlot(SAVE_GAME_NAME, 0));
	if (!SavedData)
	{
		return false;
	}
	
	SavedGameData = SavedData;
	SavedGameData->bSetLocation = true;
	return true;
}

데이터를 저장하는 함수와 불러오는 함수의 내용이다. 저장과 불러오기 기능은 UGameplayStatics에 있는 함수로 간단하게 만들 수 있다.

- SaveGameToSlot함수를 통해 데이터를 저장할 수 있다. 매게변수는 SaveGame클래스(게임 데이터), FString(이름), int(슬롯 번호)다. 지정한 이름과 슬롯에 게임 데이터를 저장하는 기능이다.

- LoadGameFromSlot함수는 반대로 저장된 데이터를 불러온다. 지정한 이름과 슬롯 번호에 해당되는 데이터를 가져오는데 함수의 결과는 원형인 USaveGame이니 캐스팅할 필요가 있다.

게임을 저장했다면 [프로젝트 폴더]/Saved/SaveGames에 저장된다. 위 두 함수는 이 경로에 있는 파일과 관련 있다.

 

● 게임 모드

UCLASS()
class DESECRATION_API AT3TitleGameMode : public AGameModeBase
{
	GENERATED_BODY()
	
protected:
	virtual void BeginPlay() override;
	
public:
	//클래스 선택 화면으로
	void MoveToSelectClassLevel();
	
	//마지막 저장 장소에서 계속하기 (저장된 게임 데이터 적용)
	void MoveToLastSavedLevel();
	
	//게임 종료
	void QuitGame();
	
private:
	//게임 인스턴스
	UPROPERTY()
	TObjectPtr<UT3GameInstance> T3GameInstance;
};

타이틀 화면에서 구현할 기능은 게임 모드에서 구현한다.

 

void AT3TitleGameMode::BeginPlay()
{
	Super::BeginPlay();
	
	//게임 인스턴스
	T3GameInstance = Cast<UT3GameInstance>(GetGameInstance());
	if (!T3GameInstance)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : T3GameInstance is NULL"), *GetNameSafe(this));
		return;
	}
}

게임 모드 시작시 게임 인스턴스를 미리 캐싱한다. 저장된 게임과 관련된 기능이 있기 때문에 게임 인스턴스를 자주 이용할 것이기 때문이다.

 

void AT3TitleGameMode::MoveToSelectClassLevel()
{
	UGameplayStatics::OpenLevel(GetWorld(), TEXT("T3SelectClassLevel");
}

void AT3TitleGameMode::MoveToLastSavedLevel()
{
	//TODO : 게임 불러오기
}

void AT3TitleGameMode::QuitGame()
{
	UKismetSystemLibrary::QuitGame(GetWorld(), GetWorld()->GetFirstPlayerController(), EQuitPreference::Quit, false);
}

다만 현 시점에서는 아직 게임 불러오기를 구현하지 않는다. 아직 SaveGame클래스의 내용을 정해놓지 않았기 때문에 보류한다. 그래서 새 게임 시작시 클래스 선택 화면으로 넘어가는 것과 게임을 종료하는 기능만 구현한다.

 

● 플레이어 컨트롤러

UCLASS()
class DESECRATION_API AT3TitlePlayerController : public APlayerController
{
	GENERATED_BODY()
	
protected:
	virtual void BeginPlay() override;
	
public:
	//클래스 선택 화면으로
	void MoveToSelectClassLevel();
	
	//마지막 저장 장소에서 계속하기 (저장된 게임 데이터 적용)
	void MoveToLastSavedLevel();
	
	//저장된 게임 데이터 존재 여부
	bool DoesSavedDataExist();
	
	//게임 종료
	void QuitGame();
	
private:
	//타이틀 화면의 위젯 클래스
	UPROPERTY(EditDefaultsOnly, Category = "Widget", meta = (AllowPrivateAccess = true))
	TSubclassOf<UT3TitleLevelWidget> TitleLevelWidgetClass;
	
	//타이틀 화면의 위젯
	UPROPERTY()
	TObjectPtr<UT3TitleLevelWidget> TitleLevelWidgetInstance;
	
	//게임 모드
	UPROPERTY()
	TObjectPtr<AT3TitleGameMode> TitleGameMode;
	
	//게임 인스턴스
	UPROPERTY()
	TObjectPtr<UT3GameInstance> T3GameInstance;
};

플레이어 컨트롤러는 UI를 생성하고 UI와 기능을 중계하는 역할을 담당한다.

 

void AT3TitlePlayerController::BeginPlay()
{
	Super::BeginPlay();
	
	//마우스 사용
	bShowMouseCursor = true;
	const FInputModeUIOnly InputModeUIOnly;
	SetInputMode(InputModeUIOnly);
	
	//게임 모드
	TitleGameMode = Cast<AT3TitleGameMode>(GetWorld()->GetAuthGameMode());
	if (!TitleGameMode)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : TitleGameMode is NULL"), *GetNameSafe(this));
		return;
	}
	
	//게임 인스턴스
	T3GameInstance = Cast<UT3GameInstance>(GetGameInstance());
	if (!T3GameInstance)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : T3GameInstance is NULL"), *GetNameSafe(this));
		return;
	}
	
	//위젯 생성
	TitleLevelWidgetInstance = CreateWidget<UT3TitleLevelWidget>(this, TitleLevelWidgetClass);
	if (!TitleLevelWidgetInstance)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : Failed to create TitleLevelWidget"), *GetNameSafe(this));
		return;
	}
	TitleLevelWidgetInstance->AddToViewport();
}

BeginPlay에서는 게임 인스턴스와 게임 모드를 캐싱한다. 그 다음 UI를 화면에 출력한다. 타이틀 화면은 직접 움직일 폰은 없고 UI만 있는 레벨이니 UI전용 입력으로 설정하고 마우스를 보이게 한다.

 

void AT3TitlePlayerController::MoveToSelectClassLevel()
{
	TitleGameMode->MoveToSelectClassLevel();
}

void AT3TitlePlayerController::MoveToLastSavedLevel()
{	
	TitleGameMode->MoveToLastSavedLevel();
}

bool AT3TitlePlayerController::DoesSavedDataExist()
{
	return T3GameInstance->GetSavedGameData() != nullptr;
}

void AT3TitlePlayerController::QuitGame()
{
	TitleGameMode->QuitGame();
}

나머지 함수는 위젯에서 사용할 것이며 모두 게임 인스턴스 및 게임 모드를 사용한다.

 

● UI

알다시피 UI를 구현하려면 유저 위젯(UserWidget) 클래스가 필요하다. 이 클래스를 기반으로 위젯 블루프린트를 만들기 때문이다.

 

1. 타이틀 화면

UCLASS()
class DESECRATION_API UT3TitleLevelWidget : public UUserWidget
{
	GENERATED_BODY()
	
public:
	virtual void NativeConstruct() override;
	
private:
	//새로하기
	UFUNCTION()
	void OnClickNewGameButton();
	
	//이어하기
	UFUNCTION()
	void OnClickLoadButton();
	
	//설정
	UFUNCTION()
	void OnClickSettingsButton();
	
	//나가기
	UFUNCTION()
	void OnClickQuitButton();
	
	//새로하기 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> NewGameButton;
	
	//이어하기 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> LoadButton;
	
	//설정 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> SettingsButton;
	
	//나가기 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> QuitButton;
    
    //풀레이어 컨트롤러
	UPROPERTY()
	TObjectPtr<AT3TitlePlayerController> TitlePlayerController;
}

위젯 블루프린트에 4개의 버튼이 있을 것을 생각해서 4개의 버튼 위젯 변수를 선언한다. 그리고 이 버튼에 사용할 함수도 각각 선언한다. 그 외에 플레이어 컨트롤러를 자주 이용하니 캐싱용으로 해당 변수도 선언한다.

 

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

	//플레이어 컨트롤러
	TitlePlayerController = Cast<AT3TitlePlayerController>(GetOwningPlayer());
	if (!TitlePlayerController)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : TitlePlayerController is NULL"), *GetNameSafe(this));
		return;
	}
	
	//버튼 바인딩
	NewGameButton->OnClicked.AddDynamic(this, &ThisClass::OnClickNewGameButton);
	SettingsButton->OnClicked.AddDynamic(this, &ThisClass::OnClickSettingsButton);
	QuitButton->OnClicked.AddDynamic(this, &ThisClass::OnClickQuitButton);
	//불러오기는 저장된 게임이 있을 때만 사용
	if (TitlePlayerController->DoesSavedDataExist())
	{
		LoadButton->OnClicked.AddDynamic(this, &ThisClass::OnClickLoadButton);
	}
	else
	{
		LoadButton->SetIsEnabled(false);
	}
}

자주 사용할 플레이어 컨트롤러를 캐싱한다. 그 다음 각 버튼에 알맞는 함수를 바인딩한다. 이때 불러오기 버튼에 대한 바인딩은 저장된 게임 데이터가 존재할 때만 바인딩하도록 하고 아니면 버튼을 사용하지 않는다.

 

저장된 게임 데이터 존재 여부는 플레이어 컨트롤러에서 구현한 함수를 사용하면 알 수 있다.

 

void UT3TitleLevelWidget::OnClickNewGameButton()
{
	//TODO : 저장된 게임이 있는 경우 물어보기
	TitlePlayerController->MoveToSelectClassLevel();
}

void UT3TitleLevelWidget::OnClickLoadButton()
{
	//저장된 게임이 없다면 무시
	if (!TitlePlayerController->DoesSavedDataExist())
	{
		return;
	}
	
	//TODO : 저장된 게임을 이어할지 여부를 물어보기
	TitlePlayerController->MoveToLastSavedLevel();
}

void UT3TitleLevelWidget::OnClickSettingsButton()
{
	//TODO : 설정 패널 열기
}

void UT3TitleLevelWidget::OnClickQuitButton()
{
	//TODO : 게임 종료 여부 물어보기
	TitlePlayerController->QuitGame();
}

 

버튼에 바인딩된 각 함수는 플레이어 컨트롤러의 함수를 사용한다. TODO에 적은 내용을 보면 알겠지만, 지금은 기능을 즉시 실행하지만 차후 유저에게 물어보는 기능을 구현할 예정이다.

 

2. 설정 패널

UCLASS()
class DESECRATION_API UT3SettingsPanel : public UUserWidget
{
	GENERATED_BODY()
	
public:
	virtual void NativeConstruct() override;
	
private:
	//확인 버튼
	UFUNCTION()
	void OnClickConfirmButton();
	
public:
	//설정 패널 열기
	void OpenSettingsPanel();

private:
	//닫기 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> CloseButton;
	
	//확인 버튼
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UButton> ConfirmButton;
	
	//게임 인스턴스
	UPROPERTY()
	TObjectPtr<UT3GameInstance> T3GameInstance;
}

설정 기획에 있는 각 내용은 나중에 구현하더라도 일단 열고 닫는 것 까지는 필요해 보인다. 그래서 설정 패널에서는 확인, 닫기 버튼과 바인딩할 함수 1개를 만든다. (확인 버튼과 닫기 버튼은 같은 동작을 실행하니 바인딩할 함수가 2개일 필요는 없다.)

 

void UT3SettingsPanel::NativeConstruct()
{
	Super::NativeConstruct();
	
	//게임 인스턴스
	T3GameInstance = Cast<UT3GameInstance>(GetGameInstance());
	if (!T3GameInstance)
	{
		UE_LOG(LogTemp, Error, TEXT("%s : T3GameInstance가 NULL"), *GetNameSafe(this));
		return;
	}
	
	//버튼 바인딩
	CloseButton->OnClicked.AddDynamic(this, &ThisClass::OnClickConfirmButton);
	ConfirmButton->OnClicked.AddDynamic(this, &ThisClass::OnClickConfirmButton);
	
	//숨김 상태로 시작
	SetVisibility(ESlateVisibility::Collapsed);
}

시작시 캐싱 및 바인딩은 타이틀 화면 위젯과 비슷하지만 숨김 상태로 시작한다는 차이점이 있다. 이때 Hidden대신 Collapsed를 쓰는 것이 좋은데 Hidden은 눈에 보이지만 않을 뿐 Tick이나 크기 계산등 기능이 계속 작동하는 상태지만 Collapsed는 완전히 사용하지 않는 상태이기 때문이다.

 

void UT3SettingsPanel::OnClickConfirmButton()
{
	//패널 닫기
	SetVisibility(ESlateVisibility::Collapsed);
}

void UT3SettingsPanel::OpenSettingsPanel()
{
	//패널 활성화
	SetVisibility(ESlateVisibility::Visible);
}

버튼에 사용할 함수는 1개 뿐이고 그 내용은 패널을 숨기는 기능을 실행한다. 다른 하나는 반대로 패널을 출력하는 기능을 실행한다.

 

//T3TitleLevelWidget.h

private:
	//설정 패널 위젯
	UPROPERTY(meta = (AllowPrivateAccess = true, BindWidget))
	TObjectPtr<UT3SettingsPanel> SettingsPanel;
//T3TitleLevelWidget.cpp

void UT3TitleLevelWidget::OnClickSettingsButton()
{
	SettingsPanel->OpenSettingsPanel();
}

설정 패널 클래스의 기초 완성됐으니 타이틀 화면 위젯에서 사용하도록 한다.