// 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 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,先强制清理 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(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( 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( 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( 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( 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( 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) { 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( 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); } } void FEffectManager::ExecuteToggleDoor() { if (!CurrentContext.TargetComp.IsValid()) return; if (UDoorInteractableComponent* DoorComp = Cast(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& 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]; }