← 모든 글

언리얼과 C++이용한 웨이브가 있는 게임 3 - 버프 시스템과 범용 아이템 테이블

가챠 결과를 퀘스트·소모 아이템으로 분리하고, Data Table 기반 즉시·지속 버프와 스탯 변경 델리게이트를 체력 UI에 연결한 과정을 정리했습니다.

Unreal Engine 5 C++Unreal Engine 5DataTableBuff SystemInventoryDelegateUMGStatComponent

가챠 결과를 실제 게임 데이터로 연결하기

지난 글에서는 필드의 물음표 상자를 획득할 때 Data Table의 가중치에 따라 결과를 추첨하고, 점수를 Game Instance에 보관하는 구조를 만들었다.

지난 글: 언리얼과 C++이용한 웨이브가 있는 게임 2 - 아이템 가챠와 점수 저장

이번 작업에서는 가챠 결과를 실제 게임 데이터에 연결했다. 아이템을 퀘스트 아이템과 소모 아이템으로 나누고, 퀘스트 아이템은 인벤토리와 웨이브 목표에 반영한다. 소모 아이템은 별도의 버프 Data Table을 찾아 플레이어 스탯에 즉시 또는 일정 시간 동안 영향을 주도록 구성했다.

체력처럼 화면에 계속 보여야 하는 값은 UStatComponent의 변경 델리게이트와 UPlayerStatWidget을 연결했다. 이제 스탯을 바꾸는 코드가 UI를 직접 찾지 않아도 체력 바와 텍스트가 자동으로 갱신된다.

가챠 결과가 퀘스트 아이템과 소모 아이템으로 나뉘어 처리되는 전체 흐름

이번에 변경한 내용

아이템을 목적에 따라 두 종류로 나누기

2편에서는 코인, 회복, 지뢰처럼 아이템의 구체적인 효과를 EItemType으로 구분했다. 아이템이 늘어나면서 이 방식은 종류를 추가할 때마다 분기문도 함께 커지는 문제가 있었다.

이번에는 구체적인 효과보다 아이템의 사용 목적을 기준으로 Enum을 다시 구성했다.

UENUM(BlueprintType)
enum class EItemType : uint8
{
    QuestItem,
    ConsumeItem
};

QuestItem은 웨이브 목표처럼 인벤토리에 누적되어야 하는 아이템이고, ConsumeItem은 획득 후 버프를 적용하는 아이템이다. 회복 아이템이나 이동 속도 증가 아이템이 추가되더라도 모두 ConsumeItem으로 처리한 뒤 Data Table에서 실제 버프를 결정할 수 있다.

아이템 종류와 실제 효과를 분리했기 때문에 Enum을 계속 늘리는 대신 데이터를 추가하는 방식으로 확장할 수 있다.

공통 정보는 부모 테이블 행에 모으기

퀘스트 아이템과 소모 아이템은 표시 이름과 중첩 가능 여부를 공통으로 사용한다. 이 값은 FItemTableRowBase에 두었다.

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString ItemDisplayName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    bool IsStackable;
};

퀘스트 아이템 행은 현재 공통 정보만 사용한다.

USTRUCT(BlueprintType)
struct FQuestItemTableRow : public FItemTableRowBase
{
    GENERATED_BODY()
};

소모 아이템 행에는 적용할 버프의 종류와 버프 Data Table에서 찾을 행 이름이 추가된다.

USTRUCT(BlueprintType)
struct FConsumeItemTableRow : public FItemTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    EBuffType BuffType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName BuffRowName;
};

예를 들어 SmallHealthPotion이라는 소모 아이템은 자신이 체력을 얼마나 회복하는지 직접 보관하지 않는다. 대신 BuffType을 Instant, BuffRowName을 HealSmall로 지정한다. 실제 회복 대상과 수치는 즉시 버프 Data Table의 HealSmall 행에서 관리한다.

FTableRowBase에서 필드 아이템, 아이템, 버프용 행으로 나뉘는 Data Table 계층 구조

인벤토리에서 아이템 종류와 행 이름을 함께 저장하기

인벤토리 데이터도 정수 키 하나를 사용하던 구조에서 아이템 종류와 FName 키를 함께 저장하는 구조로 바꿨다.

USTRUCT(BlueprintType)
struct FInventoryItemData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FName TableKey;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    EItemType ItemType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int StackCount;
};

