언리얼 팀프로젝트

최종 팀 프로젝트 커스터마이징 시스템 데이터화 및 UI 구현

Turtle_Jun 2025. 12. 4. 23:36

Customize 기능 구현

UE5 모듈러 캐릭터 커스터마이징 시스템 구현 정리

1. 개요

프로젝트에서는 캐릭터의 파츠(상의, 하의, 헤어, 장갑 등)를 런타임에서 자유롭게 변경하는 모듈형 시스템(Modular Character System)을 구현했다.

주요 요구사항은 다음과 같다.

  • 모듈 파츠 구조를 스켈레탈 메시 단위로 유연하게 교체
  • 여러 파츠를 조합할 수 있는 FastArray 기반 구조
  • 클라이언트 UI에서 선택한 파츠를 서버에서 장착 후 모든 클라이언트에 반영
  • 특정 액터 근처에서 커스터마이징 UI 자동 열림
  • 파츠 목록은 DataTable 기반으로 관리
  • 슬롯(부위)별 탭, 슬롯별 파츠 표시, 클릭하여 즉시 변경

아래는 전체 구현 과정을 설명과 코드 중심으로 정리한 내용이다.

  1. 캐릭터 모듈 정의

캐릭터 파츠들을 파악한 후 Enum 형으로 파츠를 정의해준다.

  1. 파츠의 데이터 구조
  2. FastArrayReplication 기반 파츠 리스트

2.1 파츠 데이터 구조 (FCharacterPart)

// CharacterPartType.h

// 파츠 슬롯 정의 (모자, 상의, 하의, 몸통 등)
UENUM(BlueprintType)
enum class ECharacterPartSlot : uint8
{
    None,
    Backpacks, //    가방
    Beards, //    수염
    Body, //    몸 (피부 색)
    Boots, // 신발
    Brows, //    눈썹
    Eyes, // 눈
    Glasses, // 안경
    Gloves, // 장갑
    Hairs, // 머리카락
    Hats,    // 모자
    Pants, // 바지
    Suits,    // 한벌 옷
    Upper_Wear    // 상의
};

/*
 * 단일 파츠 정보 구조체 (어떤 슬롯에 어떤 메시를 쓸지 결정)
 */
USTRUCT(BlueprintType)
struct FCharacterPart
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cosmetic")
    ECharacterPartSlot Slot = ECharacterPartSlot::None;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cosmetic")
    TObjectPtr<USkeletalMesh> Mesh = nullptr;
};

2.2 파츠 리스트 (FFastArraySerializer)

/*
 *    개별 적용된 파츠 배열의 개별 항목
 */
USTRUCT()
struct FAppliedCharacterPartEntry : public FFastArraySerializerItem
{
    GENERATED_BODY()

    // 적용된 파츠의 데이터(부위, 메시) / 네트워크 복제 ㅇ
    UPROPERTY()
    FCharacterPart Part;

    // 런타임에 생성된 스켈레탈 메쉬 컴포넌트 포인터    / 복제 X
    UPROPERTY(NotReplicated)
    TObjectPtr<USkeletalMeshComponent> SpawnedComponent = nullptr;
};

// 캐릭터에 적용된 모든 파츠 목록을 관리하는 구조체 / 네트워크 최적화
USTRUCT()
struct FCharacterPartList : public FFastArraySerializer
{
    GENERATED_BODY()

public:
    FCharacterPartList() : OwnerComponent(nullptr)
    {
    }

    FCharacterPartList(ULNCharacterPartComponent* InOwnerComponent) : OwnerComponent(InOwnerComponent)
    {
    }

    void PreReplicatedRemove(const TArrayView<int32> RemoveIndices, int32 FinalSize);
    void PostReplicatedAdd(const TArrayView<int32> AddIndices, int32 FinalSize);
    void PostReplicatedChange(const TArrayView<int32> ChangeIndices, int32 FinalSize);

    bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
    {
        return FastArrayDeltaSerialize<FAppliedCharacterPartEntry, FCharacterPartList>(Entries, DeltaParms, *this);
    }

