← 모든 글

언리얼과 C++이용한 웨이브가 있는 게임 4 - 버프 팩토리와 스테이지 완성

버프 팩토리로 즉시·지속 버프 생성을 통합하고, 이동 속도 버프와 남은 시간 UI, 체력 제한과 사망 처리, 세 개 스테이지와 승리 화면을 연결한 과정을 정리했습니다.

Unreal Engine 5 C++Unreal Engine 5Factory PatternBuff SystemDelegateUMGGameInstanceLevel TransitionLineTrace

설계해 둔 버프를 실제 플레이 흐름으로 완성하기

지난 글에서는 아이템을 퀘스트 아이템과 소모 아이템으로 나누고, Data Table 행을 즉시 버프와 지속 버프 객체로 변환하는 구조를 만들었다.

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

하지만 당시 지속 버프는 배열에 등록되고 시간이 지나면 제거될 뿐 실제 스탯 조회에는 반영되지 않았다. 버프 시작 시각도 Data Table 값에 의존했고, 남은 지속 시간을 확인할 UI도 없었다.

이번 작업에서는 이 부분을 실제 플레이에 연결했다. 버프 팩토리가 Data Table을 런타임 객체로 만들고, Stat Component가 활성 버프를 스탯 계산에 합성한다. 이동 속도 버프와 남은 시간 UI를 추가했으며, 스테이지 1~3과 승리·실패 화면까지 연결했다.

소모 아이템 획득부터 버프 생성, 스탯 적용, UI 표시와 제거까지의 흐름

이번에 변경한 내용

버프 생성을 Factory로 모으기

3편에서는 버프 Data Table 행이 직접 런타임 객체를 만들었다. 이번에는 생성 책임을 FBuffFactory로 옮겼다.

class FBuffFactory
{
public:
    static FBuff* CreateBuff(
        UWorld* InWorld,
        EBuffType BuffType,
        const FBuffTableRowBase* BuffData);
};

플레이어는 소모 아이템이 가리키는 행을 찾은 뒤 Factory에 전달한다.

NewBuff = FBuffFactory::CreateBuff(
    GetWorld(),
    ConsumeItemRow->BuffType,
    BuffTableRow);

PlayerStat->AddBuff(NewBuff);

Factory는 BuffType에 맞게 공통 행 포인터를 실제 행 타입으로 변환한다.

case EBuffType::Duration:
{
    const FDurationBuffTableRow* Row =
        static_cast<const FDurationBuffTableRow*>(BuffData);

    return new FDurationBuff(
        Row->TargetStat,
        Row->Operator,
        Row->IsIntValue,
        Row->IntValue,
        Row->FloatValue,
        Row->Duration,
        InWorld->TimeSeconds,
        Row->IconTexture);
}

Data Table 행은 설정값을 담고, Factory는 어떤 런타임 클래스를 만들지 결정하며, Stat Component는 완성된 버프를 관리한다.

지속 버프의 시작 시각을 런타임에 정하기

Data Table은 여러 객체가 공유하는 고정 설정이므로 플레이어가 버프를 얻은 실제 시각을 저장하기에 적합하지 않다. 현재는 Factory가 버프를 생성할 때 InWorld->TimeSeconds를 전달한다.

같은 버프를 여러 번 얻어도 각 객체가 서로 다른 시작 시각을 가지며, Data Table에는 지속 시간과 효과처럼 변하지 않는 설정만 남는다.

활성 버프를 실제 스탯 계산에 합성하기

UStatComponent::GetInt()와 GetFloat()은 원본 값을 읽은 뒤 활성 버프를 순회한다.

int BuffedStat = *IntStats.Find(InStatType);

for (FBuff* Buff : Buffs)
{
    Buff->GetBuffedStat(
        InStatType,
        &BuffedStat);
}

return BuffedStat;

원본 값은 IntStats와 FloatStats에 그대로 두고 반환할 값을 만들 때만 버프를 적용한다. 지속 시간이 끝나 목록에서 제거되면 다음 조회부터 자연스럽게 원본 값으로 돌아간다.