같은 행 이름이 서로 다른 Data Table에 존재할 수 있으므로 TryGetItem()은 ItemType과 TableKey를 모두 비교한다.

if (Items[i].TableKey == TableKey
    && Items[i].ItemType == InItemType)
{
    *OutFoundIndex = i;
    return true;
}

아이템을 추가할 때는 종류에 맞는 Data Table에서 행을 찾고 IsStackable을 확인한다. 중첩 가능한 아이템이 이미 있다면 StackCount를 늘리고, 그렇지 않다면 새 항목을 추가한다.

switch (InItemType)
{
case EItemType::ConsumeItem:
    ItemRow = ConsumeItemTable->FindRow<FItemTableRowBase>(
        InTableKey, TEXT("TPS Player"));
    break;

case EItemType::QuestItem:
    ItemRow = QuestItemTable->FindRow<FItemTableRowBase>(
        InTableKey, TEXT("TPS Player"));
    break;
}

가챠 애니메이션이 끝난 뒤 결과 적용하기

플레이어가 물음표 상자와 겹치면 먼저 AFieldItem::Roll()로 결과 행을 선택한다. 선택된 행의 키, 아이템 종류, 수량은 플레이어가 잠시 보관하고 가챠 위젯에 아이콘과 애니메이션을 요청한다.

FFieldItemSpawnRow* FieldItemTableRow = FieldItem->Roll();

CurrentItemKey = FieldItemTableRow->GetTableKey();
CurrentItemType = FieldItemTableRow->GetItemType();
CurrentAmount = FieldItemTableRow->RollAmount();

GatchaWidgetInstance->SetIcon(
    FieldItemTableRow->GetIconTexture());
GatchaWidgetInstance->PlayAnimation(
    AnimationFinishedCallback);

효과를 즉시 적용하지 않고 애니메이션 종료 콜백인 OnGatchaAnimationFinished()에서 처리한다. 덕분에 플레이어는 어떤 결과를 얻었는지 화면으로 확인한 뒤 실제 효과를 받는다.

switch (CurrentItemType)
{
case EItemType::QuestItem:
    Inventory->AddItem(
        CurrentItemType,
        CurrentItemKey,
        CurrentAmount);
    break;

case EItemType::ConsumeItem:
    // ConsumeItemTable에서 버프 정보를 찾고 적용
    break;
}

퀘스트 아이템은 인벤토리에 저장하지만, 소모 아이템은 현재 획득 즉시 버프로 변환한다. 따라서 회복 아이템을 별도의 Actor나 인벤토리 사용 버튼으로 구현하지 않아도 가챠 결과에서 바로 스탯 효과로 연결할 수 있다.

프로그레스 바 애니메이션 종료를 게임 로직과 연결하기

가챠 위젯에서는 결과가 공개되는 시간을 프로그레스 바 애니메이션으로 표현한다. C++ 코드는 매 프레임 프로그레스 바의 값을 직접 검사하지 않고, 위젯 애니메이션이 끝났다는 이벤트를 받아 다음 행동을 실행한다.

전체 순서는 다음과 같다.

물음표 상자와 Overlap
  → 결과 행 추첨
  → 아이템 키·종류·수량 임시 저장
  → 결과 아이콘 설정
  → 가챠 위젯 표시
  → 프로그레스 바 애니메이션 재생
  → 애니메이션 종료 이벤트 발생
  → 플레이어 콜백 실행
  → QuestItem 또는 ConsumeItem 처리
애니메이션 종료 이벤트 등록하기

UGatchaWidget::NativeOnInitialized()에서는 GatchaAnimation이 끝났을 때 호출할 위젯 내부 함수를 등록한다.

void UGatchaWidget::NativeOnInitialized()
{
    Super::NativeOnInitialized();

    checkf(
        IsValid(GatchaAnimation),
        TEXT("GatchaAnimation is not set"));

    FWidgetAnimationDynamicEvent FinishedEvent;
    FinishedEvent.BindDynamic(
        this,
        &UGatchaWidget::HandleAnimationFinshed);

    BindToAnimationFinished(
        GatchaAnimation,
        FinishedEvent);
}

BindToAnimationFinished()를 사용했기 때문에 프로그레스 바의 현재 값을 Tick에서 반복해서 확인할 필요가 없다. Blueprint 위젯에서 만든 애니메이션의 재생이 완료되면 Unreal이 HandleAnimationFinshed()를 호출한다.

