프로젝트 MyaCat을 개발하면서 구현한 내용 정리


구현 핵심 기술

GameplayMessageRouter 연동

GameplayMessageRouter는 Lyra에서 가져온 Epic Games 플러그인
서로 직접 연결되지 않은 gameplay object 사이에서 메시지를 등록하고 전송할 수 있게 해주는 subsystem이다.

ProjectFT에서는 Actor 간 직접 Cast를 줄이고, UI, GameFlow, Inventory, Quest, AI, DamageText 같은 시스템을 느슨하게 연결하기 위해 사용했다.

구성

GameplayTag = 메시지 채널
USTRUCT = 메시지 페이로드
UGameplayMessageSubsystem = 메시지 라우터

Broadcast 흐름

메시지를 보내는 쪽은 BroadcastMessage를 호출한다.

FFTMessagePayloadStruct Payload;
Payload.InstigatorActor = InstigatorActor;
Payload.TargetActor = TargetActor;
Payload.Value = 1.0f;

UGameplayMessageSubsystem::Get(this).BroadcastMessage(
    TAG_FT_Event_ShelfDamaged,
    Payload);

Listener 흐름

메시지를 받는 쪽은 RegisterListener를 호출한다.

ListenerHandle = UGameplayMessageSubsystem::Get(this).RegisterListener<FFTMessagePayloadStruct>(
    TAG_FT_Event_ItemPickedUp,
    this,
    &ThisClass::HandleItemPickedUpMessage);

본 프로젝트에서 사용 구조

  • GameFlow 요청
    UI, EscapeZone, Grab Ability, Fail/Escape Widget 등이 직접 GameFlow 함수를 호출하지 않고 메시지를 보낸다.

    UGameplayMessageSubsystem::Get(this).BroadcastMessage(
      TAG_FT_Request_Flow_ReturnToBase,
      Payload);

    UFTGameFlowSubsystem은 여러 Flow 요청 태그를 등록해두고 상태 전환을 처리한다.

    FlowRequestListenerHandles.Add(
      MessageSubsystem.RegisterListener(
          TAG_FT_Request_Flow_StartRaid,
          this,
          &ThisClass::HandleFlowRequestMessage));
  • Inventory / Item Flow
    아이템 획득, 아이템 사용, 드랍 요청이 메시지로 전달된다.

  • TAG_FT_Event_ItemPickedUp

  • TAG_FT_Event_ItemConsumed

  • TAG_FT_Request_DropItem

UFTInventoryComponent는 PickedUp / Consumed 이벤트를 듣고 자기 인벤토리를 갱신한다.
드랍 요청은 UFTItemPoolSubsystem이 받아 필드 아이템을 생성하거나 풀에서 꺼낸다.

  • Quest / Objective
    UFTObjectiveSubsystem은 다양한 게임 이벤트를 Listen하고, 퀘스트 진행도를 갱신한다.
  • TAG_FT_Event_CraftCompleted
  • TAG_FT_Event_ShopPurchased
  • TAG_FT_Event_ShelfDamaged
  • TAG_FT_Event_StealCompleted
  • TAG_FT_Event_RaidEscaped
  • TAG_FT_Event_RaidFailed

각 시스템은 자기 이벤트를 방송하고, 퀘스트 시스템은 필요한 이벤트만 구독한다.

  • AI / Security
    NPC, Security, ReportGauge, ChaseGauge는 감지, 신고, 추격, 복귀 상태를 메시지로 주고받는다.

  • TAG_FT_Event_NPCDetectedPlayer

  • TAG_FT_Event_NPCReportStarted

  • TAG_FT_Event_SecurityCalled

  • TAG_FT_Event_SecurityTargetCaptured

  • TAG_FT_Event_SecurityChaseEnded

  • TAG_FT_Event_SecurityReturnedToRoom

  • UI 갱신
    UIManager와 개별 Widget도 일부 GameplayMessage를 듣고 UI를 갱신한다.

    • Objective 진행도 변경
    • Quest 완료
    • Security Gauge 변경
    • NPC Report Gauge 변경
    • DamageText 표시

AssetManager

Soft Reference 기반 Asset 접근, 전역 GameData 로드, Level Preload, UI Class 조회, Preload 생명주기 관리를 담당하는 프로젝트 전용 UAssetManager 구조 구성

DataAsset 중심 구조와 Loading Level 흐름을 연결하는 중심 클래스

등록

DefaultEngine.ini에서 엔진 AssetManager 클래스를 ProjectFT 전용 클래스로 교체한다.

[/Script/Engine.Engine]
AssetManagerClassName=/Script/ProjectFT.FTAssetManager

DefaultGame.ini에서 전역 GameData 경로를 지정한다.

[/Script/ProjectFT.FTAssetManager]
GameDataPath=/Game/Data/Game/DA_FTGameData.DA_FTGameData

핵심 책임

책임 설명
전역 접근 UFTAssetManager::Get()으로 프로젝트 AssetManager를 가져온다.
동기 Soft Object 로드 GetAsset()으로 TSoftObjectPtr를 로드한다.
동기 Soft Class 로드 GetSubclass()TSoftClassPtrTSubclassOf로 로드한다.
비동기 로드 LoadAssetAsync(), LoadSubclassAsync()로 필요한 Asset/Class를 비동기 로드한다.
GameData 관리 GameDataPath를 통해 DA_FTGameData를 로드하고 캐싱한다.

GameData 접근

StartInitialLoading()에서 LoadGameData()를 호출해 DA_FTGameData를 준비한다.

void UFTAssetManager::StartInitialLoading()
{
    Super::StartInitialLoading();
    LoadGameData();
}

