오늘은 게임 내 매뉴얼(설명서) UI 시스템을 구축했음
플레이 도중 Tab키를 누르면 QuickManual이 나오며, 현재 캐릭터의 스킬 조작법을 볼 수 있는 기능임
RTS와 TPS 두 가지 컨트롤러 모드가 있고, TPS 모드에서는 4개의 서로 다른 캐릭터(Seeker 3개 + Guardian 1개)가 있는 상황에서, 각 모드와 캐릭터마다 다른 매뉴얼 이미지를 보여줘야 했음
처음에는 if문으로 모든 케이스를 처리하려 했지만, 인터페이스를 도입하여 확장 가능하고 유지보수가 쉬운 구조로 개선했음
매뉴얼 데이터 구조체 정의
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();
}
문제점 발견
// 각 컨트롤러/캐릭터마다 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");
}
핵심 문제
캐릭터 상속 구조 파악
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도 동일하게 구현
인터페이스 정의
// 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 클래스도 동일하게 구현
통합된 단순 처리
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);
}
}
}
}
문제 발생
error: 'IGS_ManualDataInterface': no member 'StaticClass'
note: see reference to function template instantiation
'bool UObject::Implements<IGS_ManualDataInterface>(void) const'
원인 분석
언리얼 인터페이스는 두 개의 클래스가 생성됨:
해결 방법
// 잘못된 사용
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 가능
코드 간소화
// 기존: 모든 캐릭터마다 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);
}
개발자 경험 개선
배운 교훈