플레이어의 후속 행동을 콜백으로 전달하기

플레이어는 애니메이션을 실행하기 전에 OnGatchaAnimationFinished()를 Single-cast Delegate에 연결한다.

UGatchaWidget::FGatchaAnimationFinishedEvent
    AnimationFinishedCallback;

AnimationFinishedCallback.BindDynamic(
    this,
    &ATpsPlayer::OnGatchaAnimationFinished);

GatchaWidgetInstance->PlayAnimation(
    AnimationFinishedCallback);

가챠 위젯은 전달받은 콜백을 멤버 변수에 보관하고 애니메이션을 시작한다.

void UGatchaWidget::PlayAnimation(
    FGatchaAnimationFinishedEvent Callback)
{
    AnimationFinishedCallback = Callback;
    Super::PlayAnimation(GatchaAnimation);
}

애니메이션이 끝나면 위젯 내부 종료 함수가 보관된 콜백을 실행한다.

void UGatchaWidget::HandleAnimationFinshed()
{
    if (AnimationFinishedCallback.IsBound())
    {
        AnimationFinishedCallback.Execute();
    }
}

현재 프로젝트 코드에서는 바로 Execute()를 호출하고 있다. 콜백이 연결되지 않은 상황까지 안전하게 처리하려면 위 예시처럼 IsBound()를 먼저 확인하거나 ExecuteIfBound()를 사용하는 편이 좋다.

애니메이션이 끝난 뒤 실제 결과 적용하기

종료 콜백으로 호출되는 ATpsPlayer::OnGatchaAnimationFinished()는 미리 저장한 CurrentItemType, CurrentItemKey, CurrentAmount를 사용한다.

퀘스트 아이템이면 인벤토리에 추가한다.

case EItemType::QuestItem:
{
    Inventory->AddItem(
        CurrentItemType,
        CurrentItemKey,
        CurrentAmount);
    break;
}

소모 아이템이면 FConsumeItemTableRow에서 버프 종류와 행 이름을 찾는다. 즉시 버프와 지속 버프 중 알맞은 Data Table 행을 런타임 버프로 변환해 Stat Component에 전달한다.

case EItemType::ConsumeItem:
{
    FConsumeItemTableRow* ConsumeItemRow =
        ConsumeItemTable->FindRow<FConsumeItemTableRow>(
            CurrentItemKey,
            TEXT("Tps Player"));

    switch (ConsumeItemRow->BuffType)
    {
    case EBuffType::Instant:
    {
        FInstantBuffTableRow* BuffRow =
            InstantBuffTable->FindRow<FInstantBuffTableRow>(
                ConsumeItemRow->BuffRowName,
                TEXT("TpsPlayer"));

        PlayerStat->AddBuff(BuffRow->ToBuff());
        break;
    }

    case EBuffType::Duration:
    {
        FDurationBuffTableRow* BuffRow =
            DurationBuffTable->FindRow<FDurationBuffTableRow>(
                ConsumeItemRow->BuffRowName,
                TEXT("TpsPlayer"));

        PlayerStat->AddBuff(BuffRow->ToBuff());
        break;
    }
    }
    break;
}

이 흐름의 핵심은 연출이 끝나는 시점과 게임 효과가 적용되는 시점을 하나의 이벤트로 연결한 것이다. 가챠 결과를 추첨하자마자 효과를 적용하면 프로그레스 바가 차는 동안 이미 체력이나 웨이브 진행도가 바뀔 수 있다. 애니메이션 종료 후 처리하면 화면의 결과 공개와 실제 데이터 변경이 같은 시점에 일어난다.

다만 애니메이션 재생 중 다른 상자를 다시 획득하면 CurrentItemKey 등의 임시 값이 덮어써질 수 있다. 이후에는 bIsGatchaPlaying 같은 상태로 중복 획득을 막거나, 추첨 결과 자체를 콜백 데이터로 전달하는 방식으로 보완할 예정이다.

Data Table 행을 실행 가능한 버프로 바꾸기

버프 Data Table의 공통 행에는 대상 스탯, 연산 방법, 값의 자료형과 실제 수치가 들어 있다.

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

    FString Description;
    ECharacterStatType TargetStat;
    EStatOperatorType Operator;
    bool IsIntValue;
    int IntValue;
    float FloatValue;

    virtual FBuff* ToBuff() PURE_VIRTUAL(
        FBuff::ToBuff, return nullptr; );
};