예를 들어 이동 속도 원본이 500이고 Multiply 1.5 버프가 활성화되어 있으면 Getter는 750을 반환한다. 버프가 제거되면 별도의 복구 연산 없이 다시 500이 된다.

이동 속도를 스탯으로 관리하기

ECharacterStatType에 MovingSpeed를 추가했다.

enum class ECharacterStatType : uint8
{
    Health,
    MaxHealth,
    MovingSpeed
};

플레이어 Tick은 버프가 반영된 이동 속도를 Character Movement에 적용한다.

float BuffedMovingSpeed =
    PlayerStat->GetFloat(
        ECharacterStatType::MovingSpeed);

GetCharacterMovement()->MaxWalkSpeed =
    BuffedMovingSpeed;

Golden Boot를 얻으면 이동 속도 지속 버프가 적용되고, 시간이 끝나면 기본 속도로 돌아온다. 현재는 매 Tick마다 값을 대입하므로 이후에는 버프 추가·제거 이벤트가 발생했을 때만 갱신하도록 개선할 수 있다.

버프 추가와 제거를 Delegate로 알리기

Stat Component에 버프 추가·제거 Multicast Delegate를 만들었다.

DECLARE_MULTICAST_DELEGATE_OneParam(
    FOnBuffAddedEvent,
    FBuff*);

DECLARE_MULTICAST_DELEGATE_OneParam(
    FOnBuffRemovedEvent,
    FBuff*);

버프를 추가한 뒤에는 UI에 알린다.

Buffs.Add(Buff);
BuffAddedCallbacks.Broadcast(Buff);

버프가 끝나면 배열에서 제거하고, 객체를 삭제하기 전에 제거 이벤트를 보낸다.

FBuff* OldBuff = Buffs[i];
Buffs.RemoveAt(i);
BuffRemovedCallbacks.Broadcast(OldBuff);
delete OldBuff;

삭제 전에 알리는 이유는 UI가 어떤 버프 표시를 제거해야 하는지 포인터를 비교할 시간이 필요하기 때문이다.

지속 버프의 남은 시간을 UI로 표시하기

UPlayerStatWidget은 버프 Delegate를 구독한다. 지속 버프가 추가되면 UBuffProgressBar를 생성해 Scroll Box에 넣는다.

FDurationBuff* DurationBuff =
    static_cast<FDurationBuff*>(NewBuff);

UBuffProgressBar* ProgressBar =
    CreateWidget<UBuffProgressBar>(
        this,
        BuffProgressBarClass);

ProgressBar->BindBuff(DurationBuff);
BuffScrollBox->AddChild(ProgressBar);

프로그레스 바는 시작 시각과 지속 시간을 읽어 남은 비율을 계산한다.

float Elapsed =
    GetWorld()->TimeSeconds
    - DurationBuff->GetStartTime();

float Percent =
    1.0f - Elapsed
    / DurationBuff->GetDuration();

RemainTimeProgressBar->SetPercent(Percent);

버프가 제거되면 같은 버프를 참조하는 프로그레스 바를 찾아 배열과 부모 위젯에서 제거한다.

지속 버프가 추가될 때 프로그레스 바를 만들고 시간이 끝나면 함께 제거하는 UI 생명주기

체력 Clamp와 사망 처리

체력은 최대 체력을 넘거나 0보다 작아질 수 없다. Stat Component는 값을 저장한 뒤 Single-cast Delegate로 플레이어에게 Clamp를 요청한다.

IntStats[InStatType] = InValue;

if (IntStatClampCallback.IsBound())
{
    IntStatClampCallback.Execute(InStatType);
}

OnIntStatChangedCallbacks.Broadcast(
    InStatType,
    InValue);

플레이어는 체력을 0과 최대 체력 사이로 제한하고, 반복 알림을 피하려고 SetOrInsertWithoutNotify()로 보정값을 저장한다.

CurrentHealth = FMath::Clamp(
    CurrentHealth,
    0,
    MaxHealth);

PlayerStat->SetOrInsertWithoutNotify(
    ECharacterStatType::Health,
    CurrentHealth);