LoadGameData()GameDataPath를 통해 UFTGameDataAsset을 로드하고 GameData에 캐싱한다.

GameDataPath
-> /Game/Data/Game/DA_FTGameData
-> UFTGameDataAsset

LoadedAssets와 GC

UFTAssetManager는 로드된 Asset을 LoadedAssets에 보관한다.

UPROPERTY()
TSet<TObjectPtr<const UObject>> LoadedAssets;

Preload 생명주기 관리

현재 Preload에서 유지해야 할 Asset 경로는 ActivePreloadAssetPathStrings에 저장된다.
새 Preload가 시작되면 RetainPreloadedAssetsForNextPreload()가 이전 목록과 다음 목록을 비교한다.

다음 Preload에도 포함됨
-> 유지

다음 Preload에 없음
-> LoadedAssets에서 제거

MVVM 패턴 구조 사용

Widget과 게임 로직 사이에 ViewModel UObject를 두어 사용했다.

UObject ViewModel이 UI에 필요한 상태를 들고 있고 Widget이 그 값을 읽거나 ViewModel 함수를 호출하는 방식

Model / Gameplay Layer
- InventoryComponent
- AbilitySystemComponent / AttributeSet
- GameFlowSubsystem
- SaveSubsystem
- QuestSubsystem
- Item DataAsset 등

ViewModel
- FTHUDViewModel
- FTInventoryViewModel
- FTPauseMenuViewModel
- FTCraftingViewModel
- FTQuestViewModel
- FTHubStorageViewModel 등

View
- WBP_MainHUD / FTMainHUDWidget
- WBP_Inventory / FTInventoryWidget
- WBP_PauseMenu / FTPauseMenuWidget
- Hub 관련 Widget들

예) HUD 흐름

flowchart LR
    A["Player / ASC / AttributeSet"] --> B["UFTHUDViewModel"]
    B --> C["UFTMainHUDWidget"]
    C --> D["WBP_MainHUD"]

UFTHUDViewModel은 Player의 AbilitySystemComponent에 바인딩해서 Health, MaxHealth, Stamina, MaxStamina 변경을 감지한다. 그리고 TargetHPPercent, TargetStaminaPercent, CrosshairState 같은 UI 표시용 상태로 정리한다.

UE MVVM 플러그인과 다른 점

UE MVVM 플러그인 방식
- UMVVMViewModelBase 사용
- FieldNotify로 값 변경 알림
- UMG에서 ViewModel Binding 설정
- Widget 코드가 줄어듦
- 에디터 바인딩 중심

우리 프로젝트 방식
- UObject 기반 ViewModel 직접 생성
- Widget이 ViewModel을 직접 참조
- 필요한 곳에서 NotifyChanged / Delegate / Tick / 직접 Getter 사용
- C++와 Blueprint를 섞어 수동으로 연결
- 구조는 MVVM이지만 플러그인 기능은 거의 사용하지 않음

Damage Text Pooling

스크린샷 2026-07-27 131720

Damage Text Pooling은 데미지가 발생할 때마다 Widget을 새로 만들지 않고, 미리 만들어둔 Widget들을 재사용하는 구조

흐름

flowchart TD
    A["Damage 발생"] --> B["GameplayMessage Broadcast"]
    B --> C["UFTDamageTextSubsystem Listener"]
    C --> D["로컬 플레이어 관련 데미지인지 검사"]
    D --> E["Pool에서 Widget 하나 획득"]
    E --> F["Damage 값 세팅"]
    F --> G["WorldLocation -> ScreenPosition 변환"]
    G --> H["WBP_DamageText 표시"]
    H --> I["Fade Animation 재생"]
    I --> J["Timer 후 Hide"]
    J --> K["Pool로 반환"]

구조

Initialize()에서 CreateDamageTextWidget()을 호출해서 Pool에 넣는다.

for (int32 Index = 0; Index < InitialPoolSize; ++Index)
{
    if (UUserWidget* Widget = CreateDamageTextWidget())
    {
        FFTDamageTextPoolItemStruct& PoolItem = DamageTextPool.AddDefaulted_GetRef();
        PoolItem.Widget = Widget;
        PoolItem.bInUse = false;
    }
}

Pool Item 구조

struct FFTDamageTextPoolItemStruct
{
    UUserWidget* Widget;
    FVector WorldLocation;
    bool bInUse;
    FTimerHandle HideTimerHandle;
};

표시 요청

Subsystem에 요청

ShowDamageText(float Damage, FVector HitLocation)
ShowFloatingText(const FText& Text, FVector HitLocation)

ShowDamageText()는 숫자를 텍스트로 바꿔서 ShowFloatingText()로 넘긴다.

ShowFloatingText(FText::AsNumber(FMath::RoundToInt(Damage)), HitLocation);

Pool 획득

if (!PoolItem.bInUse && PoolItem.Widget)
{
    PoolItem.bInUse = true;
    return PoolItem.Widget;
}

화면 위치 계산

UMG Widget을 Viewport에 올리고, 매 Tick마다 World Position을 Screen Position으로 변환

UWidgetLayoutLibrary::ProjectWorldLocationToWidgetPosition(
    PlayerController,
    PoolItem.WorldLocation,
    WidgetPosition,
    true
);

반환

시간이 지나면 HideDamageText()가 호출된다.

PoolItem.bInUse = false;
PoolItem.WorldLocation = FVector::ZeroVector;
Widget->StopAllAnimations();
Widget->SetVisibility(ESlateVisibility::Collapsed);

Widget을 숨긴 뒤 Pool로 돌려보낸다.

반응형