    // 스켈레탈 메쉬 컴포넌트 생성 후 부착하는 함수
    void SpawnMeshForEntry(FAppliedCharacterPartEntry& Entry);
    // 스켈레탈 메쉬 컴포넌트 제거하는 함수
    void DestroyMeshForEntry(FAppliedCharacterPartEntry& Entry);

    UPROPERTY()
    TArray<FAppliedCharacterPartEntry> Entries;

    UPROPERTY(NotReplicated)
    TObjectPtr<ULNCharacterPartComponent> OwnerComponent;
};

2.3 TStructOpsTypeTraits (FastArray Serializer 사용 선언)

// 템플릿 특수화
template <>
struct TStructOpsTypeTraits<FCharacterPartList> : public TStructOpsTypeTraitsBase2<FCharacterPartList>
{
    enum { WithNetDeltaSerializer = true };
};

3. 캐릭터 파츠 변경을 담당하는 컴포넌트

컴포넌트 이름: ULNCharacterPartComponent

3.1 파츠 적용 요청 함수 (서버 전용)

// LNCharacterPartComponent.h

#include "LNCharacterPartComponent.h"

#include "GameFramework/Character.h"
#include "Net/UnrealNetwork.h"

ULNCharacterPartComponent::ULNCharacterPartComponent()
    : AppliedCharacterPartList(this)    
{
    SetIsReplicatedByDefault(true);
}

void ULNCharacterPartComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, AppliedCharacterPartList);    //적용된 파츠들 네트워크 복제
}

void ULNCharacterPartComponent::SetCharacterPart(const FCharacterPart& NewPart)
{
    if (!GetOwner() || !GetOwner()->HasAuthority())
        return;

    //  Suits 들어오면 유지할 파츠만 남기기
    if (NewPart.Slot == ECharacterPartSlot::Suits)
    {
        // 유지할 슬롯 목록
        TSet<ECharacterPartSlot> SlotsToKeep =
        {
            ECharacterPartSlot::Body,
            ECharacterPartSlot::Eyes,
            ECharacterPartSlot::Brows,
            ECharacterPartSlot::Suits   // 자기가 들어갈 슬롯 포함
        };

        for (auto It = AppliedCharacterPartList.Entries.CreateIterator(); It; ++It)
        {
            if (!SlotsToKeep.Contains(It->Part.Slot))
            {
                // 제거
                AppliedCharacterPartList.DestroyMeshForEntry(*It);
                It.RemoveCurrent();
                AppliedCharacterPartList.MarkArrayDirty();
            }
        }
    }
    //  여기까지 Suits 특수 로직 

    // 기존 슬롯 제거 및 Suits가 남아있다면 Suits 제거 (중복 슬롯 제거 및 Suits 제거)
    for (auto It = AppliedCharacterPartList.Entries.CreateIterator(); It; ++It)
    {
        if (It->Part.Slot == NewPart.Slot || It->Part.Slot == ECharacterPartSlot::Suits)
        {
            AppliedCharacterPartList.DestroyMeshForEntry(*It);
            It.RemoveCurrent();
            AppliedCharacterPartList.MarkArrayDirty();
            //break;
        }
    }

    if (!IsValid(NewPart.Mesh)) return;

    // 새로운 엔트리 추가
    FAppliedCharacterPartEntry& NewEntry = AppliedCharacterPartList.Entries.AddDefaulted_GetRef();
    NewEntry.Part = NewPart;

    AppliedCharacterPartList.SpawnMeshForEntry(NewEntry);
    AppliedCharacterPartList.MarkItemDirty(NewEntry);   // replication notify
}

USkeletalMeshComponent* ULNCharacterPartComponent::GetParentMeshComponent() const
{
    if (ACharacter* Char = Cast<ACharacter>(GetOwner()))
    {
        return Char->GetMesh();
    }
    return nullptr;
}

3.2 파츠 추가/제거 복제 처리

FastArraySerializer에서는 클라이언트가 직접 파츠를 생성하지 않고,

서버 변경 후 아래 함수들이 클라이언트에서 자동 호출된다.

// CharacterPartType.cpp

#include "LNCharacterPartType.h"
#include "LNCharacterPartComponent.h"
#include "Components/SkeletalMeshComponent.h"

