SaveGame의 기본 개념
메모리의 특성
RAM(메모리)의 치명적 약점
- 휘발성 (Volatile) : 전원이 꺼지면 모든 데이터가 사라짐
- 모든 게임 데이터가 RAM에 저장됨
-> 하드 디스크/SSD에 저장해야만 데이터가 살아있음
파일에 텍스트로 저장하면 안 되는 이유
1. 플랫폼 차이
- PlayStation, Xbox, Switch : 각각 다른 저장 위치와 방식
2. 복잡한 데이터 구조 & 데이터 타입 변환의 복잡성
- 객체의 복잡한 데이터 구조를 저장하기 어려움
- 저장할 때, 저장 가능한 데이터 타입으로 변환이 필요
- 불러올 때, 읽을 수 있는 데이터 타입으로 변환이 필요
3. 보안
- 텍스트로 저장 시에 누구나 메모장으로 수정 가능
- 게임의 중요한 데이터 등이 외부에서 쉽게 변경 가능
언리얼의 SaveGame 시스템
SaveGame이 제공해주는 기능
- 플랫폼 자동 대응 : Windows, Mac, PlayStation, Xbox 등 자동 처리
- 자동 직렬화 : 복잡한 객체를 파일로 저장 가능한 형태로 자동 변환
- 타입 안정성 : C++의 타입 시스템 그대로 활용
- 바이너리 저장 : 일반 사용자가 쉽게 수정할 수 없음
SaveGame의 핵심 개념
- 사용 방법
USaveGame을 상속
UCLASS() 매크로 필요
- 저장할 변수에는
UPROPERTY() 매크로 필요
- 내부 메커니즘(런타임 동작)
UGameplayStatics::SaveGameToSlot(SaveGameInstance, TEXT("MySlot"), 0);
- SaveGame 객체 생성 -> 리플렉션 시스템이 UPROPERTY 변수 확인
- 저장 시 : 변수들을 직렬화를 통해 바이너리 데이터로 변환
- 플랫폼에 맞는 위치에 자동 저장
- 클래스 예시
#pragma once
#include "GameFramework/SaveGame.h"
#include "MySaveGame.generated.h"
UCLASS()
class MYGAME_API UMySaveGame : public USaveGame
{
GENERATED_BODY()
public:
UMySaveGame();
// 저장할 플레이어 데이터들
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FString PlayerName;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
int32 PlayerLevel;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
float PlayerHealth;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FVector PlayerLocation;
// 세이브 슬롯 정보
UPROPERTY(VisibleAnywhere, Category = "SaveData")
FString SaveSlotName;
UPROPERTY(VisibleAnywhere, Category = "SaveData")
uint32 UserIndex;
};
SaveGame 객체 생성과 저장 메커니즘
1. SaveGame 객체 생성
잘못된 방법
// 직접 new를 통해 메모리 할당하면 안 됨
UMySaveGame* SaveGame = new UMySaveGame();
올바른 방법
void AMyPlayerController::SaveGame()
{
// UGameplayStatics::CreateSaveGameObject() 함수를 통해 생성
UMySaveGame* SaveGameInstance = Cast<UMySaveGame>(
UGameplayStatics::CreateSaveGameObject(UMySaveGame::StaticClass())
);
// 생성 실패 체크한 후에 로그 출력
if (!SaveGameInstance)
{
UE_LOG(LogTemp, Error, TEXT("세이브 객체 생성에 실패했습니다!"));
return;
}
}
UGameplayStatics::CreateSaveGameObject() 함수를 통해 생성
-> 내부에서 SaveGame 객체를 상속받는지 확인한 후에, NewObject()를 통해 생성해줌
-> 언리얼의 가비지 컬렉션 시스템에 등록 및 안전한 메모리 관리
-> 플랫폼별 최적화 적용
2. 게임 데이터를 복사 및 세이브 슬롯 정보 설정
SaveGameInstance->PlayerName = MyCharacter->GetPlayerName();
- 게임 데이터는 정보를 복사해서 저장할 것
- 참조를 할 시에 원본이 사라지면 데이터도 사라짐
SaveGameInstance->SaveSlotName = TEXT("MyGameSave");
SaveGameInstance->UserIndex = 0;
- SaveSlotName : 실제 파일명의 기준 (
MyGameSave.sav)
- UserIndex : 사용자 구분 (보통 0, 멀티플레이에서는 각자 다른 번호)
3. 실제 파일 저장
// UGameplayStatics::SaveGameToSlot() 함수를 통해 파일 저장
bool bSaveSuccess = UGameplayStatics::SaveGameToSlot(
SaveGameInstance, // SaveGame 객체
SaveGameInstance->SaveSlotName, // 파일 이름
SaveGameInstance->UserIndex // 사용자 인덱스
);
if (bSaveSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("게임 저장 성공: %s"), *SaveGameInstance->SaveSlotName);
}
UGameplayStatics::SaveGameToSlot() 함수를 통해 실제 파일 저장
-> 리플렉션을 통해 저장할 목록 조사 및 값 추출
-> 직렬화 수행
-> 플랫폼별 저장 위치 결정 및 파일 쓰기 작업
SaveGame 불러오기 메커니즘
1. 파일 존재 확인
void AMyPlayerController::LoadGame()
{
// 세이브 파일 정보
FString SlotName = TEXT("MyGameSave");
uint32 UserIndex = 0;
// UGameplayStatics::DoesSaveGameExist() 함수를 통해 파일 존재 확인
if (!UGameplayStatics::DoesSaveGameExist(SlotName, UserIndex))
{
// 첫 플레이인 경우로 기본값으로 게임 시작
InitializeNewGame();
return;
}
// 불러오기 실행
UE_LOG(LogTemp, Warning, TEXT("세이브 파일 발견됨, 불러오기 진행"));
}
UGameplayStatics::DoesSaveGameExist() 함수를 통해 파일 존재 확인
-> 내부에서 플랫폼에 맞게 저장을 확인하고 존재하지 않으면 nullptr 반환
-> 첫 플레이인 경우에는 세이브 파일이 없기 때문에 기본값으로 설정
2. 파일 불러오기
USaveGame* LoadedGame = UGameplayStatics::LoadGameFromSlot(SlotName, UserIndex);
if (!LoadedGame)
{
UE_LOG(LogTemp, Error, TEXT("세이브 파일을 읽는 중 오류 발생!"));
return;
}
UGameplayStatics::LoadGameFromSlot() 함수를 통해 파일 불러오기
-> 파일 읽기 : 바이너리 데이터를 메모리로 로드
-> 헤더 정보 확인 : 언러일 버전, SaveGame 클래스 정보
-> 클래스 타입 확인 : 호환성 검사
-> 역직렬화 : 바이너리 -> 객체로 변환
3. 안전한 타입 변환
UMySaveGame* LoadedSaveGame = Cast<UMySaveGame>(LoadedGame);
if (!LoadedSaveGame)
{
UE_LOG(LogTemp, Error, TEXT("세이브 파일 타입이 올바르지 않습니다!"));
return;
}
- Cast가 실패하는 경우
-> 다른 게임의 세이브 파일
-> 게임 업데이트로 SaveGame 클래스가 변경됨
-> 파일 손상, 악의적으로 변조된 파일
4. 데이터 검증
bool bIsDataValid = ValidateSaveData(LoadedSaveGame);
if (!bIsDataValid)
{
UE_LOG(LogTemp, Error, TEXT("세이브 데이터가 유효하지 않습니다!"));
return;
}
bool AMyPlayerController::ValidateSaveData(UMySaveGame* SaveData)
{
if (!SaveData)
{
UE_LOG(LogTemp, Error, TEXT("SaveData가 null입니다"));
return false;
}
// 플레이어 레벨 검증
if (SaveData->PlayerLevel < 1 || SaveData->PlayerLevel > 1000)
{
UE_LOG(LogTemp, Error, TEXT("플레이어 레벨이 비정상적입니다: %d"), SaveData->PlayerLevel);
return false;
}
// 플레이어 체력 검증
if (SaveData->PlayerHealth < 0.0f || SaveData->PlayerHealth > 999.0f)
{
UE_LOG(LogTemp, Error, TEXT("플레이어 체력이 비정상적입니다: %f"), SaveData->PlayerHealth);
return false;
}
// 플레이어 이름 검증
if (SaveData->PlayerName.IsEmpty() || SaveData->PlayerName.Len() > 50)
{
UE_LOG(LogTemp, Error, TEXT("플레이어 이름이 비정상적입니다"));
return false;
}
// 위치 검증 (맵 경계 내에 있는지)
FVector Location = SaveData->PlayerLocation;
if (FMath::Abs(Location.X) > 100000.0f ||
FMath::Abs(Location.Y) > 100000.0f ||
Location.Z < -10000.0f || Location.Z > 10000.0f)
{
UE_LOG(LogTemp, Warning, TEXT("플레이어 위치가 맵 경계를 벗어남"));
SaveData->PlayerLocation = FVector(0, 0, 100);
}
return true;
}
5. 게임 상태에 데이터 적용
void AMyPlayerController::ApplySaveDataToGame(UMySaveGame* SaveData)
{
AMyCharacter* MyCharacter = Cast<AMyCharacter>(GetPawn());
if (!MyCharacter)
{
UE_LOG(LogTemp, Error, TEXT("플레이어 캐릭터를 찾을 수 없습니다"));
return;
}
// 플레이어 정보 복원
MyCharacter->SetPlayerName(SaveData->PlayerName);
MyCharacter->SetLevel(SaveData->PlayerLevel);
MyCharacter->SetHealth(SaveData->PlayerHealth);
// 플레이어 위치 복원
RestorePlayerLocation(SaveData->PlayerLocation);
}
void AMyPlayerController::RestorePlayerLocation(const FVector& SavedLocation)
{
AMyCharacter* MyCharacter = Cast<AMyCharacter>(GetPawn());
if (!MyCharacter) return;
// 안전한 스폰을 위해 Z값 조정
FVector SpawnLocation = SavedLocation;
SpawnLocation.Z += 100.0f;
MyCharacter->SetActorLocation(SpawnLocation);
UE_LOG(LogTemp, Warning, TEXT("플레이어 위치 복원 완료: %s"),
*SpawnLocation.ToString());
}
직렬화
직렬화의 개념
- 메모리의 복잡한 구조 -> 파일의 일렬된 구조로 변환
- 직렬화 : 복잡한 구조를 일렬로 변환
- int32 15 ->
0F 00 00 00
- float 87.5 ->
00 00 AF 42
- 역직렬화 : 일렬된 데이터를 복잡한 구조로 복원
언리얼 엔진의 직렬화
UPROPERTY() 매크로를 작성 시에 UHT에서 직렬화 실행
- 컴파일 시점
- Unreal Header Tool(UHT) 코드 분석
UPROPERTY() 변수 찾기
- 자동으로 직렬화 코드 생성
- 런타임 시점
- 리플렉션 정보 확인
- 각 타입에 맞는 직렬화 적용
- 바이너리 데이터로 변환
- 파일에 저장
커스텀 구조체 직렬화
USTRUCT(BlueprintType)
struct FInventoryItem
{
GENERATED_BODY()
UPROPERTY()
FString ItemName;
UPROPERTY()
int32 Quantity;
UPROPERTY()
float Durability;
// 기본 생성자 필수!
FInventoryItem()
{
ItemName = TEXT("");
Quantity = 0;
Durability = 100.0f;
}
};
- 필수 요소들
USTRUCT()
GENERATED_BODY()
- 모든 멤버에
UPROPERTY()
- 기본 생성자
저장되지 않는 것들
- 문제가 되는 것들
- 주소 대신 정보를 직접 저장
UCLASS()
class UCorrectSaveGame : public USaveGame
{
GENERATED_BODY()
public:
// 식별 정보 저장
UPROPERTY()
FString ActorName; // 액터 이름
UPROPERTY()
FGuid ActorGUID; // 고유 식별자
UPROPERTY()
FString TexturePath; // 텍스처 경로
// 상태 정보 저장
UPROPERTY()
float TimerRemainingTime;
UPROPERTY()
bool bTimerIsActive;
};
void SaveActorReference(AActor* ActorToSave)
{
if (ActorToSave)
{
// 이름 저장
MySaveGame->ActorName = ActorToSave->GetName();
// 고유 ID가 있으면 같이 저장
if (ActorToSave->GetUniqueID().IsValid())
{
MySaveGame->ActorGUID = ActorToSave->GetUniqueID();
}
}
}
AActor* FindSavedActor()
{
for (TActorIterator<AActor> ActorItr(GetWorld()); ActorItr; ++ActorItr)
{
AActor* Actor = *ActorItr;
if (Actor->GetName() == MySaveGame->ActorName)
{
return Actor;
}
}
UE_LOG(LogTemp, Warning, TEXT("저장된 액터를 찾을 수 없습니다: %s"), *MySaveGame->ActorName);
return nullptr;
}
JSON vs Binary
Binary 방식 (.sav)
장점
단점
- 디버깅 어려움
- 다른 시스템과 호환 어려움
- 버전 관리 복잡
사용하는 경우
- 플레이어 진행상황 저장
- 자주 저장/로드되는 데이터
JSON 방식 (.json)
{
"PlayerName": "김철수",
"Level": 25,
"Health": 87.5,
"Position": {
"X": 1250.0,
"Y": -500.0,
"Z": 128.0
}
}
장점
- 사람이 읽을 수 있음
- 디버깅 쉬움
- 웹과 연동 쉬움
- 설정 파일에 적합
단점
사용하는 경우
- 설정 파일들
- 개발 단계의 데이터
- 웹 연동이 필요한 데이터
언리얼에서 사용하는 방법
"Dom/JsonObject.h", "Serialization/JsonSerializer.h", "Serialization/JsonWriter.h", "HAL/PlatformFileManager.h" 헤더 파일 추가
- JSON 저장하기
bool UJsonSaveSystem::SavePlayerDataToJson(const FString& SlotName)
{
UE_LOG(LogTemp, Warning, TEXT("JSON 저장을 시작합니다: %s"), *SlotName);
// 1단계: 현재 플레이어 찾기
AMyCharacter* Player = GetCurrentPlayer();
if (!Player)
{
UE_LOG(LogTemp, Error, TEXT("플레이어를 찾을 수 없습니다!"));
return false;
}
// 2단계: JSON 루트 객체 만들기
TSharedPtr<FJsonObject> RootObject = MakeShareable(new FJsonObject);
// 3단계: 기본 데이터를 JSON에 추가
RootObject->SetStringField(TEXT("PlayerName"), Player->GetPlayerName());
RootObject->SetNumberField(TEXT("Level"), Player->GetLevel());
RootObject->SetNumberField(TEXT("Health"), Player->GetHealth());
// 4단계: 복잡한 구조체 데이터 추가 (FVector)
FVector PlayerPos = Player->GetActorLocation();
TSharedPtr<FJsonObject> PositionObject = MakeShareable(new FJsonObject);
PositionObject->SetNumberField(TEXT("X"), PlayerPos.X);
PositionObject->SetNumberField(TEXT("Y"), PlayerPos.Y);
PositionObject->SetNumberField(TEXT("Z"), PlayerPos.Z);
RootObject->SetObjectField(TEXT("Position"), PositionObject);
// 5단계: 배열 데이터 추가
TArray<FString> CompletedQuests = Player->GetCompletedQuests();
TArray<TSharedPtr<FJsonValue>> QuestArray;
for (const FString& QuestName : CompletedQuests)
{
TSharedPtr<FJsonValue> QuestValue = MakeShareable(new FJsonValueString(QuestName));
QuestArray.Add(QuestValue);
}
RootObject->SetArrayField(TEXT("CompletedQuests"), QuestArray);
// 6단계: JSON을 텍스트로 변환
FString OutputString;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutputString);
bool bSerializeSuccess = FJsonSerializer::Serialize(RootObject.ToSharedRef(), Writer);
if (!bSerializeSuccess)
{
UE_LOG(LogTemp, Error, TEXT("JSON 텍스트 변환 실패"));
return false;
}
// 7단계: 파일 경로 만들기
FString SavePath = FPaths::ProjectSavedDir() / TEXT("SaveGames") / SlotName + TEXT(".json");
// 8단계: 디렉토리 생성
FString Directory = FPaths::GetPath(SavePath);
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
if (!PlatformFile.DirectoryExists(*Directory))
{
PlatformFile.CreateDirectoryTree(*Directory);
}
// 9단계: 파일에 저장
bool bSaveSuccess = FFileHelper::SaveStringToFile(OutputString, *SavePath);
if (bSaveSuccess)
{
UE_LOG(LogTemp, Warning, TEXT("JSON 저장 완료: %s"), *SavePath);
}
return bSaveSuccess;
}
bool UJsonSaveSystem::LoadPlayerDataFromJson(const FString& SlotName)
{
// 1단계: 파일 경로 만들기
FString LoadPath = FPaths::ProjectSavedDir() / TEXT("SaveGames") / SlotName + TEXT(".json");
// 2단계: 파일 존재 확인
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
if (!PlatformFile.FileExists(*LoadPath))
{
UE_LOG(LogTemp, Error, TEXT("JSON 파일이 없습니다: %s"), *LoadPath);
return false;
}
// 3단계: 파일을 텍스트로 읽기
FString JsonString;
if (!FFileHelper::LoadFileToString(JsonString, *LoadPath))
{
UE_LOG(LogTemp, Error, TEXT("파일 읽기 실패"));
return false;
}
// 4단계: 텍스트를 JSON 객체로 파싱
TSharedPtr<FJsonObject> JsonObject;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(JsonString);
if (!FJsonSerializer::Deserialize(Reader, JsonObject) || !JsonObject.IsValid())
{
UE_LOG(LogTemp, Error, TEXT("JSON 파싱 실패"));
return false;
}
// 5단계: 데이터 안전하게 읽기
FString PlayerName;
if (JsonObject->TryGetStringField(TEXT("PlayerName"), PlayerName))
{
UE_LOG(LogTemp, Warning, TEXT("플레이어 이름: %s"), *PlayerName);
}
int32 Level = 1;
double LevelDouble;
if (JsonObject->TryGetNumberField(TEXT("Level"), LevelDouble))
{
Level = static_cast<int32>(LevelDouble);
}
float Health = 100.0f;
double HealthDouble;
if (JsonObject->TryGetNumberField(TEXT("Health"), HealthDouble))
{
Health = static_cast<float>(HealthDouble);
}
// 6단계: 중첩 객체 읽기 (Position)
const TSharedPtr<FJsonObject>* PositionObject = nullptr;
if (JsonObject->TryGetObjectField(TEXT("Position"), PositionObject) &&
PositionObject->IsValid())
{
double X = 0.0, Y = 0.0, Z = 0.0;
(*PositionObject)->TryGetNumberField(TEXT("X"), X);
(*PositionObject)->TryGetNumberField(TEXT("Y"), Y);
(*PositionObject)->TryGetNumberField(TEXT("Z"), Z);
FVector LoadedPosition(static_cast<float>(X), static_cast<float>(Y), static_cast<float>(Z));
UE_LOG(LogTemp, Warning, TEXT("플레이어 위치: %s"), *LoadedPosition.ToString());
}
// 7단계: 배열 읽기 (CompletedQuests)
const TArray<TSharedPtr<FJsonValue>>* QuestArray = nullptr;
if (JsonObject->TryGetArrayField(TEXT("CompletedQuests"), QuestArray))
{
for (const TSharedPtr<FJsonValue>& QuestValue : *QuestArray)
{
FString QuestName;
if (QuestValue->TryGetString(QuestName))
{
UE_LOG(LogTemp, Log, TEXT("완료된 퀘스트: %s"), *QuestName);
}
}
}
return true;
}
출처 : 팀스파르타 내일배움캠프