Files
ChildHood/Source/ChildHood/Interaction/EffectManager.cpp
T
starmen1265 1e5f40e633 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>
2026-06-16 23:30:50 +08:00

608 lines
18 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ChildHood Project - Effect Timeline 调度器 实现
// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节 + 第6节
#include "Interaction/EffectManager.h"
#include "Engine/World.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/Character.h"
#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"
#include "Kismet/GameplayStatics.h"
#include "Animation/AnimInstance.h"
// ============================================================================
// 公开接口
// ============================================================================
void FEffectManager::SetOnEffectComplete(FOnEffectComplete&& Callback)
{
OnEffectComplete = MoveTemp(Callback);
}
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,先强制清理
if (bIsPlaying)
{
PerformCleanup();
}
CurrentEffect = Effect;
CurrentContext = Context;
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsPlaying = true;
bIsWaitingForInput = false;
TurnState.Reset();
LastNarrativeIndex = -1;
CachedPushCamera = nullptr;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayEffect '%s' (Duration=%.2f, Actions=%d)"),
*CurrentEffect.EffectName, CurrentEffect.TotalDuration, CurrentEffect.Actions.Num());
}
void FEffectManager::CancelEffect()
{
if (!bIsPlaying) return;
if (!CurrentEffect.bCancellable) return;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CancelEffect (WaitingForInput=%s)"),
bIsWaitingForInput ? TEXT("true") : TEXT("false"));
if (bIsWaitingForInput)
{
// 用户输入是期望的恢复信号 -> 走正常恢复
ResumeFromWait();
return;
}
// 非暂停态强制取消
bIsPlaying = false;
PerformCleanup();
}
// ============================================================================
// 每帧更新
// ============================================================================
void FEffectManager::Update(float DeltaTime)
{
if (!bIsPlaying) return;
if (bIsWaitingForInput) return; // 暂停等待用户输入
// 1. 推进时间
ElapsedTime += DeltaTime;
// 2. 检查是否到达 TotalDuration
if (ElapsedTime >= CurrentEffect.TotalDuration)
{
FinishEffect();
return;
}
// 3. 触发到时的 Action
while (NextActionIndex < CurrentEffect.Actions.Num())
{
const FInteractAction& Action = CurrentEffect.Actions[NextActionIndex];
if (ElapsedTime >= Action.TriggerTime)
{
ExecuteAction(Action);
NextActionIndex++;
}
else
{
break;
}
}
// 4. 更新 TurnToTarget 插值
UpdateTurn(DeltaTime);
}
// ============================================================================
// Action 分发
// ============================================================================
void FEffectManager::ExecuteAction(const FInteractAction& Action)
{
switch (Action.Type)
{
case EInteractActionType::Wait:
ExecuteWait(Action);
break;
case EInteractActionType::TurnToTarget:
ExecuteTurnToTarget(Action);
break;
case EInteractActionType::PlayMontage:
ExecutePlayMontage(Action);
break;
case EInteractActionType::CameraPush:
ExecuteCameraPush(Action);
break;
case EInteractActionType::CameraReturn:
ExecuteCameraReturn(Action);
break;
case EInteractActionType::Consume:
ExecuteConsume();
break;
case EInteractActionType::HideActor:
ExecuteHideActor();
break;
case EInteractActionType::ShowActor:
ExecuteShowActor();
break;
case EInteractActionType::ShowNarrative:
ExecuteShowNarrative(Action);
break;
case EInteractActionType::HideNarrative:
ExecuteHideNarrative();
break;
case EInteractActionType::ShowPickupNotification:
ExecuteShowPickupNotification();
break;
case EInteractActionType::ToggleDoor:
ExecuteToggleDoor();
break;
// AddToBackpack:通过 Driver 回调链通知收集系统(收集系统技术方案第13节)
case EInteractActionType::AddToBackpack:
if (CurrentContext.TargetComp.IsValid())
{
const FInteractableData& ItemData = CurrentContext.TargetComp->GetInteractableData();
if (OnItemAdded.IsBound())
{
// 回调连线:EffectManager → ASystemDriver → FCollectionManager::OnItemObtained
OnItemAdded.Execute(ItemData.ItemId, ItemData.DisplayName, ItemData.Category);
}
UE_LOG(LogTemp, Log, TEXT("EffectManager: AddToBackpack - Item: %s (Category: %s)"),
*ItemData.DisplayName, *ItemData.Category.ToString());
}
break;
case EInteractActionType::PlayMontageOnTarget:
case EInteractActionType::PlayDialogue:
case EInteractActionType::PlayStory:
case EInteractActionType::BroadcastEvent:
UE_LOG(LogTemp, Log, TEXT("EffectManager: Action %d not implemented in v0.1"),
static_cast<int32>(Action.Type));
break;
}
}
// ============================================================================
// Effect 结束 / 清理 / 恢复
// ============================================================================
void FEffectManager::FinishEffect()
{
bIsPlaying = false;
PerformCleanup();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: FinishEffect '%s'"), *CurrentEffect.EffectName);
// 广播完成回调
if (OnEffectComplete.IsBound())
{
OnEffectComplete.Execute();
}
}
void FEffectManager::ResumeFromWait()
{
bIsWaitingForInput = false;
// Timeline 继续推进,后续 Action 按 TriggerTime 正常触发
}
void FEffectManager::PerformCleanup()
{
// 1. 文本底框强制淡出移除(ShowNarrative 曾显示但未 HideNarrative 时兜底)
RemoveNarrativeWidget();
RemovePickupWidget();
// 2. 相机回位(如果推镜过)
if (CachedPushCamera.IsValid())
{
if (CurrentContext.Instigator.IsValid())
{
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (PC)
{
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), 0.2f);
}
}
// 销毁推镜摄像机
APushCameraActor* PushCam = CachedPushCamera.Get();
CachedPushCamera = nullptr;
if (PushCam && PushCam->GetWorld())
{
PushCam->Destroy();
}
}
// 3. 蒙太奇停止
if (CurrentContext.Instigator.IsValid())
{
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Stop(0.2f);
}
}
// 4. TurnToTarget 未完成 -> 瞬移到目标旋转
if (TurnState.bIsActive && CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
TurnState.Reset();
}
// 5. 重置所有状态变量
ElapsedTime = 0.f;
NextActionIndex = 0;
bIsWaitingForInput = false;
TurnState.Reset();
}
// ============================================================================
// 各 Action 实现
// ============================================================================
void FEffectManager::ExecuteWait(const FInteractAction& Action)
{
// Wait Action 由 Timeline 自然推进处理,此处仅做日志
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Wait (%.2fs)"), Action.WaitDuration);
}
void FEffectManager::ExecuteTurnToTarget(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid() || !CurrentContext.TargetActor.IsValid()) return;
ACharacter* Char = CurrentContext.Instigator.Get();
FVector DirToTarget = CurrentContext.TargetActor->GetActorLocation() - Char->GetActorLocation();
DirToTarget.Z = 0;
DirToTarget.Normalize();
TurnState.bIsActive = true;
TurnState.StartRotation = Char->GetActorRotation();
TurnState.TargetRotation = DirToTarget.Rotation();
TurnState.ElapsedTime = 0.f;
TurnState.Duration = Action.TurnDuration;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: TurnToTarget (Duration=%.2fs)"), Action.TurnDuration);
}
void FEffectManager::ExecutePlayMontage(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
if (!Action.Montage) return;
ACharacter* Char = CurrentContext.Instigator.Get();
if (Char->GetMesh() && Char->GetMesh()->GetAnimInstance())
{
Char->GetMesh()->GetAnimInstance()->Montage_Play(Action.Montage);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: PlayMontage '%s'"),
*Action.Montage->GetName());
}
}
void FEffectManager::ExecuteCameraPush(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
// 计算推镜摄像机位置:沿角色到目标方向偏移
FVector CharLoc = CurrentContext.Instigator.Get()->GetActorLocation();
FVector DirToTarget(0.f, 0.f, 0.f);
if (CurrentContext.TargetActor.IsValid())
{
DirToTarget = (CurrentContext.TargetActor->GetActorLocation() - CharLoc).GetSafeNormal();
}
else
{
// 没有目标 Actor 时使用角色朝向
DirToTarget = CurrentContext.Instigator.Get()->GetActorForwardVector();
}
FVector PushLoc = CharLoc + DirToTarget * Action.PushDistance + FVector(0.f, 0.f, 80.f);
FActorSpawnParameters Params;
Params.Owner = CurrentContext.Instigator.Get();
APushCameraActor* PushCam = CurrentContext.Instigator.Get()->GetWorld()->SpawnActor<APushCameraActor>(
PushLoc, DirToTarget.Rotation(), Params);
if (PushCam)
{
PC->SetViewTargetWithBlend(PushCam, Action.PushDuration);
CachedPushCamera = PushCam;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraPush (Dist=%.1f, Dur=%.2fs)"),
Action.PushDistance, Action.PushDuration);
}
}
void FEffectManager::ExecuteCameraReturn(const FInteractAction& Action)
{
if (!CurrentContext.Instigator.IsValid()) return;
APlayerController* PC = Cast<APlayerController>(
CurrentContext.Instigator.Get()->GetController());
if (!PC) return;
PC->SetViewTargetWithBlend(CurrentContext.Instigator.Get(), Action.ReturnDuration);
// 相机已回到角色,立即销毁推镜摄像机并清空缓存,
// 避免 PerformCleanup 检测到有效缓存后冗余回位 + 重复销毁。
if (APushCameraActor* PushCam = CachedPushCamera.Get())
{
CachedPushCamera = nullptr;
if (PushCam->GetWorld())
{
PushCam->Destroy();
}
}
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: CameraReturn (Dur=%.2fs)"), Action.ReturnDuration);
}
void FEffectManager::ExecuteConsume()
{
if (!CurrentContext.TargetComp.IsValid()) return;
CurrentContext.TargetComp->Consume();
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: Consume - %s"),
*CurrentContext.TargetComp->GetInteractableData().DisplayName);
}
void FEffectManager::ExecuteHideActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(true);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: HideActor"));
}
void FEffectManager::ExecuteShowActor()
{
if (!CurrentContext.TargetActor.IsValid()) return;
CurrentContext.TargetActor.Get()->SetActorHiddenInGame(false);
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: ShowActor"));
}
void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action)
{
FString SelectedText;
if (Action.bRandomSelect && Action.NarrativeTexts.Num() > 1)
{
SelectedText = PickRandomNarrative(Action.NarrativeTexts);
}
else if (Action.NarrativeTexts.Num() > 0)
{
SelectedText = Action.NarrativeTexts[0];
}
// 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)
{
bIsWaitingForInput = true;
UE_LOG(LogTemp, Verbose, TEXT("EffectManager: WaitForInput paused"));
}
}
void FEffectManager::ExecuteHideNarrative()
{
// 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()
{
FString DisplayName;
if (CurrentContext.TargetComp.IsValid())
{
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);
}
}
void FEffectManager::ExecuteToggleDoor()
{
if (!CurrentContext.TargetComp.IsValid()) return;
if (UDoorInteractableComponent* DoorComp = Cast<UDoorInteractableComponent>(CurrentContext.TargetComp.Get()))
{
DoorComp->ToggleDoor();
UE_LOG(LogTemp, Log, TEXT("EffectManager: ToggleDoor executed"));
}
}
// ============================================================================
// 表现层 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 插值
// ============================================================================
void FEffectManager::UpdateTurn(float DeltaTime)
{
if (!TurnState.bIsActive) return;
TurnState.ElapsedTime += DeltaTime;
if (TurnState.IsComplete())
{
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(TurnState.TargetRotation);
}
TurnState.bIsActive = false;
return;
}
float Alpha = TurnState.GetAlpha();
FRotator NewRot = FMath::Lerp(TurnState.StartRotation, TurnState.TargetRotation, Alpha);
if (CurrentContext.Instigator.IsValid())
{
CurrentContext.Instigator.Get()->SetActorRotation(NewRot);
}
}
// ============================================================================
// 辅助方法
// ============================================================================
FString FEffectManager::PickRandomNarrative(const TArray<FString>& Texts)
{
if (Texts.Num() == 0) return TEXT("");
if (Texts.Num() == 1) return Texts[0];
int32 CandidateIndex;
do
{
CandidateIndex = FMath::RandRange(0, Texts.Num() - 1);
} while (CandidateIndex == LastNarrativeIndex && Texts.Num() > 1);
LastNarrativeIndex = CandidateIndex;
return Texts[CandidateIndex];
}