//    서버에서 파츠 제거되었다는 정보 도착시 클라이언트에서 호출
void FCharacterPartList::PreReplicatedRemove(const TArrayView<int32> RemoveIndices, int32 FinalSize)
{
    for (const int32 Index : RemoveIndices)
    {
        FAppliedCharacterPartEntry& Entry = Entries[Index];
        DestroyMeshForEntry(Entry);
    }
}

//    서버에서 새로운 파츠가 추가 되었다는 정보 도착시 클라이언트에서 호출
void FCharacterPartList::PostReplicatedAdd(const TArrayView<int32> AddIndices, int32 FinalSize)
{
    for (const int32 Index : AddIndices)
    {
        FAppliedCharacterPartEntry& Entry = Entries[Index];
        SpawnMeshForEntry(Entry);
    }
}

//    서버에서 기존 파츠의 정보가 변경될 때 클라에서 호출
void FCharacterPartList::PostReplicatedChange(const TArrayView<int32> ChangeIndices, int32 FinalSize)
{
    for (const int32 Index : ChangeIndices)
    {
        FAppliedCharacterPartEntry& Entry = Entries[Index];
        DestroyMeshForEntry(Entry);
        SpawnMeshForEntry(Entry);
    }
}

//    파츠 데이터를 기반으로 실제 스켈레탈 메시 컴포넌트를 생성하고 캐릭터에 부착
void FCharacterPartList::SpawnMeshForEntry(FAppliedCharacterPartEntry& Entry)
{
    if (!IsValid(OwnerComponent) || !IsValid(Entry.Part.Mesh)) return;

    USkeletalMeshComponent* ParentMesh = OwnerComponent->GetParentMeshComponent();
    if (!IsValid(ParentMesh)) return;    

    AActor* OwnerActor = OwnerComponent->GetOwner();

    FName ComponentName = *FString::Printf(TEXT("CosmeticPart_%s"), *UEnum::GetValueAsString(Entry.Part.Slot));
    USkeletalMeshComponent* NewPartComponent = NewObject<USkeletalMeshComponent>(OwnerActor, ComponentName);

    NewPartComponent->SetupAttachment(ParentMesh);

    NewPartComponent->SetSkeletalMesh(Entry.Part.Mesh);

    // 모든 파츠 컴포넌트가 부모(몸통)의 애니를 그대로 따라가도록 설정
    NewPartComponent->SetLeaderPoseComponent(ParentMesh);

    NewPartComponent->RegisterComponent();

    Entry.SpawnedComponent = NewPartComponent;
}

// 파츠 제거될때 생성한 스켈레탈 메쉬를 안전하게 제거
void FCharacterPartList::DestroyMeshForEntry(FAppliedCharacterPartEntry& Entry)
{
    if (IsValid(Entry.SpawnedComponent))
    {
        Entry.SpawnedComponent->DestroyComponent();
        Entry.SpawnedComponent = nullptr;
    }
}

4. 파츠 데이터 관리(DataTable 기반)

파츠는 DataTable을 사용해 부위별로 관리한다.

4.1 DataTable Row 구조 FPartRow

// CharacterPartRow.h

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "LNCharacterPartRow.generated.h"

USTRUCT(BlueprintType)
struct FCharacterPartRow : public FTableRowBase
{
    GENERATED_BODY()

public:

    // UI에 표시할 아이콘
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TObjectPtr<UTexture2D> Icon;

    // 장착될 스케레탈 메쉬
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TObjectPtr<USkeletalMesh> Mesh;

};

4.2 카테고리 구조(FPartCategory)

// CharacterPartCategory.h

#pragma once

#include "CoreMinimal.h"
#include "Character/Components/LNCharacterPartType.h"
#include "LNCharacterPartCategory.generated.h"

USTRUCT(BlueprintType)
struct FCharacterPartCategory
{
    GENERATED_BODY()

public:
    // 어떤 파츠 슬롯인지 선택 ( 상/하의 등 )
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    ECharacterPartSlot Slot = ECharacterPartSlot::None;

    // 해당 파츠의 데이터 테이블
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    TObjectPtr<UDataTable> CharacterPartTable = nullptr;

    // UI 에서 보여줄 이름
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FText DisplayName;
};

5. 커스터마이징 UI 구조

