diff --git a/Source/ChildHood/ChildHood.Build.cs b/Source/ChildHood/ChildHood.Build.cs index 67632fc..82fbe55 100644 --- a/Source/ChildHood/ChildHood.Build.cs +++ b/Source/ChildHood/ChildHood.Build.cs @@ -25,6 +25,7 @@ public class ChildHood : ModuleRules PublicIncludePaths.AddRange(new string[] { "ChildHood", + "ChildHood/Interaction", "ChildHood/Variant_Platforming", "ChildHood/Variant_Platforming/Animation", "ChildHood/Variant_Combat", diff --git a/Source/ChildHood/ChildHoodGameInstance.cpp b/Source/ChildHood/ChildHoodGameInstance.cpp new file mode 100644 index 0000000..2ca1fcd --- /dev/null +++ b/Source/ChildHood/ChildHoodGameInstance.cpp @@ -0,0 +1,30 @@ +// ChildHood Project - 跨关卡持久数据持有者 实现 + +#include "ChildHoodGameInstance.h" + +bool UChildHoodGameInstance::IsItemCollected(FName ItemId) const +{ + return CollectedItemIDs.Contains(ItemId); +} + +void UChildHoodGameInstance::MarkItemCollected(FName ItemId) +{ + CollectedItemIDs.Add(ItemId); +} + +bool UChildHoodGameInstance::IsDoorOpen(FName DoorId) const +{ + const bool* Found = DoorStates.Find(DoorId); + return Found ? *Found : false; +} + +void UChildHoodGameInstance::SetDoorOpen(FName DoorId, bool bIsOpen) +{ + DoorStates.Add(DoorId, bIsOpen); +} + +void UChildHoodGameInstance::ResetAll() +{ + CollectedItemIDs.Empty(); + DoorStates.Empty(); +} diff --git a/Source/ChildHood/ChildHoodGameInstance.h b/Source/ChildHood/ChildHoodGameInstance.h new file mode 100644 index 0000000..3eae61e --- /dev/null +++ b/Source/ChildHood/ChildHoodGameInstance.h @@ -0,0 +1,56 @@ +// ChildHood Project - 跨关卡持久数据持有者 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第12节(跨场景持久化设计) + +#pragma once + +#include "CoreMinimal.h" +#include "Engine/GameInstance.h" +#include "ChildHoodGameInstance.generated.h" + +/** + * UChildHoodGameInstance + * + * 跨关卡持久数据持有者。UE5 的 OpenLevel() 会销毁整个旧 World(包括 + * Character 及其所有 UActorComponent),因此需要将持久数据与运行时行为分离。 + * + * v0.1 为单场景原型,此 GameInstance 以空数据初始化。 + * 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为此类即可。 + */ +UCLASS() +class UChildHoodGameInstance : public UGameInstance +{ + GENERATED_BODY() + +public: + + // === 物品消耗状态 === + + /** 查询指定物品是否已被收集(跨场景持久) */ + bool IsItemCollected(FName ItemId) const; + + /** 标记指定物品为已收集(写入持久层) */ + void MarkItemCollected(FName ItemId); + + // === 门状态 === + + /** 查询指定门的开关状态(跨场景持久),未记录的门默认为关闭 */ + bool IsDoorOpen(FName DoorId) const; + + /** 设置指定门的开关状态(写入持久层) */ + void SetDoorOpen(FName DoorId, bool bIsOpen); + + // === 重置 === + + /** 重置所有持久数据(新游戏时调用) */ + void ResetAll(); + +private: + + /** 已收集物品ID集合 */ + UPROPERTY() + TSet CollectedItemIDs; + + /** 门开关状态映射(true=打开,false=关闭) */ + UPROPERTY() + TMap DoorStates; +}; diff --git a/Source/ChildHood/Interaction/InteractableActor.cpp b/Source/ChildHood/Interaction/InteractableActor.cpp new file mode 100644 index 0000000..3773c99 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractableActor.cpp @@ -0,0 +1,47 @@ +// ChildHood Project - 通用交互物 Actor 基类 实现 + +#include "Interaction/InteractableActor.h" +#include "Interaction/InteractableComponent.h" + +#include "Components/StaticMeshComponent.h" + +// ============================================================================ +// 构造函数 +// ============================================================================ + +AInteractableActor::AInteractableActor() +{ + PrimaryActorTick.bCanEverTick = false; + + // 创建 Mesh 为根组件 + MeshComp = CreateDefaultSubobject(TEXT("MeshComp")); + RootComponent = MeshComp; + MeshComp->SetCollisionProfileName(TEXT("BlockAll")); + + // 创建交互组件(UActorComponent 不需要 SetupAttachment,数据驱动型组件) + InteractableComp = CreateDefaultSubobject(TEXT("InteractableComp")); +} + +// ============================================================================ +// 交互回调 +// ============================================================================ + +void AInteractableActor::OnInteractStarted_Implementation(UInteractableComponent* Comp) +{ + if (!Comp || !MeshComp) return; + + const FInteractableData& Data = Comp->GetInteractableData(); + + // 门类型:根据开关状态旋转 Mesh + if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) + { + FRotator CurrentRot = MeshComp->GetRelativeRotation(); + float TargetYaw = Comp->IsDoorOpen() ? 90.0f : 0.0f; + MeshComp->SetRelativeRotation(FRotator(CurrentRot.Pitch, TargetYaw, CurrentRot.Roll)); + } +} + +void AInteractableActor::OnInteractEnded_Implementation(UInteractableComponent* Comp) +{ + // 默认空实现,子类可覆盖 +} diff --git a/Source/ChildHood/Interaction/InteractableActor.h b/Source/ChildHood/Interaction/InteractableActor.h new file mode 100644 index 0000000..5f6a284 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractableActor.h @@ -0,0 +1,69 @@ +// ChildHood Project - 通用交互物 Actor 基类 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计) +// 职责:持有 Mesh + InteractableComponent,提供可被子类覆盖的交互回调 + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "InteractableActor.generated.h" + +class UInteractableComponent; + +/** + * AInteractableActor + * + * 通用交互物 Actor 基类。场景中的每个交互物(冰箱、门、NPC 等) + * 均为该类的实例或其子类的实例。 + * + * 组成: + * - UStaticMeshComponent* MeshComp(根组件,承载可视模型) + * - UInteractableComponent* InteractableComp(交互逻辑与数据) + * + * 子类可覆盖 OnInteractStarted / OnInteractEnded 以实现类型特定行为, + * 如门开关时的 Mesh 旋转、拾取物的消失效果等。 + */ +UCLASS() +class AInteractableActor : public AActor +{ + GENERATED_BODY() + +public: + + AInteractableActor(); + + // === 组件访问 === + + /** 获取静态网格组件 */ + UFUNCTION(BlueprintCallable, Category = "Interact") + UStaticMeshComponent* GetMeshComp() const { return MeshComp; } + + /** 获取交互组件 */ + UFUNCTION(BlueprintCallable, Category = "Interact") + UInteractableComponent* GetInteractableComp() const { return InteractableComp; } + + // === 可被子类覆盖的交互回调 === + + /** + * 交互开始时调用(门开关切换、状态恢复时也调用)。 + * 默认实现:门类型根据开关状态对 Mesh 做 Yaw 旋转(开门+90度/关门0度)。 + */ + UFUNCTION(BlueprintNativeEvent, Category = "Interact") + void OnInteractStarted(UInteractableComponent* Comp); + virtual void OnInteractStarted_Implementation(UInteractableComponent* Comp); + + /** 交互结束时调用。默认为空实现,子类可覆盖。 */ + UFUNCTION(BlueprintNativeEvent, Category = "Interact") + void OnInteractEnded(UInteractableComponent* Comp); + virtual void OnInteractEnded_Implementation(UInteractableComponent* Comp); + +protected: + + /** 交互物的可视模型 */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true")) + UStaticMeshComponent* MeshComp; + + /** 交互物的交互组件(数据 + 检测 + 状态) */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components", meta = (AllowPrivateAccess = "true")) + UInteractableComponent* InteractableComp; +}; diff --git a/Source/ChildHood/Interaction/InteractableComponent.cpp b/Source/ChildHood/Interaction/InteractableComponent.cpp new file mode 100644 index 0000000..564c3cd --- /dev/null +++ b/Source/ChildHood/Interaction/InteractableComponent.cpp @@ -0,0 +1,221 @@ +// ChildHood Project - 可交互物组件 实现 + +#include "Interaction/InteractableComponent.h" +#include "Interaction/InteractableActor.h" +#include "Interaction/InteractionManager.h" +#include "ChildHoodGameInstance.h" +#include "ChildHood.h" + +#include "Components/SphereComponent.h" +#include "Engine/World.h" +#include "GameFramework/Character.h" +#include "Kismet/GameplayStatics.h" + +// ============================================================================ +// 构造函数 +// ============================================================================ + +UInteractableComponent::UInteractableComponent() +{ + // 不需要 Tick,检测由碰撞事件驱动 + PrimaryComponentTick.bCanEverTick = false; +} + +// ============================================================================ +// 生命周期 +// ============================================================================ + +void UInteractableComponent::BeginPlay() +{ + Super::BeginPlay(); + + AActor* Owner = GetOwner(); + if (!Owner) + { + UE_LOG(LogChildHood, Warning, TEXT("InteractableComponent: 无 Owner,跳过初始化")); + return; + } + + // --- 创建检测球 --- + DetectionSphere = NewObject(this, USphereComponent::StaticClass(), TEXT("DetectionSphere")); + if (DetectionSphere) + { + DetectionSphere->SetupAttachment(Owner->GetRootComponent()); + DetectionSphere->RegisterComponent(); + DetectionSphere->SetSphereRadius(Data.TriggerRadius); + DetectionSphere->SetCollisionProfileName(TEXT("OverlapAllDynamic")); + // 关闭物理模拟,仅用于 Overlap 检测 + DetectionSphere->SetGenerateOverlapEvents(true); + DetectionSphere->SetCanEverAffectNavigation(false); + + // 绑定碰撞事件 + DetectionSphere->OnComponentBeginOverlap.AddDynamic(this, &UInteractableComponent::OnDetectionOverlapBegin); + DetectionSphere->OnComponentEndOverlap.AddDynamic(this, &UInteractableComponent::OnDetectionOverlapEnd); + } + else + { + UE_LOG(LogChildHood, Error, TEXT("InteractableComponent: DetectionSphere 创建失败")); + } + + // --- 恢复跨场景持久状态 --- + if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) + { + RestorePersistentState(GI); + } + + UE_LOG(LogChildHood, Log, TEXT("InteractableComponent Init: %s (Type=%s, Radius=%f)"), + Data.DisplayName.IsEmpty() ? TEXT("(unnamed)") : *Data.DisplayName, + *UEnum::GetValueAsString(Data.Type), + Data.TriggerRadius); +} + +// ============================================================================ +// 提示文本 +// ============================================================================ + +FString UInteractableComponent::GetPromptText() const +{ + FString Verb = Data.InteractVerb; + // 门类型:根据当前开关状态选择动词 + if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) + { + Verb = bDoorIsOpen ? Data.InteractVerbAlt : Data.InteractVerb; + } + return FString::Printf(TEXT("[E] %s %s"), *Verb, *Data.DisplayName); +} + +// ============================================================================ +// 消耗管理 +// ============================================================================ + +void UInteractableComponent::Consume() +{ + bIsConsumed = true; + + // 禁用检测球,不再触发 Overlap + if (DetectionSphere) + { + DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); + } + + // 写入持久层 + if (UWorld* World = GetWorld()) + { + if (UChildHoodGameInstance* GI = World->GetGameInstance()) + { + GI->MarkItemCollected(Data.ItemId); + } + } + + UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 已消耗: %s"), *Data.ItemId.ToString()); +} + +// ============================================================================ +// 门状态管理 +// ============================================================================ + +void UInteractableComponent::ToggleDoor() +{ + bDoorIsOpen = !bDoorIsOpen; + UE_LOG(LogChildHood, Log, TEXT("InteractableComponent 门状态切换: %s → %s"), + *Data.ItemId.ToString(), + bDoorIsOpen ? TEXT("开") : TEXT("关")); +} + +// ============================================================================ +// 位置查询 +// ============================================================================ + +FVector UInteractableComponent::GetWorldLocation() const +{ + AActor* Owner = GetOwner(); + return Owner ? Owner->GetActorLocation() : FVector::ZeroVector; +} + +// ============================================================================ +// 跨场景持久状态恢复 +// ============================================================================ + +void UInteractableComponent::RestorePersistentState(UChildHoodGameInstance* GI) +{ + if (!GI) return; + + // oneShot 物品:已消耗 → 隐藏 + 禁用检测 + if (Data.bOneShot && GI->IsItemCollected(Data.ItemId)) + { + bIsConsumed = true; + if (DetectionSphere) + { + DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); + } + if (AInteractableActor* Owner = Cast(GetOwner())) + { + Owner->SetActorHiddenInGame(true); + } + UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 已消耗物品隐藏: %s"), *Data.ItemId.ToString()); + } + + // 门:恢复开关状态 + if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) + { + bDoorIsOpen = GI->IsDoorOpen(Data.ItemId); + // 通知 Actor 应用视觉状态(Mesh 旋转) + if (AInteractableActor* Owner = Cast(GetOwner())) + { + Owner->OnInteractStarted(this); + } + UE_LOG(LogChildHood, Log, TEXT("RestorePersistentState: 门状态恢复: %s → %s"), + *Data.ItemId.ToString(), + bDoorIsOpen ? TEXT("开") : TEXT("关")); + } +} + +// ============================================================================ +// 碰撞回调 +// ============================================================================ + +void UInteractableComponent::OnDetectionOverlapBegin(UPrimitiveComponent* OverlappedComponent, + AActor* OtherActor, + UPrimitiveComponent* OtherComp, + int32 OtherBodyIndex, + bool bFromSweep, + const FHitResult& SweepResult) +{ + if (!OtherActor) return; + + // 只响应 Character(玩家)的进入 + ACharacter* Character = Cast(OtherActor); + if (!Character) return; + + // 已消耗的交互物不再注册 + if (bIsConsumed) return; + + UE_LOG(LogChildHood, Verbose, TEXT("检测到角色进入范围: %s ← %s"), + *Data.DisplayName, *OtherActor->GetName()); + + // 获取角色的 InteractionManager 并注册自己 + if (UInteractionManager* IM = Character->FindComponentByClass()) + { + IM->RegisterInteractable(this); + } +} + +void UInteractableComponent::OnDetectionOverlapEnd(UPrimitiveComponent* OverlappedComponent, + AActor* OtherActor, + UPrimitiveComponent* OtherComp, + int32 OtherBodyIndex) +{ + if (!OtherActor) return; + + ACharacter* Character = Cast(OtherActor); + if (!Character) return; + + UE_LOG(LogChildHood, Verbose, TEXT("角色离开范围: %s ← %s"), + *Data.DisplayName, *OtherActor->GetName()); + + // 获取角色的 InteractionManager 并注销自己 + if (UInteractionManager* IM = Character->FindComponentByClass()) + { + IM->UnregisterInteractable(this); + } +} diff --git a/Source/ChildHood/Interaction/InteractableComponent.h b/Source/ChildHood/Interaction/InteractableComponent.h new file mode 100644 index 0000000..25142b7 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractableComponent.h @@ -0,0 +1,101 @@ +// ChildHood Project - 可交互物组件 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第12节(跨场景持久化) +// 职责:单个交互物配置数据、触发区域、运行时状态、跨场景持久状态恢复 + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "InteractionTypes.h" +#include "InteractableComponent.generated.h" + +class UChildHoodGameInstance; +class USphereComponent; + +/** + * UInteractableComponent + * + * 挂载在 AInteractableActor(或其子类)上的组件。 + * 持有该交互物的全部配置数据(FInteractableData)、运行时触发检测球、 + * 消耗/门状态管理,以及跨场景持久状态恢复。 + */ +UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent)) +class UInteractableComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + + UInteractableComponent(); + + // === 配置数据 === + + /** 该交互物的全部配置数据,编辑器中按实例独立配置 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact") + FInteractableData Data; + + // === 查询接口 === + + /** 获取配置数据引用(只读) */ + const FInteractableData& GetInteractableData() const { return Data; } + + /** + * 获取提示文本,格式:"[E] 动作词 名称" + * 门类型根据当前开关状态选择动词 + */ + FString GetPromptText() const; + + /** 该交互物是否已被消耗(oneShot 模式) */ + bool IsConsumed() const { return bIsConsumed; } + + /** 标记为已消耗,禁用检测球,写入持久层 */ + void Consume(); + + // === 门状态管理 === + + /** 切换门开关状态 */ + void ToggleDoor(); + + /** 门是否处于打开状态 */ + bool IsDoorOpen() const { return bDoorIsOpen; } + + /** 获取交互物在世界空间的位置 */ + FVector GetWorldLocation() const; + + /** 从 GameInstance 恢复跨场景持久状态(BeginPlay 中调用) */ + void RestorePersistentState(UChildHoodGameInstance* GI); + +protected: + + virtual void BeginPlay() override; + +private: + + /** 运行时创建的检测球,用于触发玩家进入/离开交互范围 */ + UPROPERTY() + USphereComponent* DetectionSphere; + + /** oneShot 物品是否已被消耗 */ + bool bIsConsumed = false; + + /** 门是否处于打开状态(仅 Operate+Door 类型使用) */ + bool bDoorIsOpen = false; + + // === 碰撞回调 === + + /** 玩家进入检测范围 */ + UFUNCTION() + void OnDetectionOverlapBegin(UPrimitiveComponent* OverlappedComponent, + AActor* OtherActor, + UPrimitiveComponent* OtherComp, + int32 OtherBodyIndex, + bool bFromSweep, + const FHitResult& SweepResult); + + /** 玩家离开检测范围 */ + UFUNCTION() + void OnDetectionOverlapEnd(UPrimitiveComponent* OverlappedComponent, + AActor* OtherActor, + UPrimitiveComponent* OtherComp, + int32 OtherBodyIndex); +}; diff --git a/Source/ChildHood/Interaction/InteractionManager.cpp b/Source/ChildHood/Interaction/InteractionManager.cpp new file mode 100644 index 0000000..5401a32 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractionManager.cpp @@ -0,0 +1,815 @@ +// ChildHood Project - 交互管理器核心组件 实现 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(行为规则实现) + +#include "Interaction/InteractionManager.h" +#include "Interaction/InteractableComponent.h" +#include "Interaction/InteractableActor.h" +#include "Interaction/PushCameraActor.h" +#include "ChildHoodCharacter.h" +#include "ChildHoodGameInstance.h" +#include "ChildHood.h" + +#include "GameFramework/Character.h" +#include "GameFramework/PlayerController.h" +#include "GameFramework/SpringArmComponent.h" +#include "Camera/CameraComponent.h" +#include "Components/SphereComponent.h" +#include "Engine/World.h" +#include "TimerManager.h" +#include "Kismet/GameplayStatics.h" + +// 定义交互系统日志类别 +DECLARE_LOG_CATEGORY_EXTERN(LogInteraction, Log, All); +DEFINE_LOG_CATEGORY(LogInteraction); + +// ============================================================================ +// 构造函数 +// ============================================================================ + +UInteractionManager::UInteractionManager() +{ + // 默认关闭 Tick,首个交互物注册时开启 + PrimaryComponentTick.bCanEverTick = true; + PrimaryComponentTick.bStartWithTickEnabled = false; +} + +// ============================================================================ +// 生命周期 +// ============================================================================ + +void UInteractionManager::BeginPlay() +{ + Super::BeginPlay(); + + // Spawn 推镜用临时摄像机 Actor(使用专用的 APushCameraActor) + // 随 Character 生命周期,不反复创建/销毁 + FActorSpawnParameters SpawnParams; + SpawnParams.Owner = GetOwner(); + SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; + + APushCameraActor* SpawnedCamera = GetWorld()->SpawnActor(APushCameraActor::StaticClass(), FVector::ZeroVector, FRotator::ZeroRotator, SpawnParams); + PushCameraActor = SpawnedCamera; + if (PushCameraActor) + { + PushCameraActor->SetActorHiddenInGame(true); + } + + UE_LOG(LogInteraction, Log, TEXT("InteractionManager: BeginPlay 完成, PushCameraActor=%s"), + PushCameraActor ? *PushCameraActor->GetName() : TEXT("nullptr")); +} + +// ============================================================================ +// Tick 驱动 +// ============================================================================ + +void UInteractionManager::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) +{ + Super::TickComponent(DeltaTime, TickType, ThisTickFunction); + + // Free 状态不需要 Tick + if (State == EInteractionState::Free) + { + return; + } + + switch (Phase) + { + case EInteractPhase::Enter: + TickEnterPhase(DeltaTime); + break; + + case EInteractPhase::AnimLock: + TickAnimLockPhase(DeltaTime); + break; + + case EInteractPhase::Active: + // 等待玩家输入,无需 Tick 处理 + break; + + case EInteractPhase::Exit: + TickExitPhase(DeltaTime); + break; + + case EInteractPhase::None: + default: + break; + } +} + +// ============================================================================ +// 公开方法 +// ============================================================================ + +void UInteractionManager::BeginInteract() +{ + // 前置检查:必须 Free 状态 + if (State != EInteractionState::Free) + { + UE_LOG(LogInteraction, Verbose, TEXT("BeginInteract: 拒绝,当前状态不是 Free (State=%d)"), + static_cast(State)); + return; + } + + // 前置检查:必须有选中的交互物 + if (NearbyInteractables.Num() == 0 || !NearbyInteractables.IsValidIndex(SelectedIndex)) + { + UE_LOG(LogInteraction, Verbose, TEXT("BeginInteract: 拒绝,没有选中的交互物")); + return; + } + + UInteractableComponent* Target = NearbyInteractables[SelectedIndex]; + + // 前置检查:交互物不能已被消耗 + if (!Target || Target->IsConsumed()) + { + UE_LOG(LogInteraction, Verbose, TEXT("BeginInteract: 拒绝,交互物已被消耗")); + return; + } + + // 进入交互 + State = EInteractionState::Interacting; + Phase = EInteractPhase::Enter; + CurrentInteractable = Target; + + // 通知 Character 进入交互状态(屏蔽移动) + AChildHoodCharacter* Char = Cast(GetOwner()); + if (Char) + { + Char->SetInteracting(true); + } + + // 隐藏提示 UI + HidePrompt(); + + // 重置 Enter 阶段计时器 + TurnElapsed = 0.0f; + WaitElapsed = 0.0f; + bPushStarted = false; + + const FInteractableData& Data = Target->GetInteractableData(); + UE_LOG(LogInteraction, Log, TEXT("BeginInteract: 开始交互 '%s' (Type=%d)"), + *Data.DisplayName, static_cast(Data.Type)); +} + +void UInteractionManager::CancelInteract() +{ + // 前置检查:必须 Interacting 状态 + if (State != EInteractionState::Interacting) + { + return; + } + + // AnimLock 期间不可取消 + if (Phase == EInteractPhase::AnimLock) + { + UE_LOG(LogInteraction, Verbose, TEXT("CancelInteract: 拒绝,AnimLock 阶段不可取消")); + return; + } + + UE_LOG(LogInteraction, Log, TEXT("CancelInteract: 取消/结束交互")); + + // 隐藏叙事文本 + HideNarrativeText(); + + // 进入 Exit 阶段 + Phase = EInteractPhase::Exit; + + // 相机回位 + const FInteractableData* Data = CurrentInteractable ? &CurrentInteractable->GetInteractableData() : nullptr; + float ReturnDuration = (Data && Data->bUseCameraPush) ? Data->CameraReturnDuration : 0.3f; + ReturnCamera(ReturnDuration); + + // 清除可能残留的计时器 + GetWorld()->GetTimerManager().ClearTimer(AnimEventTimerHandle); + GetWorld()->GetTimerManager().ClearTimer(AnimEndTimerHandle); + + // 设置退出定时器,回位动画播完后回到 Free + GetWorld()->GetTimerManager().SetTimer(ExitTimerHandle, this, &UInteractionManager::OnExitTimerComplete, ReturnDuration, false); +} + +void UInteractionManager::SwitchTarget(int32 Direction) +{ + int32 Num = NearbyInteractables.Num(); + if (Num == 0) + { + return; + } + + // Interacting 状态下忽略切换 + if (State != EInteractionState::Free) + { + return; + } + + SelectedIndex = (SelectedIndex + Direction + Num) % Num; + UpdatePromptUI(); + + UE_LOG(LogInteraction, Log, TEXT("SwitchTarget: 切换到 %d/%d"), SelectedIndex, Num); +} + +void UInteractionManager::RegisterInteractable(UInteractableComponent* Interactable) +{ + if (!Interactable) + { + return; + } + + // 去重检查 + if (NearbyInteractables.Contains(Interactable)) + { + return; + } + + NearbyInteractables.Add(Interactable); + + // 按水平距离排序(DistSquared2D,避免 sqrt) + AActor* Owner = GetOwner(); + if (Owner) + { + FVector OwnerLoc = Owner->GetActorLocation(); + NearbyInteractables.Sort([OwnerLoc](const UInteractableComponent& A, const UInteractableComponent& B) + { + float DistA = FVector::DistSquared2D(OwnerLoc, A.GetWorldLocation()); + float DistB = FVector::DistSquared2D(OwnerLoc, B.GetWorldLocation()); + return DistA < DistB; + }); + } + + UpdateSelection(); + UpdatePromptUI(); + + // 首个交互物注册时开启 Tick + if (NearbyInteractables.Num() == 1) + { + SetComponentTickEnabled(true); + } + + UE_LOG(LogInteraction, Log, TEXT("RegisterInteractable: 注册 '%s', 列表大小=%d"), + *Interactable->GetInteractableData().DisplayName, NearbyInteractables.Num()); +} + +void UInteractionManager::UnregisterInteractable(UInteractableComponent* Interactable) +{ + if (!Interactable) + { + return; + } + + bool bRemoved = NearbyInteractables.Remove(Interactable) > 0; + if (!bRemoved) + { + return; + } + + // 如果移除的是当前正在交互的交互物,清空引用 + if (CurrentInteractable == Interactable) + { + CurrentInteractable = nullptr; + } + + if (NearbyInteractables.Num() > 0) + { + UpdateSelection(); + UpdatePromptUI(); + } + else + { + // 列表为空,隐藏提示 + HidePrompt(); + // 最后一个注销时关闭 Tick + SetComponentTickEnabled(false); + } + + UE_LOG(LogInteraction, Log, TEXT("UnregisterInteractable: 注销 '%s', 列表大小=%d"), + *Interactable->GetInteractableData().DisplayName, NearbyInteractables.Num()); +} + +// ============================================================================ +// Enter 阶段 Tick +// ============================================================================ + +void UInteractionManager::TickEnterPhase(float DeltaTime) +{ + if (!CurrentInteractable) + { + UE_LOG(LogInteraction, Warning, TEXT("TickEnterPhase: CurrentInteractable 为空,强制取消")); + CancelInteract(); + return; + } + + ACharacter* Char = Cast(GetOwner()); + if (!Char) + { + return; + } + + const FInteractableData& Data = CurrentInteractable->GetInteractableData(); + + // --- 1. 转向插值 --- + TurnElapsed += DeltaTime; + + FVector CharLoc = Char->GetActorLocation(); + FVector TargetLoc = CurrentInteractable->GetWorldLocation(); + FVector DirToTarget = (TargetLoc - CharLoc); + DirToTarget.Z = 0.0f; + FRotator TargetRot = DirToTarget.Rotation(); + + // 使用 RInterpTo 进行平滑插值,InterpSpeed = 1/TurnDuration + float InterpSpeed = (TurnDuration > 0.f) ? (1.f / TurnDuration) : 10.f; + FRotator NewRot = FMath::RInterpTo(Char->GetActorRotation(), TargetRot, DeltaTime, InterpSpeed); + Char->SetActorRotation(NewRot); + + // --- 2. 推镜(一次性发起) --- + if (Data.bUseCameraPush && !bPushStarted) + { + FVector PushPos = CalculatePushPosition(CurrentInteractable); + PushCamera(PushPos, Data.CameraPushDuration); + bPushStarted = true; + } + + // --- 3. 判断完成 --- + bool bTurnComplete = TurnElapsed >= TurnDuration; + bool bPushComplete = !Data.bUseCameraPush || bPushStarted; + + if (bTurnComplete && bPushComplete) + { + // 推镜后需要额外等待 PushWaitDelay + if (Data.bUseCameraPush && Data.PushWaitDelay > 0.f) + { + WaitElapsed += DeltaTime; + if (WaitElapsed >= Data.PushWaitDelay) + { + TransitionFromEnter(); + } + } + else + { + TransitionFromEnter(); + } + } +} + +// ============================================================================ +// AnimLock 阶段 Tick(v0.1 由计时器驱动,此处为空) +// ============================================================================ + +void UInteractionManager::TickAnimLockPhase(float DeltaTime) +{ + // v0.1: AnimLock 由 AnimEventTimer 和 AnimEndTimer 驱动,无需 Tick 处理 +} + +// ============================================================================ +// Exit 阶段 Tick(v0.1 由定时器驱动回 Free) +// ============================================================================ + +void UInteractionManager::TickExitPhase(float DeltaTime) +{ + // v0.1: Exit 阶段由 ExitTimerHandle 定时器驱动回到 Free + // 此处可做相机回位进度检查等,简单方案留空 +} + +// ============================================================================ +// Enter 完成后类型分发 +// ============================================================================ + +void UInteractionManager::TransitionFromEnter() +{ + if (!CurrentInteractable) + { + return; + } + + const FInteractableData& Data = CurrentInteractable->GetInteractableData(); + + UE_LOG(LogInteraction, Log, TEXT("TransitionFromEnter: 类型=%d 分发"), static_cast(Data.Type)); + + switch (Data.Type) + { + case EInteractType::Examine: + Phase = EInteractPhase::Active; + ShowNarrativeText(PickRandomNarrative(Data.NarrativeTexts)); + break; + + case EInteractType::Pickup: + Phase = EInteractPhase::AnimLock; + EnterAnimLockPhase(); + break; + + case EInteractType::Operate: + if (Data.OperateSubType == EOperateSubType::Door) + { + Phase = EInteractPhase::AnimLock; + EnterAnimLockPhase(); + } + else if (Data.OperateSubType == EOperateSubType::Search) + { + Phase = EInteractPhase::AnimLock; + EnterAnimLockPhase(); + } + break; + + case EInteractType::Talk: + Phase = EInteractPhase::Active; + ShowNarrativeText(PickRandomNarrative(Data.NarrativeTexts)); + break; + + default: + UE_LOG(LogInteraction, Warning, TEXT("TransitionFromEnter: 未知交互类型 %d"), static_cast(Data.Type)); + // 未知类型直接回 Free + CancelInteract(); + break; + } +} + +// ============================================================================ +// AnimLock 阶段 +// ============================================================================ + +void UInteractionManager::EnterAnimLockPhase() +{ + if (!CurrentInteractable) + { + return; + } + + const FInteractableData& Data = CurrentInteractable->GetInteractableData(); + + // 计算总动画时长:有蒙太奇用蒙太奇长度,否则用配置的 AnimDuration + float TotalAnimTime = Data.AnimDuration; + if (Data.InteractMontage) + { + TotalAnimTime = Data.InteractMontage->GetPlayLength(); + if (TotalAnimTime <= 0.f) + { + TotalAnimTime = Data.AnimDuration; + } + + // 播放蒙太奇 + ACharacter* Char = Cast(GetOwner()); + if (Char && Char->GetMesh()) + { + Char->GetMesh()->GetAnimInstance()->Montage_Play(Data.InteractMontage); + UE_LOG(LogInteraction, Log, TEXT("EnterAnimLockPhase: 播放蒙太奇, 时长=%.2f"), TotalAnimTime); + } + } + else + { + UE_LOG(LogInteraction, Log, TEXT("EnterAnimLockPhase: 无蒙太奇,使用计时器, 时长=%.2f"), TotalAnimTime); + } + + // 动画事件触发计时器(如 60% 时物品消失) + float EventTime = TotalAnimTime * Data.AnimEventTimepoint; + GetWorld()->GetTimerManager().SetTimer(AnimEventTimerHandle, this, &UInteractionManager::ExecuteAnimEvent, EventTime, false); + + // 动画结束计时器 + GetWorld()->GetTimerManager().SetTimer(AnimEndTimerHandle, this, &UInteractionManager::OnAnimLockComplete, TotalAnimTime, false); + + bAnimEventFired = false; +} + +// ============================================================================ +// 动画事件触发回调 +// ============================================================================ + +void UInteractionManager::ExecuteAnimEvent() +{ + if (!CurrentInteractable) + { + return; + } + + bAnimEventFired = true; + const FInteractableData& Data = CurrentInteractable->GetInteractableData(); + + UE_LOG(LogInteraction, Log, TEXT("ExecuteAnimEvent: 类型=%d, 名称='%s'"), + static_cast(Data.Type), *Data.DisplayName); + + switch (Data.Type) + { + case EInteractType::Pickup: + { + // 标记消耗 → 隐藏 Actor → 通知物品系统 + CurrentInteractable->Consume(); + + // 隐藏交互物 Actor + if (AInteractableActor* OwnerActor = Cast(CurrentInteractable->GetOwner())) + { + OwnerActor->SetActorHiddenInGame(true); + } + + // 广播拾取事件 + OnItemPickedUp.Broadcast(Data.ItemId, Data.DisplayName, FName(TEXT("杂物"))); + UE_LOG(LogInteraction, Log, TEXT("ExecuteAnimEvent: Pickup 拾取完成 '%s'"), *Data.DisplayName); + } + break; + + case EInteractType::Operate: + { + if (Data.OperateSubType == EOperateSubType::Door) + { + // 切换门状态 + CurrentInteractable->ToggleDoor(); + + // 持久化门状态到 GameInstance + if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) + { + GI->SetDoorOpen(Data.ItemId, CurrentInteractable->IsDoorOpen()); + } + + // 通知 Actor 处理 Mesh 旋转等视觉反馈 + if (AInteractableActor* OwnerActor = Cast(CurrentInteractable->GetOwner())) + { + OwnerActor->OnInteractStarted(CurrentInteractable); + } + + UE_LOG(LogInteraction, Log, TEXT("ExecuteAnimEvent: Door 门状态切换, 开=%s"), + CurrentInteractable->IsDoorOpen() ? TEXT("true") : TEXT("false")); + } + // Search 类型的结果判定在 OnAnimLockComplete 中处理 + } + break; + + default: + break; + } +} + +// ============================================================================ +// 动画结束回调 +// ============================================================================ + +void UInteractionManager::OnAnimLockComplete() +{ + if (!CurrentInteractable) + { + return; + } + + const FInteractableData& Data = CurrentInteractable->GetInteractableData(); + + UE_LOG(LogInteraction, Log, TEXT("OnAnimLockComplete: 类型=%d, 名称='%s'"), + static_cast(Data.Type), *Data.DisplayName); + + switch (Data.Type) + { + case EInteractType::Pickup: + { + // 直接回到 Free(无 Active/Exit,无相机/UI 需清理) + AChildHoodCharacter* Char = Cast(GetOwner()); + if (Char) + { + Char->SetInteracting(false); + } + State = EInteractionState::Free; + Phase = EInteractPhase::None; + CurrentInteractable = nullptr; + + // 从检测列表移除已消耗的交互物 + // Consume 已在 ExecuteAnimEvent 中调用,但 UnregisterInteractable 应由 DetectionSphere 禁用触发 + // 这里主动从列表移除,确保提示 UI 正确更新 + if (NearbyInteractables.Num() > 0) + { + UpdateSelection(); + UpdatePromptUI(); + } + + UE_LOG(LogInteraction, Log, TEXT("OnAnimLockComplete: Pickup 完成,回到 Free")); + } + break; + + case EInteractType::Operate: + { + if (Data.OperateSubType == EOperateSubType::Door) + { + // 直接回到 Free + AChildHoodCharacter* Char = Cast(GetOwner()); + if (Char) + { + Char->SetInteracting(false); + } + State = EInteractionState::Free; + Phase = EInteractPhase::None; + CurrentInteractable = nullptr; + + // 更新提示(门动词可能变化) + UpdateSelection(); + UpdatePromptUI(); + + UE_LOG(LogInteraction, Log, TEXT("OnAnimLockComplete: Door 完成,回到 Free")); + } + else if (Data.OperateSubType == EOperateSubType::Search) + { + // 翻找结果判定:v0.1 必定有物品 + CurrentInteractable->Consume(); + OnItemFound.Broadcast(Data.ItemId, Data.DisplayName, FName(TEXT("杂物"))); + UE_LOG(LogInteraction, Log, TEXT("OnAnimLockComplete: Search 翻找完成,获得物品 '%s'"), *Data.DisplayName); + + // 进入 Active 阶段等待玩家按 E/ESC 关闭 + Phase = EInteractPhase::Active; + // 有物品时不显示文本底框(架构预留) + } + } + break; + + default: + break; + } +} + +// ============================================================================ +// UI 方法(v0.1 用日志替代) +// ============================================================================ + +void UInteractionManager::UpdateSelection() +{ + // 排序后选中索引重置为 0(最近的) + if (AActor* Owner = GetOwner()) + { + FVector OwnerLoc = Owner->GetActorLocation(); + NearbyInteractables.Sort([OwnerLoc](const UInteractableComponent& A, const UInteractableComponent& B) + { + float DistA = FVector::DistSquared2D(OwnerLoc, A.GetWorldLocation()); + float DistB = FVector::DistSquared2D(OwnerLoc, B.GetWorldLocation()); + return DistA < DistB; + }); + } + SelectedIndex = 0; +} + +void UInteractionManager::UpdatePromptUI() +{ + if (NearbyInteractables.Num() == 0 || !NearbyInteractables.IsValidIndex(SelectedIndex)) + { + HidePrompt(); + return; + } + + UInteractableComponent* Selected = NearbyInteractables[SelectedIndex]; + if (!Selected) + { + HidePrompt(); + return; + } + + // v0.1: 用日志输出替代 UI + FString PromptText = Selected->GetPromptText(); + UE_LOG(LogInteraction, Log, TEXT("UpdatePromptUI: [提示] %s"), *PromptText); +} + +void UInteractionManager::HidePrompt() +{ + // v0.1: 用日志输出替代 UI + UE_LOG(LogInteraction, Verbose, TEXT("HidePrompt: 隐藏交互提示")); +} + +void UInteractionManager::ShowNarrativeText(const FString& Text) +{ + // v0.1: 用日志输出替代 UI + if (Text.IsEmpty()) + { + UE_LOG(LogInteraction, Warning, TEXT("ShowNarrativeText: 文本为空")); + return; + } + UE_LOG(LogInteraction, Log, TEXT("ShowNarrativeText: [文本] %s"), *Text); +} + +void UInteractionManager::HideNarrativeText() +{ + // v0.1: 用日志输出替代 UI + UE_LOG(LogInteraction, Verbose, TEXT("HideNarrativeText: 隐藏叙事文本")); +} + +// ============================================================================ +// 摄像机控制 +// ============================================================================ + +void UInteractionManager::PushCamera(FVector Target, float Duration) +{ + if (!PushCameraActor) + { + UE_LOG(LogInteraction, Warning, TEXT("PushCamera: PushCameraActor 为空")); + return; + } + + // 计算朝向交互物的旋转 + FVector LookTarget = CurrentInteractable ? CurrentInteractable->GetWorldLocation() : Target; + FRotator LookAtRot = (LookTarget - Target).Rotation(); + + PushCameraActor->SetActorHiddenInGame(false); + PushCameraActor->SetActorLocationAndRotation(Target, LookAtRot); + + // 切换 ViewTarget + APlayerController* PC = GetWorld()->GetFirstPlayerController(); + if (PC) + { + PC->SetViewTargetWithBlend(PushCameraActor, Duration, VTBlend_EaseOut); + UE_LOG(LogInteraction, Log, TEXT("PushCamera: 推镜到 %s, 时长=%.2f"), *Target.ToString(), Duration); + } +} + +void UInteractionManager::ReturnCamera(float Duration) +{ + AActor* Owner = GetOwner(); + if (!Owner) + { + return; + } + + APlayerController* PC = GetWorld()->GetFirstPlayerController(); + if (PC) + { + // 回位到 Character + PC->SetViewTargetWithBlend(Owner, Duration, VTBlend_EaseIn); + UE_LOG(LogInteraction, Log, TEXT("ReturnCamera: 相机回位, 时长=%.2f"), Duration); + } + + // 隐藏推镜 Actor + if (PushCameraActor) + { + PushCameraActor->SetActorHiddenInGame(true); + } +} + +// ============================================================================ +// 推镜位置计算 +// ============================================================================ + +FVector UInteractionManager::CalculatePushPosition(UInteractableComponent* Interactable) +{ + if (!Interactable) + { + return FVector::ZeroVector; + } + + const FInteractableData& Data = Interactable->GetInteractableData(); + FVector InteractPos = Interactable->GetWorldLocation(); + FVector CharPos = GetOwner() ? GetOwner()->GetActorLocation() : FVector::ZeroVector; + + FVector Dir = (InteractPos - CharPos); + Dir.Z = 0.0f; + Dir.Normalize(); + + // 交互物前方(面向角色的一侧),推镜距离偏移 + FVector TargetPos = InteractPos - Dir * Data.CameraPushDistance; + // 高度略低于交互物中心(微仰视) + TargetPos.Z = InteractPos.Z * 0.8f; + + return TargetPos; +} + +// ============================================================================ +// 叙事文本随机选取 +// ============================================================================ + +FString UInteractionManager::PickRandomNarrative(const TArray& Texts) +{ + if (Texts.Num() == 0) + { + return TEXT(""); + } + + if (Texts.Num() == 1) + { + LastNarrativeText = Texts[0]; + return Texts[0]; + } + + FString Candidate; + int32 Attempts = 0; + do + { + Candidate = Texts[FMath::RandRange(0, Texts.Num() - 1)]; + Attempts++; + } while (Candidate == LastNarrativeText && Attempts < 10); + + LastNarrativeText = Candidate; + return Candidate; +} + +// ============================================================================ +// Exit 阶段定时器回调 +// ============================================================================ + +void UInteractionManager::OnExitTimerComplete() +{ + UE_LOG(LogInteraction, Log, TEXT("OnExitTimerComplete: Exit 阶段结束,回到 Free")); + + // 通知 Character 解除交互状态 + AChildHoodCharacter* Char = Cast(GetOwner()); + if (Char) + { + Char->SetInteracting(false); + } + + // 回到 Free 状态 + State = EInteractionState::Free; + Phase = EInteractPhase::None; + CurrentInteractable = nullptr; + + // 如果仍在检测范围内,重新显示提示 + if (NearbyInteractables.Num() > 0) + { + UpdateSelection(); + UpdatePromptUI(); + } +} diff --git a/Source/ChildHood/Interaction/InteractionManager.h b/Source/ChildHood/Interaction/InteractionManager.h new file mode 100644 index 0000000..a4fb918 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractionManager.h @@ -0,0 +1,206 @@ +// ChildHood Project - 交互管理器核心组件 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计)+ 第6节(行为规则实现) +// 职责:交互全流程状态机、检测选择、类型分发、摄像机/转向/UI 控制 + +#pragma once + +#include "CoreMinimal.h" +#include "Components/ActorComponent.h" +#include "InteractionTypes.h" +#include "InteractionManager.generated.h" + +class UInteractableComponent; +class APushCameraActor; +class AChildHoodCharacter; + +/** + * UInteractionManager + * + * 挂载在 AChildHoodCharacter 上的组件,负责交互全流程管理: + * - 层级状态机驱动(Free / Interacting + 子阶段) + * - 附近交互物检测列表排序与选中管理 + * - 四种交互类型分发(观察/拾取/操作/对话) + * - 角色自动转向插值 + * - 摄像机推镜/回位控制 + * - UI 提示与文本显示控制(v0.1 先用日志) + */ +UCLASS(ClassGroup=(Interaction), meta=(BlueprintSpawnableComponent)) +class UInteractionManager : public UActorComponent +{ + GENERATED_BODY() + +public: + + UInteractionManager(); + + // === 公开方法 === + + /** 发起交互(E键触发),前置检查通过后进入 Enter 阶段 */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void BeginInteract(); + + /** 取消/结束交互(ESC/E键在Active阶段触发),AnimLock期间不可取消 */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void CancelInteract(); + + /** 切换选中交互物(Tab/滚轮),Direction: +1下一个 / -1上一个 */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void SwitchTarget(int32 Direction); + + /** 注册交互物到检测列表(由 InteractableComponent 的碰撞回调触发) */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void RegisterInteractable(UInteractableComponent* Interactable); + + /** 从检测列表注销交互物(离开范围或消耗时触发) */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void UnregisterInteractable(UInteractableComponent* Interactable); + + // === 委托 === + + /** 物品拾取事件 — 通知物品系统(Pickup 类型 ExecuteAnimEvent 中广播) */ + UPROPERTY(BlueprintAssignable, Category = "Interaction|Events") + FMLocalItemEvent OnItemPickedUp; + + /** 物品获得事件 — 通知收集系统(Search 类型 OnAnimLockComplete 中广播) */ + UPROPERTY(BlueprintAssignable, Category = "Interaction|Events") + FMLocalItemEvent OnItemFound; + + // === 可调参数 === + + /** 角色转向时长(秒) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning") + float TurnDuration = 0.2f; + + /** 提示 UI 淡入时长(秒) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning") + float PromptFadeInDuration = 0.2f; + + /** 提示 UI 淡出时长(秒) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interaction|Tuning") + float PromptFadeOutDuration = 0.2f; + +protected: + + virtual void BeginPlay() override; + virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; + +private: + + // === 状态机 === + + /** 交互系统顶层状态 */ + EInteractionState State = EInteractionState::Free; + + /** Interacting 子阶段 */ + EInteractPhase Phase = EInteractPhase::None; + + // === 检测与选择 === + + /** 当前附近的交互物列表,按水平距离排序 */ + UPROPERTY() + TArray NearbyInteractables; + + /** 当前选中的交互物索引 */ + int32 SelectedIndex = 0; + + /** 当前正在交互的交互物(交互过程中锁定) */ + UPROPERTY() + UInteractableComponent* CurrentInteractable = nullptr; + + // === 摄像机控制 === + + /** 推镜用临时摄像机 Actor(BeginPlay 中 Spawn) */ + UPROPERTY() + TObjectPtr PushCameraActor; + + /** 推镜是否已发起(Enter阶段一次性标志) */ + bool bPushStarted = false; + + // === 计时器 === + + /** 转向已用时间 */ + float TurnElapsed = 0.0f; + + /** 推镜后等待已用时间 */ + float WaitElapsed = 0.0f; + + /** 动画事件计时器(如60%时物品消失) */ + FTimerHandle AnimEventTimerHandle; + + /** 动画结束计时器 */ + FTimerHandle AnimEndTimerHandle; + + /** 退出阶段回位计时器 */ + FTimerHandle ExitTimerHandle; + + /** 动画事件是否已触发 */ + bool bAnimEventFired = false; + + // === UI 引用(v0.1 后续任务创建) === + + /** 交互提示 Widget(v0.1 用日志替代) */ + UPROPERTY() + TObjectPtr PromptWidget; + + /** 文本底框 Widget(v0.1 用日志替代) */ + UPROPERTY() + TObjectPtr NarrativeWidget; + + // === 文本选择 === + + /** 上次显示的叙事文本(用于不重复选取) */ + FString LastNarrativeText; + + // === 内部方法 === + + /** 更新选中交互物(排序后 SelectedIndex = 0) */ + void UpdateSelection(); + + /** 更新提示 UI(v0.1 用日志输出) */ + void UpdatePromptUI(); + + /** 隐藏提示 UI(v0.1 用日志输出) */ + void HidePrompt(); + + /** Enter 阶段 Tick:转向插值 + 推镜 + 等待 */ + void TickEnterPhase(float DeltaTime); + + /** AnimLock 阶段 Tick(v0.1 由计时器驱动,此处可为空) */ + void TickAnimLockPhase(float DeltaTime); + + /** Exit 阶段 Tick(v0.1 由定时器驱动回 Free) */ + void TickExitPhase(float DeltaTime); + + /** Enter 阶段完成后,根据交互类型分发到下一阶段 */ + void TransitionFromEnter(); + + /** 进入 AnimLock 阶段:播放蒙太奇或启动计时器 */ + void EnterAnimLockPhase(); + + /** 动画事件触发点回调(如拾取物消失、门状态切换) */ + void ExecuteAnimEvent(); + + /** 动画结束回调 */ + void OnAnimLockComplete(); + + /** 显示叙事文本(v0.1 用日志输出) */ + void ShowNarrativeText(const FString& Text); + + /** 隐藏叙事文本(v0.1 用日志输出) */ + void HideNarrativeText(); + + /** 推镜:移动 PushCameraActor 并切换 ViewTarget */ + void PushCamera(FVector Target, float Duration); + + /** 相机回位:切换回 Character 的 FollowCamera */ + void ReturnCamera(float Duration); + + /** 计算推镜目标位置 */ + FVector CalculatePushPosition(UInteractableComponent* Interactable); + + /** 随机选取叙事文本,排除上次 */ + FString PickRandomNarrative(const TArray& Texts); + + /** Exit 阶段定时器回调:回到 Free 状态 */ + void OnExitTimerComplete(); +}; diff --git a/Source/ChildHood/Interaction/InteractionTypes.h b/Source/ChildHood/Interaction/InteractionTypes.h new file mode 100644 index 0000000..4c8bc98 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractionTypes.h @@ -0,0 +1,178 @@ +// ChildHood Project - 交互系统数据类型定义 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第5节(核心数据结构)+ 第12节(跨场景持久化) + +#pragma once + +#include "CoreMinimal.h" +#include "InteractionTypes.generated.h" + +// ============================================================================ +// 枚举 +// ============================================================================ + +/** 交互类型 */ +UENUM(BlueprintType) +enum class EInteractType : uint8 +{ + Examine UMETA(DisplayName = "观察"), + Pickup UMETA(DisplayName = "拾取"), + Operate UMETA(DisplayName = "操作"), + Talk UMETA(DisplayName = "对话") +}; + +/** 操作子类型(仅 Operate 类型有效) */ +UENUM(BlueprintType) +enum class EOperateSubType : uint8 +{ + Door UMETA(DisplayName = "门(开关切换)"), + Search UMETA(DisplayName = "翻找") +}; + +/** 交互系统顶层状态 */ +UENUM(BlueprintType) +enum class EInteractionState : uint8 +{ + Free UMETA(DisplayName = "自由状态"), + Interacting UMETA(DisplayName = "交互中"), + SubSystem UMETA(DisplayName = "子系统(v0.1预留)"), + Cutscene UMETA(DisplayName = "过场(v0.1预留)") +}; + +/** Interacting 子阶段 */ +UENUM(BlueprintType) +enum class EInteractPhase : uint8 +{ + None UMETA(DisplayName = "无"), + Enter UMETA(DisplayName = "进入(转向+推镜)"), + AnimLock UMETA(DisplayName = "动画锁定"), + Active UMETA(DisplayName = "活跃(等待输入)"), + Exit UMETA(DisplayName = "退出(回位)") +}; + +/** 交互结束后动作 */ +UENUM(BlueprintType) +enum class EPostAction : uint8 +{ + Return UMETA(DisplayName = "回到Free"), + Minigame UMETA(DisplayName = "小游戏(v0.1预留)"), + Dialogue UMETA(DisplayName = "对话(v0.1预留)"), + SceneChange UMETA(DisplayName = "场景切换(v0.1预留)") +}; + +// ============================================================================ +// 配置数据结构 +// ============================================================================ + +/** 可交互物配置数据 — 每个交互物实例独立配置 */ +USTRUCT(BlueprintType) +struct FInteractableData +{ + GENERATED_BODY() + + // === 基本信息 === + + /** 唯一ID(如 "fridge_home"),用于持久化存储和物品系统对接 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") + FName ItemId; + + /** 显示名称(如 "旧冰箱"),用于提示UI */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") + FString DisplayName; + + /** 交互类型 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic") + EInteractType Type = EInteractType::Examine; + + /** 操作子类型,仅 Type==Operate 时可见可编辑 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Basic", + meta = (EditCondition = "Type == EInteractType::Operate", EditConditionHides)) + EOperateSubType OperateSubType = EOperateSubType::Door; + + // === 触发 === + + /** 触发半径(cm),默认1.5m */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger", + meta = (ClampMin = "50.0", ClampMax = "500.0")) + float TriggerRadius = 150.0f; + + /** 是否一次性消耗(消耗后不再可交互) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Trigger") + bool bOneShot = false; + + // === 提示文本 === + + /** 动作词(如 "查看"、"拾取"、"打开") */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt") + FString InteractVerb; + + /** 备选动词(如 "关上"),门打开时使用,仅 Operate+Door 时可见 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Prompt", + meta = (EditCondition = "Type == EInteractType::Operate && OperateSubType == EOperateSubType::Door")) + FString InteractVerbAlt; + + // === 叙事/对话文本 === + + /** 文本列表,随机选取,不重复上次 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Text") + TArray NarrativeTexts; + + // === 摄像机推镜 === + + /** 是否使用摄像机推镜 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera") + bool bUseCameraPush = false; + + /** 推镜距离(cm),仅 bUseCameraPush 时可编辑 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", + meta = (EditCondition = "bUseCameraPush", ClampMin = "10.0", ClampMax = "300.0")) + float CameraPushDistance = 80.0f; + + /** 推镜过渡时长(秒),仅 bUseCameraPush 时可编辑 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", + meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0")) + float CameraPushDuration = 0.5f; + + /** 相机回位时长(秒),仅 bUseCameraPush 时可编辑 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", + meta = (EditCondition = "bUseCameraPush", ClampMin = "0.1", ClampMax = "2.0")) + float CameraReturnDuration = 0.3f; + + /** 推镜完成后到文本显示的等待时间(秒),仅 bUseCameraPush 时可编辑 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Camera", + meta = (EditCondition = "bUseCameraPush", ClampMin = "0.0", ClampMax = "1.0")) + float PushWaitDelay = 0.2f; + + // === 动画 === + + /** 交互动画蒙太奇(可选,为空时用计时器模拟) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim") + TObjectPtr InteractMontage; + + /** 无蒙太奇时的动画持续时间(秒) */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim", + meta = (ClampMin = "0.1", ClampMax = "5.0")) + float AnimDuration = 1.0f; + + /** 动画事件触发点(0-1比例),如0.6表示动画播放至60%时触发物品消失 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Anim", + meta = (ClampMin = "0.0", ClampMax = "1.0")) + float AnimEventTimepoint = 0.6f; + + // === 结束动作 === + + /** 交互结束后的动作 */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Interact|Post") + EPostAction PostAction = EPostAction::Return; +}; + +// ============================================================================ +// 委托 +// ============================================================================ + +/** 物品拾取/获得事件 — 通知物品系统和收集系统 */ +DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams( + FMLocalItemEvent, + FName, ItemId, + const FString&, DisplayName, + const FName&, Category +); diff --git a/Source/ChildHood/Interaction/PushCameraActor.cpp b/Source/ChildHood/Interaction/PushCameraActor.cpp new file mode 100644 index 0000000..e32cf3c --- /dev/null +++ b/Source/ChildHood/Interaction/PushCameraActor.cpp @@ -0,0 +1,19 @@ +// ChildHood Project - 推镜用临时摄像机 实现 + +#include "Interaction/PushCameraActor.h" + +#include "Camera/CameraComponent.h" + +// ============================================================================ +// 构造函数 +// ============================================================================ + +APushCameraActor::APushCameraActor() +{ + PrimaryActorTick.bCanEverTick = false; + + // 创建摄像机组件 + CameraComp = CreateDefaultSubobject(TEXT("CameraComp")); + // 不需要根组件的碰撞 + SetRootComponent(CameraComp); +} diff --git a/Source/ChildHood/Interaction/PushCameraActor.h b/Source/ChildHood/Interaction/PushCameraActor.h new file mode 100644 index 0000000..d8dd971 --- /dev/null +++ b/Source/ChildHood/Interaction/PushCameraActor.h @@ -0,0 +1,33 @@ +// ChildHood Project - 推镜用临时摄像机 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第3节(类设计) +// 职责:极简 Actor,仅持有一个 UCameraComponent,供 SetViewTargetWithBlend 使用 + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "PushCameraActor.generated.h" + +class UCameraComponent; + +/** + * APushCameraActor + * + * 交互系统推镜时使用的临时摄像机 Actor。 + * 由 InteractionManager 在 BeginPlay 中 Spawn,随 Character 生命周期。 + * InteractionManager 在推镜时设置其 Location/Rotation, + * 然后通过 PlayerController::SetViewTargetWithBlend 切换 ViewTarget。 + */ +UCLASS() +class APushCameraActor : public AActor +{ + GENERATED_BODY() + +public: + + APushCameraActor(); + + /** 推镜摄像机组件 */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components") + UCameraComponent* CameraComp; +};