Class GameDev* SheepAdult

[Unreal Engine 4.27] Multi Slot Save & Load Game C++ 본문

Unreal Project

[Unreal Engine 4.27] Multi Slot Save & Load Game C++

SheepAdult 2022. 5. 3. 15:50

결과물 Youtube Link:

https://www.youtube.com/watch?v=JjcCQbMbJbo 

 

 일반적인 콘솔 게임을 보면 여러 개의 슬롯이 있는 것을 알 수 있다. 게임을 진행하다가도 현재 데이터는 그대로 둔 채 새 게임을 하고 싶은 경우에, 혹은 다른 사람이 플레이하고 싶을 때 다른 슬롯을 사용하기 위함이다. 각각의 슬롯은 독립적으로 게임 데이터를 가지고 있어야 하며, 게임을 로드했을 시 맵 내의 오브젝트들이 데이터를 저장할 때의 상태 그대로 있어야 한다. 이 기능을 현재 프로젝트에 적용하기 위해 구현했다. 

 

 일단 멀티 슬롯이다.

이는 Multi-Slot Widget이며, 3칸 하나하나는 각각 Slot 위젯이다.

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

	BackButton = Cast<UButton>(GetWidgetFromName(TEXT("BackButton")));

	BackButton->OnClicked.AddDynamic(this, &UMultiSlotWidget::BackToMainMenu);

	Widget_Slot_1->SlotIndex = 1;
	Widget_Slot_2->SlotIndex = 2;
	Widget_Slot_3->SlotIndex = 3;

	Widget_Slot_1->SlotName = FString("Slot1");
	Widget_Slot_2->SlotName = FString("Slot2");
	Widget_Slot_3->SlotName = FString("Slot3");
}

위는 멀티 슬롯 코드 중 NativeConstruct()이며, 예시이다. 슬롯마다 이름이 있어야 슬롯을 저장하거나 불러올 때 이름과 대조가 가능하다. 그리고 슬롯에 이름을 붙여 사용자에게 알려주는 용도로도 쓰인다. Widget_Slot_n은 각 Slot이다. 즉, 각 Slot의 SlotIndex, SlotName에 값을 넣어준 것이다.

 각 슬롯의 이름, 그리고 총 플레이 시간을 나타내 주기 위해서 Slot.cpp에 코드를 작성했다. 아래는 일부이다.

void USlotWidget::NativeTick(const FGeometry& MyGeometry, float DeltaTime)
{
	Super::NativeTick(MyGeometry, DeltaTime);
	SetSlotInfoText();
}

void USlotWidget::SetSlotInfoText()
{
	FString SlotNameTextSetting;
	SlotNameTextSetting = FString::Printf(TEXT("Slot%d"), SlotIndex);
	SlotNameText->SetText(FText::FromString(SlotNameTextSetting));

	FString SlotInfo;
	bool bDoesSaveGameExist = UGameplayStatics::DoesSaveGameExist(SlotName, 0);
	SaveGameData = Cast<USaveGameObject>(UGameplayStatics::LoadGameFromSlot(SlotName, 0));


	if (bDoesSaveGameExist)
	{
		SlotInfo = FString::Printf(TEXT("%f"), SaveGameData->TotalPlayTime);
	}
	else
	{
		SlotInfo = FString::Printf(TEXT("None"));
	}
	SlotInfoText->SetText(FText::FromString(SlotInfo));
}

 NativeConstruct에 텍스트를 작성하는 코드를 넣으면 SaveGameData를 순서상 불러오지 못해 NativeTick() 함수에 넣어줬다. 뒤에서 SaveGameData에 총 플레이 시간을 저장하는 과정이 나온다.

 본문에서는 Slot을 클릭하면 시작할지, 삭제할지 정하는 위젯이 한차례 더 나온다.

