diff --git a/Source/P_RD/GameMode/CombatGameMode.cpp b/Source/P_RD/GameMode/CombatGameMode.cpp index 9d7dfaafd..a673f86b1 100644 --- a/Source/P_RD/GameMode/CombatGameMode.cpp +++ b/Source/P_RD/GameMode/CombatGameMode.cpp @@ -319,6 +319,9 @@ void ACombatGameMode::InitializeCombat() const FRoomTransactionState& Transactions = GetRunPersistData()->GetRoomTransactions(); mGoldRewardClaimed = Transactions.GoldClaimed; mExpRewardClaimed = Transactions.ExpClaimed; + mResumePendingLevelUpAfterExp = Transactions.ExpClaimed + && Transactions.LevelUpSkills.ContainsByPredicate( + [](const FLevelUpSkillReward& Reward) { return !Reward.Completed; }); mClaimedRewardChoiceIndices = Transactions.ClaimedChoices; mRewardSelectionClaimed = Transactions.SelectedArtifact.IsValid(); mSelectedRewardArtifactId = Transactions.SelectedArtifact; @@ -495,6 +498,8 @@ void ACombatGameMode::InitializeCombat() // HUD가 먼저 만들어져 앵커를 등록한 경우에도 구독 직후 같은 값을 카메라에 적용한다. HandleChangeFocusScreenAnchor(mCombatUIModel->GetFocusScreenAnchor()); mRewardUIModel->OnRewardClaimRequested.AddUniqueDynamic(this, &ACombatGameMode::HandleRewardClaimed); + mRewardUIModel->OnExperiencePresentationCompleted.AddUniqueDynamic( + this, &ACombatGameMode::HandleExperiencePresentationCompleted); mRewardUIModel->OnRewardSelectionRequested.AddUniqueDynamic( this, &ACombatGameMode::HandleRewardSelectionRequested); @@ -594,8 +599,7 @@ void ACombatGameMode::BeginCombatAfterEntrance() } UE_LOG(LogCombatGameMode, Display, TEXT("RD_BOSS_ENTRANCE combat begins after entrance=%d"), bHadEntrance); if (auto* Combat = GetWorldSubsystemModel(this)) Combat->BeginCombat(); - // Pending reward choices belong to the completed-room checkpoint, not a new EXP grant. - mLevelUpSkillRewardFlow->Open(GetRunPersistData(), GetPartyModel(), GetWorld()->GetFirstPlayerController()); + // A restored skill choice resumes only after the experience page is advanced. } UCombatUIModel* ACombatGameMode::GetCombatUIModel() const @@ -864,6 +868,14 @@ void ACombatGameMode::HandleRewardClaimed(ERewardClaimKind ClaimKind, int32 Choi } } +void ACombatGameMode::HandleExperiencePresentationCompleted() +{ + if (!mResumePendingLevelUpAfterExp || !mLevelUpSkillRewardFlow) return; + mResumePendingLevelUpAfterExp = false; + mLevelUpSkillRewardFlow->Open(GetRunPersistData(), GetPartyModel(), + GetWorld()->GetFirstPlayerController()); +} + void ACombatGameMode::HandleRewardSelectionRequested( const FPrimaryAssetId RewardId) { diff --git a/Source/P_RD/GameMode/CombatGameMode.h b/Source/P_RD/GameMode/CombatGameMode.h index d2cdbb6c2..089d6da9f 100644 --- a/Source/P_RD/GameMode/CombatGameMode.h +++ b/Source/P_RD/GameMode/CombatGameMode.h @@ -94,6 +94,8 @@ class P_RD_API ACombatGameMode : public ARoomGameModeBase UFUNCTION() void HandleRewardClaimed(ERewardClaimKind ClaimKind, int32 ChoiceIndex); + UFUNCTION() + void HandleExperiencePresentationCompleted(); /** @brief 엘리트·보스 SelectOne 정책 요청을 실제 아티팩트 지급으로 연결한다. */ UFUNCTION() @@ -325,6 +327,7 @@ class P_RD_API ACombatGameMode : public ARoomGameModeBase UPROPERTY(Category = "UI", VisibleAnywhere, DuplicateTransient, meta = (DisplayName = "RewardUIModel")) TObjectPtr mRewardUIModel; UPROPERTY() TObjectPtr mLevelUpSkillRewardFlow; + bool mResumePendingLevelUpAfterExp = false; private: /** @brief 다음 액션/턴이 시작돼 더 이상 유효하지 않은 카메라 복귀 대기를 취소한다. */ diff --git a/Source/P_RD/GameMode/RoomGameModeBase.cpp b/Source/P_RD/GameMode/RoomGameModeBase.cpp index 01bfe91d8..d9cecb5e3 100644 --- a/Source/P_RD/GameMode/RoomGameModeBase.cpp +++ b/Source/P_RD/GameMode/RoomGameModeBase.cpp @@ -11,6 +11,8 @@ #include "Singleton/WorldSubsystem/WorldWidgetSubsystem.h" #include "Blueprint/UserWidget.h" #include "UI/RDUserWidget.h" +#include "UI/SettingsPanelWidget.h" +#include "Singleton/WorldSubsystem/WorldWidgetType.h" #include "Setting/RDWorldSettings.h" @@ -355,6 +357,13 @@ void ARoomGameModeBase::InitializeCommonRoom() void ARoomGameModeBase::BeginRoom() { Super::BeginRoom(); + if (USettingsPanelWidget* Settings = GetRunSettingsPanel()) + { + Settings->OnSaveAndExitRequested.AddUniqueDynamic( + this, &ARoomGameModeBase::HandleSettingsSaveAndExitRequested); + Settings->OnAbandonRunConfirmed.AddUniqueDynamic( + this, &ARoomGameModeBase::HandleSettingsAbandonConfirmed); + } /* 터치 세팅 */ @@ -372,6 +381,69 @@ void ARoomGameModeBase::BeginRoom() PlayerController->SetInputMode(InputMode); } +USettingsPanelWidget* ARoomGameModeBase::GetRunSettingsPanel() const +{ + UWorld* World = GetWorld(); + UWorldWidgetSubsystem* Widgets = World ? World->GetSubsystem() : nullptr; + return Widgets ? Widgets->GetWorldWidget(EWorldWidgetType::InGameSettings) : nullptr; +} + +void ARoomGameModeBase::CompleteSettingsRunAction(bool bSuccess, bool bSaveAndExit) +{ + if (bSuccess) return; // The room transition owns the successful path. + mCommonSettingsRunActionPending = false; + if (USettingsPanelWidget* Settings = GetRunSettingsPanel()) + { + Settings->SetRunActionsEnabled(true); + Settings->SetStatusText(bSaveAndExit + ? NSLOCTEXT("RoomGameModeBase", "SaveAndExitFailed", "저장 후 종료에 실패했습니다.") + : NSLOCTEXT("RoomGameModeBase", "AbandonRunFailed", "런 포기 요청에 실패했습니다.")); + } +} + +void ARoomGameModeBase::HandleSettingsSaveAndExitRequested() +{ + if (mCommonSettingsRunActionPending || IsSaveAndExitPending()) return; + USettingsPanelWidget* Settings = GetRunSettingsPanel(); + if (Settings) + { + Settings->SetRunActionsEnabled(false); + Settings->SetStatusText(NSLOCTEXT("RoomGameModeBase", "SavingRun", "저장 중...")); + } + USaveGameSubsystem* Saves = GetGameInstance() + ? GetGameInstance()->GetSubsystem() : nullptr; + if (!Saves) { CompleteSettingsRunAction(false, true); return; } + mCommonSettingsRunActionPending = true; + Saves->SaveOptionAsync(FAsyncSaveGameToSlotDelegate::CreateWeakLambda(this, + [this](const FString&, int32, bool bSaved) + { + if (!bSaved) { CompleteSettingsRunAction(false, true); return; } + SaveAndExitRunFromRoomAsync(FOnRoomSaveAndExitComplete::CreateWeakLambda(this, + [this](bool bSuccess) { CompleteSettingsRunAction(bSuccess, true); })); + })); +} + +void ARoomGameModeBase::HandleSettingsAbandonConfirmed() +{ + if (mCommonSettingsRunActionPending || IsSaveAndExitPending()) return; + USettingsPanelWidget* Settings = GetRunSettingsPanel(); + if (Settings) + { + Settings->HideAbandonConfirm(); + Settings->SetRunActionsEnabled(false); + Settings->SetStatusText(FText::GetEmpty()); + } + USaveGameSubsystem* Saves = GetGameInstance() + ? GetGameInstance()->GetSubsystem() : nullptr; + if (!Saves) { CompleteSettingsRunAction(false, false); return; } + mCommonSettingsRunActionPending = true; + Saves->SaveOptionAsync(FAsyncSaveGameToSlotDelegate::CreateWeakLambda(this, + [this](const FString&, int32, bool bSaved) + { + CompleteSettingsRunAction(bSaved && AbandonRunFromRoom(), false); + })); +} + bool ARoomGameModeBase::SelectNextRoom(int32 RoomRow, int32 RoomColumn) { if (mWasNextRoomPreloadRequested == true) diff --git a/Source/P_RD/GameMode/RoomGameModeBase.h b/Source/P_RD/GameMode/RoomGameModeBase.h index 6f334baf3..2239dc152 100644 --- a/Source/P_RD/GameMode/RoomGameModeBase.h +++ b/Source/P_RD/GameMode/RoomGameModeBase.h @@ -17,6 +17,7 @@ DECLARE_LOG_CATEGORY_EXTERN(LogRoomGameMode, Log, All) class UPartyModel; class UPlayerUnitModel; +class USettingsPanelWidget; struct FCombatArtifactUI; struct FSkillDetailUI; @@ -193,6 +194,12 @@ class P_RD_API ARoomGameModeBase : public ARDGameModeBase int32 mSelectedRoomColumn = INDEX_NONE; private: + USettingsPanelWidget* GetRunSettingsPanel() const; + UFUNCTION() void HandleSettingsSaveAndExitRequested(); + UFUNCTION() void HandleSettingsAbandonConfirmed(); + void CompleteSettingsRunAction(bool bSuccess, bool bSaveAndExit); + + bool mCommonSettingsRunActionPending = false; FName mSelectedRoomSpawnSettingName = NAME_None; bool mSaveAndExitPending = false; bool mFinalRunClosed = false; diff --git a/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.cpp b/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.cpp index 8ff648344..4fe06bcb9 100644 --- a/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.cpp +++ b/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.cpp @@ -470,10 +470,10 @@ void UCombatLayoutHUDWidget::NativeDestruct() // 화면이 바뀐 뒤 0.5초 타이머가 살아서 닫힌 HUD 위에 상세를 여는 일을 막는다. CancelStatusPress(); CancelMonsterSkillPress(); + mCommandLongPressTimerHandle.Cancel(); + mArtifactLongPressTimerHandle.Cancel(); if (UWorld* World = GetWorld()) { - World->GetTimerManager().ClearTimer(mCommandLongPressTimerHandle); - World->GetTimerManager().ClearTimer(mBoardLongPressTimerHandle); World->GetTimerManager().ClearTimer(mCombatAnnouncementTimerHandle); World->GetTimerManager().ClearTimer(mCombatResultStartDelayTimerHandle); } @@ -1445,15 +1445,11 @@ void UCombatLayoutHUDWidget::HandleAnyPressed() { continue; } - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().SetTimer(mCommandLongPressTimerHandle, - FTimerDelegate::CreateWeakLambda(this, [this, SlotIndex]() - { - HandleCommandLongPress(SlotIndex); - }), - LongPressSeconds, false); - } + mCommandLongPressTimerHandle.Start(this, LongPressSeconds, + FSimpleDelegate::CreateWeakLambda(this, [this, SlotIndex]() + { + HandleCommandLongPress(SlotIndex); + })); break; } @@ -1683,10 +1679,7 @@ void UCombatLayoutHUDWidget::HandleTurnPageRightClicked() void UCombatLayoutHUDWidget::HandleAnyReleased() { // 놓았으니 카드 긴 누름 판정은 끝났다. - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().ClearTimer(mCommandLongPressTimerHandle); - } + mCommandLongPressTimerHandle.Cancel(); if (mPressedTarget != nullptr) { @@ -3734,7 +3727,7 @@ FReply UCombatLayoutHUDWidget::NativeOnMouseMove(const FGeometry& InGeometry, && RDPointerGesture::HasMoved(mPressOrigin, InMouseEvent.GetScreenSpacePosition(), false)) { mPressMoved = true; - if (UWorld* World = GetWorld()) World->GetTimerManager().ClearTimer(mBoardLongPressTimerHandle); + mBoardLongPressTimerHandle.Cancel(); } return Super::NativeOnMouseMove(InGeometry, InMouseEvent); } @@ -4236,11 +4229,8 @@ FReply UCombatLayoutHUDWidget::NativeOnMouseButtonDown(const FGeometry& InGeomet mBoardPressIsTouch = false; mPressMoved = false; mPressActive = true; - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().SetTimer(mBoardLongPressTimerHandle, this, - &UCombatLayoutHUDWidget::HandleBoardLongPress, LongPressSeconds, false); - } + mBoardLongPressTimerHandle.Start(this, LongPressSeconds, + FSimpleDelegate::CreateUObject(this, &UCombatLayoutHUDWidget::HandleBoardLongPress)); return FReply::Handled(); } @@ -4257,11 +4247,8 @@ FReply UCombatLayoutHUDWidget::NativeOnTouchStarted(const FGeometry& InGeometry, mBoardPressIsTouch = true; mPressMoved = false; mPressActive = true; - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().SetTimer(mBoardLongPressTimerHandle, this, - &UCombatLayoutHUDWidget::HandleBoardLongPress, LongPressSeconds, false); - } + mBoardLongPressTimerHandle.Start(this, LongPressSeconds, + FSimpleDelegate::CreateUObject(this, &UCombatLayoutHUDWidget::HandleBoardLongPress)); return FReply::Handled(); } @@ -4322,7 +4309,7 @@ void UCombatLayoutHUDWidget::CancelBoardPress() { mPressActive = false; mPressMoved = true; - if (UWorld* World = GetWorld()) World->GetTimerManager().ClearTimer(mBoardLongPressTimerHandle); + mBoardLongPressTimerHandle.Cancel(); } void UCombatLayoutHUDWidget::ObserveBoardTouchDown(uint32 Pointer) @@ -4425,10 +4412,7 @@ FReply UCombatLayoutHUDWidget::NativeOnTouchMoved(const FGeometry& InGeometry, const bool bDragJustStarted = mPressActive && mPressMoved == false; mPressMoved = true; // 끌기 시작했다. 지도를 미는 손을 긴 누름으로 오인하지 않는다. - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().ClearTimer(mBoardLongPressTimerHandle); - } + mBoardLongPressTimerHandle.Cancel(); // 0823 확정: 카드는 손을 뗄 때가 아니라 **끌기 시작하는 순간** 접는다. // 손을 뗀 뒤에 접히면 화면을 미는 내내 카드가 시야를 가린다. if (bDragJustStarted && IsAiming() == false && mIsActionPlaying == false @@ -4467,10 +4451,7 @@ void UCombatLayoutHUDWidget::FinishBoardPress(const FVector2D& ScreenPosition) * 확정이 같이 되어 그 자리에서 발동했다. */ // 놓았으니 긴 누름 판정은 끝났다. 발화 전에 놓았으면 탭이다. - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().ClearTimer(mBoardLongPressTimerHandle); - } + mBoardLongPressTimerHandle.Cancel(); if (mTurnSwipeConsumed) { mPressActive = false; @@ -4841,10 +4822,6 @@ void UCombatLayoutHUDWidget::HandleSettingsMenuClicked() // OnBackRequested만 보내더라도 사용자가 설정 화면에 갇히지 않게 한다. Settings->OnBackRequested.AddUniqueDynamic( this, &UCombatLayoutHUDWidget::HandleSettingsPanelBackRequested); - Settings->OnSaveAndExitRequested.AddUniqueDynamic( - this, &UCombatLayoutHUDWidget::HandleSettingsPanelSaveAndExitRequested); - Settings->OnAbandonRunConfirmed.AddUniqueDynamic( - this, &UCombatLayoutHUDWidget::HandleSettingsPanelAbandonRunConfirmed); Settings->SetPanelMode(ESettingsPanelMode::InGame); // 전투 중에는 런을 저장하거나 포기할 수 있다. 타이틀에서 열 때와 다른 점이다. Settings->RefreshPanelState(true, true); @@ -4874,58 +4851,6 @@ void UCombatLayoutHUDWidget::HandleSettingsPanelBackRequested() RefreshWorldGestureInputBlock(); } -/** @brief 런 액션을 잠그고 저장 후 종료 의도를 게임플레이에 전달한다. */ -void UCombatLayoutHUDWidget::HandleSettingsPanelSaveAndExitRequested() -{ - UWorld* World = GetWorld(); - UWorldWidgetSubsystem* WorldWidgetSubsystem = - World != nullptr ? World->GetSubsystem() : nullptr; - USettingsPanelWidget* Settings = WorldWidgetSubsystem != nullptr - ? WorldWidgetSubsystem->GetWorldWidget( - EWorldWidgetType::InGameSettings) - : nullptr; - if (Settings != nullptr) - { - Settings->SetRunActionsEnabled(false); - Settings->SetStatusText(LOCTEXT("SavingRun", "저장 중...")); - } - - if (mUIModel != nullptr) - { - mUIModel->RequestSaveAndExitRun(); - return; - } - HandleSaveAndExitCompleted(false); -} - -/** @brief 확인이 끝난 런 포기 요청을 기존 UIModel -> CombatGameMode 경로로 보낸다. */ -void UCombatLayoutHUDWidget::HandleSettingsPanelAbandonRunConfirmed() -{ - UWorld* World = GetWorld(); - UWorldWidgetSubsystem* WorldWidgetSubsystem = - World != nullptr ? World->GetSubsystem() : nullptr; - USettingsPanelWidget* Settings = WorldWidgetSubsystem != nullptr - ? WorldWidgetSubsystem->GetWorldWidget( - EWorldWidgetType::InGameSettings) - : nullptr; - if (Settings != nullptr) - { - Settings->HideAbandonConfirm(); - Settings->SetRunActionsEnabled(false); - Settings->SetStatusText(FText::GetEmpty()); - } - - if (mUIModel != nullptr) - { - mUIModel->RequestAbandonRun(); - return; - } - if (Settings != nullptr) - { - HandleAbandonRunCompleted(false); - } -} - /** @brief 저장/전환 실패만 현재 화면에서 복구한다. 성공 시 방 전환이 이어진다. */ void UCombatLayoutHUDWidget::HandleSaveAndExitCompleted(const bool bSuccess) { @@ -4989,21 +4914,17 @@ void UCombatLayoutHUDWidget::BeginArtifactPress(const int32 SlotIndex) { return; } - World->GetTimerManager().SetTimer(mArtifactLongPressTimerHandle, - FTimerDelegate::CreateWeakLambda(this, [this, SlotIndex]() - { - HandleArtifactLongPress(SlotIndex); - }), - LongPressSeconds, false); + mArtifactLongPressTimerHandle.Start(this, LongPressSeconds, + FSimpleDelegate::CreateWeakLambda(this, [this, SlotIndex]() + { + HandleArtifactLongPress(SlotIndex); + })); } void UCombatLayoutHUDWidget::HandleArtifactReleased() { // 꾹 누르기 전에 떼면 아무 일도 없다. - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().ClearTimer(mArtifactLongPressTimerHandle); - } + mArtifactLongPressTimerHandle.Cancel(); mArtifactPressedSlot = INDEX_NONE; } @@ -5268,14 +5189,11 @@ void UCombatLayoutHUDWidget::BeginMonsterSkillPress(const int32 SlotIndex) } mMonsterSkillPressActive = true; mMonsterSkillPressedSlot = SlotIndex; - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().SetTimer(mMonsterSkillLongPressTimerHandle, - FTimerDelegate::CreateWeakLambda(this, [this, SlotIndex]() - { - HandleMonsterSkillLongPress(SlotIndex); - }), LongPressSeconds, false); - } + mMonsterSkillLongPressTimerHandle.Start(this, LongPressSeconds, + FSimpleDelegate::CreateWeakLambda(this, [this, SlotIndex]() + { + HandleMonsterSkillLongPress(SlotIndex); + })); } /** @brief 짧게 뗀 손은 상세를 열지 않고 후보만 무른다. */ @@ -5307,11 +5225,7 @@ void UCombatLayoutHUDWidget::HandleMonsterSkillLongPress(const int32 SlotIndex) void UCombatLayoutHUDWidget::CancelMonsterSkillPress() { - if (UWorld* World = GetWorld()) - { - World->GetTimerManager().ClearTimer(mMonsterSkillLongPressTimerHandle); - } - mMonsterSkillLongPressTimerHandle.Invalidate(); + mMonsterSkillLongPressTimerHandle.Cancel(); mMonsterSkillPressedSlot = INDEX_NONE; mMonsterSkillPressActive = false; } @@ -6384,9 +6298,7 @@ void UCombatLayoutHUDWidget::TriggerMonsterSkillLongPressForTest(const int32 Slo bool UCombatLayoutHUDWidget::IsMonsterSkillLongPressPendingForTest() const { - const UWorld* World = GetWorld(); - return mMonsterSkillPressActive == true && World != nullptr - && World->GetTimerManager().IsTimerActive(mMonsterSkillLongPressTimerHandle); + return mMonsterSkillPressActive && mMonsterSkillLongPressTimerHandle.IsPending(); } bool UCombatLayoutHUDWidget::IsMonsterSkillPressActiveForTest(const int32 SlotIndex) const diff --git a/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.h b/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.h index 88e8fe440..36208ae5d 100644 --- a/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.h +++ b/Source/P_RD/UI/Combat/CombatLayoutHUDWidget.h @@ -20,6 +20,7 @@ */ #include "RDMinimal.h" +#include "UI/RealTimeLongPressTimer.h" #include "UI/Combat/CombatUIWidgetBase.h" #include "UI/Combat/CombatUITypes.h" @@ -362,10 +363,6 @@ class P_RD_API UCombatLayoutHUDWidget : public UCombatUIWidgetBase UFUNCTION() void HandleSettingsMenuClicked(); /** @brief 설정 패널의 Back 요청을 받아 패널을 닫는다. */ UFUNCTION() void HandleSettingsPanelBackRequested(); - /** @brief 설정 패널의 저장 후 종료 요청을 UIModel로 전달한다. */ - UFUNCTION() void HandleSettingsPanelSaveAndExitRequested(); - /** @brief 확인을 마친 런 포기 요청을 기존 전투 UIModel 경로로 전달한다. */ - UFUNCTION() void HandleSettingsPanelAbandonRunConfirmed(); /** @brief 저장 또는 프론트엔드 전환 실패 시 설정판 입력과 상태를 복구한다. */ UFUNCTION() void HandleSaveAndExitCompleted(bool bSuccess); /** @brief 런 포기 또는 프론트엔드 전환 실패 시 설정판 입력과 상태를 복구한다. */ @@ -390,7 +387,7 @@ class P_RD_API UCombatLayoutHUDWidget : public UCombatUIWidgetBase void BeginArtifactPress(int32 SlotIndex); UPROPERTY(Transient) TArray> mArtifactButtons; - FTimerHandle mArtifactLongPressTimerHandle; + FRealTimeLongPressTimer mArtifactLongPressTimerHandle; int32 mArtifactPressedSlot = INDEX_NONE; /** @@ -521,11 +518,11 @@ class P_RD_API UCombatLayoutHUDWidget : public UCombatUIWidgetBase * 되돌아오고, 적이면 위협 범위도 판에 같이 칠려 온다. */ void HandleBoardLongPress(); - FTimerHandle mBoardLongPressTimerHandle; + FRealTimeLongPressTimer mBoardLongPressTimerHandle; /** @brief 카드를 오래 눌렀다. 그 스킬의 상세를 요청하고 이어질 클릭은 삼킨다. */ void HandleCommandLongPress(int32 SlotIndex); - FTimerHandle mCommandLongPressTimerHandle; + FRealTimeLongPressTimer mCommandLongPressTimerHandle; /** * @brief 긴 누름이 발화했으면 뒤따라 오는 클릭(뗌)을 한 번 무시한다. @@ -1230,7 +1227,7 @@ class P_RD_API UCombatLayoutHUDWidget : public UCombatUIWidgetBase int32 mMonsterTabInspectedUnitId = INDEX_NONE; /** @brief 보이는 슬롯→FUnitDetailSkillUI::mSkillIndex 왕복 식별자. */ TArray mMonsterTabSkillIndices; - FTimerHandle mMonsterSkillLongPressTimerHandle; + FRealTimeLongPressTimer mMonsterSkillLongPressTimerHandle; int32 mMonsterSkillPressedSlot = INDEX_NONE; bool mMonsterSkillPressActive = false; // 아래 넷도 프레젠터 캐시의 비친 포인터다. 값 채우기는 HUD 상세 경로가 한다. diff --git a/Source/P_RD/UI/RealTimeLongPressTimer.h b/Source/P_RD/UI/RealTimeLongPressTimer.h new file mode 100644 index 000000000..4b05b8e9c --- /dev/null +++ b/Source/P_RD/UI/RealTimeLongPressTimer.h @@ -0,0 +1,36 @@ +#pragma once + +#include "Containers/Ticker.h" +#include "HAL/PlatformTime.h" + +/** A cancelable hold timer measured in wall-clock seconds, independent of world time dilation. */ +class FRealTimeLongPressTimer +{ +public: + ~FRealTimeLongPressTimer() { Cancel(); } + + void Start(UObject* Owner, double DurationSeconds, FSimpleDelegate Callback) + { + Cancel(); + const double Deadline = FPlatformTime::Seconds() + DurationSeconds; + Handle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateWeakLambda(Owner, + [this, Deadline, Callback](float) + { + if (FPlatformTime::Seconds() < Deadline) return true; + Handle.Reset(); + Callback.ExecuteIfBound(); + return false; + })); + } + + void Cancel() + { + if (Handle.IsValid()) FTSTicker::GetCoreTicker().RemoveTicker(Handle); + Handle.Reset(); + } + + bool IsPending() const { return Handle.IsValid(); } + +private: + FTSTicker::FDelegateHandle Handle; +}; diff --git a/Source/P_RD/UI/Reward/RewardConcept03Widget.cpp b/Source/P_RD/UI/Reward/RewardConcept03Widget.cpp index e97299a6a..109b6e913 100644 --- a/Source/P_RD/UI/Reward/RewardConcept03Widget.cpp +++ b/Source/P_RD/UI/Reward/RewardConcept03Widget.cpp @@ -1005,6 +1005,7 @@ void URewardConcept03Widget::AdvanceRewardFlow() ClaimExperienceReward(); PresentationState = EPresentationState::ChestAwaitInput; SetCurrentStep(RewardConcept03::ChestStep); + if (UIModel != nullptr) UIModel->RequestExperiencePresentationCompleted(); break; case RewardConcept03::GoldStep: if (PresentationState == EPresentationState::AwaitGoldContinue) diff --git a/Source/P_RD/UI/Reward/RewardUIModel.h b/Source/P_RD/UI/Reward/RewardUIModel.h index d89e9570c..2c87047bf 100644 --- a/Source/P_RD/UI/Reward/RewardUIModel.h +++ b/Source/P_RD/UI/Reward/RewardUIModel.h @@ -15,6 +15,7 @@ DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRewardUIChanged); DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRewardClaimed); +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnExperiencePresentationCompleted); DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnRewardChoicesChanged); DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnRewardChosen, int32, ChoiceIndex); DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnRewardClaimRequested, ERewardClaimKind, ClaimKind, int32, ChoiceIndex); @@ -43,6 +44,10 @@ class P_RD_API URewardUIModel : public UObject UPROPERTY(BlueprintAssignable, Category = "Reward|Input") FOnRewardClaimed OnRewardClaimed; + /** Experience screen was advanced, including when its grant was restored from a save. */ + UPROPERTY(BlueprintAssignable, Category = "Reward|Input") + FOnExperiencePresentationCompleted OnExperiencePresentationCompleted; + /** @brief 룸 보상 항목이 설정/갱신됐음을 알림. 위젯은 보상 행을 다시 그린다. */ UPROPERTY(BlueprintAssignable, Category = "Reward|UI") FOnRewardChoicesChanged OnChoicesChanged; @@ -106,6 +111,10 @@ class P_RD_API URewardUIModel : public UObject UFUNCTION(BlueprintCallable, Category = "Reward|Push") void ConfirmSelectedReward(FPrimaryAssetId RewardId); UFUNCTION(BlueprintCallable, Category = "Reward|Push") void ConfirmGrantBundle(const FRewardGrantBundleResultUI& Result); UFUNCTION(BlueprintCallable, Category = "Reward|Input") void RequestFinishPresentation() { RequestClaim(); } + UFUNCTION(BlueprintCallable, Category = "Reward|Input") void RequestExperiencePresentationCompleted() + { + OnExperiencePresentationCompleted.Broadcast(); + } /* ───────── 위젯이 읽는다 ───────── */ public: diff --git a/Source/P_RD/UI/RunOptionsRailWidget.cpp b/Source/P_RD/UI/RunOptionsRailWidget.cpp index 2a9665255..b40b3fcba 100644 --- a/Source/P_RD/UI/RunOptionsRailWidget.cpp +++ b/Source/P_RD/UI/RunOptionsRailWidget.cpp @@ -928,21 +928,17 @@ void URunOptionsRailWidget::BeginDetailPress( { return; } - GetWorld()->GetTimerManager().ClearTimer(DetailLongPressTimer); + DetailLongPressTimer.Cancel(); PressedDetailSlot = SlotIndex; bPressedDetailIsArtifact = bArtifact; bDetailLongPressTriggered = false; - GetWorld()->GetTimerManager().SetTimer(DetailLongPressTimer, - this, &URunOptionsRailWidget::FireHeldDetail, - DetailLongPressSeconds, false); + DetailLongPressTimer.Start(this, DetailLongPressSeconds, + FSimpleDelegate::CreateUObject(this, &URunOptionsRailWidget::FireHeldDetail)); } void URunOptionsRailWidget::EndDetailPress() { - if (GetWorld() != nullptr) - { - GetWorld()->GetTimerManager().ClearTimer(DetailLongPressTimer); - } + DetailLongPressTimer.Cancel(); PressedDetailSlot = INDEX_NONE; bDetailLongPressTriggered = false; } diff --git a/Source/P_RD/UI/RunOptionsRailWidget.h b/Source/P_RD/UI/RunOptionsRailWidget.h index c601f2a3a..6453e6026 100644 --- a/Source/P_RD/UI/RunOptionsRailWidget.h +++ b/Source/P_RD/UI/RunOptionsRailWidget.h @@ -2,6 +2,7 @@ // @brief 전투 HUD의 지도·용병·몬스터·설정 레일을 방 공용 화면에서 재사용합니다. #pragma once +#include "UI/RealTimeLongPressTimer.h" #include "CoreMinimal.h" #include "Blueprint/UserWidget.h" @@ -192,7 +193,7 @@ class P_RD_API URunOptionsRailWidget : public UUserWidget bool bMapContext = false; bool bInventoryPageShown = false; int32 SelectedPartyMember = 0; - FTimerHandle DetailLongPressTimer; + FRealTimeLongPressTimer DetailLongPressTimer; int32 PressedDetailSlot = INDEX_NONE; bool bPressedDetailIsArtifact = false; bool bDetailLongPressTriggered = false; diff --git a/Source/P_RD/UI/Shop/ShopUIWidgetBase.cpp b/Source/P_RD/UI/Shop/ShopUIWidgetBase.cpp index 78b2bed09..a5a2d2963 100644 --- a/Source/P_RD/UI/Shop/ShopUIWidgetBase.cpp +++ b/Source/P_RD/UI/Shop/ShopUIWidgetBase.cpp @@ -778,10 +778,10 @@ void UShopUIWidgetBase::BeginSkillSlotPress(const int32 SkillSlotIndex) return; } - GetWorld()->GetTimerManager().ClearTimer(mSkillLongPressTimer); + mSkillLongPressTimer.Cancel(); mPressedSkillSlotIndex = SkillSlotIndex; mSkillLongPressTriggered = false; - FTimerDelegate LongPressDelegate = FTimerDelegate::CreateWeakLambda(this, + FSimpleDelegate LongPressDelegate = FSimpleDelegate::CreateWeakLambda(this, [this, SkillSlotIndex]() { if (mPressedSkillSlotIndex == SkillSlotIndex) @@ -789,8 +789,7 @@ void UShopUIWidgetBase::BeginSkillSlotPress(const int32 SkillSlotIndex) ShowHeldSkillDetails(SkillSlotIndex); } }); - GetWorld()->GetTimerManager().SetTimer(mSkillLongPressTimer, - LongPressDelegate, SkillDetailLongPressSeconds, false); + mSkillLongPressTimer.Start(this, SkillDetailLongPressSeconds, LongPressDelegate); } void UShopUIWidgetBase::EndSkillSlotPress(const int32 SkillSlotIndex) @@ -799,10 +798,7 @@ void UShopUIWidgetBase::EndSkillSlotPress(const int32 SkillSlotIndex) { return; } - if (GetWorld() != nullptr) - { - GetWorld()->GetTimerManager().ClearTimer(mSkillLongPressTimer); - } + mSkillLongPressTimer.Cancel(); if (mSkillLongPressTriggered) { @@ -931,10 +927,10 @@ void UShopUIWidgetBase::BeginRailSlotPress(const int32 RailSlotIndex) { return; } - GetWorld()->GetTimerManager().ClearTimer(mRailLongPressTimer); + mRailLongPressTimer.Cancel(); mPressedRailSlotIndex = RailSlotIndex; mRailLongPressTriggered = false; - FTimerDelegate LongPressDelegate = FTimerDelegate::CreateWeakLambda(this, + FSimpleDelegate LongPressDelegate = FSimpleDelegate::CreateWeakLambda(this, [this, RailSlotIndex]() { if (mPressedRailSlotIndex == RailSlotIndex) @@ -942,8 +938,7 @@ void UShopUIWidgetBase::BeginRailSlotPress(const int32 RailSlotIndex) ShowHeldRailItemDetails(RailSlotIndex); } }); - GetWorld()->GetTimerManager().SetTimer(mRailLongPressTimer, - LongPressDelegate, SkillDetailLongPressSeconds, false); + mRailLongPressTimer.Start(this, SkillDetailLongPressSeconds, LongPressDelegate); } void UShopUIWidgetBase::EndRailSlotPress(const int32 RailSlotIndex) @@ -952,10 +947,7 @@ void UShopUIWidgetBase::EndRailSlotPress(const int32 RailSlotIndex) { return; } - if (GetWorld() != nullptr) - { - GetWorld()->GetTimerManager().ClearTimer(mRailLongPressTimer); - } + mRailLongPressTimer.Cancel(); mPressedRailSlotIndex = INDEX_NONE; mRailLongPressTriggered = false; } @@ -2567,11 +2559,8 @@ void UShopUIWidgetBase::ShowShopDetail(const FText& Name, void UShopUIWidgetBase::NativeDestruct() { RemoveFirstVisitGuide(); - if (GetWorld() != nullptr) - { - GetWorld()->GetTimerManager().ClearTimer(mSkillLongPressTimer); - GetWorld()->GetTimerManager().ClearTimer(mRailLongPressTimer); - } + mSkillLongPressTimer.Cancel(); + mRailLongPressTimer.Cancel(); mPressedSkillSlotIndex = INDEX_NONE; mSkillLongPressTriggered = false; mPressedRailSlotIndex = INDEX_NONE; diff --git a/Source/P_RD/UI/Shop/ShopUIWidgetBase.h b/Source/P_RD/UI/Shop/ShopUIWidgetBase.h index 67463050b..ae9168237 100644 --- a/Source/P_RD/UI/Shop/ShopUIWidgetBase.h +++ b/Source/P_RD/UI/Shop/ShopUIWidgetBase.h @@ -3,6 +3,7 @@ // @date 2026-06-18 #pragma once +#include "UI/RealTimeLongPressTimer.h" #include "RDMinimal.h" #include "TimerManager.h" @@ -474,12 +475,12 @@ class P_RD_API UShopUIWidgetBase : public URDUserWidget bool mIsArtifactInventoryOpen = false; /** 모바일 장착 스킬 길게 누르기 상태. */ - FTimerHandle mSkillLongPressTimer; + FRealTimeLongPressTimer mSkillLongPressTimer; int32 mPressedSkillSlotIndex = INDEX_NONE; bool mSkillLongPressTriggered = false; /** 판매 아티팩트/스킬 카드는 손을 떼기 전 임계시간에 상세를 연다. */ - FTimerHandle mRailLongPressTimer; + FRealTimeLongPressTimer mRailLongPressTimer; int32 mPressedRailSlotIndex = INDEX_NONE; bool mRailLongPressTriggered = false; diff --git a/Source/P_RDTests/UI/RealTimeLongPressTimerTests.cpp b/Source/P_RDTests/UI/RealTimeLongPressTimerTests.cpp new file mode 100644 index 000000000..10ced19cb --- /dev/null +++ b/Source/P_RDTests/UI/RealTimeLongPressTimerTests.cpp @@ -0,0 +1,31 @@ +#include "Misc/AutomationTest.h" +#include "UI/RealTimeLongPressTimer.h" +#include "UI/Reward/RewardUIModel.h" +#include "HAL/PlatformProcess.h" +#include "UObject/StrongObjectPtr.h" + +#if WITH_DEV_AUTOMATION_TESTS +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FRealTimeLongPressTimerTest, + "P_RD.UI.LongPress.RealTimeClock", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FRealTimeLongPressTimerTest::RunTest(const FString& Parameters) +{ + TStrongObjectPtr Owner(NewObject()); + FRealTimeLongPressTimer Timer; + int32 Fired = 0; + Timer.Start(Owner.Get(), 0.08, FSimpleDelegate::CreateLambda([&Fired]() { ++Fired; })); + FTSTicker::GetCoreTicker().Tick(4.f); // Simulated fast game ticks must not expire a real-time hold. + TestEqual(TEXT("Accelerated tick does not trigger long press"), Fired, 0); + Timer.Cancel(); + FPlatformProcess::Sleep(0.1f); + FTSTicker::GetCoreTicker().Tick(0.f); + TestEqual(TEXT("Canceled hold stays canceled"), Fired, 0); + Timer.Start(Owner.Get(), 0.08, FSimpleDelegate::CreateLambda([&Fired]() { ++Fired; })); + FPlatformProcess::Sleep(0.1f); + FTSTicker::GetCoreTicker().Tick(0.f); + TestEqual(TEXT("Real elapsed time triggers hold once"), Fired, 1); + TestFalse(TEXT("Expired hold is no longer pending"), Timer.IsPending()); + return true; +} +#endif diff --git a/Source/P_RDTests/UI/RewardFlowTests.cpp b/Source/P_RDTests/UI/RewardFlowTests.cpp index 140b0d592..1d664b0d4 100644 --- a/Source/P_RDTests/UI/RewardFlowTests.cpp +++ b/Source/P_RDTests/UI/RewardFlowTests.cpp @@ -1,7 +1,9 @@ #include "UI/RewardFlowTests.h" #include "Misc/AutomationTest.h" +#include "Editor.h" #include "Singleton/InstanceSubsystem/PersistentData.h" +#include "UI/Reward/RewardConcept03Widget.h" #include "UI/Reward/RewardUIModel.h" #include "UObject/StrongObjectPtr.h" @@ -19,8 +21,45 @@ void URewardFlowTestListener::HandleClaimConfirmed(ERewardClaimKind ClaimKind, i mLastConfirmationChoiceIndex = ChoiceIndex; } +void URewardFlowTestListener::HandleExperiencePresentationCompleted() +{ + ++mExperiencePresentationCount; +} + #if WITH_DEV_AUTOMATION_TESTS +IMPLEMENT_SIMPLE_AUTOMATION_TEST(FRestoredExperiencePresentationOrderTest, + "P_RD.UI.Reward.RestoredExperiencePresentationOrder", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FRestoredExperiencePresentationOrderTest::RunTest(const FString& Parameters) +{ + UWorld* World = GEditor ? GEditor->GetEditorWorldContext().World() : nullptr; + if (!TestNotNull(TEXT("Editor world"), World)) return false; + UClass* Class = LoadClass(nullptr, + TEXT("/Game/UI/RewardConcept03New/WBP_RewardConcept03_Frameless_NoArtifact.WBP_RewardConcept03_Frameless_NoArtifact_C")); + if (!TestNotNull(TEXT("Reward screen"), Class)) return false; + URewardConcept03Widget* Widget = CreateWidget(World, Class); + if (!TestNotNull(TEXT("Reward widget"), Widget)) return false; + TStrongObjectPtr Model(NewObject()); + TStrongObjectPtr Listener(NewObject()); + FRewardUI RestoredReward; + RestoredReward.mExpGained = 0; + Model->SetReward(RestoredReward); + Model->OnExperiencePresentationCompleted.AddDynamic(Listener.Get(), + &URewardFlowTestListener::HandleExperiencePresentationCompleted); + Widget->BindUIModel(Model.Get()); + Widget->ResetRewardFlow(); + TestEqual(TEXT("Restored reward starts at experience"), Widget->GetCurrentStepIndex(), 0); + TestEqual(TEXT("Level-up is not requested before experience continues"), + Listener->mExperiencePresentationCount, 0); + Widget->AdvanceRewardFlow(); + TestEqual(TEXT("Experience page advances once"), Listener->mExperiencePresentationCount, 1); + TestEqual(TEXT("Chest page is ready behind level-up choice"), Widget->GetCurrentStepIndex(), 1); + Widget->RemoveFromParent(); + return true; +} + IMPLEMENT_SIMPLE_AUTOMATION_TEST( FRewardClaimAcknowledgementTest, "P_RD.UI.Reward.ClaimAcknowledgement", diff --git a/Source/P_RDTests/UI/RewardFlowTests.h b/Source/P_RDTests/UI/RewardFlowTests.h index 935dfa0b0..863e14a6b 100644 --- a/Source/P_RDTests/UI/RewardFlowTests.h +++ b/Source/P_RDTests/UI/RewardFlowTests.h @@ -18,8 +18,12 @@ class URewardFlowTestListener : public UObject UFUNCTION() void HandleClaimConfirmed(ERewardClaimKind ClaimKind, int32 ChoiceIndex); + UFUNCTION() + void HandleExperiencePresentationCompleted(); + int32 mRequestCount = 0; int32 mConfirmationCount = 0; + int32 mExperiencePresentationCount = 0; ERewardClaimKind mLastRequestKind = ERewardClaimKind::Gold; ERewardClaimKind mLastConfirmationKind = ERewardClaimKind::Gold; int32 mLastRequestChoiceIndex = INDEX_NONE;