호영 튜터님께서 만들어주신 프로젝트 공부하기
CommonUI_Test/
├── Config/
│ ├── DefaultEditor.ini
│ ├── DefaultEditorPerProjectUserSettings.ini
│ ├── DefaultEngine.ini
│ ├── DefaultGame.ini
│ ├── DefaultGameplayTags.ini - 태그 추가
│ └── DefaultInput.ini
│
└── Source/
└── CommonUI_Test/
├── DataAssets/
│ ├── UIMapDataAsset.cpp
│ └── UIMapDataAsset.h - 에디터에서 데이터만 관리하는 에셋으로 사용됨
│
├── UI/
│ ├── Subsystem/
│ │ ├── UIManageSubsystem.cpp
│ │ └── UIManageSubsystem.h
│ │
│ ├── PrimaryLayout.cpp
│ └── PrimaryLayout.h
│
├── CommonUI_Test.cpp
├── CommonUI_Test.h
│
├── CommonUI_Test.Build.cs
│
├── CommonUI_TestCharacter.cpp
├── CommonUI_TestCharacter.h
│
├── CommonUI_TestGameMode.cpp
└── CommonUI_TestGameMode.h
│
├── MyHUD.cpp
├── MyHUD.h
│
├── MyPlayerController.cpp
└── MyPlayerController.h
│
├── CommonUI_Test.Target.cs
└── CommonUI_TestEditor.Target.cs
DefaultGameplayTags.ini
UIMapDataAsset.h
UIManageSubsystem.h
MyHUD
[UI Request (GameplayTag)]
|
v
--------------------------
| UUIManageSubsystem |
| |
| - UIMapDataAsset |
| - CachedWidgetMap |
| - RootLayout |
--------------------------
|
v
[PrimaryLayout] -- contains --> [Multiple Layers]
|
v
[Spawn Widget on that Layer]
“요청한 UI를 어떤 위젯으로, 어떤 레이어에서 열지”를 정의하는 구조체
| 구분 | USTRUCT (구조체) | UCLASS (클래스) |
|---|---|---|
| 기본 접근 지정자 | public | private |
| 상속 시 기본 접근 | public | private |
| 특징 | 경량 데이터 묶음 (Value Type) | UObject 기반 오브젝트 (LifeCycle) |
| 복사 | 값 복사 가능 | 복사 불가 (포인터/레퍼런스 기반) |
| 엔진 관리 | GC 대상 아님 (가벼움) | GC 관리 대상, 엔진 시스템과 완전 연동 |
| 상속 | 불가능 | 가능 (이벤트/가상함수/오버라이드) |
| 주 용도 | 스탯/설정 값/정책/DT Row | Actor, Component, Widget, Subsystem, DataAsset, GameMode, HUD, UObject 기반 오브젝트 등 |
USTRUCT(BlueprintType)
struct FUIWidgetPolicy
{
GENERATED_BODY()
// 1. 무엇을
//TODO 소프트 클래스 포인터 찾아보고 공부할 것! (<=> TSubclassOf, 하드 포인터)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Policy")
TSoftClassPtr<UCommonActivatableWidget> WidgetClass;
// 2. 어디에
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Policy", meta = (Categories = "UI.Layer"))
FGameplayTag TargetLayer;
};
| 항목 | 타입 | 기능 |
|---|---|---|
WidgetClass | TSoftClassPtr<UCommonActivatableWidget> | 열고 싶은 위젯의 소프트 레퍼런스 |
TargetLayer | FGameplayTag | 위젯을 어떤 레이어(UI.Layer.*)에 붙일지 |
UPROPERTY(EditDefaultsOnly, Category = "UI Policy", meta = (Categories = "UI.Request"))
TMap<FGameplayTag, FUIWidgetPolicy> WidgetMap;
| 용어 | 실체 | UE 공식 의미 |
|---|---|---|
| 하드 포인터 | UClass*, UCommonActivatableWidget* 등 | 참조하는 객체가 반드시 메모리에 로드됨 |
| TSubclassOf | 클래스 타입 제한 포인터 | |
| (하드 레퍼런스) | “이 클래스 또는 자식 클래스만 허용” | |
| TSoftClassPtr | *소프트 레퍼런스 | |
| (Soft Object Path)* | 실제 로드 없이 경로만 저장, 필요할 때 로드 |
| 종류 | 저장하는 대상 | 타입 제한 | 용도 |
|---|---|---|---|
| 하드 포인터(UClass*) | 어떤 클래스든 UClass 포인터 | ❌ 제한 없음 | 런타임에 특정 클래스를 가리키기 |
| TSubclassOf | T 또는 T의 자식만 가리킴 | ✔ 타입 제한 있음 | “T로 생성 가능한 클래스만 허용” |
| 타입 | 로딩 시점 | 종속성 | 메모리 |
|---|---|---|---|
| 하드 포인터 | 즉시 | 강한 의존성 | 높음 |
| TSubclassOf | 즉시 | 강한 의존성 | 중간 |
| TSoftClassPtr | 필요할 때 | 약한 의존성 | 매우 적음 |
UPROPERTY()
UCommonActivatableWidget* Widget;
특징
사용 예시
UPROPERTY(EditAnywhere)
TSubclassOf<UCommonActivatableWidget> WidgetClass;
특징
사용 예시
UPROPERTY(EditAnywhere)
TSoftClassPtr<UCommonActivatableWidget> WidgetClass;
특징
LoadSynchronous() 또는 Streamer 로딩사용 예시
UIMapDataAsset 에 TSoftClassPtr을 써야 하는 이유UI는 처음부터 전부 로드할 필요 없음
메인 메뉴, HUD, 팝업 등 모두 한 번에 메모리에 올릴 이유 없음
레벨이 바뀔 때 로딩 시간 줄임
SoftClass는 필요한 레벨에서만 로드하도록 설계
DataAsset에 Hard Reference 넣었다면
→ DataAsset이 참조하는 모든 BP UI가 항상 로드됨
→ 옵션UI, 상점UI, 인벤UI까지 전부 로드됨
→ 프레임 드랍 + 로딩 시간 증가 발생
UGameInstanceSubsystem 기반의 UI 관리 시스템
게임 전체에서 공통으로 UI를 열고 닫는 중앙 컨트롤러 역할
UI 요청 태그 → UI 위젯 생성 → 적절한 레이어 배치
모든 UI 열기 로직을 관리하는 게임 전체 단위 UI 컨트롤러
void UUIManageSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
if (UIMapDataAsset)
{
CachedWidgetMap = UIMapDataAsset->WidgetMap;
UE_LOG(LogTemp, Log, TEXT("UIManageSubsystem: %d개의 UI 맵 로드됨."), CachedWidgetMap.Num());
}
else
{
UE_LOG(LogTemp, Error, TEXT("UIManageSubsystem: UIMapDataAsset이 설정필요. BP_UIManageSubsystem 확인바람"));
}
}
Subsystem 생성 시 초기화
→ UI 시스템 전부의 시작 지점
UIMapDataAsset
→ UI 구조를 코드가 아닌 데이터로 관리함
CachedWidgetMap
→ UIMap 빌 때 DataAsset 파일을 계속 읽지 않고 빠르게 접근
void UUIManageSubsystem::SetRootLayout(UPrimaryLayout* InRootLayout)
{
if (RootLayout == InRootLayout)
{
return; // 이미 등록됨
}
RootLayout = InRootLayout;
UE_LOG(LogTemp, Log, TEXT("UIManageSubsystem: RootLayout 등록 완료"));
}
UI의 가장 최상위 레이아웃(Primary Layout)을 저장
→ UI 열기 전에 반드시 RootLayout이 등록되어야 함
→ 여기에선 PrimaryLayout Widget
RootLayout
→ Subsystem이 UI를 어디에 띄울지 결정하는 기준
CommonUI 기반 구조에서는
→ 같은 Layout을 넣으면 다시 설정할 이유 없으니 return;
→ 같은 포인터면 return 하는 것이 안정적UCommonActivatableWidget* UUIManageSubsystem::ShowWidget(FGameplayTag RequestTag)
{
// 0. 루트 레이아웃이 유효한지 확인
if (!RootLayout)
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): RootLayout이 없다!"), *RequestTag.ToString());
return nullptr;
}
// 1. UI Policy 검색
const FUIWidgetPolicy* Policy = CachedWidgetMap.Find(RequestTag);
if (!Policy)
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy에 등록된 태그가 없음"), *RequestTag.ToString());
return nullptr;
}
// 2. 레이어 정보 확인
const FGameplayTag& LayerTag = Policy->TargetLayer;
if (!LayerTag.IsValid())
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy Layer 확인 바람"), *RequestTag.ToString());
return nullptr;
}
// 3. RootLayout에서 해당 레이어 스택 검색
UCommonActivatableWidgetContainerBase* Stack = RootLayout->GetLayerWidget(LayerTag);
if (!Stack)
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): RootLayout에서 %s 레이어 스택이 없음"), *RequestTag.ToString(), *LayerTag.ToString());
return nullptr;
}
// 4. 무엇을 띄울지 TSoftClassPtr로 되어있는 위젯 검색
TSoftClassPtr<UCommonActivatableWidget> WidgetClassPtr = Policy->WidgetClass;
if (WidgetClassPtr.IsNull())
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): UI Policy에서 WidgetClass 설정 확인 바람"), *RequestTag.ToString());
return nullptr;
}
// 5. TSoftClassPtr를 동기식으로 로드 (추후 비동기 로드로 개선 가능)
TSubclassOf<UCommonActivatableWidget> WidgetClass = WidgetClassPtr.LoadSynchronous();
if (!WidgetClass)
{
UE_LOG(LogTemp, Warning, TEXT("ShowWidget(%s): %s 클래스를 로드할 수 없음"), *RequestTag.ToString(), *WidgetClassPtr.ToString());
return nullptr;
}
// 6. 스택에 위젯을 Push
return Stack->AddWidget<UCommonActivatableWidget>(WidgetClass, [RequestTag, LayerTag, WidgetClass](UCommonActivatableWidget& WidgetToInit) {
// 이 람다(Lambda)는 위젯이 생성된 직후 화면에 보이기(Construct) 직전에 호출.
UE_LOG(LogTemp, Log, TEXT("ShowWidget(%s): %s 레이어에 %s 위젯 Push 완료"), *RequestTag.ToString(), *LayerTag.ToString(), *WidgetClass->GetName());
});
}
FStreamableManager& Loader = UAssetManager::GetStreamableManager();
Loader.RequestAsyncLoad(
WidgetClassPtr.ToSoftObjectPath(),
FStreamableDelegate::CreateLambda([Stack, RequestTag, WidgetClassPtr]()
{
TSubclassOf<UCommonActivatableWidget> LoadedClass = WidgetClassPtr.Get();
Stack->AddWidget(LoadedClass);
})
);
이 UI 요청이 표시될 Layer가 이미 다른 UI에 의해 사용중인가?
bool UUIManageSubsystem::IsLayerBusy(FGameplayTag RequestTag)
{
// 1. UI Policy 검색
const FUIWidgetPolicy* Policy = CachedWidgetMap.Find(RequestTag);
if (!Policy)
{
return false;
}
// 2. 레이어 정보 확인
const FGameplayTag& LayerTag = Policy->TargetLayer;
if (!LayerTag.IsValid() || !RootLayout)
{
return false;
}
// 3. RootLayout에서 해당 레이어 스택 검색
UCommonActivatableWidgetContainerBase* Stack = RootLayout->GetLayerWidget(LayerTag);
if (!Stack)
{
return false;
}
// 4. 스택 위에 활성 위젯이 있는지 체크
return (Stack->GetActiveWidget() != nullptr);
}
AMyHUD는 게임 시작 시 RootLayout을 생성해 화면에 올리고,
RootLayout이 준비되면 UIManageSubsystem을 통해 첫 UI(MainMenu or HUD)를 띄운다.
여기서 RootLayout 생성
void AMyHUD::BeginPlay()
{
Super::BeginPlay();
// 2. RootLayoutClass가 BP_MyHUD에 할당되었는지 확인
if (!RootLayoutClass)
{
UE_LOG(LogTemp, Error, TEXT("AMyHUD: RootLayoutClass가 설정되지 않았습니다! (BP_MyHUD에서 설정 필요)"));
return;
}
// 3. RootLayout을 생성
RootLayoutInstance = CreateWidget<UPrimaryLayout>(GetOwningPlayerController(), RootLayoutClass);
if (!RootLayoutInstance) // 생성 실패 시
{
UE_LOG(LogTemp, Error, TEXT("AMyHUD: RootLayout 인스턴스 생성 실패."));
return; // UI 띄울 방법 없음 -> 종료
}
// 성공했을 때 로그 출력
UE_LOG(LogTemp, Display, TEXT("AMyHUD: RootLayout 인스턴스 생성 완료."));
// 4. 추가
RootLayoutInstance->AddToViewport();
}
RootLayout 생성이 끝났을 때 HUD가 Subsystem에 UI를 띄우라고 지시하는 함수
RootLayout을 화면에 올렸다고 해서 끝이 아님
실제 첫 화면(UI)을 띄우려면 Subsystem에게 알려줘야 함
void AMyHUD::OnRootLayoutReady()
{
FString CurrentLevelName = GetWorld()->GetName(); // 1. 현재 레벨 이름 가져오기
if (CurrentLevelName.Contains("Frontend")) // 2. Frontend 레벨인지 검사
{
// 3. UIManageSubsystem 가져오기
if (UUIManageSubsystem* UIManager = GetGameInstance()->GetSubsystem<UUIManageSubsystem>())
{
UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.MainMenu")));
}
else
{
UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.ShowHUD")));
}
}
}
UIManager null 체크와 Forntend/HUD 조건 분리해야 하고,
null일 때 ShowWidget을 호출하면 100% 크래시 나므로
반드시 UIManager 체크 뒤에 레벨 분기(if/else)를 둬야 한다.
Frontend 레벨 → MainMenu UI 띄우기
그 외 레벨 → 인게임 HUD UI 띄우기
else 위치 잘못된 이유 : UIManager가 nullptr일 때 UIManager→ShowWidget 호출하기 때문
수정 방법
void AMyHUD::OnRootLayoutReady()
{
UUIManageSubsystem* UIManager = GetGameInstance()->GetSubsystem<UUIManageSubsystem>();
if (!UIManager)
{
UE_LOG(LogTemp, Error, TEXT("AMyHUD: UIManageSubsystem을 가져올 수 없습니다!"));
return;
}
FString CurrentLevelName = GetWorld()->GetName();
if (CurrentLevelName.Contains("Frontend"))
{
// 프론트엔드면 메인메뉴 UI 띄우기
UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.MainMenu")));
}
else // 레벨 구분
{
// 그 외 인게임 레벨이면 HUD 띄우기
UIManager->ShowWidget(FGameplayTag::RequestGameplayTag(TEXT("UI.Request.ShowHUD")));
}
}