Ch3 팀 프로젝트(8) - Raid End Transition(1)

yys·2026년 5월 24일

TIL

목록 보기
51/80

사망/탈출 결산 시스템


이제 플레이어가 죽었을 때, 따로 애니메이션이 출력되면서 결산 내용을 보이게 하도록 하는 것이 목표이다.

전체 흐름은 다음과 같다.

  • APDGameMode: 사망/탈출 양쪽의 합류 지점. 게임이 종료될 때 호출되는 이벤트 함수인 EndRaid가 존재
  • UPDRaidEndTransitionWidget: 게임 종료 애니메이션 + 결산 위젯
  • UPDPlayerRaidEntryWidget: 플레이어 당 결산 내용을 보여주는 서브 위젯. 트랜지션 위젯 내부에 동적으로 채워짐.

데이터 구조


결산 위젯이 받는 페이로드는 두 개의 USTRUCT로 분리되어 있다.

// PDRaidStats.h
USTRUCT(BlueprintType)
struct PROJECTD_API FPDRaidStats
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadOnly, Category = "PD|Raid")
    int32 Kills = 0;

    UPROPERTY(BlueprintReadOnly, Category = "PD|Raid")
    float SurvivalSeconds = 0.f;

    // 성공 시 +, 실패 시 −. 부호 없이 그대로 표시
    UPROPERTY(BlueprintReadOnly, Category = "PD|Raid")
    int32 GoldDelta = 0;

    UPROPERTY(BlueprintReadOnly, Category = "PD|Raid")
    int32 ItemDelta = 0;
};

// 결산 위젯이 받는 한 줄 단위. PlayerName + 생존 여부 + 성과를 묶음
USTRUCT(BlueprintType)
struct PROJECTD_API FPDPlayerRaidEntryData
{
    GENERATED_BODY()

    UPROPERTY(BlueprintReadWrite, Category = "PD|Raid")
    FString PlayerName;

    UPROPERTY(BlueprintReadWrite, Category = "PD|Raid")
    bool bSurvived = false;

    UPROPERTY(BlueprintReadWrite, Category = "PD|Raid")
    FPDRaidStats Stats;
};

FPDRaidStats는 개인 성과(킬, 골드 변화, 아이템 변화)만 담고, FPDPlayerRaidEntryData는 거기에 PlayerName + 생존 여부를 덧붙여 결산 한 줄에 들어가는 단위가 된다.

게임모드의 진입점


사망/탈출이 어디서 발생하든 APDGameMode::EndRaid(bool) 한 함수로 모이도록 되어있다.

// PDGameMode.cpp
void APDGameMode::EndRaid(bool bSuccess)
{
    // 같은 프레임에 사망/탈출이 겹쳐도 결산은 한 번만 수행
    if (CurrentRaidState == ERaidState::Ended) return;
    SetRaidState(ERaidState::Ended);

    UPDGameInstance* GI = GetGameInstance<UPDGameInstance>();

    if (APlayerController* PC = GetWorld()->GetFirstPlayerController())
    {
        // 탈출 성공이면 인벤토리를 스태시로 합치고, 실패면 그대로 소실
        if (bSuccess)
        {
            TransferPlayerInventoryToStash(PC);
        }

        if (APawn* Pawn = PC->GetPawn())
        {
            // 시큐어 컨테이너는 성공/실패 무관하게 항상 보존
            if (UPDSecureContainerComponent* SecureContainer =
                Pawn->FindComponentByClass<UPDSecureContainerComponent>())
            {
                SecureContainer->SaveSecureContainer();
            }
        }
    }

    if (GI) GI->SaveToDisk();

    // BP 후처리 로직. 결산 위젯 삽입에 활용
    OnRaidEnded(bSuccess);
}

흐름을 정리하자면 다음과 같다.

  1. 재진입 가드 — RaidEnd 상태이면 즉시 return. 사망 직후 탈출 트리거가 한 프레임 늦게 도착해도 결산은 단 한 번만.
  2. 인벤토리 정산 — 성공 시 인벤토리를 스태시에 머지. 실패 시 인벤토리는 그대로 소실. 시큐어 컨테이너만 항상 보존.
  3. 세이브 디스크 기록 — 정산이 끝난 시점에 영속화.

탈출과 사망의 진입 경로는 다음과 같이 둘 다 EndRaid로 수렴된다.

