feat(interaction): 接通叙事/拾取通知Widget表现层

P-016: 将EffectManager的ShowNarrative/HideNarrative/ShowPickupNotification
从日志占位改为真实CreateWidget+AddToViewport+淡入淡出生命周期。

- 新增UPickupNotificationWidget C++基类(对称NarrativeTextWidget)
- EffectManager新增SetWidgetClasses注入接口与Widget弱引用缓存
- PerformCleanup补齐Narrative/Pickup Widget兜底淡出移除
- ASystemDriver新增两个TSubclassOf配置点,BeginPlay注入给EffectManager
- Widget Class未注入时回退日志占位,保证Effect流程不卡

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 23:30:50 +08:00
parent 11895bddfc
commit 1e5f40e633
6 changed files with 323 additions and 9 deletions
+140 -9
View File
@@ -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<UNarrativeTextWidget> NarrativeClass,
TSubclassOf<UPickupNotificationWidget> 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<APlayerController>(
CurrentContext.Instigator.Get()->GetController()))
{
// 若上一次的 Widget 仍在,先移除(避免叠加)
RemoveNarrativeWidget();
UNarrativeTextWidget* Widget = CreateWidget<UNarrativeTextWidget>(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<APlayerController>(
CurrentContext.Instigator.Get()->GetController()))
{
RemovePickupWidget();
UPickupNotificationWidget* Widget = CreateWidget<UPickupNotificationWidget>(PC, PickupWidgetClass);
if (Widget)
{
Widget->AddToViewport();
Widget->SetPickupText(NotificationText);
Widget->Show();
CachedPickupWidget = Widget;
// 自动淡出(执行案要求通知短暂停留后消失),2.0s 后 Hide
if (UWorld* World = Widget->GetWorld())
{
TWeakObjectPtr<UPickupNotificationWidget> 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 插值
// ============================================================================
@@ -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<UNarrativeTextWidget> NarrativeClass,
TSubclassOf<UPickupNotificationWidget> PickupClass);
/** 播放 Effect — 初始化状态,开始 Timeline 推进 */
void PlayEffect(const FInteractEffect& Effect, const FEffectContext& Context);
@@ -85,6 +102,20 @@ private:
/** 推镜期间 Spawn 的临时摄像机(弱引用,Effect 结束时销毁) */
TWeakObjectPtr<APushCameraActor> CachedPushCamera;
// === 表现层 Widget 缓存(P-016 接通真实 UI)===
/** NarrativeText Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */
TSubclassOf<UNarrativeTextWidget> NarrativeWidgetClass;
/** PickupNotification Widget 蓝图类(由 ASystemDriver 注入,nullptr 时回退日志占位) */
TSubclassOf<UPickupNotificationWidget> PickupWidgetClass;
/** 当前显示中的叙事文本 Widget(弱引用,HideNarrative / PerformCleanup 时淡出移除) */
TWeakObjectPtr<UNarrativeTextWidget> CachedNarrativeWidget;
/** 当前显示中的拾取通知 Widget(弱引用) */
TWeakObjectPtr<UPickupNotificationWidget> 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);
};
@@ -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);
}
}
@@ -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;
};
+3
View File
@@ -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 初始化完成"));
}
+15
View File
@@ -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<UNarrativeTextWidget> NarrativeWidgetClass;
/** 拾取通知 Widget 蓝图类(编辑器指定 WBP_PickupNotification)。留空则回退日志占位 */
UPROPERTY(EditAnywhere, Category = "Interaction|UI")
TSubclassOf<UPickupNotificationWidget> PickupWidgetClass;
// === Effect 完成回调连线 ===
/** EffectManager 的 OnEffectComplete 回调目标 */