삭제는 UGameplayStatics::DeleteGameInSlot(SlotName, 0); 과 같이 간단한 방법으로 삭제가 가능하고, 시작을 할 시에는 GameInstance를 봐야 알 수 있다. 

 

 게임을 세이브 / 로드하기 위해 GameInstance와 SaveGameObject 클래스 객체를 만들었다. 후자는 콘솔게임 세이브 데이터를 로컬 저장소에 저장해주는 클래스로 프로젝트의 Saved/SaveGames/파일 이름. sav 형태로 저장한다. 게임을 종료하고 다시 시작하면 게임 데이터는 모두 초기화되므로 SaveGameObject에서 불러와 Load 해주는 것이다. GameInstance는 게임이 시작되고 종료될 때까지 남아있는 오브젝트로 게임 관리, 즉 게임 세이브 로드 / 리스폰 / 등 전체적인 관리를 해주는 역할을 하면 된다. (사실 GameInstance, GameState, GameModeBase와 같은 게임 오브젝트들에 대한 구분이 확실치 않아 추후에 공부해서 업로드해야겠다..)

 

 일단 GameInstance 코드이다. 내가 복잡하게 짠 건지 원래 좀 복잡한 건지 생각보다 시스템이 복잡해서 코드를 일단 나열하겠다.

#include "MyGameInstance.h"
#include "Kismet/GameplayStatics.h"
#include "Kismet/KismetSystemLibrary.h"
#include "SaveGameObject.h"
#include "MainCharacter.h"
#include "Master_Movable.h"
#include "InteractableObject_Door.h"
#include "CheckPoint.h"

void UMyGameInstance::LoadGame()
{
	if (UGameplayStatics::DoesSaveGameExist(SaveSlotName, 0))
	{
		SaveGameData = Cast<USaveGameObject>(UGameplayStatics::LoadGameFromSlot(SaveSlotName, 0));
	}
	else
	{
		SaveGameData = Cast<USaveGameObject>(UGameplayStatics::CreateSaveGameObject(USaveGameObject::StaticClass()));
	}
	
}

void UMyGameInstance::SaveData()
{
	if (SaveGameData)
	{
		if (OnSave.IsBound() == true)
		{
			OnSave.Broadcast();
		}

		EndTime = UGameplayStatics::GetTimeSeconds(GetWorld());
		SaveGameData->TotalPlayTime += EndTime;

		SaveLevelName = UGameplayStatics::GetCurrentLevelName(GetWorld());
		SaveGameData->SaveLevelName = SaveLevelName;

		UGameplayStatics::SaveGameToSlot(SaveGameData, SaveSlotName, 0);
	}
}

void UMyGameInstance::LoadData()
{
	// Main Character Data Load
	if (SaveGameData)
	{
		if (OnReset.IsBound() == true)
		{
			OnReset.Broadcast();

			// When Load Game at first, Broadcast is faster than MainCharacter binding Delegate. So MainCharacter's initial setting is not operate.
			// -> Call Reset Function about MainCharacter Not in MainCharacter script, but in GameInstance script
			MainCharacter = Cast<AMainCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
			MainCharacter->SetActorLocation(SaveGameData->MainCharacterStruct.MainCharacterLocation);
			MainCharacter->bIsStartingPoint = SaveGameData->MainCharacterStruct.bIsStatingPoint;
		}
	}
}

void UMyGameInstance::DeleteData()
{
	UGameplayStatics::DeleteGameInSlot(SaveSlotName, 0);
}

LoadGame()은 게임을 로드하는 함수로, 저장된 데이터를 로드하는 게 아니라 SlotName에 맞는 Slot을 가져온다. 그리고 LoadData()로 게임에 대한 데이터를 Delegate로 불러오며, 저장은 CheckPoint에 도달했을 시, SaveData()로 한다. LoadGame()은 SlotName에 대한 데이터가 없으면 Create 하고, 있으면 Load 한다.

 이를 호출하는 위치는 아래와 같다.

void USlotDetailWidget::StartGame()
{
	GameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (GameInstance)
	{
		GameInstance->SaveSlotName = SlotName;
		GameInstance->SlotDetailWidget = this;
		GameInstance->LoadGame();	
		UGameplayStatics::OpenLevel(GetWorld(), FName(GameInstance->SaveGameData->SaveLevelName));
		//UGameplayStatics::OpenLevel(GetWorld(), FName("Main_Yang"));
	}
}

