diff --git a/Source/ChildHood/Interaction/EffectManager.cpp b/Source/ChildHood/Interaction/EffectManager.cpp index cace5e5..caedf25 100644 --- a/Source/ChildHood/Interaction/EffectManager.cpp +++ b/Source/ChildHood/Interaction/EffectManager.cpp @@ -8,6 +8,9 @@ #include "Interaction/InteractableComponent.h" #include "Interaction/PersistenceSubsystem.h" #include "Interaction/PushCameraActor.h" +#include "Interaction/NarrativeTextWidget.h" +#include "Interaction/PickupNotificationWidget.h" +#include "Blueprint/UserWidget.h" #include "Components/SphereComponent.h" #include "Interaction/DoorInteractableComponent.h" #include "Interaction/DoorActor.h" @@ -28,6 +31,17 @@ void FEffectManager::SetOnItemAddedCallback(FOnItemAdded&& Callback) OnItemAdded = MoveTemp(Callback); } +void FEffectManager::SetWidgetClasses(TSubclassOf NarrativeClass, + TSubclassOf PickupClass) +{ + NarrativeWidgetClass = NarrativeClass; + PickupWidgetClass = PickupClass; + + UE_LOG(LogTemp, Log, TEXT("EffectManager: WidgetClasses 注入 — Narrative=%s, Pickup=%s"), + NarrativeClass ? *NarrativeClass->GetName() : TEXT("(null,日志占位)"), + PickupClass ? *PickupClass->GetName() : TEXT("(null,日志占位)")); +} + void FEffectManager::PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context) { // 如果有正在播放的 Effect,先强制清理 @@ -202,8 +216,9 @@ void FEffectManager::ResumeFromWait() void FEffectManager::PerformCleanup() { - // 1. 文本底框淡出移除(v0.1 日志占位,后续替换为 Widget 操作) - // TODO: 如果 NarrativeWidget 显示中,触发淡出动画 + // 1. 文本底框强制淡出移除(ShowNarrative 曾显示但未 HideNarrative 时兜底) + RemoveNarrativeWidget(); + RemovePickupWidget(); // 2. 相机回位(如果推镜过) if (CachedPushCamera.IsValid()) @@ -391,8 +406,33 @@ void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action) SelectedText = Action.NarrativeTexts[0]; } - // v0.1: 日志输出(后续替换为 Widget 显示) - UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative - %s"), *SelectedText); + // P-016:接通真实 Widget(半透明底框 + 整句一次显示 + 淡入) + if (NarrativeWidgetClass && CurrentContext.Instigator.IsValid()) + { + if (APlayerController* PC = Cast( + CurrentContext.Instigator.Get()->GetController())) + { + // 若上一次的 Widget 仍在,先移除(避免叠加) + RemoveNarrativeWidget(); + + UNarrativeTextWidget* Widget = CreateWidget(PC, NarrativeWidgetClass); + if (Widget) + { + Widget->AddToViewport(); + Widget->SetNarrativeText(SelectedText); + Widget->Show(); + CachedNarrativeWidget = Widget; + + UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative (Widget) - %s"), *SelectedText); + } + } + } + else + { + // Widget Class 未注入(蓝图未创建/未配置)→ 回退日志占位,保证 Effect 流程不卡 + UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowNarrative (日志占位,WBP_NarrativeText 未配置) - %s"), + *SelectedText); + } if (Action.bWaitForInput) { @@ -403,17 +443,74 @@ void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action) void FEffectManager::ExecuteHideNarrative() { - // v0.1: 日志占位(后续替换为 Widget 操作) - UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative")); + // P-016:淡出叙事文本 Widget(执行案要求 0.2s 淡出) + // Hide() 播放 FadeOutAnim,0.2s 后 SetVisibility(Hidden)。Widget 实例保留在视口中, + // 下次 ShowNarrative 时由 RemoveNarrativeWidget 先行清理避免叠加。 + if (CachedNarrativeWidget.IsValid()) + { + CachedNarrativeWidget->Hide(); + CachedNarrativeWidget = nullptr; + UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative (Widget 淡出)")); + } + else + { + UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideNarrative (无活动 Widget)")); + } } void FEffectManager::ExecuteShowPickupNotification() { - // v0.1: 日志占位(后续替换为 Widget 显示) + FString DisplayName; if (CurrentContext.TargetComp.IsValid()) { - UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification - %s"), - *CurrentContext.TargetComp->GetInteractableData().DisplayName); + DisplayName = CurrentContext.TargetComp->GetInteractableData().DisplayName; + } + + // 执行案 §跨系统交互:拾取通知("找到了 XXX")归物品系统控制,不在交互系统执行案范围。 + // P-016 范围:仅提供表现能力,v0.1 由交互系统 EffectManager 先行接通(物品系统 AddToBackpack + // 接通属于 P-013)。若 Widget Class 已注入则显示,否则日志占位。 + const FString NotificationText = DisplayName.IsEmpty() + ? TEXT("找到了东西!") + : FString::Printf(TEXT("找到了 %s!"), *DisplayName); + + if (PickupWidgetClass && CurrentContext.Instigator.IsValid()) + { + if (APlayerController* PC = Cast( + CurrentContext.Instigator.Get()->GetController())) + { + RemovePickupWidget(); + + UPickupNotificationWidget* Widget = CreateWidget(PC, PickupWidgetClass); + if (Widget) + { + Widget->AddToViewport(); + Widget->SetPickupText(NotificationText); + Widget->Show(); + CachedPickupWidget = Widget; + + // 自动淡出(执行案要求通知短暂停留后消失),2.0s 后 Hide + if (UWorld* World = Widget->GetWorld()) + { + TWeakObjectPtr WeakWidget = Widget; + FTimerHandle AutoHideHandle; + World->GetTimerManager().SetTimer(AutoHideHandle, [WeakWidget]() + { + if (WeakWidget.IsValid()) + { + WeakWidget->Hide(); + } + }, 2.0f, false); + } + + UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification (Widget) - %s"), + *NotificationText); + } + } + } + else + { + UE_LOG(LogTemp, Log, TEXT("EffectManager: ShowPickupNotification (日志占位,WBP_PickupNotification 未配置) - %s"), + *NotificationText); } } @@ -428,6 +525,40 @@ void FEffectManager::ExecuteToggleDoor() } } +// ============================================================================ +// 表现层 Widget 辅助(P-016) +// ============================================================================ + +void FEffectManager::RemoveNarrativeWidget() +{ + if (CachedNarrativeWidget.IsValid()) + { + UNarrativeTextWidget* Widget = CachedNarrativeWidget.Get(); + CachedNarrativeWidget = nullptr; + + // 强制移除并清理(PerformCleanup / 下次 ShowNarrative 调用) + // 若 Widget 正在播放淡出动画,RemoveFromParent 会中断它,这是兜底清理的可接受行为 + if (Widget && Widget->IsInViewport()) + { + Widget->RemoveFromParent(); + } + } +} + +void FEffectManager::RemovePickupWidget() +{ + if (CachedPickupWidget.IsValid()) + { + UPickupNotificationWidget* Widget = CachedPickupWidget.Get(); + CachedPickupWidget = nullptr; + + if (Widget && Widget->IsInViewport()) + { + Widget->RemoveFromParent(); + } + } +} + // ============================================================================ // TurnToTarget 插值 // ============================================================================ diff --git a/Source/ChildHood/Interaction/EffectManager.h b/Source/ChildHood/Interaction/EffectManager.h index 5da6812..7e21a78 100644 --- a/Source/ChildHood/Interaction/EffectManager.h +++ b/Source/ChildHood/Interaction/EffectManager.h @@ -7,8 +7,11 @@ #include "CoreMinimal.h" #include "InteractionTypes.h" +#include "Templates/SubclassOf.h" class APushCameraActor; +class UNarrativeTextWidget; +class UPickupNotificationWidget; /** * FEffectManager @@ -35,6 +38,20 @@ public: * 由 ASystemDriver 绑定到 FCollectionManager::OnItemObtained(收集系统技术方案第13节)。 */ void SetOnItemAddedCallback(FOnItemAdded&& Callback); + /** + * 设置表现层 Widget 蓝图类(由 ASystemDriver 在 BeginPlay 中注入)。 + * FEffectManager 是普通 C++ 类,不能持有 UPROPERTY,故 Widget Class 的编辑器配置点 + * 放在 ASystemDriver 上(TSubclassOf UPROPERTY),运行时通过本接口注入。 + * UClass 为全局单例永久存活,普通 TSubclassOf 引用安全。 + * + * P-016:接通 ShowNarrative / HideNarrative / ShowPickupNotification 的真实 Widget 显示。 + * + * @param NarrativeClass WBP_NarrativeText 蓝图类(nullptr 时回退日志占位) + * @param PickupClass WBP_PickupNotification 蓝图类(nullptr 时回退日志占位) + */ + void SetWidgetClasses(TSubclassOf NarrativeClass, + TSubclassOf PickupClass); + /** 播放 Effect — 初始化状态,开始 Timeline 推进 */ void PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context); @@ -85,6 +102,20 @@ private: /** 推镜期间 Spawn 的临时摄像机(弱引用,Effect 结束时销毁) */ TWeakObjectPtr CachedPushCamera; + // === 表现层 Widget 缓存(P-016 接通真实 UI)=== + + /** NarrativeText Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */ + TSubclassOf NarrativeWidgetClass; + + /** PickupNotification Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */ + TSubclassOf PickupWidgetClass; + + /** 当前显示中的叙事文本 Widget(弱引用,HideNarrative / PerformCleanup 时淡出移除) */ + TWeakObjectPtr CachedNarrativeWidget; + + /** 当前显示中的拾取通知 Widget(弱引用) */ + TWeakObjectPtr CachedPickupWidget; + // === 回调 === /** Effect 完成回调(正常结束或取消后清理完毕时触发) */ @@ -125,6 +156,14 @@ private: void ExecuteShowPickupNotification(); void ExecuteToggleDoor(); + // === 表现层 Widget 辅助(P-016)=== + + /** 移除并清理缓存的 Narrative Widget(若存在则触发淡出,超时强制移除) */ + void RemoveNarrativeWidget(); + + /** 移除并清理缓存的 Pickup Widget(若存在) */ + void RemovePickupWidget(); + /** 更新 TurnToTarget 跨帧插值(在 Update 末尾调用) */ void UpdateTurn(float DeltaTime); }; diff --git a/Source/ChildHood/Interaction/PickupNotificationWidget.cpp b/Source/ChildHood/Interaction/PickupNotificationWidget.cpp new file mode 100644 index 0000000..e6630c4 --- /dev/null +++ b/Source/ChildHood/Interaction/PickupNotificationWidget.cpp @@ -0,0 +1,57 @@ +// ChildHood Project - 拾取通知 UI 基类 实现 + +#include "Interaction/PickupNotificationWidget.h" +#include "Components/TextBlock.h" + +void UPickupNotificationWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + + // 初始隐藏 + SetVisibility(ESlateVisibility::Hidden); +} + +void UPickupNotificationWidget::SetPickupText(const FString& Text) +{ + if (PickupTextBlock) + { + PickupTextBlock->SetText(FText::FromString(Text)); + } +} + +void UPickupNotificationWidget::Show() +{ + if (bIsVisible) return; + bIsVisible = true; + + SetVisibility(ESlateVisibility::HitTestInvisible); + + if (FadeInAnim) + { + PlayAnimation(FadeInAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + } +} + +void UPickupNotificationWidget::Hide() +{ + if (!bIsVisible) return; + + if (FadeOutAnim) + { + PlayAnimation(FadeOutAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + FTimerHandle TempHandle; + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(TempHandle, [this]() + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + }, 0.2f, false); + } + } + else + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + } +} diff --git a/Source/ChildHood/Interaction/PickupNotificationWidget.h b/Source/ChildHood/Interaction/PickupNotificationWidget.h new file mode 100644 index 0000000..cbf9230 --- /dev/null +++ b/Source/ChildHood/Interaction/PickupNotificationWidget.h @@ -0,0 +1,69 @@ +// ChildHood Project - 拾取通知 UI 基类 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第7节(WBP_PickupNotification) +// 职责:拾取/获得物品时的通知弹窗("找到了 XXX"),带淡入淡出 +// 注意:执行案 §跨系统交互规定拾取通知归物品系统控制,本基类为交互系统 ShowPickupNotification +// Action 提供表现能力,v0.1 暂由 EffectManager 日志占位或本 Widget 简化显示,物品系统 +// 接通后(P-013 后续)再正式启用。 + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "PickupNotificationWidget.generated.h" + +class UTextBlock; + +/** + * UPickupNotificationWidget + * + * 拾取通知 UI 的 C++ 基类。蓝图子类(WBP_PickupNotification)继承此类, + * 在 UMG 编辑器中布局可视组件,并确保组件名与 BindWidget 匹配。 + * + * 绑定要求: + * - TextBlock 名为 "PickupTextBlock" — 显示拾取通知文字(如 "找到了 旧橡皮!") + * - WidgetAnimation 名为 "FadeInAnim" — 淡入动画(0.2s) + * - WidgetAnimation 名为 "FadeOutAnim" — 淡出动画(0.2s) + * + * 布局要求: + * - Anchors: 右下角或屏幕中下,与 NarrativeText 错位 + * - Border: 半透明背景,圆角 + * - TextBlock: 白色文字,居中 + */ +UCLASS(Abstract, Blueprintable) +class UPickupNotificationWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + /** 设置通知文本内容 */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup") + void SetPickupText(const FString& Text); + + /** 显示通知(播放淡入动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup") + void Show(); + + /** 隐藏通知(播放淡出动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Pickup") + void Hide(); + +protected: + + /** 通知文字 TextBlock,蓝图子类必须有一个同名 TextBlock */ + UPROPERTY(meta = (BindWidget)) + UTextBlock* PickupTextBlock; + + /** 淡入动画(0.2s),蓝图子类必须有同名 WidgetAnimation */ + UPROPERTY(meta = (BindWidgetAnim), Transient) + UWidgetAnimation* FadeInAnim; + + /** 淡出动画(0.2s),蓝图子类必须有同名 WidgetAnimation */ + UPROPERTY(meta = (BindWidgetAnim), Transient) + UWidgetAnimation* FadeOutAnim; + + /** 是否正在显示中 */ + bool bIsVisible = false; + + virtual void NativeOnInitialized() override; +}; diff --git a/Source/ChildHood/SystemDriver.cpp b/Source/ChildHood/SystemDriver.cpp index b075ef5..c3f4124 100644 --- a/Source/ChildHood/SystemDriver.cpp +++ b/Source/ChildHood/SystemDriver.cpp @@ -59,6 +59,9 @@ void ASystemDriver::BeginPlay() Callback.BindLambda([this]() { this->OnEffectComplete(); }); EffectManager.SetOnEffectComplete(MoveTemp(Callback)); + // P-016:注入表现层 Widget Class(将编辑器配置的 WBP 蓝图类传给 EffectManager) + EffectManager.SetWidgetClasses(NarrativeWidgetClass, PickupWidgetClass); + UE_LOG(LogChildHood, Log, TEXT("SystemDriver: EffectManager 初始化完成")); } diff --git a/Source/ChildHood/SystemDriver.h b/Source/ChildHood/SystemDriver.h index 2eb256c..5a243bf 100644 --- a/Source/ChildHood/SystemDriver.h +++ b/Source/ChildHood/SystemDriver.h @@ -13,6 +13,9 @@ #include "Collection/CollectionManager.h" #include "SystemDriver.generated.h" +class UNarrativeTextWidget; +class UPickupNotificationWidget; + /** * ASystemDriver * @@ -74,6 +77,18 @@ private: /** 收集管理器(收集系统逻辑核心) */ FCollectionManager CollectionManager; + // === 表现层 Widget 配置点(P-016)=== + // FEffectManager 是普通 C++ 类不能持有 UPROPERTY,编辑器配置点放此处, + // BeginPlay 时通过 EffectManager.SetWidgetClasses() 注入。 + + /** 叙事文本 Widget 蓝图类(编辑器指定 WBP_NarrativeText)。留空则回退日志占位 */ + UPROPERTY(EditAnywhere, Category = "Interaction|UI") + TSubclassOf NarrativeWidgetClass; + + /** 拾取通知 Widget 蓝图类(编辑器指定 WBP_PickupNotification)。留空则回退日志占位 */ + UPROPERTY(EditAnywhere, Category = "Interaction|UI") + TSubclassOf PickupWidgetClass; + // === Effect 完成回调连线 === /** EffectManager 的 OnEffectComplete 回调目标 */