Chapter 09. Game Data
9.1 Game Data
9.1-1 GameInstance
- 데이터를 관리할 클래스
게임에 사용될 데이터가 여러 클래스에 저장된다면?
각 클래스 간의 동기화 문제가 생김. A클래스에선 10이라 했는데, B클래스는 7.
따라서 게임 내에서 단 하나의 개체임이 보장되어야 함. 이를 싱글톤 패턴이라 함.
또한 언리얼 싱글톤 클래스는 엔진 초기화 때부터 게임 종료까지 살아남아 있음.
게임에 사용될 데이터도 동일한 라이프 사이클을 가지므로, 언리얼 싱글톤 클래스를 사용.
다만 싱글톤 클래스의 개체를 언리얼 오브젝트 클래스 생성자에서 사용하면 안됨.
- 언리얼 엔진에서 제공하는 싱글톤 클래스
게임 인스턴스
애셋 매니저
게임 플레이 관련 액터(게임 모드, 게임 스테이트)
프로젝트에 싱글톤으로 등록한 언리얼 오브젝트 클래스
Settings > Project Settings > Engine > General Settings > Default Classes에서
Advanced 탭에 보면 Game Singleton Class가 있음.
- 이전에 생성했던 SGameInstance 클래스 재활용
아래와 같이 작성 후 컴파일.
StudyProject > Game > 새 블루프린트 애셋 > SGameInstance 부모 클래스 > "BP_GameInstance"
Project Settings > "Game Instance Class" 검색 후 BP_GameInstance로 지정.
<hide/>
// SGameInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "SGameInstance.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
GENERATED_BODY()
};
<hide/>
// SGameInstance.cpp
#include "Game/SGameInstance.h"
- 데이터 기반 게임 시스템 설계
기획 파트에서 작성된 엑셀 파일은 .csv라는 Comma Seperate Value 형식의
파일로 export 됨. 해당 파일을 언리얼 엔진 에디터에서 import함.
import된 .csv 파일 애셋은 FTableRowBase를 상속 받은 구조체로 읽고 쓸 수 있음.
- 데이터 속성 별 관리 클래스
CurrentHP와 CurrentMP 같은 경우엔 캐릭터 뿐만 아니라 몬스터도 가지고 있는 속성.
따라서 컴포넌트(ex. ActorComponent)에서 관리하는게 맞음.
Level과 EXP는 플레이어에게 특별히 주어지는 속성.
그래서 PlayerState에서 관리하는게 맞음.
다만 MaxHP, MaxMP는 기획적인 속성. 그래서 .csv 파일로 관리해서 기획팀에서 제공.
- 실습 준비
아래 .zip 파일을 다운로드 후 프로젝트 폴더에 압축을 풀어줌.
언리얼 엔진은 행과 열로 구성된 테이블 데이터를 불러오는 기능을 제공함.
다만 엑셀 파일 형식은 사용할 수 없음. CSV 파일 형식으로 변환해야 함.
- FTableRowBase 구조체 실습
파일을 불러들이기 위해 DT_StatTable.csv 파일의 각 열의 이름과
유형이 동일한 구조체를 선언해야 함. FTableRowBase 구조체를 상속 받은
FSCharacterStatTableRow 구조체를 게임 인스턴스의 헤더에 선언.
CSV 파일의 각 열의 이름과 동일한 멤버 변수를 타입에 맞춰 선언.
이때 테이블의 첫 번째에 위치한 Name 열 데이터는 자동으로 키 값으로
사용하기 때문에 Name 열은 선언에서 제외. 우리는 Name 열 데이터를 Level로 활용.
<hide/>
// SGameInstance.h
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "Engine/DataTable.h"
#include "SGameInstance.generated.h"
USTRUCT(BlueprintType)
struct FSStatTableRow : public FTableRowBase
{
GENERATED_BODY()
public:
FSStatTableRow()
{
}
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float MaxHP;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float MaxEXP;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float Attack;
};
/**
*
*/
UCLASS()
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
...
};
- .csv 파일 읽어들이기
Content Browser > StudyProject 우클릭 > 새 폴더 "Data"
Data 우클릭 > Import to /Game/... 클릭
DT_CharacterStat.csv 파일 선택 > 데이터테이블 옵션 다이얼로그가 뜸.
Choose DataTable Row Type에 SStatTableRow 지정.
import 되었다면, SCharacterData 애셋을 열어서 확인.
주의 할 점은 해당 .csv 파일이 엑셀로 열려있는 상태라면 꺼야함.
아래와 같이 작성 후 컴파일.
BP_GameInstance 더블클릭 > Details > Character Stat Data Table에 DT_StatTable 지정.
Settings > Project Settings > Maps & Modes > Game Instance Class에 BP_GameInstance 되었는지 확인.
<hide/>
// SGameInstance.h
...
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
GENERATED_BODY()
public:
USGameInstance();
virtual void Init() override;
const UDataTable* GetStatDataTable() { return StatDataTable; }
FSStatTableRow* USGameInstance::GetStatRowWithLevel(int32 InLevel);
private:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = USGameInstance, Meta = (AllowPrivateAccess))
class UDataTable* StatDataTable;
};
<hide/>
// SGameInstance.cpp
#include "Game/SGameInstance.h"
USGameInstance::USGameInstance()
{
}
void USGameInstance::Init()
{
Super::Init();
if (nullptr == StatDataTable || StatDataTable->GetRowMap().Num() <= 0)
{
UE_LOG(LogTemp, Error, TEXT("Not enuough data in StatDataTable."));
}
else
{
for (int32 i = 1; i <= StatDataTable->GetRowMap().Num(); ++i)
{
check(nullptr != GetStatRowWithLevel(i));
}
}
}
FSStatTableRow* USGameInstance::GetStatRowWithLevel(int32 InLevel)
{
if (nullptr != StatDataTable)
{
return StatDataTable->FindRow<FSStatTableRow>(*FString::FromInt(InLevel), TEXT(""));
}
return nullptr;
}
8.1-2 Actor Component
- 액터 컴포넌트
특정 액터에 원하는 기능을 부여하기 위한 컴포넌트.
예로들어, 몬스터 액터에게 HP와 MP 속성을 부여하기 위해서 액터 컴포넌트를 활용하면 좋음.
물론 몬스터 액터 클래스에 직접적으로 속성을 작성해도 되지만, 캐릭터 클래스에서도 쓰이기에
따로 Stat Component라는 이름으로 액터 컴포넌트 클래스를 정의하는게 더 효율적임.
- 우리가 만들 Stat Component의 역할
이벤트(피격 당함, 스킬 사용, ...)에 의해 스탯이 변경되면
델리게이트에 연결된 컴포넌트에 알림을 보내 데이터를 갱신
스탯 컴포넌트와 UI 컴포넌트 사이의 느슨한 결합의 생성.
이를 발행-구독 디자인 패턴이라고 함.

