[25.10.28] :: 가디언 앤 시커 프로젝트 25

chooha·2025년 10월 27일

가디언앤시커

목록 보기
25/25

📝 개발일지 - 매뉴얼 UI 시스템 구현 (인터페이스 기반 데이터 접근)

👨💻 오늘의 개발 작업

오늘은 게임 내 매뉴얼(설명서) UI 시스템을 구축했음
플레이 도중 Tab키를 누르면 QuickManual이 나오며, 현재 캐릭터의 스킬 조작법을 볼 수 있는 기능임

RTS와 TPS 두 가지 컨트롤러 모드가 있고, TPS 모드에서는 4개의 서로 다른 캐릭터(Seeker 3개 + Guardian 1개)가 있는 상황에서, 각 모드와 캐릭터마다 다른 매뉴얼 이미지를 보여줘야 했음

처음에는 if문으로 모든 케이스를 처리하려 했지만, 인터페이스를 도입하여 확장 가능하고 유지보수가 쉬운 구조로 개선했음


💡 오늘의 5분 기록

1. 데이터 테이블 구조 설계 및 이해

매뉴얼 데이터 구조체 정의

USTRUCT(BlueprintType)
struct FManualImageRow : public FTableRowBase
{
    GENERATED_BODY()
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Manual")
    uint8 PageIndex;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Manual")
    FText Title;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Manual")
    TSoftObjectPtr<UTexture2D> ManualImage;
};

데이터 테이블 구조 이해

RowName(키)    | PageIndex | Title      | ManualImage
---------------|-----------|------------|-------------
"Guardian"     | 1         | "가디언"    | Texture...
"Ares"         | 2         | "아레스"    | Texture...
"Chan"         | 3         | "찬"       | Texture...
"Merci"        | 4         | "머시"      | Texture...

데이터 접근 방법

// RowName으로 검색 (O(1), 빠름)
FManualImageRow* RowData = ManualImageTable->FindRow<FManualImageRow>(
    FName("Guardian"), 
    TEXT("InitImage")
);

if (RowData)
{
    UTexture2D* LoadedTexture = RowData->ManualImage.LoadSynchronous();
}

2. 초기 접근: if문 분기 처리의 한계

문제점 발견

// 각 컨트롤러/캐릭터마다 if문 필요
APlayerController* PC = GetOwningPlayer();
FName RowName;

if (ARTSController* RTSCtrl = Cast<ARTSController>(PC))
{
    RowName = FName("Guardian");
}
else if (ATPSController* TPSCtrl = Cast<ATPSController>(PC))
{
    APawn* Pawn = TPSCtrl->GetPawn();
    
    if (AAres* Ares = Cast<AAres>(Pawn))
        RowName = FName("Ares");
    else if (AChan* Chan = Cast<AChan>(Pawn))
        RowName = FName("Chan");
    else if (AMerci* Merci = Cast<AMerci>(Pawn))
        RowName = FName("Merci");
    else if (AGuardian* Guardian = Cast<ADrakhar>(Pawn))
        RowName = FName("Drakhar");
}

핵심 문제

  • 새 캐릭터 추가 시 UI 코드 수정 필요
  • 중복 코드 발생
  • 유지보수성 저하

3. 설계 개선: 부모 클래스에 데이터 저장

캐릭터 상속 구조 파악

TPS Controller
├── Seeker (부모)
│   ├── Ares
│   ├── Chan
│   └── Merci
└── Guardian (부모)

부모 클래스에 RowName 저장

// Seeker.h
class ASeeker : public ACharacter
{
    GENERATED_BODY()
    
protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Manual")
    FName ManualRowName;
    
public:
    FName GetManualRowName() const { return ManualRowName; }
};

// 자식 클래스 생성자에서 설정
AAres::AAres()
{
    ManualRowName = FName("Ares");
}

AChan::AChan()
{
    ManualRowName = FName("Chan");
}

Guardian도 동일하게 구현

4. 최종 해결책: 인터페이스 도입

인터페이스 정의

// IManualDataInterface.h
UINTERFACE(MinimalAPI, Blueprintable)
class UGS_ManualDataInterface : public UInterface
{
    GENERATED_BODY()
};

class IGS_ManualDataInterface
{
    GENERATED_BODY()

public:
    UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Manual")
    FName GetManualRowName() const;
};

Seeker 클래스에 인터페이스 구현