UI는 다음 구성으로 제작하였다.

  • 상단 탭 버튼 (각 부위)
  • ScrollBox 내부 GridPanel (파츠 목록)
  • 파츠 아이템은 동적 생성 (WBP_PartItem)

5.1 탭 클릭 시 파츠 로드 (LoadPartCategory 함수구현)

// CharacterCustomizeWidget.cpp

#include "UI/Widget/LNCharacterCustomizeWidget.h"

#include "Character/LNCharacter.h"
#include "Character/Components/LNCharacterPartComponent.h"
#include "Components/Button.h"
#include "UI/Widget/LNCharacterPartItemWidget.h"
#include "Components/GridPanel.h"
#include "Components/GridSlot.h"
#include "Hash/BuzHash.h"
#include "Player/Controller/LNControllerInGame.h"

void ULNCharacterCustomizeWidget::NativeConstruct()
{
    Super::NativeConstruct();
    Button_Close->OnClicked.AddDynamic(this, &ThisClass::HandleCloseWidget);
}

// WBP에서 CategoryTab 버튼 생성후LoadPartCategory 실행
void ULNCharacterCustomizeWidget::LoadPartCategory(ECharacterPartSlot PartSlot)
{
    // 현재 UI 화면 정리
    GridPanel_Parts->ClearChildren();

    // 파츠 슬롯 저장
    CurrentSlot = PartSlot;

    for (const FCharacterPartCategory& Category : PartCategories)
    {
        if (Category.Slot != PartSlot) continue;

        ECharacterPartSlot CharPartSlot = Category.Slot;

        // 해당 슬롯의 DataTable 가져오기
        UDataTable* DataTable = Category.CharacterPartTable;

        if (!DataTable || !PartItemClass) return;

        TArray<FCharacterPartRow*> Rows;
        DataTable->GetAllRows(TEXT("Load"), Rows);

        int32 Index = 0;

        for (auto* Row : Rows)
        {
            ULNCharacterPartItemWidget* ItemWidget = CreateWidget<
                ULNCharacterPartItemWidget>(GetWorld(), PartItemClass);
            ItemWidget->Init(Row->Icon, CharPartSlot, Row->Mesh);
            // PartItemWidget에 있는 OnclickedPartItem 델리게이트 바인딩
            ItemWidget->OnClickedPartItem.AddDynamic(this, &ThisClass::HandlePartItemClicked);

            int32 RowIndex = Index / GridRowSize;
            int32 ColIndex = Index % GridRowSize;

            GridPanel_Parts->AddChildToGrid(ItemWidget, RowIndex, ColIndex);
            Index++;
        }

        return;
    }
}

void ULNCharacterCustomizeWidget::HandlePartItemClicked(ECharacterPartSlot NewPartSlot, USkeletalMesh* Mesh)
{
    ALNControllerInGame* PC = Cast<ALNControllerInGame>(GetOwningPlayer());
    if (!IsValid(PC)) return;

    // Character컴포넌트의 SetCharacterPart 호출
    PC->Server_RequestEquipPart(NewPartSlot, Mesh);
}

void ULNCharacterCustomizeWidget::HandleCloseWidget()
{
    ALNControllerInGame* PC = Cast<ALNControllerInGame>(GetOwningPlayer());
    if (!IsValid(PC)) return;

    PC->Client_CloseCustomizeUI();
}

6. 클라이언트 → 서버 파츠 변경 요청

UI에서 직접 파츠를 변경하는 것은 안 된다.

서버가 권한(Authority)을 갖고 있으므로 PlayerController를 통한 Server RPC가 필수이다.

6.1 PlayerController RPC

// ControllerInGame.h

// ======= 캐릭터 파츠 요청
UFUNCTION(Server, Reliable)
void Server_RequestEquipPart(FGameplayTag SlotTag, USkeletalMesh* Mesh);

//======= 클라이언트 UI 열고 닫기

    // 커스터마이즈 UI 열기
    UFUNCTION(Client, Reliable, Category="LN|Customize")
    void Client_OpenCustomizeUI();

    // 커스터마이스 UI 닫기
    UFUNCTION(Client, Reliable, Category="LN|Customize")
    void Client_CloseCustomizeUI();