// 탈출 성공
void APDGameMode::RequestExtraction(APlayerController* PC)
{
    if (!PC || CurrentRaidState != ERaidState::InProgress) return;
    SetRaidState(ERaidState::Extracting);
    EndRaid(true);
}

// 사망
void APDGameMode::OnPlayerDied(APlayerController* PC, AActor* Killer)
{
    if (!PC) return;
    if (CurrentRaidState == ERaidState::Ended) return;
    EndRaid(false);
}

결산 위젯


UPDActivatableBase를 상속받는, Frontend 레이어에 띄워지는 활성 위젯이다. HUD를 덮지 않고 그 위에 겹치도록 구성했다.

// PDRaidEndTransitionWidget.cpp
void UPDRaidEndTransitionWidget::Configure(bool bInSuccess, const TArray<FPDPlayerRaidEntryData>& Entries, float RaidDurationSeconds)
{
    bSuccess = bInSuccess;

    // bSuccess에 따라 메인/서브 텍스트와 액센트 컬러를 교체
    if (Text_Main)
    {
        Text_Main->SetText(bSuccess ? SuccessMainText : FailureMainText);
        Text_Main->SetColorAndOpacity(FSlateColor(bSuccess ? SuccessAccentColor : FailureAccentColor));
    }
    if (Text_Subtitle)
    {
        Text_Subtitle->SetText(bSuccess ? SuccessSubtitleText : FailureSubtitleText);
    }

    // 레이드 지속 시간을 MM:SS 포맷으로 변환
    if (Text_RaidDuration)
    {
        const int32 TotalSeconds = FMath::Max(0, FMath::FloorToInt(RaidDurationSeconds));
        const int32 Minutes = TotalSeconds / 60;
        const int32 Seconds = TotalSeconds % 60;
        Text_RaidDuration->SetText(FText::FromString(FString::Printf(TEXT("%02d:%02d"), Minutes, Seconds)));
    }

    // Entries가 비어있으면 GameState->PlayerArray로 fallback 빌드
    TArray<FPDPlayerRaidEntryData> ResolvedEntries = Entries;
    if (ResolvedEntries.Num() == 0)
    {
        BuildFallbackEntries(ResolvedEntries);
    }

    if (Box_PlayerEntries)
    {
        Box_PlayerEntries->ClearChildren();
    }

    // 자식 엔트리 인스턴싱은 BP에 위임 (CreateWidget + AddChild)
    PopulateEntries(ResolvedEntries, EntryStaggerInterval);

    // 장식 위젯들(머티리얼 파라미터 등)에 액센트 컬러를 BP가 적용
    ApplyAccent(bSuccess ? SuccessAccentColor : FailureAccentColor);
}

Configure가 하는 일은 다음과 같이 정리된다.

  1. bSuccess로 텍스트/컬러 스왑 — 성공/실패에 따라 메인 문구, 부제, 액센트 컬러가 달라진다. EditDefaultsOnly로 디자이너가 BP 클래스 디폴트에서 조정
  2. 레이드 시간 포맷팅 — 초 단위 float을 MM:SS로 변환
  3. Entries 폴백 — 외부에서 빈 배열이 들어오면 GameState->PlayerArray를 순회해 PlayerName만이라도 채워 엔트리를 만든다.
  4. 자식 인스턴싱은 BP에 위임 — PopulateEntries는 BlueprintImplementableEvent. BP에서 ForEachLoopWithIndex → CreateWidget→ Configure → Box_PlayerEntries.AddChild 패턴으로 채운다.
  5. 액센트 색 적용 위임 — ApplyAccent도 BlueprintImplementableEvent로 작성했는데, 머티리얼 인스턴스에 색상을 흘리는 작업이 BP에서 가장 편한 것 같아서 이렇게 작성했다.

Travel


RaidEnd 위젯이 활성화되면 바로 클릭을 받지 않고 Raid 종료 애니메이션이 끝난 후에 기지로 넘어가는 Travel을 수행할 지에 대한 입력을 받는 로직을 구현한다.

// PDRaidEndTransitionWidget.cpp
void UPDRaidEndTransitionWidget::NativeOnActivated()
{
    Super::NativeOnActivated();

    bReadyForInput = false;
    bInputConsumed = false;

    if (Text_ContinuePrompt)
    {
        Text_ContinuePrompt->SetVisibility(ESlateVisibility::Hidden);
    }

    if (Anim_IntroSequence)
    {
        PlayAnimation(Anim_IntroSequence);
    }
    else
    {
        OnIntroFinished();
    }
}