EStatOperatorType은 더하기, 빼기, 곱하기, 나누기를 제공한다. 같은 버프 클래스라도 Data Table의 조합에 따라 체력 회복, 피해, 능력치 배율 증가처럼 서로 다른 효과를 만들 수 있다.

UENUM(BlueprintType)
enum class EStatOperatorType : uint8
{
    Add,
    Subtract,
    Multiply,
    Divide
};

Data Table 행은 설정 데이터일 뿐 직접 실행되지는 않는다. ToBuff()는 행의 데이터를 런타임 객체인 FBuff로 변환한다.

FBuff* FInstantBuffTableRow::ToBuff()
{
    return new FInstantBuff(
        TargetStat,
        Operator,
        IsIntValue,
        IntValue,
        FloatValue);
}

이 구조를 사용하면 에디터에서 조정하기 쉬운 Data Table과 매 프레임 동작해야 하는 C++ 버프 객체의 역할을 분리할 수 있다.

즉시 버프와 지속 버프 구분하기

모든 버프는 FBuff를 상속하고 Tick()과 제거 대기 상태를 공통 인터페이스로 사용한다.

추상 부모 FBuff에서 FInstantBuff와 FDurationBuff가 파생되고 StatComponent가 이를 관리하는 클래스 계층 구조

class FBuff
{
public:
    virtual ~FBuff() = default;
    virtual void Tick(UStatComponent* StatComponent) = 0;

    bool GetPendingRemoval()
    {
        return bPendingRemoval;
    }
};

FInstantBuff는 한 번 Tick될 때 대상 스탯에 연산을 적용한 뒤 제거 대기 상태가 된다. 회복이나 즉시 피해처럼 한 번만 적용할 효과에 사용할 수 있다.

virtual void Tick(UStatComponent* StatComponent) override
{
    if (IsIntValue)
    {
        TickInt(StatComponent);
    }
    else
    {
        TickFloat(StatComponent);
    }

    bPendingRemoval = true;
}

FDurationBuff는 시작 시각과 지속 시간을 저장한다. 매 Tick마다 경과 시간을 확인하고, 지속 시간이 끝나면 제거 대기 상태로 전환한다.

float Now = StatComponent->GetWorld()->GetTimeSeconds();

if (Now - StartTime >= Duration)
{
    bPendingRemoval = true;
}

지속 버프는 원본 스탯 값을 직접 바꾸는 대신 GetBuffedStat()에서 계산 결과에 연산을 더하는 방향으로 설계했다. 이 방식은 버프가 끝났을 때 원래 값을 다시 계산하기 쉽고, 여러 지속 버프를 순서대로 합성할 수 있다는 장점이 있다.

StatComponent에서 버프 수명 관리하기

UStatComponent는 활성화된 FBuff 포인터를 배열에 보관한다.

void UStatComponent::AddBuff(FBuff* Buff)
{
    Buffs.Add(Buff);
}

컴포넌트 Tick에서는 배열의 뒤에서 앞으로 순회한다. 각 버프를 갱신한 뒤 제거 대기 상태라면 메모리를 해제하고 배열에서 제거한다.

for (int i = Buffs.Num() - 1; i >= 0; --i)
{
    Buffs[i]->Tick(this);

    if (Buffs[i]->GetPendingRemoval())
    {
        delete Buffs[i];
        Buffs.RemoveAt(i);
    }
}

앞에서부터 삭제하면 뒤쪽 원소의 인덱스가 당겨져 일부 버프를 건너뛸 수 있다. 역순 순회는 같은 프레임에 여러 버프가 끝나더라도 안전하게 제거하기 위한 선택이다.

Data Table 행이 버프 객체로 변환되고 StatComponent에서 적용·제거되는 흐름

스탯 변경 델리게이트로 UI 갱신하기

UStatComponent는 정수 스탯과 실수 스탯을 각각 TMap으로 관리한다. 값을 설정할 때는 저장한 뒤 변경 델리게이트를 호출한다.

void UStatComponent::SetOrInsert(
    ECharacterStatType InStatType,
    int InValue)
{
    IntStats[InStatType] = InValue;
    OnIntStatChangedCallbacks.Broadcast(
        InStatType,
        InValue);
}

