포스트

언리얼 인벤토리 시스템과 UMG 위젯 연동

TArray·TMap·TSet이 각자 맡는 자리

목록은 순서가, 아이템 정의는 즉시 조회가, 칭호는 중복 제거가 필요하다 — 인벤토리 하나에 컨테이너 셋이 다 들어가는 이유. generated.h include 순서와 GameMode 위젯 생성에서 걸린 오류까지.

언리얼 인벤토리 시스템과 UMG 위젯 연동

인벤토리는 자료구조 선택이 그대로 기능이 되는 시스템이다. 아이템 목록은 순서가 필요하고, 아이템 정의는 이름으로 즉시 찾아야 하고, 획득한 칭호는 중복이 없어야 한다 — TArray·TMap·TSet이 각각 자리를 잡는 지점이 여기다. 이 글에서는 캐릭터에 인벤토리를 직접 얹어 만든 과정을 이야기하려 한다. 구현은 FItem 구조체 정의부터 UMG 위젯 연동까지고, 트러블슈팅은 컴파일 오류와 “위젯은 떴는데 버튼이 안 눌리는” 문제를 포함한 디버깅 기록이다.

같은 날 푼 CodeKata 19와 정렬 알고리즘 강의 내용은 이 글의 주제와 달라 덜어냈다. 정렬은 list와 sort·LeetCode 912 Sort an Array에 따로 정리돼 있다.

언리얼 마스터 과제 — 인벤토리 시스템 (필수 + 도전)

참고 챕터: 1-7 (리플렉션) · 3-1 (아이템 설계) · 3-3 (데이터 관리) · 4-1 (UMG)

구조 설계 — Character 직접 구현

Component 분리 없이 AMyCharacter에 인벤토리 로직을 직접 구현. UMG 도전과제를 위해 AInvGameMode(AGameModeBase 상속)를 별도로 만들어 위젯 생성 담당.

1
2
3
AInvGameMode (AGameModeBase)   ← 위젯 생성 · PlayerController 설정
AMyCharacter (ACharacter)      ← 인벤토리 로직 (Bag/ItemDB/OwnedTitles)
WBP_Inventory (UUserWidget)    ← BagList TextBlock / TitleList TextBlock / UseBtn

STEP 1 — FItem USTRUCT + 헤더 선언

include 순서 규칙: generated.h는 파일의 절대 마지막 include, 모든 USTRUCT/UCLASS는 그 에 선언.

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
// MyCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"   // ← 마지막 include

USTRUCT(BlueprintType)
struct FItem
{
    GENERATED_BODY()   // ← 없으면 StaticStruct() 미생성 → C2039 에러

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FString Name;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FString Desc;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
    FName RequiredTitle;   // NAME_None = 조건 없음
};

UCLASS()
class NBC_JANGSIK_INV_API AMyCharacter : public ACharacter
{
    GENERATED_BODY()

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventory")
    TArray<FItem> Bag;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
    TMap<FString, FItem> ItemDB;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Inventory")
    TSet<FName> OwnedTitles;

    UFUNCTION(BlueprintCallable, Category = "Inventory") void AddItem(FItem Item);
    UFUNCTION(BlueprintCallable, Category = "Inventory") void AcquireTitle(FName Title);
    UFUNCTION(BlueprintPure,     Category = "Inventory") bool HasTitle(FName Title) const;
    UFUNCTION(BlueprintCallable, Category = "Inventory") void UseItem(FString ItemName);
    UFUNCTION(BlueprintCallable, Category = "Inventory") void PrintBag() const;
    UFUNCTION(BlueprintCallable, Category = "Inventory") void UpdateInventoryUI();
};

FName 사용 이유: RequiredTitleOwnedTitles의 타입을 일치시켜 Contains() 직접 비교 가능. FString보다 비교 연산이 빠름.