- 스탯 컴포넌트 생성
새 C++ 클래스 > ActorComponent 부모 클래스 > "SStatComponent"
Path > ActorComponents
<hide/>
// USStatComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "SStatComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnOutOfCurrentHPDelegate);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnCurrentHPChangedDelegate, float, InOldHP, float, InNewHP);
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class STUDYPROJECT_API USStatComponent : public UActorComponent
{
GENERATED_BODY()
public:
USStatComponent();
virtual void InitializeComponent() override;
float GetMaxHP() { return MaxHP; }
float GetCurrentHP() { return CurrentHP; }
int32 GetAttack() { return Attack; }
void SetCurrentHP(float InCurrentHP);
float ApplyDamage(float InDamage);
public:
FOnOutOfCurrentHPDelegate OnOutOfCurrentHPDelegate;
FOnCurrentHPChangedDelegate OnCurrentHPChangedDelegate;
private:
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = USStatComponent, meta = (AllowPrivateAccess))
TObjectPtr<class USGameInstance> GameInstance;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = USStatComponent, meta = (AllowPrivateAccess))
float MaxHP;
UPROPERTY(Transient, VisibleInstanceOnly, BlueprintReadOnly, Category = USStatComponent, meta = (AllowPrivateAccess))
float CurrentHP;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = USStatComponent, meta = (AllowPrivateAccess))
float Attack;
};
<hide/>
// SStatComponent.cpp
#include "ActorComponents/SStatComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Game/SGameInstance.h"
USStatComponent::USStatComponent()
{
PrimaryComponentTick.bCanEverTick = false;
bWantsInitializeComponent = true;
}
void USStatComponent::InitializeComponent()
{
Super::InitializeComponent();
if (GameInstance = Cast<USGameInstance>(GetWorld()->GetGameInstance()))
{
if (nullptr != GameInstance->GetStatDataTable() || nullptr != GameInstance->GetStatRowWithLevel(1))
{
MaxHP = GameInstance->GetStatRowWithLevel(1)->MaxHP;
CurrentHP = MaxHP;
}
}
}
void USStatComponent::SetCurrentHP(float InCurrentHP)
{
if (true == OnCurrentHPChangedDelegate.IsBound())
{
OnCurrentHPChangedDelegate.Broadcast(CurrentHP, InCurrentHP);
}
CurrentHP = FMath::Clamp<float>(InCurrentHP, 0.f, MaxHP);
if (CurrentHP < KINDA_SMALL_NUMBER)
{
OnOutOfCurrentHPDelegate.Broadcast();
}
}
float USStatComponent::ApplyDamage(float InDamage)
{
float ActualDamage = FMath::Clamp<float>(InDamage, 0.f, GetCurrentHP());
SetCurrentHP(GetCurrentHP() - ActualDamage);
return ActualDamage;
}
- Transient 키워드
Stat Component에서 Current-가 붙은 멤버들은 현재 파일로 저장되거나 읽어오지 않음.
InitializeComponent() 함수에서 Max 값으로 초기화되거나 0으로 초기화됨.
따라서 굳이 시리얼라이즈 될 필요가 없음. 그런 멤버들에는 UPROPERTY()에
Transient 키워드를 추가함.
- bWantsInitializeComponent 멤버
액터의 PostInitializeComponents() 함수에 대응하는
컴포넌트의 함수는 InitializeComponent() 함수임.
이 함수는 액터의 PostInitializeComponents() 함수가 호출되기 바로 전에 호출됨.
이 함수를 사용해서 컴포넌트의 초기화 로직을 구현해주는데, 이 함수가 호출되려면
생성자에서 bWantsInitializeComponent 값을 true로 설정해줘야 함.
- KINDA_SMALL_NUMBER
float의 값을 0과 비교할 때는 미세한 오차 범위 내에 있는지를 보고 판단하는게 옳음.
언리얼 엔진은 무시 가능한 오차를 측정할 때 사용하도록 KINDA_SMALL_NUMBER 제공.
- NPC 클래스에 Stat Component 부착 실습
<hide/>
// SNonPlayerCharacter.h
...
class STUDYPROJECT_API ASNonPlayerCharacter : public ACharacter
{
...
public:
...
class USStatComponent* GetStatComponent() { return StatComponent; }
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
private:
...
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, Meta = (AllowPrivateAccess))
TObjectPtr<class USStatComponent> StatComponent;
};
<hide/>
// SNonPlayerCharacter.cpp
...
#include "ActorComponents/SStatComponent.h"
ASNonPlayerCharacter::ASNonPlayerCharacter()
{
...
StatComponent = CreateDefaultSubobject<USStatComponent>(TEXT("StatComponent"));
}
...
float ASNonPlayerCharacter::TakeDamage(float Damage, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
float FinalDamageAmount = Super::TakeDamage(Damage, DamageEvent, EventInstigator, DamageCauser);
FinalDamageAmount = StatComponent->ApplyDamage(FinalDamageAmount);
return FinalDamageAmount;
}
...
<hide/>
// SAnimInstance.h
...
class STUDYPROJECT_API USAnimInstance : public UAnimInstance
{
...
private:
...
UFUNCTION()
void OnCharacterDead();
private:
...
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USAnimInstance, meta = (AllowPrivateAccess))
uint8 bIsDead : 1;
};
<hide/>
// SAnimInstance.cpp
...
#include "Characters/SPlayerCharacter.h"
#include "ActorComponents/SStatComponent.h"
void USAnimInstance::NativeInitializeAnimation()
{
...
bIsDead = false;
}
void USAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
...
if (true == ::IsValid(OwnerCharacter))
{
...
ASPlayerCharacter* OwnerPlayerCharacter = Cast<ASPlayerCharacter>(OwnerCharacter);
if (true == ::IsValid(OwnerPlayerCharacter))
{
USStatComponent* StatComponent = OwnerPlayerCharacter->GetStatComponent();
if (nullptr != StatComponent)
{
if (false == StatComponent->OnOutOfCurrentHPDelegate.IsAlreadyBound(this, &ThisClass::OnCharacterDead))
{
StatComponent->OnOutOfCurrentHPDelegate.AddDynamic(this, &ThisClass::OnCharacterDead);
}
}
}
}
}
...
void USAnimInstance::OnCharacterDead()
{
bIsDead = true;
}
9.1-3 Widget Component
- 위젯 컴포넌트
이번 절에서 구현할 HP UI는 캐릭터의 MaxHP값과 CurrentHP값을 알아야함.
즉, HP를 보여주는 "기능"에 해당하므로 컴포넌트 방식으로 구현.
이를 위해서 Widget Component를 활용할 예정.
MaxHP와 CurrentHP값이 변경됨에 발행-구독 패턴으로 리프레시 구현.
- 캐릭터 상태 변화를 발행-구독 패턴으로 구현시 문제점.
Widget Component의 InitWidget()은 액터의 BeginPlay() 이후에 호출됨.
즉, Widget Component의 UI는 Owner인 캐릭터를 BeginPlay() 이후에 알 수 있음.
이때문에 생성자(CDO)에서 미리 초기화해둘 수 없음. 이로인해 상당히 복잡해짐.
또한 UI는 하위 레이어이므로 인터페이스를 통해서 캐릭터에 접근하는 것이 올바름.