UPlayerStatWidget은 플레이어의 Stat Component를 전달받고 정수 스탯 변경 델리게이트에 자신의 함수를 연결한다.

TargetStatComponent
    ->GetIntCallbacks()
    ->AddUObject(
        this,
        &UPlayerStatWidget::OnStatChanged);

변경된 스탯이 Health 또는 MaxHealth라면 체력 UI를 다시 그린다.

void UPlayerStatWidget::OnStatChanged(
    ECharacterStatType InStatType,
    int Value)
{
    switch (InStatType)
    {
    case ECharacterStatType::Health:
    case ECharacterStatType::MaxHealth:
        RefreshHealthUi();
        break;
    }
}

RefreshHealthUi()는 현재 체력과 최대 체력을 읽어 프로그레스 바의 비율과 텍스트를 함께 갱신한다.

float Percent =
    static_cast<float>(CurrentHealth) /
    static_cast<float>(MaxHealth);

HealthProgressBar->SetPercent(Percent);

스탯 시스템은 UI 컴포넌트를 직접 알지 않고 값이 바뀌었다는 사실만 알린다. 위젯은 자신에게 필요한 스탯 변화만 골라 화면에 반영하므로, 공격력이나 이동 속도 UI를 추가할 때도 같은 델리게이트를 재사용할 수 있다.

퀘스트 아이템 수량으로 웨이브 완료하기

웨이브 완료 조건도 필드 아이템을 몇 번 주웠는지가 아니라 인벤토리에 저장된 퀘스트 아이템의 누적 수량을 기준으로 바뀌었다.

UInventoryComponent::AddItem()은 아이템 추가가 끝나면 Game Mode에 최종 인벤토리 데이터를 전달한다.

AIngameGameMode* IngameGameMode =
    Cast<AIngameGameMode>(GetWorld()->GetAuthGameMode());

IngameGameMode->OnItemAdded(*AddedInventoryItemData);

Game Mode는 퀘스트 아이템만 웨이브 진행도에 반영한다.

if (AddedItem.ItemType == EItemType::QuestItem)
{
    IngameState->SetCurrentCoinAmount(
        AddedItem.StackCount);

    if (IngameState->GetCurrentCoinCount()
        >= IngameState->GetTargetCoinCount())
    {
        SetNextWave();
    }
}

회복이나 능력치 버프를 얻었다고 웨이브가 진행되지 않고, 목표로 지정된 퀘스트 아이템을 충분히 모았을 때만 다음 웨이브로 넘어간다. 가챠 결과의 종류가 실제 진행 조건에 영향을 주게 된 것이다.

오늘 깨우친 내용

이번 작업을 진행하면서 Unreal C++의 Delegate, Data Table, Pure Virtual이 각각 어떤 문제를 해결하는지 이해할 수 있었다.

1. 언리얼에서의 Delegate

Delegate는 특정 객체의 함수를 직접 호출하는 대신, 나중에 실행할 함수를 등록해 두고 필요한 시점에 호출하는 방식이다. 호출하는 쪽은 결과를 받을 객체의 구체적인 타입을 몰라도 되므로 객체 사이의 결합을 줄일 수 있다.

Single-cast Delegate

Single-cast Delegate에는 하나의 함수만 연결할 수 있다. 이번 프로젝트에서는 가챠 애니메이션이 끝난 뒤 플레이어에게 결과 적용을 요청하는 콜백에 사용했다.

DECLARE_DYNAMIC_DELEGATE(FGatchaAnimationFinishedEvent);

플레이어는 자신의 함수를 콜백으로 등록한다.

UGatchaWidget::FGatchaAnimationFinishedEvent Callback;
Callback.BindDynamic(
    this,
    &ATpsPlayer::OnGatchaAnimationFinished);

GatchaWidgetInstance->PlayAnimation(Callback);

가챠 위젯은 애니메이션 종료 시 전달받은 Delegate를 실행한다.

void UGatchaWidget::HandleAnimationFinshed()
{
    AnimationFinishedCallback.Execute();
}

애니메이션이 끝난 뒤 실행할 대상은 하나이므로 Single-cast Delegate가 잘 맞는다. 위젯은 플레이어 클래스를 직접 참조하지 않고 전달받은 콜백만 실행한다.

Multicast Delegate