// ControllerInGame.cpp

void AMyPlayerController::Server_RequestEquipPart_Implementation(FGameplayTag SlotTag, USkeletalMesh* Mesh)
{
    APawn* Pawn = GetPawn();
    if (!Pawn) return;

    ULNCharacterPartComponent* Comp = Pawn->FindComponentByClass<ULNCharacterPartComponent>();
    if (!Comp) return;

    FCharacterPart NewPart;
    NewPart.SlotTag = SlotTag;
    NewPart.Mesh = Mesh;

    Comp->SetCharacterPart(NewPart);
}

void ALNControllerInGame::Client_OpenCustomizeUI_Implementation()
{
    if (!IsValid(CustomizeMenuClass)) return;

    if (CustomizeWidget == nullptr)
    {
        CustomizeWidget = CreateWidget(this, CustomizeMenuClass);
    }

    if (CustomizeWidget && !CustomizeWidget->IsInViewport())
    {
        CustomizeWidget->AddToViewport();

        // Cusomize 중 캐릭이동 불가능
        FInputModeUIOnly InputMode;
        InputMode.SetWidgetToFocus(CustomizeWidget->TakeWidget());
        InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
        SetInputMode(InputMode);

        bShowMouseCursor = true;
    }
}

void ALNControllerInGame::Client_CloseCustomizeUI_Implementation()
{
    if (CustomizeWidget && CustomizeWidget->IsInViewport())
    {
        CustomizeWidget->RemoveFromParent();
    }

    FInputModeGameOnly InputMode;
    SetInputMode(InputMode);
    bShowMouseCursor = false;
}

7. 커스터마이징 스테이션 (상호작용 인터페이스)

플레이어가 특정 액터 근처로 접근 후 상호작용을 하면

커스터마이징 메뉴가 열리는 액터를 구현

7.1 커스터마이징 스테이션 액터

// LNCustomizeStation.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interface/LNIInteractable.h"
#include "LNCustomizeStation.generated.h"

class UStaticMeshComponent;
class USphereComponent;

UCLASS()
class OURLONGNIGHT_API ALNCustomizeStation : public AActor, public ILNIInteractable
{
    GENERATED_BODY()

public:    
    ALNCustomizeStation();

    virtual void BeginPlay() override;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
    TObjectPtr<UStaticMeshComponent> StationMesh;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
    TObjectPtr<USphereComponent> TriggerSphere;

    virtual void LeftClickInteract(AActor* Interactor) override;
    virtual void RightClickInteract(AActor* Interactor) override;
};
// LNCustomizeStation.cpp

#include "Character/LNCustomizeStation.h"

#include "LNCharacter.h"
#include "Player/Controller/LNControllerInGame.h"
#include "Components/SphereComponent.h"

ALNCustomizeStation::ALNCustomizeStation()
{
    PrimaryActorTick.bCanEverTick = false;

    StationMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StationMesh"));
    RootComponent = StationMesh;

}

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

}

void ALNCustomizeStation::LeftClickInteract(AActor* Interactor)
{
    ALNCharacter* Character = Cast<ALNCharacter>(Interactor);
    if (!IsValid(Character)) return;

    ALNControllerInGame* PC = Cast<ALNControllerInGame>(Character->GetController());
    if (!IsValid(PC)) return;

    // 커스터마이징 메뉴 생성
    PC->Client_OpenCustomizeUI();
}

void ALNCustomizeStation::RightClickInteract(AActor* Interactor)
{
    // 필요시 구현
}

Interact 인터페이스 구현

해당 기록 보드에서 생성한 Interactible 콜리전 프리셋을 해당 액터에 적용해준다.

9. 동작 흐름 정리

  1. 플레이어가 커스터마이징 스테이션 근처에 접근
  2. PlayerController(Client_OpenCustomizeUI) 호출
  3. UI에서 파츠 선택
  4. Server_RequestEquipPart() 실행
  5. 서버에서 SetCharacterPart() 실행
  6. FastArrayReplication으로 모든 클라이언트에 변경 사항 동기화
  7. SpawnMeshForEntry / DestroyMeshForEntry 가 클라이언트에서 자동으로 호출
  8. 실제 스켈레탈 메시 구성 갱신