- UI 구조 구현
새 C++ 클래스 > UserWidget 부모 클래스 > "StudyUserWidget"
Path > UI
새 C++ 클래스 > UnrealInterface 부모 클래스 > "CanHasWidget"
Path > Interfaces
새 C++ 클래스 > SUserWidget 부모 클래스 > "SHPBarWidget"
Path > UI
새 C++ 클래스 > WidgetComponent 부모 클래스 > "SWidgetComponent"
Path > ActorComponents
<hide/>
// StudyUserWidget.h
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "StudyUserWidget.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API UStudyUserWidget : public UUserWidget
{
GENERATED_BODY()
public:
void SetOwningActor(AActor* InOwningActor) { OwningActor = InOwningActor; }
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = UStudyUserWidget)
TObjectPtr<AActor> OwningActor;
};
<hide/>
// StudyUserWidget.cpp
#include "UI/StudyUserWidget.h"
<hide/>
// CanHasWidget.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "CanHasWidget.generated.h"
UINTERFACE(MinimalAPI)
class UCanHasWidget : public UInterface
{
GENERATED_BODY()
};
/**
*
*/
class STUDYPROJECT_API ICanHasWidget
{
GENERATED_BODY()
public:
virtual void SetWidget(class UStudyUserWidget* InStudyUserWidget) = 0;
};
<hide/>
// CanHasWidget.cpp
#include "Interfaces/CanHasWidget.h"
<hide/>
// SHPBarWidget.h
#pragma once
#include "CoreMinimal.h"
#include "UI/StudyUserWidget.h"
#include "SHPBarWidget.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API USHPBarWidget : public UStudyUserWidget
{
GENERATED_BODY()
public:
USHPBarWidget(const FObjectInitializer& ObjectInitializer = FObjectInitializer::Get());
void SetMaxHP(float InMaxHP);
UFUNCTION()
void OnCurrentHPChanged(float InOldHP, float InNewHP);
protected:
virtual void NativeConstruct() override;
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USHPBarWidget)
TObjectPtr<class UProgressBar> HPBar;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = USHPBarWidget)
float MaxHP;
};
<hide/>
// SHPBarWidget.cpp
#include "UI/SHPBarWidget.h"
#include "Components/ProgressBar.h"
#include "Interfaces/CanHasWidget.h"
USHPBarWidget::USHPBarWidget(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
}
void USHPBarWidget::SetMaxHP(float InMaxHP)
{
if (InMaxHP < KINDA_SMALL_NUMBER)
{
return;
}
MaxHP = InMaxHP;
}
void USHPBarWidget::OnCurrentHPChanged(float InOldHP, float InNewHP)
{
if (nullptr != HPBar || KINDA_SMALL_NUMBER < MaxHP)
{
HPBar->SetPercent(InNewHP / MaxHP);
}
}
void USHPBarWidget::NativeConstruct()
{
Super::NativeConstruct();
HPBar = Cast<UProgressBar>(GetWidgetFromName("HPBarWidget"));
check(nullptr != HPBar);
if (ICanHasWidget* OwningPC = Cast<ICanHasWidget>(OwningActor))
{
OwningPC->SetWidget(this);
}
}
<hide/>
// SWidgetComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/WidgetComponent.h"
#include "SWidgetComponent.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECT_API USWidgetComponent : public UWidgetComponent
{
GENERATED_BODY()
public:
virtual void InitWidget() override;
};
<hide/>
// SWidgetComponent.cpp
#include "ActorComponents/SWidgetComponent.h"
#include "UI/StudyUserWidget.h"
void USWidgetComponent::InitWidget()
{
Super::InitWidget();
UStudyUserWidget* SWidget = Cast<UStudyUserWidget>(GetWidget());
if (true == ::IsValid(SWidget))
{
SWidget->SetOwningActor(GetOwner());
}
}
- NPC 클래스와 ICanHasWidget 인터페이스
<hide/>
// SNonPlayerCharacter.h
...
#include "Interfaces/CanHasWidget.h"
#include "SNonPlayerCharacter.generated.h"
UCLASS()
class STUDYPROJECT_API ASNonPlayerCharacter
: public ACharacter
, public ICanHasWidget
{
...
public:
...
virtual void SetWidget(class UStudyUserWidget* InStudyUserWidget);
private:
...
private:
...
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USWidgetComponent> HPBarWidgetComponent;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = ASPlayerCharacter, meta = (AllowPrivateAccess))
TObjectPtr<class USHPBarWidget> HPBarWidget;
};
<hide/>
// SNonPlayerCharacter.cpp
...
#include "ActorComponents/SStatComponent.h"
#include "ActorComponents/SWidgetComponent.h"
#include "UI/StudyUserWidget.h"
#include "UI/SHPBarWidget.h"
ASNonPlayerCharacter::ASNonPlayerCharacter()
{
...
HPBarWidgetComponent = CreateDefaultSubobject<USWidgetComponent>(TEXT("HPBarWidgetComponent"));
HPBarWidgetComponent->SetupAttachment(GetRootComponent());
HPBarWidgetComponent->SetRelativeLocation(FVector(0.f, 0.f, 200.f));
HPBarWidgetComponent->SetWidgetSpace(EWidgetSpace::Screen);
HPBarWidgetComponent->SetDrawSize(FVector2D(150.0f, 15.0f));
HPBarWidgetComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
...
void ASNonPlayerCharacter::SetWidget(UStudyUserWidget* InStudyUserWidget)
{
HPBarWidget = Cast<USHPBarWidget>(InStudyUserWidget);
if (nullptr != HPBarWidget)
{
HPBarWidget->SetMaxHP(StatComponent->GetMaxHP());
HPBarWidget->OnCurrentHPChanged(0, StatComponent->GetCurrentHP());
StatComponent->OnCurrentHPChangedDelegate.AddDynamic(HPBarWidget, &USHPBarWidget::OnCurrentHPChanged);
}
}
...
- UI 생성
Content Browser > StudyProject 우클릭 > 새 폴더 "UI"
UI > 새 User Interface 애셋 > Widget Blueprint > SHPBarWidget 부모 클래스 > "WBP_HPBar"
Designer > 그래프 우상단 Fill Screen 클릭 > Custom 지정 > Width 300, Height 100 설정.
Palette > Vertical Box 검색 후 드래그 > Hierarchy > WBP_HPBar에 드랍.
Palette > Spacer 검색 후 드래그 Vertical Box에 드랍. 2개 생성.
Palette > Progress Bar 검색 후 드래그, Spacer 2개 사이에 드랍.
ProgressBar 클릭 후 F2 > "HPBarWidget" 이름 변경. SHPBarWidget 클래스 개체에서 바인드 되게끔.
Spacer 2개를 블럭지정 > Details > Size에 Fill 클릭 > 우측에 1.0을 0.3으로 지정.
HPBar 클릭 > Details > Size에 Fill 클릭 > 우측에 1.0을 0.4로 지정.
Fill Color and Opacity > 색상 클릭해서 빨간색으로 지정.
BP_NPC > HPBar Widget Component 클릭 > Details > Widget Class에 WBP_HPBar 지정.
- UserWidget에서 StatComponent 포인터를 멤버로 갖는다면.
UniquePtr은 사용할 수 없음. 공유할 수 없기 때문.
SharedPtr도 불가능함. StatComponent에서도 UserWidget 포인터를 SharedPtr로 갖을 수 있기 때문.
- 공격 구현
<hide/>
// SPlayerCharacter.h
...
#include "Engine/DamageEvents.h"
...
void ASPlayerCharacter::CheckHit()
{
...
if (true == bResult)
{
if (true == ::IsValid(HitResult.GetActor()))
{
//UE_LOG(LogTemp, Error, TEXT("Hit Actor Name: %s"), *HitResult.GetActor()->GetName());
FDamageEvent DamageEvenet = {};
HitResult.GetActor()->TakeDamage(20.f, DamageEvenet, GetController(), this);
}
}
...
}
...
<hide/>
// SNonPlayerCharacter.cpp
...
#include "Engine/DamageEvents.h"
...
void ASNonPlayerCharacter::Attack()
{
...
if (true == bResult)
{
if (true == ::IsValid(HitResult.GetActor()))
{
//UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("Hit Actor Name: %s"), *HitResult.GetActor()->GetName()));
FDamageEvent DamageEvenet = {};
HitResult.GetActor()->TakeDamage(10.f, DamageEvenet, GetController(), this);
}
}
...
}
...
- [숙제] NPC 죽음 구현해보기
8.5 게임 데이터 세이브와 로드
8.5-2 Save Game System
- SaveGameSystem
언리얼이 제공하는 게임 데이터 세이브 로드 기능.
SaveGameSystem을 사용하면 각 플랫폼 별로 알맞은 최적의 위치에 데이터가 저장됨.
프로젝트 폴더 > Saved 폴더 > SaveGames 폴더에 저장됨.
새 C++ 클래스 > SaveGame 부모 클래스 > "SPlayerCharacterSaveGame"
<hide/>
// SPlayerCharacterSaveGame.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/SaveGame.h"
#include "SPlayerCharacterSaveGame.generated.h"
UCLASS()
class STUDYPROJECT_API USPlayerCharacterSaveGame : public USaveGame
{
GENERATED_BODY()
public:
USPlayerCharacterSaveGame();
UPROPERTY()
FString PlayerCharacterName;
UPROPERTY()
int32 CurrentLevel;
UPROPERTY()
float CurrentExp;
};
<hide/>
// SPlayerCharacterSaveGame.cpp
#include "SPlayerCharacterSaveGame.h"
USPlayerCharacterSaveGame::USPlayerCharacterSaveGame()
{
PlayerCharacterName = TEXT("PlayerCharacter");
CurrentLevel = 1;
CurrentExp = 0;
}
- PlayerState 클래스 생성
플레이어의 현재 상태를 기록해두는 클래스.
새 C++ 클래스 > PlayerState 부모 클래스 > "SPlayerState" 클래스 생성
SaveGame 기능에는 각 저장 파일에 접글 할 수 있도록 고유 이름인 SlotName이 필요함.
SlotName을 다르게 지정해서 세이브 데이터를 여러 개 만들 수 있음.
처음에는 세이브된 게임 데이터가 없으므로, PostInitializeComponents() 함수에서 초기화.
<hide/>
// SPlayerState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "SPlayerState.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnCurrentLevelChangedDelegate, int32, InOldCurrentLevel, int32, InNewCurrentLevel);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnCurrentEXPChangedDelegate, float InOldCurrentEXP, float InNewCurrentEXP);
UCLASS()
class STUDYPROJECT_API ASPlayerState : public APlayerState
{
GENERATED_BODY()
public:
ASPlayerState();
virtual void PostInitializeComponents() override;
int32 GetMaxLevel() const { return MaxLevel; }
int32 GetCurrentLevel() const { return MaxLevel; }
float GetMaxEXP() const { return MaxEXP; }
float GetCurrentEXP() const { return CurrentEXP; }
void SetCurrentLevel(int32 InCurrentLevel);
void SetCurrentEXP(float InCurrentEXP);
public:
FOnCurrentLevelChangedDelegate OnCurrentLevelChangedDelegate;
FOnCurrentEXPChangedDelegate OnCurrentEXPChangedDelegate;
FString SaveSlotName = TEXT("PlayerCharacter1");
private:
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
TObjectPtr<class USGameInstance> SGI;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
int32 MaxLevel = 20;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
int32 CurrentLevel = 1;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
float MaxEXP = 2000;
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category="USStatComponent", meta=(AllowPrivateAccess=true))
float CurrentEXP = 0;
};
<hide/>
// SPlayerState.cpp
#include "SPlayerState.h"
#include "SGameInstance.h"
#include "SPlayerCharacterSaveGame.h"
#include "Kismet/GameplayStatics.h"
ASPlayerState::ASPlayerState()
{
}
void ASPlayerState::PostInitializeComponents()
{
Super::PostInitializeComponents();
if (SGI = Cast<USGameInstance>(GetWorld()->GetGameInstance()))
{
if (nullptr != SGI->GetCharacterStatDataTable() || nullptr != SGI->GetCharacterStatRowWithLevel(1))
{
MaxLevel = SGI->GetCharacterStatDataTable()->GetRowMap().Num();
}
}
USPlayerCharacterSaveGame* PCSG = Cast<USPlayerCharacterSaveGame>(UGameplayStatics::LoadGameFromSlot(SaveSlotName, 0));
if (nullptr == PCSG)
{
PCSG = GetMutableDefault<USPlayerCharacterSaveGame>();
}
SetPlayerName(PCSG->PlayerName);
SetCurrentLevel(PCSG->CurrentLevel);
}
void ASPlayerState::SetCurrentLevel(int32 InCurrentLevel)
{
int32 ActualLevel = FMath::Clamp<int32>(InCurrentLevel, 0, MaxLevel);
if (FSCharacterStatTableRow* RowData = SGI->GetCharacterStatRowWithLevel(ActualLevel))
{
OnCurrentLevelChangedDelegate.Broadcast(CurrentLevel, ActualLevel);
CurrentLevel = ActualLevel;
MaxEXP = RowData->MaxEXP;
}
}
void ASPlayerState::SetCurrentEXP(float InCurrentEXP)
{
OnCurrentEXPChangedDelegate.Broadcast(CurrentEXP, InCurrentEXP);
CurrentEXP = FMath::Clamp<float>(InCurrentEXP, 0.f, MaxEXP);
}
<hide/>
// SStatComponent.h
...
class STUDYPROJECT_API USStatComponent : public UActorComponent
{
...
private:
void OnCurrentLevelChanged(int32 InOldCurrentLevel, int32 InNewCurrentLevel);
public:
...
};
<hide/>
// SStatComponent.cpp
...
#include "SGameInstance.h"
#include "SPlayerCharacter.h"
#include "SPlayerState.h"
...
void USStatComponent::InitializeComponent()
{
...
if (ASPlayerCharacter* OwnerPlayerCharacter = Cast<ASPlayerCharacter>(GetOwner()))
{
if (ASPlayerState* PS = Cast<ASPlayerState>(OwnerPlayerCharacter->GetPlayerState()))
{
PS->OnCurrentLevelChangedDelegate.AddUObject(this, &ThisClass::OnCurrentLevelChanged);
}
}
}
...
void USStatComponent::OnCurrentLevelChanged(int32 InOldCurrentLevel, int32 InNewCurrentLevel)
{
MaxHP = SGI->GetCharacterStatRowWithLevel(InNewCurrentLevel)->MaxHP;
SetCurrentHP(MaxHP);
Attack = SGI->GetCharacterStatRowWithLevel(InNewCurrentLevel)->Attack;
}
- 플레이어 관련 데이터 저장 구현
플레이어에 관련된 데이터가 변경될 때마다 저장하도록 하고자 함.
<hide/>
// SPlayerState.cpp
...
void ASPlayerState::PostInitializeComponents()
{
...
SavePlayerCharacterData();
}
void ASPlayerState::SetCurrentLevel(int32 InCurrentLevel)
{
...
if (FSCharacterStatTableRow* RowData = SGI->GetCharacterStatRowWithLevel(ActualLevel))
{
...
SavePlayerCharacterData();
}
}
...
void ASPlayerState::SavePlayerCharacterData()
{
USPlayerCharacterSaveGame* PCSG = NewObject<USPlayerCharacterSaveGame>();
PCSG->PlayerName = GetPlayerName();
PCSG->CurrentLevel = GetCurrentLevel();
PCSG->CurrentEXP = GetCurrentEXP();
if (true == UGameplayStatics::SaveGameToSlot(PCSG, SaveSlotName, 0))
{
UE_LOG(LogTemp, Log, TEXT("%s has been saved."), *SaveSlotName);
}
}
- PlayerState 개체 지정 방법 [수정 필요함]
BP_GameMode > Player State Class에 SPlayerState 지정.
SGameMode 클래스 > PostLogin() 함수에서 InitPlayerState() 함수를 호출.
PostLogin() 함수에서 플레이어 컨트롤러가 준비 되기 때문.
InitPlayerState()에는 위 코드의 PostInitializeComponents() 함수 내용이 들어가야할 듯.
<hide/>
// TUGameMode.cpp
#include "TUGameMode.h"
#include "TUPlayerController.h"
#include "TUCharacter.h"
#include "TUPlayerState.h"
ATUGameMode::ATUGameMode()
{
PlayerControllerClass = ATUPlayerController::StaticClass();
DefaultPawnClass = ATUCharacter::StaticClass();
PlayerStateClass = ATUPlayerState::StaticClass();
}
void ATUGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
auto TUPlayerState = Cast<ATUPlayerState>(NewPlayer->PlayerState);
check(nullptr != TUPlayerState);
TUPlayerState->InitPlayerData();
}
- NewObject() 함수
언리얼 오브젝트를 생성할 때는 NewObject() 함수를 사용함.
NewObjecT() 함수를 통해 생성된 언리얼 오브젝트는 가비지 컬렉터가 소멸시킴.
월드에 액터를 생성하는 작업도 NewObject() 함수를 활용하게 됨.
하지만 좀 더 고려할 점 들이 많아지므로 이를 포괄한 SpawnActor()라는 API 함수가 제공되는 것.
- 저장된 데이터의 삭제
프로젝트 폴더 > Saved > SaveGame 폴더에서 해당 파일을 삭제하면 됨.
파일명이 SaveSlotName과 동일하다는 것을 알 수 있음.
- 캐릭터의 스테이트
Ready > Play > Dead로 나누고자 함.
Ready: 스켈레탈 메시를 결합. 입력 비활성 상태. BT도 비활성 상태.
Play: 입력 활성. BT 활성.
Dead: 입력 비활성. BT 비활성. 타이머를 활용해서 사망 이후 로직도 구현.
<hide/>
// TUCharacter.cpp
...
ATUCharacter::ATUCharacter()
...
, DeadTimer(5.f)
{
...
}
...
void ATUCharacter::SetCharacterState(ECharacterState NewState)
{
...
switch (CurrentState)
{
case ECharacterState::LOADING:
{
if (true == bIsPlayer)
{
DisableInput(TUPlayerController);
}
SetActorHiddenInGame(true);
HPBarWidget->SetHiddenInGame(true);
SetCanBeDamaged(false);
break;
}
case ECharacterState::READY:
{
SetActorHiddenInGame(false);
HPBarWidget->SetHiddenInGame(false);
SetCanBeDamaged(true);
CharacterStat->OnHPIsZero.AddLambda([this]() -> void {
SetCharacterState(ECharacterState::DEAD);
});
auto CharacterWidget = Cast<UTUCharacterWidget>(HPBarWidget->GetUserWidgetObject());
check(nullptr != CharacterWidget);
CharacterWidget->BindCharacterStat(CharacterStat);
if (true == bIsPlayer)
{
SetControlMode(EControlMode::DIABLO);
GetCharacterMovement()->MaxWalkSpeed = 600.f;
EnableInput(TUPlayerController);
}
else
{
SetControlMode(EControlMode::NPC);
GetCharacterMovement()->MaxWalkSpeed = 300.f;
TUAIController->RunAI();
}
break;
}
case ECharacterState::DEAD:
{
SetActorEnableCollision(false);
GetMesh()->SetHiddenInGame(false);
HPBarWidget->SetHiddenInGame(true);
TUAnimInstance->SetDeadAnimation();
SetCanBeDamaged(false);
if (true == bIsPlayer)
{
DisableInput(TUPlayerController);
}
else
{
TUAIController->StopAI();
}
GetWorld()->GetTimerManager().SetTimer(DeadTimerHandle, FTimerDelegate::CreateLambda([this]()->void {
if (true == bIsPlayer)
{
TUPlayerController->RestartLevel();
}
else
{
Destroy();
}
}), DeadTimer, false);
break;
}
}
}
...
9.2 게임 모듈
9.2-1 언리얼 C++ 모듈
- 블루프린트 프로젝트 Vs. C++ 프로젝트
C++ 코드를 작성하지 않는 프로젝트를 블루프린트 프로젝트라 함.
언리얼 엔진이 기본적으로 제공하는 모듈만 임포트하여 블루프린트 코딩을 통해 로직을 구현.
이와 다르게 C++ 프로젝트는 개발자가 직접 모듈을 추가하여 임포트 할 수 있음.

