Stage 기반 게임을 만들다 보면 가장 헷갈리는 부분 중 하나가 데이터 관리다.
특히 아래 두 상황을 동시에 만족해야 할 때 구조를 어떻게 잡아야 할지 고민하게 된다.
- Player가 죽으면 현재 레벨 시작 시점의 상태로 부활해야 한다.
- Player가 Level을 클리어하면 현재 상태를 다음 Level로 넘겨야 한다.
겉보기에는 단순해 보이지만, 이 두 요구사항은 서로 반대되는 성격을 가진다.
사망 시에는 현재 진행 중인 상태를 버리고 이전 상태로 돌아가야 하고,
레벨 클리어 시에는 현재 진행 중인 상태를 유지한 채 다음 레벨로 넘겨야 하기 때문이다.
이 문제는 각 클래스에 역할을 명확히 나누면 비교적 깔끔하게 해결할 수 있다.
이번 글에서는 내가 실제로 구현한 방식을 기준으로, GameInstance와 GameMode를 이용해 Stage 진행 데이터를 관리한 구조를 정리해보려고 한다.
1. 각 객체가 맡아야 할 역할
이 로직을 이해하려면 먼저, 게임에서 데이터를 관리하는 객체들이 각각 어떤 책임을 가지는지 정리해야 한다.
GameInstance
GameInstance는 게임 전체에서 유지되는 데이터 보관소 역할을 한다.
게임이 실행될 때 생성되고, 게임이 완전히 종료될 때까지 유지되며, 레벨이 바뀌어도 사라지지 않는다.
즉, 레벨과 레벨 사이를 넘나들며 유지되어야 하는 값은 GameInstance에 저장해야 한다.
이번 구조에서는 점수를 다음 레벨로 전달하는 중간 다리 역할을 맡도록 했다.
GameMode
GameMode는 현재 레벨의 규칙을 관리하는 객체다.
레벨이 시작될 때 생성되고, 레벨이 종료되면 함께 사라진다.
따라서 플레이어 사망, 재시작, 점수 감소 같은 현재 레벨 안에서 일어나는 규칙 처리는 GameMode가 담당하는 것이 자연스럽다.
또한 레벨 시작 시에는 GameInstance에 저장된 데이터를 가져와 현재 레벨 상태를 초기화하는 책임도 맡는다.
정리하면 다음과 같다.
- GameInstance: 레벨이 바뀌어도 유지되어야 하는 데이터 저장
- GameMode: 현재 레벨에서 발생하는 규칙과 상태 관리
2. Player 사망 시 데이터는 어떻게 처리해야 하는가
이 구조에서 가장 중요한 포인트 중 하나는, Player가 죽었을 때는 GameInstance에 현재 상태를 저장하면 안 된다는 점이다.
왜냐하면 GameInstance에는 현재 레벨이 시작될 때의 상태가 남아 있어야 하고,
그래야 레벨 재시작 시 플레이어를 그 시점으로 되돌릴 수 있기 때문이다.
즉, Player 사망 흐름은 아래처럼 정리할 수 있다.
- Player가 대미지를 받아 체력이 0이 된다.
- GameMode가 이를 감지하고 사망 처리 함수를 호출한다.
- 이 시점에서는 GameInstance에 현재 상태를 저장하지 않는다.
- GameMode가 레벨 재시작을 실행한다.
- 레벨이 다시 시작되면 GameMode가 GameInstance에 저장돼 있던 값을 가져와 초기 상태를 복원한다.
핵심은 사망 직전 상태를 저장하는 것이 아니라, 레벨 시작 시점 상태를 계속 유지하는 것이다.
3. 다음 Level로 이동할 때는 어떻게 처리해야 하는가
반대로, 레벨을 클리어하고 다음 레벨로 이동하는 경우에는 현재 상태를 GameInstance에 저장해야 한다.
이 경우 흐름은 아래와 같다.
- GameMode가 현재 Player 상태를 확인한다.
- GameInstance에 접근해 기존 저장값을 현재 상태로 덮어쓴다.
- 다음 레벨이 시작되면 GameMode가 GameInstance에 접근한다.
- 저장된 값을 기준으로 다음 레벨 시작 상태를 구성한다.
즉, 같은 GameInstance를 사용하더라도
- 사망 시에는 저장하지 않고 불러오기만 한다.
- 레벨 클리어 시에는 현재 상태를 저장한다.
라는 차이가 생긴다.
이 기준만 명확히 잡아두면, 사망과 레벨 이동이라는 상반된 요구사항도 충돌 없이 처리할 수 있다.
4. 레벨 클리어 트리거 구현
이번 구조에서 GameClearTrigger는 Player가 다음 Level로 넘어가는 시점을 감지하는 액터다.
Player가 트리거에 닿으면, 현재 점수를 GameInstance에 저장하고, 이후 페이드 연출과 클리어 UI를 표시하도록 구성했다.
즉, 이 액터의 핵심 역할은 단순히 레벨 종료를 알리는 것이 아니라, 다음 레벨로 넘어가기 전에 현재 진행 데이터를 안전하게 백업하는 것이다.
GameClearTrigger.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "GameClearTrigger.generated.h"
class UBoxComponent;
class UUserWidget;
UCLASS()
class MyGame_API AGameClearTrigger : public AActor
{
GENERATED_BODY()
public:
AGameClearTrigger();
protected:
virtual void BeginPlay() override;
UFUNCTION()
void OnOverlapBegin(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult
);
public:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Trigger")
UBoxComponent* TriggerBox;
/** 게임 클리어 UI 위젯 클래스 */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI")
TSubclassOf<UUserWidget> GameClearWidgetClass;
private:
bool bHasTriggered = false;
private:
/** 게임 시작 여부 체크; BeginPlay 이후에만 트리거 활성화 */
bool bBeginPlayTriggered = false;
void HandleGameClear(APlayerController* PlayerController);
bool IsValidGameClearCandidate(AActor* OtherActor, APlayerController*& OutController) const;
void ShowGameClearUI(APlayerController* PlayerController);
void FadeOutScreen(APlayerController* PlayerController);
public:
FTimerHandle FadeCompleteHandle;
UPROPERTY(EditAnywhere, Category = "UI")
float FadeDuration = 1.5f;
};
|
cs |
GameClearTrigger.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
|
#include "TriggerBox/GameClearTrigger.h"
#include "Components/BoxComponent.h"
#include "Blueprint/UserWidget.h"
#include "GameFramework/PlayerController.h"
#include "TimerManager.h"
#include "Engine/World.h"
#include "Camera/PlayerCameraManager.h"
#include "Kismet/GameplayStatics.h"
#include "MyGame_GameModeBase.h"
#include "MyGameGameInstance.h"
#include "MyGame/Public/UI/GameClearWidget.h"
AGameClearTrigger::AGameClearTrigger()
{
PrimaryActorTick.bCanEverTick = false;
TriggerBox = CreateDefaultSubobject<UBoxComponent>(TEXT("TriggerBox"));
RootComponent = TriggerBox;
TriggerBox->SetCollisionProfileName(TEXT("Trigger"));
TriggerBox->SetGenerateOverlapEvents(true);
TriggerBox->OnComponentBeginOverlap.AddDynamic(this, &AGameClearTrigger::OnOverlapBegin);
FadeDuration = 1.2f;
}
void AGameClearTrigger::BeginPlay()
{
Super::BeginPlay();
bBeginPlayTriggered = true; // BeginPlay 호출 완료 여부 설정
}
void AGameClearTrigger::OnOverlapBegin(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult
)
{
if (!bBeginPlayTriggered || bHasTriggered || !OtherActor)
{
return;
}
APlayerController* PlayerController = nullptr;
if (!IsValidGameClearCandidate(OtherActor, PlayerController))
{
return;
}
bHasTriggered = true;
HandleGameClear(PlayerController);
}
bool AGameClearTrigger::IsValidGameClearCandidate(AActor* OtherActor, APlayerController*& OutController) const
{
APawn* PlayerPawn = Cast<APawn>(OtherActor);
if (!PlayerPawn || !PlayerPawn->IsPlayerControlled())
{
return false;
}
OutController = Cast<APlayerController>(PlayerPawn->GetController());
return OutController != nullptr;
}
void AGameClearTrigger::HandleGameClear(APlayerController* PlayerController)
{
// GameMode를 가져와 점수를 GameInstance에 저장
if (AMyGame_GameModeBase* GameMode = Cast<AMyGame_GameModeBase>(UGameplayStatics::GetGameMode(GetWorld())))
{
// GameMode의 현재 점수를 GameInstance에 저장
if (UMyGameGameInstance* GI = GameMode->GetGameInstance<UMyGameGameInstance>())
{
GI->SetSavedScore(GameMode->GetScore());
}
}
FadeOutScreen(PlayerController);
GetWorld()->GetTimerManager().SetTimer(
FadeCompleteHandle,
[this, PlayerController]()
{
ShowGameClearUI(PlayerController);
},
FadeDuration,
false
);
TriggerBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
void AGameClearTrigger::FadeOutScreen(APlayerController* PlayerController)
{
if (APlayerCameraManager* CamMgr = PlayerController->PlayerCameraManager)
{
CamMgr->StartCameraFade(
0.f, FadeDuration, FadeDuration,
FLinearColor::Black,
false,
true
);
}
}
void AGameClearTrigger::ShowGameClearUI(APlayerController* PlayerController)
{
if (GameClearWidgetClass)
{
if (UGameClearWidget* GameClearWidget = CreateWidget<UGameClearWidget>(PlayerController, GameClearWidgetClass))
{
GameClearWidget->AddToViewport();
PlayerController->SetPause(true);
PlayerController->bShowMouseCursor = true;
PlayerController->bEnableClickEvents = true;
PlayerController->bEnableMouseOverEvents = true;
FInputModeUIOnly InputMode;
InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
InputMode.SetWidgetToFocus(GameClearWidget->TakeWidget());
PlayerController->SetInputMode(InputMode);
}
}
}
|
cs |
여기서 중요한 부분은 HandleGameClear()다.
현재 레벨을 담당하는 GameMode에서 점수를 가져오고, 이를 GameInstance에 저장한 뒤 다음 흐름으로 넘긴다.
즉, 레벨 클리어 순간이 곧 데이터 갱신 시점이 된다.
5. GameMode에서 현재 레벨 상태 관리
GameMode는 현재 레벨의 점수를 관리하고, 점수가 0이 되었을 때 Player 사망을 처리하며, 일정 시간 뒤 레벨을 다시 불러오는 역할을 맡는다.
또한 StartPlay()에서는 GameInstance에 저장된 점수를 읽어와 현재 레벨의 시작 상태를 정한다.
이 덕분에 같은 레벨을 재시작할 때는 이전에 저장된 시작 점수로 복귀하고, 다음 레벨로 넘어왔을 때는 직전 레벨 클리어 시 저장한 점수를 그대로 이어받을 수 있다.
GameModeBase.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
|
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "MyGame_GameModeBase.generated.h"
class ASZ4_WaveMaster;
UCLASS()
class MyGame_API AMyGame_GameModeBase : public AGameModeBase
{
GENERATED_BODY()
public:
AMyGame_GameModeBase();
/** 변화량만큼 스코어 계산 */
UFUNCTION(BlueprintCallable, Category = "Score")
void ModifyScore(int32 Delta);
/** 현재 스코어 반환 */
UFUNCTION(BlueprintPure, Category = "Score")
int32 GetScore() const { return Score; }
/** 플레이어 사망 처리 및 레벨 재시작 준비; 점수가 0이 되었을 때 ModifyScore에서 호출 */
UFUNCTION()
void HandlePlayerDeath(APlayerController* PC);
protected:
virtual void StartPlay() override;
private:
/** 실제 스코어 값 (GameState와 동기화) */
int32 Score;
/** GameState에 동기화 */
void SyncToGameState();
/** 레벨 재시작; 타이머 이후 호출 */
void PerformLevelRestart();
/** 재시작 타이머 핸들 */
FTimerHandle RestartTimerHandle;
public:
/** 게임 시작 시 초기 스코어 */
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Score|Balance",
meta = (ClampMin = "0", ToolTip = "게임 시작 시 초기 스코어; 변경 후 저장 필수"))
int32 InitialScore = 20000;
/** 데미지 1당 차감 스코어 계수 */
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Score|Balance",
meta = (ClampMin = "0.0", UIMin = "0.0"))
float DamageToScoreFactor = 10.f;
/** 힐팩 획득 시 보너스 스코어 */
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Score|Balance",
meta = (ClampMin = "0", UIMin = "0"))
int32 HealpackScoreBonus = 10000;
/** 플레이어 사망 후 레벨이 재시작되기까지의 지연 시간 (사망 애니메이션 등) */
UPROPERTY(EditDefaultsOnly, Category = "Game", meta = (ToolTip = "사망 애니메이션 시간 등을 고려한 레벨 재시작 딜레이(sec)", ClampMin = "0.0"))
float RestartLevelDelay = 3.0f;
.
.
.
};
|
cs |
GameModeBase.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
|
#include "MyGame_GameModeBase.h"
#include "MyGame_GameStateBase.h"
#include "Kismet/GameplayStatics.h"
#include "Engine/World.h"
#include "Engine/Engine.h"
#include "MyGame_PlayerController.h"
#include "MyGamePlayer/Player_DeathComponent.h"
#include "MyGamePlayer/WeaponUpgradeManagerComponent.h"
#include "PreloadGameSubsystem.h"
#include "AIPooling/SZ4_WaveMaster.h"
#include "EngineUtils.h"
#include "GameFramework/GameUserSettings.h"
#include "MyGameGameInstance.h"
#include "TimerManager.h"
#include "GameFramework/PlayerController.h"
AMyGame_GameModeBase::AMyGame_GameModeBase()
: Score(0)
{
}
void AMyGame_GameModeBase::StartPlay()
{
if (HasAnyFlags(RF_ClassDefaultObject))
{
return;
}
Super::StartPlay();
.
.
.
// GameInstance에서 점수 불러오기; 사망 후 재시작 시 이 로직 실행
if (UMyGameGameInstance* GI = GetGameInstance<UMyGameGameInstance>())
{
int32 G_Score = GI->GetSavedScore();
if (G_Score <= 0) // 게임 최초 실행
{
Score = InitialScore;
GI->SetSavedScore(Score); // 레벨 시작 점수를 GI에 '체크포인트'로 저장
}
else
{
Score = G_Score; // 이전 레벨에서 저장한 점수 사용
}
}
else
{
Score = InitialScore;
}
SyncToGameState();
}
void AMyGame_GameModeBase::ModifyScore(int32 Delta)
{
Score += Delta;
Score = FMath::Max(Score, 0);
SyncToGameState();
// 사망 조건 처리
if (Score == 0)
{
APlayerController* PC = UGameplayStatics::GetPlayerController(this, 0);
if (PC)
{
// 사망 처리를 별도 함수로 위임하여 재시작 로직 관리
HandlePlayerDeath(PC);
}
}
}
/** 플레이어 사망 처리 및 레벨 재시작 타이머 설정 */
void AMyGame_GameModeBase::HandlePlayerDeath(APlayerController* PC)
{
if (!PC) return;
// 중복 실행 방지
if (RestartTimerHandle.IsValid())
{
return;
}
APawn* Pawn = PC->GetPawn();
if (Pawn)
{
if (UPlayer_DeathComponent* DeathComp = Pawn->FindComponentByClass<UPlayer_DeathComponent>())
{
if (!DeathComp->IsDead())
{
// 사망 컴포넌트 호출
DeathComp->HandleDeath(nullptr, PC);
PC->DisableInput(PC);
}
}
}
// 타이머 설정으로 'RestartLevelDelay' 후에 레벨 다시 로드
GetWorldTimerManager().SetTimer(
RestartTimerHandle,
this,
&AMyGame_GameModeBase::PerformLevelRestart,
RestartLevelDelay,
false
);
}
/** 레벨 재시작 */
void AMyGame_GameModeBase::PerformLevelRestart()
{
GetWorldTimerManager().ClearTimer(RestartTimerHandle);
// 현재 레벨의 이름을 가져와서 다시 로드; GameInstance의 점수는 변경되지 않았으므로, StartPlay에서 레벨 시작 시 점수를 다시 불러옴
UGameplayStatics::OpenLevel(GetWorld(), FName(*GetWorld()->GetName()));
}
void AMyGame_GameModeBase::SyncToGameState()
{
if (AMyGame_GameStateBase* GS = GetGameState<AMyGame_GameStateBase>())
{
// GameMode에서 BP로 설정된 InitialScore도 GameState로 전달
GS->SetInitialScore(InitialScore);
// Score는 항상 GameMode 기준으로 동기화
GS->SetScore(Score);
}
}
|
cs |
이 구조에서 특히 중요한 부분은 StartPlay()다.
- 저장된 점수가 없으면 게임 최초 실행으로 보고 InitialScore를 사용한다.
- 저장된 점수가 있으면 그 값을 현재 레벨 시작 점수로 사용한다.
이 덕분에 레벨 재시작 시에는 이전 레벨 시작 상태로 복귀하고, 다음 레벨 진입 시에는 방금 클리어한 상태를 이어서 시작할 수 있다.
6. GameInstance에 레벨 간 전달용 데이터 보관
GameInstance 쪽 구현은 비교적 단순하다.
이번 구조에서는 점수를 저장하고 초기화하는 함수만 별도로 만들었다.
중요한 것은 구현 복잡도가 아니라, 어떤 시점에 값을 저장하고 어떤 시점에는 저장하지 않을지 기준을 명확히 세우는 것이다.
GameInstance.h
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "MyGameGameInstance.generated.h"
class UAudioComponent;
class USoundBase;
UCLASS()
class MyGame_API UMyGameGameInstance : public UGameInstance
{
GENERATED_BODY()
.
.
.
public:
/** 점수 저장용 */
void SetSavedScore(int32 InScore);
int32 GetSavedScore() const { return SavedScore; }
/** 새 게임 시작 시, 저장된 모든 진행 상황 초기화 */
UFUNCTION(BlueprintCallable, Category = "Game")
void ResetSavedScore();
.
.
.
private:
// 저장될 점수
int32 SavedScore = 0;
};
|
cs |
GameInstance.cpp
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
#include "MyGameGameInstance.h"
#include "AIData/AIDataSubsystem.h"
#include "Engine/DataTable.h"
#include "Components/AudioComponent.h"
#include "Sound/SoundBase.h"
#include "Engine/World.h"
#include "TimerManager.h"
#include "Engine/AssetManager.h"
#include "Engine/StreamableManager.h"
#include "Engine/GameInstance.h"
#include "Settings/MyGameSettingsSave.h"
#include "Kismet/GameplayStatics.h"
#include "Settings/InputSettingsManager.h"
void UMyGameGameInstance::Init()
{
Super::Init();
// 사용자 설정 먼저 로드
LoadAndApplyCustomSettings();
.
.
.
}
.
.
.
void UMyGameGameInstance::SetSavedScore(int32 InScore)
{
SavedScore = InScore;
}
void UMyGameGameInstance::ResetSavedScore()
{
// 점수를 0으로 설정; 다음 레벨의 GameMode::StartPlay()가 0을 보고 "InitialScore' 사용
SavedScore = 0;
}
|
cs |
구조 자체는 단순하지만, 이 SavedScore 하나가 현재 레벨 재시작 기준점이 되기도 하고, 다음 레벨 시작 데이터가 되기도 한다.
결국 중요한 것은 데이터가 아니라 언제 갱신되는가다.
7. 구조 핵심 정리
이번 구현에서 가장 중요한 기준은 아래 두 가지다.
1) Player 사망 시
- GameInstance를 갱신하지 않는다.
- 레벨 재시작 후 기존 저장값을 다시 불러온다.
2) Level 클리어 시
- 현재 상태를 GameInstance에 저장한다.
- 다음 레벨에서 그 값을 시작 상태로 사용한다.
즉, GameInstance는 단순 저장소이지만, 실제로 어떤 값을 넣을지 결정하는 주체는 현재 레벨 규칙을 알고 있는 GameMode다.
이 역할 분리가 되어 있어야 로직이 꼬이지 않는다.
이번 구조는 Stage 기반 게임에서 자주 마주치는 두 가지 요구사항,
- 죽으면 현재 레벨 시작 시점으로 복귀해야 하는 것
- 레벨을 클리어하면 현재 상태를 다음 레벨로 이어가야 하는 것
을 하나의 흐름 안에서 처리하기 위해 만든 방식이다.
핵심은 단순히 데이터를 어디에 저장하느냐가 아니라, 어떤 객체가 어떤 시점에 데이터를 갱신해야 하는가를 명확히 나누는 것이다.
정리하면 다음과 같다.
- GameInstance는 레벨이 바뀌어도 유지되는 데이터를 보관한다.
- GameMode는 현재 레벨의 규칙과 상태를 관리한다.
- 사망 시에는 GameInstance를 갱신하지 않는다.
- 레벨 클리어 시에는 현재 상태를 GameInstance에 저장한다.
이렇게 역할을 분리해두면, 사망과 레벨 이동처럼 서로 반대되는 요구사항도 비교적 단순한 구조로 관리할 수 있다.
'Unreal Engine > Functional Implementation' 카테고리의 다른 글
| 언리얼 엔진 SaveGame과 GameInstance로 다회차(NG+) 구현하기 - 부활, 레벨 이동, 체크포인트 정리 (0) | 2026.03.17 |
|---|---|
| 언리얼 엔진 SaveGame 저장 시스템 구현하기 - GameInstance, 체크포인트, 자동 저장 (1) | 2026.01.29 |
| 언리얼 엔진 파괴 가능한 오브젝트 구현 - 폭발, 파편, 범위 대미지 만들기 (0) | 2025.10.21 |
| 사망 UI 출력 추가 변동 사항 (0) | 2025.09.30 |
| 언리얼 엔진 플레이어 체력 경고 UI 구현 - HP 20% 이하일 때 Widget과 사운드 출력 (0) | 2025.09.29 |