Multicast Delegate에는 여러 함수를 등록할 수 있다. 하나의 상태 변경을 여러 객체에 알려야 할 때 적합하다.

DECLARE_MULTICAST_DELEGATE_TwoParams(
    FOnIntStatChangedEvent,
    ECharacterStatType,
    int);

UStatComponent는 값을 변경한 뒤 모든 구독자에게 알린다.

IntStats[InStatType] = InValue;
OnIntStatChangedCallbacks.Broadcast(
    InStatType,
    InValue);

UPlayerStatWidget은 이 Delegate에 자신의 함수를 등록한다.

TargetStatComponent
    ->GetIntCallbacks()
    ->AddUObject(
        this,
        &UPlayerStatWidget::OnStatChanged);

현재 구독자는 체력 위젯이지만, 이후 사운드나 피격 효과가 같은 스탯 변화에 반응하더라도 UStatComponent를 수정할 필요가 없다.

정리하면 Single-cast Delegate는 하나의 후속 동작을 전달할 때, Multicast Delegate는 하나의 사건을 여러 구독자에게 알릴 때 적합하다.

Data Table

Data Table은 C++ 구조체를 행의 형식으로 사용해 게임 데이터를 에디터에서 관리하게 해준다. 코드에 수치를 직접 작성하지 않고 아이템 이름, 중첩 여부, 드롭 확률, 버프 대상과 지속 시간 등을 수정할 수 있다.

Data Table에 사용할 구조체는 FTableRowBase를 상속한다.

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    FString ItemDisplayName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    bool IsStackable;
};

필요한 행은 이름으로 찾는다.

FConsumeItemTableRow* ConsumeItemRow =
    ConsumeItemTable->FindRow<FConsumeItemTableRow>(
        CurrentItemKey,
        TEXT("Tps Player"));

이번 프로젝트에서는 Data Table을 다음과 같이 나눴다.

Data Table을 사용하면서 게임의 동작은 C++에 두고, 자주 조절할 값은 데이터로 분리한다는 의미를 이해하게 됐다. 새 회복 아이템을 추가할 때 Actor 클래스를 새로 만드는 대신 아이템 행과 버프 행을 조합할 수 있다.

Pure Virtual

순수 가상 함수는 부모 클래스가 공통 인터페이스만 정하고, 실제 동작은 자식 클래스가 구현하도록 만드는 기능이다. 일반 C++ 클래스와 Unreal 리플렉션에 참여하는 클래스에서는 선언 방법과 동작에 차이가 있다.

일반 C++ 클래스에서 만드는 방법

일반 C++ 클래스에서는 가상 함수 선언 뒤에 = 0을 붙인다.

class FBuff
{
public:
    virtual ~FBuff() = default;

    virtual void Tick(
        UStatComponent* StatComponent) = 0;
};

순수 가상 함수가 하나라도 있는 클래스는 추상 클래스가 되므로 직접 객체를 만들 수 없다.

// 컴파일 오류: FBuff는 추상 클래스다.
FBuff Buff;

자식 클래스는 부모의 순수 가상 함수를 같은 시그니처로 재정의해야 한다. override를 붙이면 함수 이름이나 매개변수를 잘못 작성했을 때 컴파일러가 오류를 알려준다.

class FInstantBuff : public FBuff
{
public:
    virtual void Tick(
        UStatComponent* StatComponent) override
    {
        // 스탯에 효과를 한 번 적용한다.
        bPendingRemoval = true;
    }

private:
    bool bPendingRemoval = false;
};

FDurationBuff도 같은 Tick()을 구현하지만, 현재 시간과 종료 시간을 비교하는 자신만의 동작을 넣을 수 있다.

class FDurationBuff : public FBuff
{
public:
    virtual void Tick(
        UStatComponent* StatComponent) override
    {
        const float Now =
            StatComponent->GetWorld()->GetTimeSeconds();

        if (Now - StartTime >= Duration)
        {
            bPendingRemoval = true;
        }
    }
};

호출하는 쪽은 실제 객체가 즉시 버프인지 지속 버프인지 몰라도 FBuff*를 통해 같은 방법으로 갱신할 수 있다.

for (FBuff* Buff : Buffs)
{
    Buff->Tick(this);
}

이것이 일반 C++의 순수 가상 함수가 제공하는 다형성이다. 자식 클래스가 구현하지 않으면 그 자식도 추상 클래스로 남기 때문에 구현 누락을 컴파일 단계에서 확인할 수 있다.