FReply UPDRaidEndTransitionWidget::NativeOnMouseButtonDown(const FGeometry& InGeometry, const FPointerEvent& InMouseEvent)
{
    // 애니메이션 끝나기 전까지 입력 무시
    if (!bReadyForInput || bInputConsumed) return FReply::Unhandled();
    if (InMouseEvent.GetEffectingButton() != EKeys::LeftMouseButton) return FReply::Unhandled();

    bInputConsumed = true;

    if (Anim_BlackFade) PlayAnimation(Anim_BlackFade);
    else                HandleTravelTrigger();

    return FReply::Handled();
}

void UPDRaidEndTransitionWidget::OnIntroFinished()
{
    bReadyForInput = true;
    if (Text_ContinuePrompt) Text_ContinuePrompt->SetVisibility(ESlateVisibility::Visible);
}

void UPDRaidEndTransitionWidget::HandleTravelTrigger()
{
    // 기지(Base)로 Travel
    if (APDPlayerController* PDPC = Cast<APDPlayerController>(GetOwningPlayer()))
    {
        PDPC->Server_RequestBaseTravel();
    }
}

흐름은 다음과 같다.

  1. NativeOnActivated에서 결산 위젯 애니메이션인 Anim_IntroSequence을 재생 시작
  2. Animation Notify가 끝점에서 OnIntroFinished를 호출 → 입력 허용 + 트래블을 하기 위한 프롬프트 출력
  3. 좌클릭 감지되면 bInputConsumed로 일회성 락을 걸어 더블 트리거 방지 후 Anim_BlackFade 재생
  4. BlackFade 끝점의 Notify가 HandleTravelTrigger 호출 → 트래블 시퀀스 시작

플레이어 엔트리 위젯


결산 한 줄에 해당하는 서브 위젯이다. PlayerName/생존 여부/Kills/Gold/Items를 한 줄에 표시하고, 부모 위젯에서 전달받은 지연 시간만큼 기다린 뒤 결산 내용이 하나씩 나타나는 애니메이션인 Anim_Reveal을 재생한다.

// PDPlayerRaidEntryWidget.h
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDPlayerRaidEntryWidget : public UUserWidget
{
    GENERATED_BODY()

public:
    // 결산 데이터로 라벨을 채우고 지연 시간 후 Anim_Reveal 재생
    UFUNCTION(BlueprintCallable, Category = "PD|Entry")
    void Configure(const FPDPlayerRaidEntryData& Data, float StaggerStartDelay);

protected:
    UPROPERTY(EditDefaultsOnly, Category = "PD|Entry")
    FText SurvivedText;

    UPROPERTY(EditDefaultsOnly, Category = "PD|Entry")
    FText DeathText;

    UPROPERTY(EditDefaultsOnly, Category = "PD|Entry")
    FLinearColor SurvivedColor = FLinearColor(0.357f, 0.753f, 0.922f);

    UPROPERTY(EditDefaultsOnly, Category = "PD|Entry")
    FLinearColor DeathColor = FLinearColor(0.878f, 0.471f, 0.337f);

    UPROPERTY(Transient, BlueprintReadOnly, meta = (BindWidgetAnimOptional))
    TObjectPtr<UWidgetAnimation> Anim_Reveal;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
    TObjectPtr<UTextBlock> Text_PlayerName;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional))
    TObjectPtr<UTextBlock> Text_Status;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
    TObjectPtr<UTextBlock> Text_KillsValue;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
    TObjectPtr<UTextBlock> Text_GoldValue;

    UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
    TObjectPtr<UTextBlock> Text_ItemsValue;

private:
    FTimerHandle RevealTimerHandle;

    void PlayRevealNow();
};

Configure에서 데이터를 라벨에 박은 뒤 FTimerHandle로 지연 시간 이후 Anim_Reveal을 재생하는 구조다. 부모 위젯이 Index * StaggerInterval을 넘기기 때문에 엔트리가 여러 개일 경우 순차적으로 한 줄씩 등장하는 연출이 자연스럽게 나온다.

생존/사망 상태는 Text_Status의 텍스트와 컬러로 표현되는데, 밝기 변조 없이 색 자체만 바꾸는 방식이라 다른 UI 요소와 일관성을 유지하도록 만들었다.


다음에는 결산 화면에 보여줄 애니메이션과 WBP 작업글을 정리할 예정이다.

profile
게임 개발 지망생

0개의 댓글