위의 삭제할지, 플레이할지 한 번 더 물어보는 위젯에서 StartGame과 바인딩된 함수이다. GameInstance를 불러온 뒤, 현재 슬롯의 이름, self에 대한 정보들을 게임 인스턴스의 슬롯 변수에 넣어준다. 그 후 로드 게임을 하면 SaveGameData에 SlotName에 맞는 데이터가 할당될 것이며, 할당된 SaveGameData를 기반으로 SaveGameData의 LevelName에 맞는 레벨을 로드한다.

 

 하지만 아직 데이터를 로드하진 않았다. 데이터를 저장하기 위해서는 SaveLevelObject에 저장된 데이터들을 레벨의 오브젝트들에 주입해줘야 한다. 우리는 이 과정들을 레벨 블루프린트의 기능을 하는 LevelScriptActor 클래스에서 호출해준다. 왜냐하면 메뉴 레벨에서 데이터들을 불러와봤자 게임 레벨에는 로드가 안되므로 플레이할 맵에 호출을 해줘야 하는 것이다. 아래는 Level 1에 대한 LevelScriptActor이다. (참고로 이를 레벨 블루프린트처럼 사용하려면 레벨 블루프린트-> 클래스 세팅에서 클래스를 해당 클래스로 설정해 주면 된다.)

void ALevel1LevelScriptActor::BeginPlay()
{
	UWidgetBlueprintLibrary::SetInputMode_GameOnly(UGameplayStatics::GetPlayerController(GetWorld(), 0));
	UGameplayStatics::GetPlayerController(GetWorld(), 0)->SetShowMouseCursor(false);

	GameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	GameInstance->LoadGame();
	GameInstance->LoadData();
}

여기서 LoadGame을 한 번 더해주는 이유는, 앞에서는 맵을 열기 위해 로드를 해준 것이고, 해당 클래스에서는 이미 열린 맵에서 오브젝트들의 정보들을 가져오기 위해 LoadGame()을 해주는 것이다. 그다음 LoadData()를 해준다. LoadData()를 보면 OnReset.BroadCast(); 코드를 볼 수 있는데, Reset시 multi-delegate에 바인딩되어있는 모든 오브젝트들의 함수들을 콜백 형식으로 호출하기 위함이다. 그리고 Reset으로 이름을 해놓은 이유는 캐릭터 사망 시 체크포인트 도달 시 상황으로 되돌리기 위한 함수를 그대로 사용하는 것이기 때문이다.

 

 바인딩을 Push Pull Object에 했을 경우이다. 

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

	GameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (GameInstance)
	{
		GameInstance->OnReset.AddUFunction(this, FName("Reset"));
		GameInstance->OnSave.AddUFunction(this, FName("Save"));
	}
}

void AMaster_Movable::Reset()
{
	if (GameInstance->SaveGameData->Level1SaveDataStruct.MovableData.Contains(this))
	{
		SetActorTransform(*GameInstance->SaveGameData->Level1SaveDataStruct.MovableData.Find(this));
	}
}

void AMaster_Movable::Save()
{
	GameInstance->SaveGameData->Level1SaveDataStruct.MovableData.Add(this, GetActorTransform());
}

위와 같이 Reset()은 캐릭터가 사망하거나 게임을 시작했을 시 호출되는 함수이고 Save는 뒤에서 설명하겠지만 CheckPoint를 밟았을 때 호출된다.

 

 이제 이쯤에서 SaveGameObject를 한 번 봐야 할 것이다. 헤더 파일만 존재해도 무방하다. 코드는 아래와 같다.

UCLASS()
class CAP2_API USaveGameObject : public USaveGame
{
	GENERATED_BODY()
	
public:
	UPROPERTY()
		float TotalPlayTime;
	UPROPERTY()
		FString SaveLevelName = "Main_Yang";

	UPROPERTY(EditAnywhere)
		FMainCharacterStruct MainCharacterStruct;

	UPROPERTY()
		FLevel1Struct Level1SaveDataStruct;
};

현재 Level 1에 대한 정보를 저장할 구조체와 캐릭터의 위치정보, 저장되는 레벨 이름, 총 플레이 시간을 저장해 뒀다.

Level 1 구조체는 아래와 같다. 위와 마찬가지로 헤더 파일만 사용한다. 일반 Actor클래스로 만들었다.