체력이 0이 되면 OnPlayerDead()를 통해 실패 레벨로 이동한다. 시간 초과도 같은 LoadFailedLevel()을 호출하므로 실패 화면 전환 책임이 Game Mode에 모였다.

웨이브와 세 개 스테이지 연결하기

퀘스트 아이템이 목표 수량에 도달하면 현재 웨이브가 마지막인지 확인한다.

if (IngameState->GetTotalWaveCount()
    == CurrentWaveIndex + 1)
{
    UGameplayStatics::OpenLevelBySoftObjectPtr(
        GetWorld(),
        NextStage);
}
else
{
    SetNextWave();
}

마지막 웨이브가 아니면 같은 스테이지에서 다음 웨이브를 시작하고, 마지막이면 Game Mode Blueprint에 지정한 다음 스테이지를 연다.

Menu → Stage 1 → Stage 2 → Stage 3 → Win

각 스테이지
  ├─ 모든 웨이브 완료 → 다음 스테이지
  ├─ 제한 시간 종료 → Failed
  └─ 플레이어 체력 0 → Failed

메뉴에서 세 개 스테이지를 거쳐 승리 화면으로 이동하고 실패 조건에서 Failed로 분기되는 레벨 흐름

웨이브가 바뀔 때 인벤토리 초기화하기

플레이어는 OnWaveIndexChanged를 구독하고 웨이브 번호가 바뀌면 인벤토리를 비운다.

void ATpsPlayer::OnWaveIndexChanged(
    int CurrentWaveIndex,
    int TotalWaveCount)
{
    Inventory->Clear();
}

이전 웨이브의 퀘스트 아이템이 남아 다음 웨이브가 즉시 완료되는 문제를 막는다. 장비나 일반 소모품까지 보관하게 되면 전체 인벤토리 대신 해당 퀘스트 아이템만 제거해야 한다.

Game Instance에 전체 플레이 시간 누적하기

Game Mode Tick은 매 프레임 DeltaTime을 Game Instance에 더한다.

GetGameInstance<UIngameGameInstance>()
    ->AddSeconds(DeltaTime);

Game Instance는 레벨이 바뀌어도 유지되므로 Stage 1부터 승리 화면까지의 시간을 누적할 수 있다.

현재 멤버 이름은 Score지만 실제 의미는 경과 시간이다. ElapsedSeconds처럼 의미가 드러나는 이름으로 바꾸고 승리 화면에는 분·초 형식으로 표시할 예정이다.

지형 표면에 아이템 생성하기

기존 Spawn Volume은 박스 내부의 임의 3차원 좌표를 사용해 아이템이 공중이나 지형 아래에 생길 수 있었다. 현재는 임의 위치 위에서 아래로 Line Trace를 실행한다.

FVector Start =
    RandomWorldPosition
    + FVector::UpVector * 100.0f;

FVector End =
    RandomWorldPosition
    - FVector::UpVector * 1000.0f;

if (GetWorld()->LineTraceSingleByChannel(
    HitResult,
    Start,
    End,
    ECC_WorldStatic))
{
    return HitResult.ImpactPoint;
}

충돌 지점에 메시 높이의 절반을 더해 아이템이 땅에 묻히지 않도록 보정한다. BeginPlay()에서도 한 번 즉시 생성해 플레이어가 첫 아이템을 기다리지 않게 했다.

기억할 내용

마무리

이번 작업에서는 3편에서 설계한 지속 버프를 실제 스탯 계산과 UI에 연결했다. Factory가 버프 생성 시점과 타입별 초기화를 담당하고, Stat Component는 원본 스탯에 활성 버프를 합성한다. 이동 속도 버프는 Character Movement에 반영되며 지속 시간은 프로그레스 바로 확인할 수 있다.

게임 진행도 Stage 1부터 Stage 3과 승리 화면까지 연결했다. 웨이브 완료는 다음 스테이지로, 시간 초과와 체력 0은 실패 화면으로 이어진다. 이제 아이템 추첨, 버프 적용, UI 피드백, 웨이브와 스테이지 전환이 하나의 게임 루프로 연결됐다.

프로젝트 저장소

NBC_CH3_3 GitHub 저장소