Unreal 클래스에서 만드는 방법

UCLASS나 USTRUCT처럼 Unreal Header Tool의 리플렉션에 참여하는 타입에서는 엔진의 객체 생성과 리플렉션 방식을 고려해야 한다. 프로젝트에서는 PURE_VIRTUAL 매크로로 반드시 재정의해야 할 함수를 표현했다.

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

    virtual FBuff* ToBuff()
        PURE_VIRTUAL(
            FBuffTableRowBase::ToBuff,
            return nullptr;);
};

PURE_VIRTUAL의 첫 번째 인자에는 클래스명::함수명을 적는다. 두 번째 인자에는 잘못 호출됐을 때 함수의 반환 형식에 맞는 반환문을 작성한다.

반환값이 없는 함수라면 다음과 같이 작성할 수 있다.

virtual void Apply()
    PURE_VIRTUAL(
        UBaseItemEffect::Apply,
        return;);

PURE_VIRTUAL은 일반 C++의 = 0과 완전히 같지는 않다. 매크로가 들어간 부모 구현이 직접 호출되면 오류를 발생시키는 형태이며, 일반 C++처럼 해당 타입을 컴파일 단계에서 완전한 추상 클래스로 만드는 것은 아니다. 따라서 자식 클래스에서 override를 사용해 구현 여부를 명확히 확인하는 것이 중요하다.

USTRUCT(BlueprintType)
struct FInstantBuffTableRow
    : public FBuffTableRowBase
{
    GENERATED_BODY()

    virtual FBuff* ToBuff() override;
};

자식 행의 구현에서는 자신의 데이터에 맞는 런타임 버프 객체를 생성한다.

FBuff* FInstantBuffTableRow::ToBuff()
{
    return new FInstantBuff(
        TargetStat,
        Operator,
        IsIntValue,
        IntValue,
        FloatValue);
}

UCLASS 자체가 직접 생성되면 안 된다는 뜻도 함께 표현하려면 클래스 지정자에 Abstract를 붙인다.

UCLASS(Abstract)
class UBaseItemEffect : public UObject
{
    GENERATED_BODY()

public:
    virtual void Apply()
        PURE_VIRTUAL(
            UBaseItemEffect::Apply,
            return;);
};

Abstract는 부모 UCLASS를 직접 생성하거나 선택하지 못하게 의도를 나타내고, PURE_VIRTUAL은 해당 C++ 함수에 부모 기본 동작이 없음을 나타낸다.

Blueprint에서 구현하게 만들고 싶다면 PURE_VIRTUAL 대신 BlueprintNativeEvent 또는 BlueprintImplementableEvent를 사용하는 것이 더 적합하다.

UFUNCTION(BlueprintNativeEvent)
void ApplyEffect();

virtual void ApplyEffect_Implementation();

정리하면 다음과 같이 구분할 수 있다.

이번 프로젝트의 FBuff는 일반 C++ 클래스이므로 = 0을 사용하고, Data Table 행인 FBuffTableRowBase는 Unreal 리플렉션 구조체이므로 PURE_VIRTUAL을 사용했다. 이를 통해 모든 버프 행이 ToBuff()라는 같은 호출 방법을 제공하면서도 즉시 버프와 지속 버프가 자신에게 맞는 런타임 객체를 만들도록 구성했다.

정리

이번 작업에서는 2편에서 만든 가챠 결과를 인벤토리, 버프, 스탯 UI, 웨이브 진행 조건에 연결했다.

아이템은 퀘스트 아이템과 소모 아이템으로 역할을 나누고, 공통 데이터는 부모 테이블 행에 모았다. 소모 아이템은 버프 행을 참조하며, 버프 Data Table은 ToBuff()를 통해 실행 가능한 런타임 객체로 변환된다. Stat Component는 버프의 수명을 관리하고 스탯 변경 델리게이트를 호출하며, 플레이어 스탯 위젯은 그 알림을 받아 체력 UI를 갱신한다.

아직 지속 버프 계산과 객체 소유권, 마지막 웨이브 전환을 보완해야 하지만, 아이템을 추가할 때 Actor와 분기문을 계속 늘리는 대신 Data Table의 행과 버프 조합으로 확장할 수 있는 기반을 만들었다.

프로젝트 저장소

NBC_CH3_3 GitHub 저장소