- 언리얼 C++ 모듈
언리얼 엔진의 소스코드는 모두 모듈 단위로 구성되어 있음.
모듈 단위로 작성된 C++ 소스 코드를 컴파일 한 결과물은
에디터 용으로 .dll 파일(동적 라이브러리), 실행 파일용으로는 .lib(정적 라이브러리)가 생성됨.
ex. UnrealEditor-{모듈명}.dll 혹은 UnrealEditor-{모듈명}.lib
즉, 모듈을 컴파일 해서 언리얼 에디터 혹은 실행 파일에 우리가 작성한 로직을 반영 가능.
- 언리얼 C++ 모듈 추가하는 방법
.uproject 파일 > Modules에 모듈을 추가 작성.
프로젝트 폴더 > Binaries > Win64 폴더에 해당 .dll 파일을 추가.
- 언리얼 C++ 모듈 소스코드 관리
언리얼 C++ 모듈 소스코드도 결국 프로젝트 폴더 > Source 폴더에 저장되어야 함.
그러면 빌드를 진행하는 Unreal Build Tool(C# 프로그램)이 Source 폴더의 소스코드를 바탕으로
플랫폼(윈도우즈, 맥, 리눅스, 안드로이드, ...)에 맞춰서 빌드를 진행함.
C# 언어가 가진 Compile on-the-fly 기능을 활용해서 런타임 중에
.cs 파일을 읽고 빌드 환경을 구축하여 진행함.
- Source 폴더 구조
타겟 설정 파일들
전체 솔루션이 사용하게 될, 즉 빌드 대상을 지정함.
{프로젝트명}.Target.cs: 실행파일 빌드 설정
{프로젝트명}Editor.Target.cs: 에디터 빌드 설정
모듈 설정 파일
해당 모듈을 빌드하기 위한 C++ 프로젝트 설정 정보.
{모듈명}.Build.cs
모듈 C++ 코드 파일
모듈의 소스코드가 작성된 파일들.

- 모듈 제작 장점과 단점
게임 설정 관련 데이터를 관리하는 코드는 다른 모듈로 분리 가능.
로직을 분리함으로써 유지보수가 더 쉬워짐.
다만, 언리얼이 C++ 프로젝트를 처음 만들 때 주 게임모듈은 자동으로 생성해주지만
추가 모듈은 자동으로 생성해주지 않음.
그래서 모듈을 추가 제작하려면 위와 같은 언리얼 빌드 규칙을 이해하고
폴더와 파일들을 만들어 줘야함.
- 모듈 제작 실습
.vs, Binaries, Intermediate, Saved, StudyProject.sln 삭제
Source 폴더 > 새 폴더 "StudyProjectSettings"
StudyProjectSettings > 새 폴더 "Private", "Public"
StudyProjectSettings > 새 txt 파일 "StudyProjectSettings.Build.cs"
StudyProjectSettings > 새 txt 파일 "StudyProjectSettings.h", "StudyProjectSettings.cpp"
아래와 같이 작성. 모두 작성 후 .uproject 파일 우클릭 > Generate Visual Studio project files
<hide/>
// StudyProjectSettings.Build.cs
using UnrealBuildTool;
public class StudyProjectSettings : ModuleRules
{
public StudyProjectSettings(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] {
// Initial Modules
"Core", "CoreUObject", "Engine", "InputCore",
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
<hide/>
// StudyProjectSettings.h
#pragma once
<hide/>
// StudyProjectSettings.cpp
#include "StudyProjectSettings.h"
#include "Modules/ModuleManager.h"
IMPLEMENT_MODULE( FDefaultModuleImpl, StudyProjectSettings );
- 매크로를 통한 모듈의 기본 코드 작성
IMPLEMENT_MODULE(): 일반 모듈
IMPLEMENT_GAME_MODULE(): 게임 모듈
IMPLEMENT_PRIMARY_GAME_MODULE(): 주 게임 모듈
일반적으로 하나의 게임 프로젝트에는 하나의 주 게임 모듈 매크로가 선언되어야 함.

- PublicDependencyModuleNames Vs. PrivateDependencyModuleNames
만약 A 모듈의 클래스들을 우리 모듈의 .h 파일과 .cpp 파일 모두에서 사용한다면 Public Dependency.
우리 모듈의 .h 파일과 .cpp 파일 모두에서 사용되면 또 다른 B 모듈이 우리 모듈을 참조할 때도
문제 되지 않게끔 하려면 Public Dependency에 A 모듈을 추가해야만 함.
근데 만약 A 모듈의 클래스들을 우리 모듈의 .cpp 파일에서만 몰래 사용한다면 Private Dependency.
그래서 또다른 B 모듈에게 숨길 수 있음.
- 새로운 모듈의 컴파일 등록
비쥬얼 스튜디오에서 새 모듈이 함께 빌드되도록
StudyProject.Target.cs 파일과 StudyProjectEditor.Target.cs 파일을 수정.
수정 후 빌드가 완료되면 Binaries 폴더에 새 모듈의 dll, lib 파일들이 생성됨.
<hide/>
// StudyProject.Target.cs
using UnrealBuildTool;
using System.Collections.Generic;
public class StudyProjectTarget : TargetRules
{
public StudyProjectTarget( TargetInfo Target) : base(Target)
{
Type = TargetType.Game;
DefaultBuildSettings = BuildSettingsVersion.V2;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_1;
ExtraModuleNames.Add("StudyProject");
ExtraModuleNames.Add("StudyProjectSettings");
}
}
<hide/>
// StudyProjectEditor.Target.cs
using UnrealBuildTool;
using System.Collections.Generic;
public class StudyProjectEditorTarget : TargetRules
{
public StudyProjectEditorTarget( TargetInfo Target) : base(Target)
{
Type = TargetType.Editor;
DefaultBuildSettings = BuildSettingsVersion.V2;
IncludeOrderVersion = EngineIncludeOrderVersion.Unreal5_1;
ExtraModuleNames.Add("StudyProject");
ExtraModuleNames.Add("StudyProjectSettings");
}
}
- 언리얼 에디터가 새로운 .dll 파일도 로딩하도록 설정하는 방법
프로젝트 폴더 > .uproject 파일 내용을 수정.
다만, StudyProjectSettings 모듈은 StudyProject 모듈 보다 먼저 로드되어야 함.
따라서 아래와 같이 LoadingPhase에 PreDefault 작성.
마찬가지로 언리얼이 기본 제공하는 모듈과 종속성을 가짐.
반대로 StudyProject 모듈에서는 AdditionalDependencies에 StudyProjectSettings 모듈 추가.
<hide/>
// StudyProject.uproject
{
"FileVersion": 3,
"EngineAssociation": "5.1",
"Category": "",
"Description": "",
"Modules": [
{
"Name": "StudyProjectSettings",
"Type": "Runtime",
"LoadingPhase": "PreDefault",
"AdditionalDependencies": [
"Engine",
]
},
{
"Name": "StudyProject",
"Type": "Runtime",
"LoadingPhase": "Default",
"AdditionalDependencies": [
"Engine",
"AIModule",
"UMG",
"StudyProjectSettings"
]
}
],
"Plugins": [
{
"Name": "ModelingToolsEditorMode",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "EnhancedInput",
"Enabled": true
}
]
}
- 언리얼 에디터의 Content Browser에서도 새 모듈 보이게 하는 방법
StudyProjectSettings 모듈에 속한 언리얼 오브젝트 클래스가 하나도 없기 때문.
새 C++ 클래스 > Object 부모 클래스 > "SCharacterSettings"
이름 옆에 해당 클래스가 속할 모듈을 지정할 수 있음. StudyProjectSettings 지정.
Path > Public
<hide/>
// SPlayerCharacterSettings.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "SPlayerCharacterSettings.generated.h"
/**
*
*/
UCLASS()
class STUDYPROJECTSETTINGS_API USPlayerCharacterSettings : public UObject
{
GENERATED_BODY()
};
<hide/>
// SPlayerCharacterSettings.cpp
#include "SPlayerCharacterSettings.h"
9.2-2 모듈의 활용
- 실습 준비
Content Browser > Steve 우클릭 > 새 폴더 "Red", "Green", "Blue", "White"
Steve 스켈레탈 메시와 Ch_49_body1 블럭지정 > 복사 후 각 폴더에 붙혀넣기.
Red > Steve를 "RedSteve"로 이름 변경. Ch_49_body1을 "Ch_49_RedBody"로 변경.
나머지 폴더에도 똑같이 작업.
RedSteve 더블클릭 > Asset Details > Material Slots > Ch_49_body1을 Ch_49_RedBody로 지정.
나머지 폴더에도 똑같은 작업.
Ch_49_RedBody 더블클릭. 아래와 같이 그래프 작성.
Toolbar > Apply 클릭. Ctrl + Shift + S.
나머지 폴더에도 똑같은 작업.

- .ini 파일을 활용하여 SCharacterSettings 클래스의 기본값 설정하기
새로 추가한 모듈에 캐릭터의 모든 스켈레탈 애셋 경로 정보를 저장해보고자 함.
.ini 파일에 적힌 내용을 활용하여 저장 가능. 이때 FSoftObjectPath 구조체가 쓰임.
프로젝트 폴더 > Config > 새 파일 "DefaultPlayerCharacterMeshPaths.ini"
<hide/>
// SPlayerCharacterSettings.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "SPlayerCharacterSettings.generated.h"
/**
*
*/
UCLASS(config = PlayerCharacterMeshPaths) // 1. 언리얼 엔진의 초기화 단계에서 Config 폴더에 위치한 DefaultPlayerCharacterMeshPaths.ini 파일을 읽어들임.
class STUDYPROJECTSETTINGS_API USPlayerCharacterSettings : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(config) // 2. 읽어들인 DefaultPlayerCharacterMeshPaths.ini 파일의 내용으로 해당 멤버의 기본값이 초기화됨.
TArray<FSoftObjectPath> CharacterMeshPaths;
};
<hide/>
// DefaultPlayerCharacterMeshPaths.ini
[/Script/StudyProjectSettings.SPlayerCharacterSettings]
+PlayerCharacterMeshPaths=/Script/Engine.SkeletalMesh'/Game/Mixamo/Meshes/Steve/Red/RedSteve.RedSteve'
+PlayerCharacterMeshPaths=/Script/Engine.SkeletalMesh'/Game/Mixamo/Meshes/Steve/Green/GreenSteve.GreenSteve'
+PlayerCharacterMeshPaths=/Script/Engine.SkeletalMesh'/Game/Mixamo/Meshes/Steve/Blue/BlueSteve.BlueSteve'
+PlayerCharacterMeshPaths=/Script/Engine.SkeletalMesh'/Game/Mixamo/Meshes/Steve/White/BlackSteve.BlackSteve'
- StudyProject 모듈과 StudyProjectSettings 모듈의 연결
구현부가 모여있는 Private 폴더에서 StudyProjectSettings 모듈을 사용할 예정이므로
PrivateDependencyModuleNames에 StudyProjectSettings 모듈을 추가.
SPlayerCharacter 클래스에 테스트 코드 작성 후 확인.
<hide/>
// StudyProject.Build.cs
...
public class StudyProject : ModuleRules
{
public StudyProject(ReadOnlyTargetRules Target) : base(Target)
{
...
PrivateDependencyModuleNames.AddRange(new string[]
{
// Custom Modules
"StudyProjectSettings",
});
}
}
<hide/>
// SPlayerCharacter.h
...
#include "SPlayerCharacterSettings.h"
ASPlayerCharacter::ASPlayerCharacter()
...
{
...
const USPlayerCharacterSettings* CDO = GetDefault<USPlayerCharacterSettings>();
if (0 < CDO->PlayerCharacterMeshPaths.Num())
{
for (FSoftObjectPath PlayerCharacterMeshPath : CDO->PlayerCharacterMeshPaths)
{
UE_LOG(LogTemp, Warning, TEXT("Path: %s"), *(PlayerCharacterMeshPath.ToString()));
}
}
}
...
- 랜덤하게 스켈레탈 메시 목록 중 하나를 골라 캐릭터 애셋을 로딩
게임 진행 중에도 비동기 방식으로 애셋을 로딩할 수 있도록
FStreamableManager라는 구조체가 제공됨. 이 매니저 구조체는
프로젝트에서 하나만 활성하는 것이 바람직하기 때문에 Game Instance 클래스에서 관리하도록 함.
FStreamableManager에서 비동기 방식으로 애셋을 로딩하는 함수는 AsyncLoad() 함수.
해당 함수에 FStreamableDelegate 형식의 델리게이트를 넘겨줄 경우,
애셋 로딩이 완료되면 해당 델리게이트에 연결된 함수를 호출해줌.
FStreamableDelegate 형식으로 델리게이트 멤버를 선언하고 넘겨줄 수 있지만
델리게이트에서 제공하는 CreateUObject() 함수를 사용해서
델리게이트를 생성함으로써 함수와 연동시킨 후 넘겨주는 방식도 간편함.
이를 사용해 애셋을 비동기로 로딩하는 로직을 구현하고자 함.
- 애셋 로딩 방법
생성자에서 미리 로딩: 프로젝트 전반적으로 해당 애셋이 필수인 경우.
런타임에서 동기적으로 로딩: 사용 할수도 안할수도 있지만, 사용 전에 반드시 로드되야 하는 경우.
런타임에서 비동기적으로 로딩: 꼭 사용 전에 로드될 필요는 없는 경우.
- Streamable Manager 실습
애셋의 동기/비동기 로딩을 지원하는 관리자 개체.
다수의 오브젝트 경로를 입력해 다수의 애셋을 로딩하는 것도 가능.
<hide/>
// SGameInstance.h
...
#include "Engine/StreamableManager.h"
#include "SGameInstance.generated.h"
...
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
...
public:
FStreamableManager StreamableManager;
private:
...
};
<hide/>
// SGameInstance.cpp
...
USGameInstance::USGameInstance()
: StreamableManager()
{
}
...
<hide/>
// SPlayerCharacter.h
...
class STUDYPROJECT_API ASPlayerCharacter
...
{
...
public:
...
virtual void PostInitializeComponents() override;
virtual void BeginPlay() override;
...
private:
...
UFUNCTION()
void OnAssetLoaded();
private:
...
FSoftObjectPath CurrentPlayerCharacterMeshPath = FSoftObjectPath();
TSharedPtr<struct FStreamableHandle> AssetStreamableHandle = nullptr;
};
<hide/>
// SPlayerCharacter.h
...
#include "Game/SGameInstance.h"
...
void ASPlayerCharacter::BeginPlay()
{
Super::BeginPlay();
const USPlayerCharacterSettings* CDO = GetDefault<USPlayerCharacterSettings>();
int32 RandIndex = FMath::RandRange(0, CDO->PlayerCharacterMeshPaths.Num() - 1);
CurrentPlayerCharacterMeshPath = CDO->PlayerCharacterMeshPaths[RandIndex];
if (USGameInstance* SGI = Cast<USGameInstance>(GetGameInstance()))
{
AssetStreamableHandle = SGI->StreamableManager.RequestAsyncLoad(
CurrentPlayerCharacterMeshPath,
FStreamableDelegate::CreateUObject(this, &ThisClass::OnAssetLoaded)
);
}
}
...
void ASPlayerCharacter::OnAssetLoaded()
{
AssetStreamableHandle->ReleaseHandle();
TSoftObjectPtr<USkeletalMesh> LoadedAsset(CurrentPlayerCharacterMeshPath);
if (true == LoadedAsset.IsValid())
{
GetMesh()->SetSkeletalMesh(LoadedAsset.Get());
}
}
- FStreamableManager는 사실 엔진 모듈 내의 UAssetManager 클래스에 선언되어 있음.
위 예제는 사실 Engine/AssetManager.h 헤더파일 인클루드 후
UAssetManager::GetStreamableManager()를 사용해도 동일함.
9.2-3 패키지
- 패키지의 중의적 개념
언리얼 엔진은 다양한 곳에서 "패키지"라는 단어를 사용하고 있음.
언리얼 오브젝트를 감싼 포장 오브젝트를 의미하기도 하고(다음 절의 주제)
개발된 최종 컨텐츠를 정리해 프로그램으로 만드는 작업을 의미하기도 하고(게임 패키징)
DLC와 같이 향후 확장 컨텐츠에 사용되는 별도의 데이터 묶음을 의미하기도 함(pkg 파일)
구분을 위해 다음 절의 주제를 언리얼 오브젝트 패키지로 부르는 것도 좋음.
- 언리얼 오브젝트 패키지
단일 언리얼 오브젝트가 가진 정보는 저장할 수 있지만, 오브젝트들이 조합되어 있다면?
저장된 언리얼 오브젝트 데이터를 효과적으로 찾고 관리하는 방법은?
복잡한 계층 구조를 가진 언리얼 오브젝트를 효과적으로 저장 및 불러들이는 방법을 통일해야 함.
언리얼 엔진은 이를 위해 패키지 단위로 언리얼 오브젝트를 관리함.
- 패키지와 애셋
언리얼 오브젝트 패키지는 다수의 언리얼 오브젝트를 포장하는데 사용하는 언리얼 오브젝트.
모든 언리얼 오브젝트는 패키지에 소속되어 있음.
언리얼 오브젝트 패키지의 서브 오브젝트를 애셋이라고 하며 에디터에는 이들이 노출됨.
구조상 패키지는 다수의 언리얼 오브젝트를 소유할 수 있으나, 일반적으로는 하나의 애셋만 가짐.
애셋은 다시 다수의 서브 오브젝트를 가질 수 있으며, 모두 언리얼 오브젝트 패키지에 포함됨.
하지만 서브 오브젝트들은 에디터에 노출되지 않음.

- 언리얼 오브젝트 패키지 생성 실습
Content Browser > Content 우클릭 > 새 폴더 "MyPackages"
<hide/>
// SGameInstance.h
...
#include "Engine/StreamableManager.h"
#include "SGameInstance.generated.h"
...
class STUDYPROJECT_API USGameInstance : public UGameInstance
{
...
public:
...
void SavePackage() const;
void LoadPackage() const;
protected:
...
public:
FStreamableManager StreamableManager;
TSharedPtr<FStreamableHandle> StreamableHandle;
private:
...
static const FString PackageName;
static const FString AssetName;
};
<hide/>
// SGameInstance.cpp
#include "SGameInstance.h"
#include "Example/SPigeon.h"
#include "UObject/SavePackage.h"
const FString USGameInstance::PackageName = TEXT("/Game/MyPackages");
const FString USGameInstance::AssetName = TEXT("Asset1");
USGameInstance::USGameInstance()
: StreamableManager()
{
/*const FString SoftObjectPath = FString::Printf(TEXT("%s.%s"), *PackageName, *AssetName);
static ConstructorHelpers::FObjectFinder<USPigeon> PigeonAsset(*SoftObjectPath);
if (true == PigeonAsset.Succeeded())
{
UE_LOG(LogTemp, Log, TEXT("PigeonAsset.Succeeded() has returned true."));
}*/
}
...
void USGameInstance::SavePackage() const
{
UPackage* Package = ::LoadPackage(nullptr, *PackageName, LOAD_None);
if (nullptr != Package)
{
Package->FullyLoad();
}
Package = CreatePackage(*PackageName);
EObjectFlags ObjectFlag = RF_Public | RF_Standalone;
USPigeon* Pigeon = NewObject<USPigeon>(Package, USPigeon::StaticClass(), *AssetName, ObjectFlag);
Pigeon->SetName(TEXT("PigeonFromPackage"));
const int32 SubObjectCount = 10;
for (int32 i = 1; i <= SubObjectCount; ++i)
{
FString SubObjectName = FString::Printf(TEXT("Pigeon%d"), i);
USPigeon* SubStudent = NewObject<USPigeon>(Pigeon, USPigeon::StaticClass(), *SubObjectName, ObjectFlag);
SubStudent->SetName(FString::Printf(TEXT("Pigeon%d"), i));
}
const FString PackageFileName = FPackageName::LongPackageNameToFilename(PackageName, FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = ObjectFlag;
if (true == UPackage::SavePackage(Package, nullptr, *PackageFileName, SaveArgs))
{
UE_LOG(LogTemp, Log, TEXT("The Unreal Object Package has been successfully saved."));
}
}
void USGameInstance::LoadPackage() const
{
UPackage* Package = ::LoadPackage(nullptr, *PackageName, LOAD_None);
if (nullptr == Package)
{
UE_LOG(LogTemp, Warning, TEXT("Cannot find a package."));
return;
}
Package->FullyLoad();
USPigeon* Pigeon = FindObject<USPigeon>(Package, *AssetName);
UE_LOG(LogTemp, Log, TEXT("The Unreal Object Package has been successfully loaded. (%s)"), *Pigeon->GetName());
}
void USGameInstance::Init()
{
...
SavePackage();
LoadPackage();
}
- 지금까지는 캐릭터의 스켈레탈 에셋을 하드 코딩으로 지정하거나
블루프린트 애셋에서 지정해주는 식이었음.
하드 코딩의 경우, 경로 정보를 아트팀에서 야근하다가 수정 한다면
클라이언트 개발자가 다시 출근해서 알려줘야 할수도 있음.
블루프린트 애셋에서 지정해주는 경우는 스켈레탈 애셋의 경로 정보를 알 수 없어서
런타임 중에 스켈레탈 애셋을 변경해준다거나 하는 일이 불가능함.
9.2-4 플러그인
- 플러그인 수동 생성 이유
다른 플러그인을 내 프로젝트에서 추가하려면 보통 마켓 플레이스에서 쉽게 가능함.
그런데 가끔 깃 소스코드로만 올라가 있는 경우가 있음.
따라서 이런 경우까지 모두 커버 가능하려면 수동 생성 방법도 한 번쯤 해보아야 함.
또는 무료 플러그인을 만들어서 배포 후 실제 사용자들에게 피드백을 받는 것도 좋은 포트폴리오.
이는 현업을 경험해 볼 수도 있는 방법.
- 플러그인 수동 생성 방법
프로젝트 폴더 > 새 폴더 "Plugins" 생성
Plugins > 새 폴더 "StudyVehicle" 생성
StudyVehicle > 새 폴더 "Source" 생성
StudyVehicle > 새 txt 파일 "StudyVehicle.uplugin" 생성 후 아래와 같이 작성.
Source > 새 폴더 "FourWheelDrive" 생성
FourWheelDrive > 새 폴더 "Public", "Private"
FourWheelDrive > 새 txt 파일 "FourWheelDrive.Build.cs", "FourWheelDrive.h", "FourWheelDrive.cpp"
아래와 같이 모두 작성 후 빌드.
<hide/>
// StudyVehicle.uplugin
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "StudyVehicle",
"Modules": [
{
"Name": "FourWheelDrive",
"Type": "Runtime",
"LoadingPhase": "Default",
"WhitelistPlatforms": [
"Win64",
]
}
]
}
<hide/>
// FourWheelDrive.Build.cs
using UnrealBuildTool;
public class FourWheelDrive : ModuleRules
{
public FourWheelDrive(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
// Initial Modules
"Core", "CoreUObject", "Engine", "InputCore",
});
PrivateDependencyModuleNames.AddRange(new string[] { });
}
}
<hide/>
// FourWheelDrive.cpp
#include "FourWheelDrive.h"
#include "Modules/ModuleManager.h"
#define LOCTEXT_NAMESPACE "FFourWheelDriveModule"
void FFourWheelDriveModule::StartupModule()
{
}
void FFourWheelDriveModule::ShutdownModule()
{
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FDefaultModuleImpl, FourWheelDrive);
- 플러그인에 코드 작성
새 C++ 클래스 > Actor 부모 클래스 > "SVan"
이름 칸 옆에 FourWheelDrive 지정.
StudyProject.Build.cs 파일과 SPlayerCharacter 클래스도 수정.
.vs, Binaries, Intermediate, Saved, StudyProject.sln 삭제 후 Generate Visual Studio project files
비쥬얼 스튜디오 > 빌드 > Output에 UnreadEditor-FourWheelDrive도 빌드되는 것을 볼 수 있음.
<hide/>
// SVan.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "SVan.generated.h"
UCLASS()
class FOURWHEELDRIVE_API ASVan : public AActor
{
GENERATED_BODY()
public:
ASVan();
void Drive();
};
<hide/>
// SVan.cpp
#include "SVan.h"
#include "Kismet/KismetSystemLibrary.h"
ASVan::ASVan()
{
PrimaryActorTick.bCanEverTick = false;
}
void ASVan::Drive()
{
UKismetSystemLibrary::PrintString(this, FString::Printf(TEXT("ASVan::Drive() has been called.")));
}
<hide/>
// StudyProject.Build.cs
...
public class StudyProject : ModuleRules
{
public StudyProject(ReadOnlyTargetRules Target) : base(Target)
{
...
PublicDependencyModuleNames.AddRange(new string[]
{
...
// MyPlugin
"FourWheelDrive",
});
...
}
}