STEP 2 — CPP 구현 + BeginPlay 통합 테스트

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
// MyCharacter.cpp
#include "MyCharacter.h"
#include "InvGameMode.h"          // .h가 아닌 .cpp에서 include → 순환참조 방지
#include "Components/TextBlock.h"
#include "Blueprint/UserWidget.h"

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

    FItem Sword  = { TEXT("검"),  TEXT("강력한 검"),  TEXT("전사") };
    FItem Potion = { TEXT("물약"), TEXT("체력 회복"), NAME_None   };
    ItemDB.Add(Sword.Name, Sword);
    ItemDB.Add(Potion.Name, Potion);

    AddItem(Sword);    // → Bag에 추가 + UpdateInventoryUI
    AddItem(Potion);
    PrintBag();

    if (FItem* Info = ItemDB.Find(TEXT("검")))
        UE_LOG(LogTemp, Warning, TEXT("[ItemDB] 검: %s"), *Info->Desc);

    UseItem(TEXT("검"));       // 칭호 없음 → 실패
    AcquireTitle(TEXT("전사"));
    UseItem(TEXT("검"));       // 칭호 있음 → 성공
    UseItem(TEXT("물약"));     // 조건 없음 → 성공
}

void AMyCharacter::AddItem(FItem Item)
{
    Bag.Add(Item);
    UpdateInventoryUI();
}

void AMyCharacter::UseItem(FString ItemName)
{
    FItem* Item = ItemDB.Find(ItemName);
    if (!Item) return;

    if (Item->RequiredTitle != NAME_None && !HasTitle(Item->RequiredTitle))
    {
        UE_LOG(LogTemp, Warning, TEXT("[UseItem] %s 불가 — 칭호 '%s' 필요"),
            *ItemName, *Item->RequiredTitle.ToString());
        return;
    }
    UE_LOG(LogTemp, Warning, TEXT("[UseItem] %s 사용 완료"), *ItemName);
}

void AMyCharacter::UpdateInventoryUI()
{
    AInvGameMode* InvGM = Cast<AInvGameMode>(GetWorld()->GetAuthGameMode());
    if (!InvGM) return;

    UUserWidget* Widget = InvGM->GetInventoryWidget();
    if (!Widget) return;

    if (UTextBlock* BagText = Cast<UTextBlock>(Widget->GetWidgetFromName(TEXT("BagList"))))
    {
        FString BagStr;
        for (const FItem& Item : Bag)
            BagStr += Item.Name + TEXT("\n");
        BagText->SetText(FText::FromString(BagStr));
    }

    if (UTextBlock* TitleText = Cast<UTextBlock>(Widget->GetWidgetFromName(TEXT("TitleList"))))
    {
        FString TitleStr;
        for (const FName& Title : OwnedTitles)
            TitleStr += Title.ToString() + TEXT("\n");
        TitleText->SetText(FText::FromString(TitleStr));
    }
}

기대 Output Log 순서:

1
2
3
4
5
6
7
8
[PrintBag] 가방 목록 (2개):
  - 검: 강력한 검
  - 물약: 체력 회복
[ItemDB] 검: 강력한 검
[UseItem] 검 불가 — 칭호 '전사' 필요
[AcquireTitle] 칭호 획득: 전사
[UseItem] 검 사용 완료
[UseItem] 물약 사용 완료

STEP 3 — UMG 도전: InvGameMode + WBP_Inventory

InvGameMode — 위젯 생성 + 마우스 입력 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// InvGameMode.h (AGameModeBase 상속)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI")
TSubclassOf<UUserWidget> InventoryWidgetClass;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "UI")
UUserWidget* InventoryWidgetInstance;

UFUNCTION(BlueprintPure, Category = "UI")
UUserWidget* GetInventoryWidget() const;

// InvGameMode.cpp — BeginPlay
APlayerController* PC = GetWorld()->GetFirstPlayerController();
InventoryWidgetInstance = CreateWidget<UUserWidget>(PC, InventoryWidgetClass);
if (InventoryWidgetInstance)
{
    InventoryWidgetInstance->AddToViewport();
    PC->SetShowMouseCursor(true);
    PC->SetInputMode(FInputModeGameAndUI());  // 버튼 클릭 허용
}

GameMode에서 CreateWidget 시 주의: this가 PlayerController가 아니므로 GetWorld()->GetFirstPlayerController()를 먼저 가져와서 넘겨야 한다.