USTRUCT(Atomic, BlueprintType)
struct FLevel1Struct
{
	GENERATED_BODY()

public:
	UPROPERTY()
		TMap<class ACheckPoint*, bool> CheckPointData;
	UPROPERTY()
		TMap<class AMaster_Movable*, FTransform> MovableData;
};

CheckPointData는 CheckPoint는 한 번 밟으면 작동을 다신 못하게 할 것이므로 이에 대한 정보를 저장하는 Map이고, MovableData는 Movable 오브젝트의 Transform만을 저장하는 Map이다. 오브젝트들마다 저장해야 하는 데이터를 상황에 맞게 달리해주면 된다.

 이런 방식으로 저장 / 불러올 때 맵에 저장하고, Key값으로 Value값을 가져오면 된다.

 

이제 Save이다. 해당 프로젝트에서는 저장버튼은 따로 없고, CheckPoint와 충돌했을 경우에만 저장한다.

CheckPoint 전체 코드이다.

#include "CheckPoint.h"
#include "MainCharacter.h"
#include "Cap2GameModeBase.h"
#include "Kismet/GameplayStatics.h"
#include "MyGameInstance.h"
#include "Master_Movable.h"
#include "SaveGameObject.h"

ACheckPoint::ACheckPoint()
{
	PrimaryActorTick.bCanEverTick = true;

	Box = CreateDefaultSubobject<UBoxComponent>(TEXT("Box"));
	RootComponent = Box;

	Arrow = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
	Arrow->SetupAttachment(RootComponent);

	Box->OnComponentBeginOverlap.AddDynamic(this, &ACheckPoint::OnOverlapBegin);
	Box->OnComponentEndOverlap.AddDynamic(this, &ACheckPoint::OnOverlapEnd);
}

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

	GameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (GameInstance)
	{
		GameInstance->OnReset.AddUFunction(this, FName("Reset"));
		GameInstance->OnSave.AddUFunction(this, FName("Save"));
	}
}

void ACheckPoint::OnOverlapBegin(UPrimitiveComponent* OverlappedComp, AActor* OtherActor, UPrimitiveComponent* OtherComp,
	int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	MainCharacter = Cast<AMainCharacter>(OtherActor);
	if (!bIsNextLevel)
	{
		if (!bIsDestroyed)
		{
			if (MainCharacter)
			{
				SaveGameToSlot();
			}
		}
	}
	else
	{
		if (UGameplayStatics::GetCurrentLevelName(GetWorld()) == "Main_Yang")
		{
			UGameplayStatics::OpenLevel(GetWorld(), FName("Main_Yang_2"));
		}
	}
}

void ACheckPoint::SaveGameToSlot()
{
	MainCharacter->MainCharacterLocation = GetActorLocation();
	bIsDestroyed = true;
	GameInstance->SaveData();
}

void ACheckPoint::Reset()
{
	if (GameInstance->SaveGameData->Level1SaveDataStruct.CheckPointData.Contains(this))
	{
		bIsDestroyed = *GameInstance->SaveGameData->Level1SaveDataStruct.CheckPointData.Find(this);
	}
}
void ACheckPoint::Save()
{
	GameInstance->SaveGameData->Level1SaveDataStruct.CheckPointData.Add(this, bIsDestroyed);
}

위에서 볼 것은 먼저 충돌했을 경우이다. bIsNextLevel은 레벨을 옮겨가는 체크포인트이다. bIsDestroyed는 한번 밟으면 작동을 멈추게 하기 위한 것이다. 만약 충돌했을 때 SaveGameToSlot()으로 저장하며, SaveData()를 호출한다. SaveData()는 다시 한번 보기 위해 아래에 작성한다.

void UMyGameInstance::SaveData()
{
	if (SaveGameData)
	{
		if (OnSave.IsBound() == true)
		{
			OnSave.Broadcast();
		}

		EndTime = UGameplayStatics::GetTimeSeconds(GetWorld());
		SaveGameData->TotalPlayTime += EndTime;
		UE_LOG(LogTemp, Error, TEXT("%f"), SaveGameData->TotalPlayTime);

		SaveLevelName = UGameplayStatics::GetCurrentLevelName(GetWorld());
		SaveGameData->SaveLevelName = SaveLevelName;

		UGameplayStatics::SaveGameToSlot(SaveGameData, SaveSlotName, 0);
	}
}