// Seeker.h
#include "IGS_ManualDataInterface.h"  // 헤더 include 필수

class ASeeker : public ACharacter, public IGS_ManualDataInterface
{
    GENERATED_BODY()
    
protected:
    UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Manual")
    FName ManualRowName;

public:
    // 인터페이스 함수 구현
    virtual FName GetManualRowName_Implementation() const override
    {
        return ManualRowName;
    }
};

Guardian 클래스도 동일하게 구현

5. UI 로직 대폭 간소화

통합된 단순 처리

void UGS_QuickManualUI::InitImage()
{
    FName RowName;
    APlayerController* PC = GetOwningPlayer();
    
    // RTS 시점일 경우
    AGS_RTSController* RTSCtrl = Cast<AGS_RTSController>(PC);
    if (RTSCtrl)
    {
        RowName = FName("Guardian");
    }
    
    // TPS 시점일 경우 - 단 하나의 if문으로 처리!
    APawn* Pawn = PC->GetPawn();
    if (Pawn && Pawn->Implements<UGS_ManualDataInterface>())  // U 접두사 사용
    {
        RowName = IGS_ManualDataInterface::Execute_GetManualRowName(Pawn);  // I 접두사 사용
    }
    
    // 데이터 테이블에서 이미지 로드
    if (!RowName.IsNone())
    {
        FManualImageRow* RowData = ManualImageTable->FindRow<FManualImageRow>(
            RowName, 
            TEXT("InitImage")
        );
        
        if (RowData)
        {
            UTexture2D* LoadedTexture = RowData->ManualImage.LoadSynchronous();
            if (LoadedTexture)
            {
                KeyManualImage->SetBrushFromTexture(LoadedTexture);
            }
        }
    }
}

6. 트러블슈팅 - 인터페이스 사용 시 컴파일 에러 ⚠️

문제 발생

error: 'IGS_ManualDataInterface': no member 'StaticClass'
note: see reference to function template instantiation 
'bool UObject::Implements<IGS_ManualDataInterface>(void) const'

원인 분석
언리얼 인터페이스는 두 개의 클래스가 생성됨:

  • U 접두사 (UGS_ManualDataInterface): 리플렉션 시스템용, UINTERFACE
  • I 접두사 (IGS_ManualDataInterface): 실제 인터페이스, 함수 정의

해결 방법

// 잘못된 사용
if (Pawn->Implements<IGS_ManualDataInterface>())  // ❌ I 접두사 사용 불가

// 올바른 사용
if (Pawn->Implements<UGS_ManualDataInterface>())  // ✅ U 접두사 사용
{
    // 함수 호출 시에는 I 접두사 사용
    FName Name = IGS_ManualDataInterface::Execute_GetManualRowName(Pawn);
}

규칙 정리
1. Implements<> 체크: U 접두사 사용
2. Execute_ 함수 호출: I 접두사 사용
3. 상속 시: I 접두사 사용 (public IGS_ManualDataInterface)
4. 헤더 include: 상속 시 필수, 사용만 할 때는 cpp에서 include 가능

7. 성과와 개선 효과

코드 간소화

// 기존: 모든 캐릭터마다 if문 (20+ 줄)
if (AAres* Ares = Cast<AAres>(Pawn))
    RowName = FName("Ares");
else if (AChan* Chan = Cast<AChan>(Pawn))
    RowName = FName("Chan");
else if (AMerci* Merci = Cast<AMerci>(Pawn))
    RowName = FName("Merci");
else if (AGuardian* Guardian = Cast<AGuardian>(Pawn))
    RowName = FName("Guardian");

// 새로운: 단 하나의 if문 (3줄)
if (Pawn && Pawn->Implements<UGS_ManualDataInterface>())
{
    RowName = IGS_ManualDataInterface::Execute_GetManualRowName(Pawn);
}

개발자 경험 개선

  • 확장성: 새 캐릭터 추가 시 UI 코드 수정 불필요
  • 유지보수성: 각 캐릭터가 자신의 데이터를 관리
  • 일관성: 모든 캐릭터를 동일한 방식으로 처리
  • 타입 안전성: 인터페이스로 계약 보장

배운 교훈

  • 데이터 테이블의 RowName은 고유 식별자로 FindRow()의 키 역할
  • 인터페이스는 다형성을 제공하여 if문 분기를 제거
  • 언리얼 인터페이스의 U/I 접두사 규칙 이해 필수

0개의 댓글