WBP_Inventory 구조:

1
2
3
4
Canvas Panel
├── TextBlock "BagList"    ← GetWidgetFromName으로 찾아 SetText
├── TextBlock "TitleList"
└── Button "UseBtn"        → OnClicked 이벤트

에디터 세팅:

  • World Settings → GameMode Override → InvGameMode (또는 BP_InvGameMode)
  • BP_InvGameMode → Inventory Widget Class → WBP_Inventory
  • BP_InvGameMode → Default Pawn Class → BP_MyCharacter

Blueprint 연결 — UseBtn OnClicked

1
2
3
4
5
6
7
[On Clicked (UseBtn)]
    ↓ 실행 핀
[Cast To MyCharacter]
    Object ← [Get Owning Player Pawn]  ← Target: self (Widget)
    ↓ As My Character
[Use Item]
    Item Name: "검"

핵심 실수: Get Player ControllerCast To MyCharacter 는 항상 실패.
PlayerController ≠ Character. Get Owning Player Pawn을 써야 Pawn(Character)을 반환.

트러블슈팅 — 만난 오류들

돌이켜보면 막힌 지점 대부분이 두 갈래였다. 하나는 generated.h include 순서, 다른 하나는 “GameMode는 PlayerController가 아니다”라는 사실을 코드 곳곳에서 다시 확인한 것.

오류원인해결
C4430 + C2039: StaticStruct not a member of FItemUSTRUCT가 generated.h 앞에 선언됨include 순서 교정: generated.h → USTRUCT 순으로
함수 중첩 컴파일 오류BeginPlay 닫는 } 누락중괄호 추가
Cast To MyCharacter always fails (Blueprint 경고)Get Player Controller 반환값은 PlayerController, MyCharacter가 아님Get Owning Player Pawn으로 교체
버튼 클릭해도 반응 없음PlayerController가 DefaultPawn을 possess, MyCharacter 아님레벨 배치 MyCharacter → Auto Possess Player: Player 0
GetController()로 캐스팅 실패GameMode에서 GetController() 없음GetWorld()->GetAuthGameMode()로 교체
CreateWidget(this, ...) 오류GameMode는 PlayerController가 아님CreateWidget(PC, ...) 로 PC 명시

정리 — 이 과제에서 남은 것

  • generated.h는 마지막 include — 이 규칙 하나만 지키면 UHT(Unreal Header Tool, 리플렉션 코드 생성 도구) 관련 C4430/C2039 대부분 예방 가능

  • GameMode ≠ PlayerController — GameMode에서 CreateWidget은 반드시 GetFirstPlayerController()를 가져와서 넘겨야 하고, 위젯 내부에서 Character를 찾을 때는 Get Owning Player Pawn 사용

  • forward declaration으로 순환참조 방지.h에는 class AInvGameMode; 전방선언, 실제 include는 .cpp에서. generated.h 이후에 다른 include 넣으면 UHT 오작동

  • SetInputMode(FInputModeGameAndUI()) 필수 — 위젯이 뜨더라도 입력모드가 Game Only면 버튼 클릭이 UI에 안 닿음. 마우스 커서 표시(SetShowMouseCursor(true))도 같이 세팅

  • Auto Possess Player — 레벨에 직접 배치한 Pawn은 기본적으로 PlayerController가 빙의하지 않음. DefaultPawnClass 설정 또는 Auto Possess Player: Player 0 중 하나 선택 필요

  • 그래프 2개 운용 — 도메인이 다른 지식은 그래프를 분리해야 god 노드가 의미 있음. CS와 UE5를 하나로 합치면 서로 다른 커뮤니티가 섞여 탐색 품질 저하


핵심 요약generated.h는 반드시 마지막 include여야 한다는 것, 그리고 GameMode는 PlayerController가 아니므로 위젯 생성 시 GetFirstPlayerController()를 넘기고 위젯 안에서는 Get Owning Player Pawn으로 캐릭터를 찾아야 한다는 것. 오늘 만난 컴파일 오류와 Blueprint 캐스팅 실패 대부분이 이 두 지점에서 나왔다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.