저장에 관한 delegate를 모두 호출해 해당 levelstruct에 map형식으로 저장하고 총 플레이타임을 저장하기 위해 gameinstance의 savegamedata의 변수에 저장한다. 그리고 레벨의 이름도 저장해줘야 하며, 마지막으로 이를 슬롯에 저장해야 하므로 SaveGameToSlot() 함수를 사용해 저장해준다.

 

 아래는 정확하지 않은 정보인데... 리스폰 관련은 GameModeBase에 해주는 게 좋다는 글을 봐서 GameModeBase에 작성해봤다. (아닌 것 같기도?)

void ACap2GameModeBase::BeginPlay()
{
	MainCharacter = Cast<AMainCharacter>(UGameplayStatics::GetPlayerCharacter(GetWorld(), 0));
	GameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (MainCharacter)
	{
		MainCharacter->OnDie.BindUFunction(this, FName("EventOnMainCharacterDie"));
	}
}

void ACap2GameModeBase::EventOnMainCharacterDie()
{
	if (GetWorld())
	{
		FTimerHandle WaitHandle;
		GetWorld()->GetTimerManager().SetTimer(WaitHandle, FTimerDelegate::CreateLambda([&]()
		{
			GEngine->AddOnScreenDebugMessage(-1, 5.0f, FColor::Red, TEXT("Respawn"));
			GameInstance->LoadData();
		}), RespawnTime, false);
	}
}

캐릭터가 죽었을 시 바인딩되는 delegate함수를 작성해주고, 위에서 말했듯 GameInstance의 LoadDatat()를 호출해주면 된다.

 

-정리

굉장히 오랜 기간 동안 구현했다. 처음에는 delegate로 안 하고 무작정 레벨 내의 모든 클래스를 읽어오는 함수를 사용하여 GameInstance에서 save / load를 직접 해줬다. 그 과정에서 처음엔 SaveGameObject에도 저장하고 불러오는데 데이터가 불러와지지 않아 무엇이 문제인가 오래 고민한 것 같다. 그러다가 출력 창을 봤는데 보이지 않던 "특정 object를 찾을 수 없다."는 출력을 보게 됐고 LevelScriptActor에서 데이터를 로드하지 않고 메인 메뉴 레벨에서 데이터를 로드해서 생긴 문제였다. 여기까지만 해도 4일 정도 걸렸다,, 생각보다 눈에 보이지 않는 여러 스크립트를 왔다 갔다 해야 했고 무엇보다 처음이라 그랬던 점이 큰 것 같다. 그리고 딱 봐도 비효율적이라고 생각해 구글링을 했고, 이런 경우 delegate로 한 번에 처리한다는 사실을 알게 됐다. 전엔 싱글 delegate만 사용했었는데 multi-delegate를 사용할 땐 broadcast 등 다르다는 것도 알았다. 그 후 다른 레벨로 넘기는 과정과 메뉴에 Game 데이터 정보를 써넣는 것이 순서 때문에 좀 막혔다. 왜냐하면 메인 메뉴에서는 게임 데이터가 필요할 거라 생각하지 않아 LoadGameFromSlot()으로 SaveGameObject 객체에 값을 할당해주지 않았는데, 정보는 객체에 값을 할당한 후 나타낼 수 있어서 좀 혼란스러웠다... 그래서 메인 메뉴에서도 LoadGame으로 데이터를 불러왔는데 잘 구현한 것인지는 모르겠다.

 물론 게임에서 정말 중요한 요소인 정보 저장 / 불러오기지만 한 번 구현에 들이는 시간이 적을 수 있는 파트인 것 같다. 어차피 모든 게임이 방식이 비슷할 것이고 인디 겜 같은 경우는 한 번 구현해 놓으면 다시 건드릴 일이 없을 것이기 때문이다.(아닌가?) 그래도 한 번 구현해봄으로써 레벨과 GameInstance의 상호작용 등 공부를 한 것 같아 좋았다.