From c4ea51b7e6cd9937872903ad622b0b4e71fa307d Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 10:52:13 +0800 Subject: [PATCH 1/7] =?UTF-8?q?chore(memory):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=AD=96=E5=88=92Agent=E8=AE=B0=E5=BF=86=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/agent-memory/game-designer/MEMORY.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.claude/agent-memory/game-designer/MEMORY.md b/.claude/agent-memory/game-designer/MEMORY.md index 11f106a..7339a7a 100644 --- a/.claude/agent-memory/game-designer/MEMORY.md +++ b/.claude/agent-memory/game-designer/MEMORY.md @@ -1,4 +1,3 @@ # 策划 Agent Memory Index - [千禧年怀旧文化灵感](nostalgia-culture-inspirations.md) — 电视剧/广播体操/广场舞/电话热线游戏的灵感记录与共识 -- [v0.1执行案状态](v01-execution-status.md) — 三份执行案已通过二次质量门审查,流水线可推进至④程序设计 From 8ee21e5e80e0f4271a340f061f81073299e32255 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 10:52:14 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(technical):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=B3=BB=E7=BB=9F=E5=92=8C=E6=94=B6=E9=9B=86?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=8A=80=E6=9C=AF=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/technical/交互系统-技术方案.md | 136 +++++++++++++++++++-- docs/technical/收集系统-技术方案.md | 179 +++++++++++++++++++++++++--- 2 files changed, 291 insertions(+), 24 deletions(-) diff --git a/docs/technical/交互系统-技术方案.md b/docs/technical/交互系统-技术方案.md index 431f08a..844d97f 100644 --- a/docs/technical/交互系统-技术方案.md +++ b/docs/technical/交互系统-技术方案.md @@ -1,8 +1,8 @@ # 交互系统 技术设计文档(v0.1) > 对应执行案: `docs/design/交互系统-执行案.md` (v0.1.2) -> 关联系统: 角色控制系统、物品系统、收集系统 -> 日期: 2026-06-07 +> 关联系统: 角色控制系统、物品系统、收集系统、GameInstance 持久化层 +> 日期: 2026-06-07(2026-06-11 更新:加入多场景持久化架构) --- @@ -14,6 +14,16 @@ **预估工期**:5-7 天 **新增/修改代码量**:约 600-800 行 C++ + 3 个 UMG 蓝图 +### 跨场景持久化架构 + +> 本项目后续将支持多场景切换(如城郊 → 乡下)。UE5 的 `OpenLevel()` 会销毁整个旧 World,包括 Character 及其所有 UActorComponent。因此需要将**持久数据**与**运行时行为**分离: + +- **持久数据层**:`UChildHoodGameInstance`(跨关卡存活),存储已收集物品 ID、门状态等 +- **行为层**:`UInteractionManager` / `UInteractableComponent`(随 Character/场景销毁),仅负责运行时交互逻辑 +- **恢复机制**:`AInteractableActor::BeginPlay()` 时查询 GameInstance,若物品已收集则隐藏并禁用交互 + +v0.1 为单场景原型,此架构以接口预留形式存在,不影响当前功能。详见 [第 12 节:跨场景持久化设计](#12-跨场景持久化设计)。 + --- ## 1. 需求功能点罗列 @@ -130,6 +140,16 @@ classDiagram +GetInteractionManager() UInteractionManager* } + class UChildHoodGameInstance { + -TSet~FName~ CollectedItemIDs + -TMap~FName_bool~ DoorStates + +IsItemCollected(FName ItemId) bool + +MarkItemCollected(FName ItemId) void + +IsDoorOpen(FName DoorId) bool + +SetDoorOpen(FName DoorId, bool bIsOpen) void + +ResetAll() void + } + class AChildHoodPlayerController { +SetupInputComponent() void +OnInteractPressed() void @@ -186,6 +206,7 @@ classDiagram +ToggleDoor() void +IsDoorOpen() bool +GetWorldLocation() FVector + +RestorePersistentState(UChildHoodGameInstance*) void #OnDetectionOverlapBegin(UPrimitiveComponent*, AActor*) void #OnDetectionOverlapEnd(UPrimitiveComponent*, AActor*) void } @@ -230,6 +251,8 @@ classDiagram AInteractableActor *-- UStaticMeshComponent : 组合 UInteractableComponent *-- FInteractableData : 组合(内含) UInteractableComponent o-- USphereComponent : 聚合(引用子对象) + UInteractableComponent ..> UChildHoodGameInstance : 恢复持久状态 + UInteractionManager ..> UChildHoodGameInstance : 写入消耗/门状态 ``` ### 关系类型说明 @@ -239,15 +262,17 @@ classDiagram | 组合 `*--` | 生命周期绑定 | Character 持有 InteractionManager;InteractableActor 持有 InteractableComponent | | 聚合 `o--` | 引用但非拥有 | InteractionManager 引用 PushCameraActor;InteractableComponent 引用 DetectionSphere(子对象) | | 关联 `-->` | 持久引用 | Character 关联 PlayerController;InteractionManager 引用 InteractableComponent 列表 | +| 依赖 `..>` | 临时使用 | InteractableComponent 从 GameInstance 恢复状态;InteractionManager 向 GameInstance 写入消耗/门状态 | ### 新增类 | 类名 | 类型 | 职责 | 位置 | |------|------|------|------| | `UInteractionManager` | UActorComponent | 交互全流程状态机、检测选择、类型分发、摄像机/转向/UI 控制 | `Source/ChildHood/Interaction/` | -| `UInteractableComponent` | UActorComponent | 单个交互物配置数据、触发区域、运行时状态 | `Source/ChildHood/Interaction/` | +| `UInteractableComponent` | UActorComponent | 单个交互物配置数据、触发区域、运行时状态、跨场景持久状态恢复 | `Source/ChildHood/Interaction/` | | `AInteractableActor` | AActor | 通用交互物 Actor 基类,持有 Mesh + InteractableComponent | `Source/ChildHood/Interaction/` | | `APushCameraActor` | AActor | 推镜用临时摄像机,仅含 UCameraComponent | `Source/ChildHood/Interaction/` | +| `UChildHoodGameInstance` | UGameInstance | 跨关卡持久数据持有者:已收集物品 ID、门状态等 | `Source/ChildHood/` | ### 修改类 @@ -599,6 +624,7 @@ void UInteractionManager::SwitchTarget(int32 Direction) - **结束回到 Free**:Exit 阶段完成后 `State = Free` - **检测标记恢复**:回到 Free 后检查 `NearbyInteractables.Num() > 0`,若有则立即更新提示 UI - **oneShot 消耗**:`Consume()` 设置 `bIsConsumed = true`,注销检测区域(`DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision)`),从 NearbyInteractables 中移除 +- **跨场景持久化**:`Consume()` 同时调用 `GameInstance->MarkItemCollected(ItemId)` 写入持久层,确保场景切换后仍可恢复已消耗状态 --- @@ -794,6 +820,11 @@ void UInteractionManager::EnterAnimLockPhase() if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) { CurrentInteractable->ToggleDoor(); // bDoorIsOpen = !bDoorIsOpen + // 持久化门状态到 GameInstance + if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) + { + GI->SetDoorOpen(Data.ItemId, CurrentInteractable->IsDoorOpen()); + } // 通知 Actor 处理门 Mesh 旋转 if (AInteractableActor* Owner = Cast(CurrentInteractable->GetOwner())) Owner->OnInteractStarted(CurrentInteractable); @@ -917,7 +948,7 @@ void UInteractionManager::EnterAnimLockPhase() 1. **Tick 开关优化**:State == Free 且 NearbyInteractables 为空时,关闭 TickComponent(`SetComponentTickEnabled(false)`)。注册首个交互物时开启。 2. **推镜摄像机生命周期**:APushCameraActor 在 InteractionManager::BeginPlay 中 Spawn,随 Character 生命周期,不反复创建/销毁。 3. **委托通知时机**:OnItemPickedUp / OnItemFound 在 ExecuteAnimEvent() 中广播,此时动画尚未结束。物品系统和收集系统需处理"动画未结束但已通知"的情况(v0.1 物品系统尚未实现,此通知仅作为架构预留)。 -4. **门状态持久化**:v0.1 门状态仅保存在内存中(bDoorIsOpen),关卡重新加载后重置。不需要持久化到存档。 +4. **门状态持久化**:门状态通过 `UChildHoodGameInstance` 持久化。每次开关门时写入 `GI->SetDoorOpen()`,`InteractableComponent::RestorePersistentState()` 在 BeginPlay 时读取恢复。v0.1 单场景下等效于首次加载时所有门为关闭状态。 5. **NPC 对话简化**:v0.1 Talk 类型在架构上与 Examine 共用 Active 阶段逻辑,仅文本来源不同。后续版本加入对话树时需扩展 Active 阶段。 --- @@ -988,10 +1019,11 @@ AInteractableActor (编辑器实例) Source/ChildHood/ ├── Interaction/ │ ├── InteractionTypes.h # 枚举、结构体、委托定义 -│ ├── InteractionManager.h/.cpp # 交互状态机核心组件 -│ ├── InteractableComponent.h/.cpp # 可交互物组件(数据+检测) -│ ├── InteractableActor.h/.cpp # 通用交互物 Actor 基类 +│ ├── InteractionManager.h/.cpp # 交互状态机核心组件(含 GameInstance 写入调用) +│ ├── InteractableComponent.h/.cpp # 可交互物组件(数据+检测+持久状态恢复) +│ ├── InteractableActor.h/.cpp # 通用交互物 Actor 基类(BeginPlay 调用恢复) │ └── PushCameraActor.h/.cpp # 推镜用临时摄像机 +├── ChildHoodGameInstance.h/.cpp # 新增:跨关卡持久数据持有者 ├── ChildHoodCharacter.h/.cpp # 修改:添加交互支持 └── ChildHoodPlayerController.h/.cpp # 修改:添加交互输入绑定 @@ -1013,6 +1045,96 @@ Content/ --- +## 12. 跨场景持久化设计 + +> UE5 的 `OpenLevel()`(非无缝跳转)会销毁整个旧 World,包括 Character 及其所有 UActorComponent。本节定义交互系统如何通过 `UChildHoodGameInstance` 实现跨场景数据持久化。 + +### 架构原则 + +``` +┌──────────────────────────────────────────┐ +│ UChildHoodGameInstance │ ← 跨关卡存活 +│ ├── CollectedItemIDs: TSet │ 交互物消耗状态 +│ └── DoorStates: TMap │ 门开关状态 +└──────────────────────────────────────────┘ + ↕ 读取/写入 +┌──────────────────────────────────────────┐ +│ 场景中的 AInteractableActor │ ← 随场景加载/销毁 +│ └── BeginPlay → RestorePersistentState │ 查询并恢复状态 +└──────────────────────────────────────────┘ +``` + +### UChildHoodGameInstance 接口 + +```cpp +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: + TSet CollectedItemIDs; + TMap DoorStates; +}; +``` + +### 恢复流程 + +`AInteractableActor::BeginPlay()` 或 `UInteractableComponent::BeginPlay()` 中: + +```cpp +void UInteractableComponent::RestorePersistentState(UChildHoodGameInstance* GI) +{ + if (!GI) return; + + // oneShot 物品:已消耗 → 隐藏 + 禁用检测 + if (Data.bOneShot && GI->IsItemCollected(Data.ItemId)) + { + bIsConsumed = true; + if (USphereComponent* Sphere = DetectionSphere) + Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision); + if (AInteractableActor* Owner = Cast(GetOwner())) + Owner->SetActorHiddenInGame(true); + } + + // 门:恢复开关状态 + if (Data.Type == EInteractType::Operate && Data.OperateSubType == EOperateSubType::Door) + { + bDoorIsOpen = GI->IsDoorOpen(Data.ItemId); + // 通知 Actor 应用视觉状态 + if (AInteractableActor* Owner = Cast(GetOwner())) + Owner->OnInteractStarted(this); // 复用已有的 Mesh 旋转逻辑 + } +} +``` + +### 写入时机 + +| 事件 | 调用位置 | 写入方法 | +|------|---------|---------| +| oneShot 物品被消耗 | `UInteractableComponent::Consume()` | `GI->MarkItemCollected(ItemId)` | +| 门开关状态变化 | `InteractionManager::ExecuteAnimEvent()` Door 分支 | `GI->SetDoorOpen(ItemId, bIsOpen)` | + +### v0.1 行为 + +- v0.1 为单场景原型,不存在跨场景切换 +- `UChildHoodGameInstance` 在 v0.1 中以空数据初始化,所有 `IsItemCollected()` 返回 false +- 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为 `UChildHoodGameInstance`,无需修改交互系统代码 + +--- + ## 工期预估与实现拆分 ### 工作量估算 diff --git a/docs/technical/收集系统-技术方案.md b/docs/technical/收集系统-技术方案.md index 581d45d..addf516 100644 --- a/docs/technical/收集系统-技术方案.md +++ b/docs/technical/收集系统-技术方案.md @@ -2,8 +2,8 @@ > 任务ID: P-009 > 对应执行案: `docs/design/收集系统-执行案.md` (v0.1.1) -> 关联系统: 物品系统(上游单向接收)、交互系统(间接,通过物品系统委托) -> 日期: 2026-06-07 +> 关联系统: 物品系统(上游单向接收)、交互系统(间接,通过物品系统委托)、GameInstance 持久化层 +> 日期: 2026-06-07(2026-06-11 更新:加入多场景持久化架构) --- @@ -15,6 +15,16 @@ **预估工期**:2-3 天 **新增/修改代码量**:约 250-350 行 C++ + 3 个 UMG 蓝图 +### 跨场景持久化架构 + +> 收集进度是玩家核心持久数据。当前设计中 `UCollectionManager` 将「行为控制」(UI、通知、动画)和「持久数据」(CollectedIDs、Progress)混在同一组件,随 Character 销毁而丢失。需要通过数据层分离解决: + +- **持久数据层**:收集状态(CollectedIDs、Progress、bIsComplete)存储在 `UChildHoodGameInstance` 中(跨关卡存活) +- **行为层**:`UCollectionManager` 仅负责运行时 UI 控制和委托监听,BeginPlay 时从 GameInstance 恢复状态 +- **交互物恢复**:场景中的已收集物品由交互系统通过 `InteractableComponent::RestorePersistentState()` 处理(见交互系统技术方案第 12 节) + +v0.1 为单场景原型,此架构以接口预留形式存在,不影响当前功能。详见 [第 12 节:跨场景持久化设计](#12-跨场景持久化设计)。 + --- ## 1. 需求功能点罗列 @@ -123,6 +133,17 @@ classDiagram -CheckCompletion() void -ShowCompletionPrompt() void -UpdateProgressUI() void + -LoadStateFromGameInstance() void + -SaveStateToGameInstance() void + } + + class UChildHoodGameInstance { + -TSet~FName~ CollectedItemIDs + -int32 CollectionProgress + -bool bCollectionComplete + +GetCollectionState(TSet_FName_&, int32&, bool&) void + +SetCollectionState(TSet_FName_&, int32, bool) void + +ResetAll() void } class UCollectionNotifWidget { @@ -175,6 +196,7 @@ classDiagram AChildHoodCharacter *-- UCollectionManager : 组合 UCollectionManager --> UInteractionManager : 关联(绑定委托) + UCollectionManager ..> UChildHoodGameInstance : 读取/写入持久状态 UCollectionManager o-- UCollectionNotifWidget : 聚合(管理生命周期) UCollectionManager o-- UCollectionProgressWidget : 聚合(管理生命周期) UCollectionManager ..> UCollectionCompleteWidget : 按需创建 @@ -188,7 +210,7 @@ classDiagram | 组合 `*--` | 生命周期绑定 | Character 持有 CollectionManager;CollectionManager 内含 Config | | 聚合 `o--` | 引用但非拥有 | CollectionManager 管理通知/进度 Widget 的创建和销毁 | | 关联 `-->` | 持久引用 | CollectionManager 绑定 InteractionManager 的委托 | -| 依赖 `..>` | 临时使用 | CollectionManager 按需创建完成提示 Widget | +| 依赖 `..>` | 临时使用 | CollectionManager 从 GameInstance 读取/写入持久状态;按需创建完成提示 Widget | ### 新增类 @@ -489,6 +511,8 @@ void UCollectionManager::ProcessNewCollection(FName ItemId) // 步骤 3: 记录 CollectedIDs.Add(ItemId); Progress = CollectedIDs.Num(); // 用 Set 大小作为权威进度值,避免状态不一致 + // 持久化到 GameInstance + SaveStateToGameInstance(); ``` --- @@ -519,6 +543,7 @@ void UCollectionManager::ProcessNewCollection(FName ItemId) if (Progress >= Config.TotalCount && !bIsComplete) { bIsComplete = true; + SaveStateToGameInstance(); // 持久化完成状态 CheckCompletion(); // 触发完成表现 } } @@ -637,10 +662,8 @@ void UCollectionManager::BeginPlay() { Super::BeginPlay(); - // 初始化状态 - Progress = 0; - CollectedIDs.Empty(); - bIsComplete = false; + // 从 GameInstance 恢复持久状态(v0.1 单场景等效于空数据初始化) + LoadStateFromGameInstance(); // 创建进度 UI if (ProgressWidgetClass) @@ -652,7 +675,12 @@ void UCollectionManager::BeginPlay() if (ProgressWidget) { ProgressWidget->AddToViewport(5); - ProgressWidget->UpdateProgress(0, Config.TotalCount); + ProgressWidget->UpdateProgress(Progress, Config.TotalCount); + // 已完成时恢复完成样式 + if (bIsComplete) + { + ProgressWidget->SetCompleteStyle(); + } } } } @@ -772,20 +800,34 @@ void UCollectionManager::ShowCompletionPrompt() #### 重启状态处理 -**实现**:v0.1 无持久化。CollectionManager::BeginPlay() 中完全重置: +**实现**:收集状态通过 `UChildHoodGameInstance` 跨场景持久化。CollectionManager::BeginPlay() 中从 GameInstance 恢复: ```cpp -void UCollectionManager::BeginPlay() +void UCollectionManager::LoadStateFromGameInstance() { - Super::BeginPlay(); - Progress = 0; - CollectedIDs.Empty(); - bIsComplete = false; - // ... 创建 UI、绑定委托 + if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) + { + GI->GetCollectionState(CollectedIDs, Progress, bIsComplete); + } + else + { + // 回退:无 GameInstance 时初始化为空(编辑器预览等场景) + Progress = 0; + CollectedIDs.Empty(); + bIsComplete = false; + } +} + +void UCollectionManager::SaveStateToGameInstance() +{ + if (UChildHoodGameInstance* GI = GetWorld()->GetGameInstance()) + { + GI->SetCollectionState(CollectedIDs, Progress, bIsComplete); + } } ``` -重启后进度 UI 恢复显示 `📦 杂物 0/10`。 +v0.1 单场景下:GameInstance 以空数据初始化,等效于 `📦 杂物 0/10`。场景内正常收集后状态写入 GameInstance,若通过 Seamless Travel 切换关卡则自动恢复。非无缝跳转(OpenLevel)时 GameInstance 仍存活,新场景的 CollectionManager 可读取之前的数据。 #### 收集通知与拾取通知时序 @@ -819,6 +861,7 @@ void UCollectionManager::BeginPlay() ### 注意事项 1. **委托绑定时机**:CollectionManager 在 BeginPlay() 中通过 `GetOwner()->GetComponentByClass()` 获取引用并绑定。需确保 InteractionManager 已初始化。若为 nullptr 则在 Tick 中延迟绑定(最多重试 N 帧后报错)。 +2. **持久化读写时机**:`SaveStateToGameInstance()` 在每次 ProcessNewCollection 记录后和完成判定后调用,保证 GameInstance 中的数据始终最新。`LoadStateFromGameInstance()` 仅在 BeginPlay 中调用一次。 2. **v0.1 无拾取通知 UI**:执行文档中拾取通知由物品系统负责,但 v0.1 物品系统仅为委托层(InteractionManager 上的委托)。收集通知的 0.3s 延迟仍按设计实现(为拾取通知预留时间窗口),即使拾取通知 UI 暂未实现。 3. **通知移除回调**:UCollectionNotifWidget 淡出完成后需通知 CollectionManager 从 ActiveNotifications 中移除自身引用。使用委托或弱引用回调实现,避免循环引用。 4. **FName 作为 ID**:收集系统使用 FName(ItemId)作为去重键,与 InteractionManager 委托广播的 ItemId 类型一致。 @@ -847,14 +890,33 @@ InteractionManager (ExecuteAnimEvent) ├─ 查品类 (Category == TrackedCategory?) → 不匹配则忽略 ├─ 查重复 (CollectedIDs.Contains?) → 已存在则忽略 ├─ 记录 (CollectedIDs.Add + Progress = Num()) + ├─ SaveStateToGameInstance() → UChildHoodGameInstance (持久化) ├─ UpdateProgressUI() → WBP_CollectionProgress (文本 + 弹性动画) ├─ SetTimer(NotifDelay) → ShowCollectionNotification() → WBP_CollectionNotification └─ 完成判定 (Progress >= TotalCount) → bIsComplete = true + → SaveStateToGameInstance() (持久化完成状态) → SetCompleteStyle() → WBP_CollectionProgress (金色) → SetTimer(TotalDelay) → ShowCompletionPrompt() → WBP_CollectionComplete ``` +### 跨场景恢复流 + +``` +场景切换 (OpenLevel / Seamless Travel) + → 新场景加载 + → AChildHoodCharacter::BeginPlay() + → UCollectionManager::BeginPlay() + → LoadStateFromGameInstance() + → UChildHoodGameInstance::GetCollectionState(OutIDs, OutProgress, OutComplete) + → 恢复 CollectedIDs / Progress / bIsComplete + → 创建进度 UI(显示已恢复的进度值) + → 若 bIsComplete → 恢复完成样式 + → 场景中 AInteractableActor::BeginPlay() + → UInteractableComponent::RestorePersistentState(GI) + → GI->IsItemCollected(ItemId) → 已收集则隐藏 + 禁用 +``` + --- ## 10. 可调参数汇总 @@ -886,10 +948,11 @@ InteractionManager (ExecuteAnimEvent) Source/ChildHood/ ├── Collection/ │ ├── CollectionTypes.h # FCollectionConfig 结构体定义 -│ ├── CollectionManager.h/.cpp # 收集系统核心组件 +│ ├── CollectionManager.h/.cpp # 收集系统核心组件(含 GameInstance 读写) │ ├── CollectionNotifWidget.h/.cpp # 收集通知 UI 基类 │ ├── CollectionProgressWidget.h/.cpp # 收集进度 UI 基类 │ └── CollectionCompleteWidget.h/.cpp # 完成提示 UI 基类 +├── ChildHoodGameInstance.h/.cpp # 新增:跨关卡持久数据持有者(收集状态在此持久化) ├── ChildHoodCharacter.h/.cpp # 修改:添加 CollectionManager 成员和访问器 Content/ @@ -928,3 +991,85 @@ Content/ > **关键路径**:6b-1 → 6b-2 → 6b-4 → 6b-5 → 6b-6。6b-3(进度 UI)可在 6b-1 完成后并行。总关键路径约 3 天。 > **前置依赖**:交互系统的 InteractionManager 组件需已完成(特别是 OnItemPickedUp/OnItemFound 委托),方可进行 6b-2 的委托绑定测试。 + +--- + +## 12. 跨场景持久化设计 + +> 收集进度是玩家的核心持久数据,必须在场景切换时保留。本节定义收集系统如何通过 `UChildHoodGameInstance` 实现跨场景数据持久化。 + +### 架构原则 + +``` +┌──────────────────────────────────────────────────┐ +│ UChildHoodGameInstance │ ← 跨关卡存活 +│ ├── CollectedItemIDs: TSet │ 所有场景共享 +│ ├── CollectionProgress: int32 │ +│ └── bCollectionComplete: bool │ +└──────────────────────────────────────────────────┘ + ↕ BeginPlay 加载 / 收集时回写 +┌──────────────────────────────────────────────────┐ +│ UCollectionManager (每个场景重建) │ ← 行为层 +│ ├── 从 GameInstance 读取恢复进度 │ 不持有持久数据所有权 +│ ├── 收集时写入 GameInstance │ +│ └── 控制 UI / 通知 / 动画 │ +└──────────────────────────────────────────────────┘ + ↕ 场景恢复 +┌──────────────────────────────────────────────────┐ +│ 场景中的 AInteractableActor │ ← 由交互系统负责恢复 +│ └── RestorePersistentState() │ (见交互系统技术方案第 12 节) +└──────────────────────────────────────────────────┘ +``` + +### UChildHoodGameInstance 收集相关接口 + +```cpp +UCLASS() +class UChildHoodGameInstance : public UGameInstance +{ + GENERATED_BODY() + +public: + // === 收集状态 === + void GetCollectionState(TSet& OutIDs, int32& OutProgress, bool& OutComplete) const; + void SetCollectionState(const TSet& IDs, int32 Progress, bool bComplete); + + // === 物品消耗状态(交互系统也使用)=== + bool IsItemCollected(FName ItemId) const; + void MarkItemCollected(FName ItemId); + + // === 门状态(交互系统也使用)=== + bool IsDoorOpen(FName DoorId) const; + void SetDoorOpen(FName DoorId, bool bIsOpen); + + // === 重置 === + void ResetAll(); + +private: + // 收集系统 + TSet CollectedItemIDs; + int32 CollectionProgress = 0; + bool bCollectionComplete = false; + // 交互系统 + TMap DoorStates; +}; +``` + +> **注意**:`CollectedItemIDs` 同时被 `IsItemCollected()`(交互物恢复)和 `GetCollectionState()`(收集进度恢复)使用,确保两系统数据一致性。 + +### 读写时机 + +| 时机 | 方法 | 说明 | +|------|------|------| +| CollectionManager::BeginPlay | `LoadStateFromGameInstance()` | 场景加载时恢复进度 | +| ProcessNewCollection 记录后 | `SaveStateToGameInstance()` | 每次新收集时同步写入 | +| 完成判定后 | `SaveStateToGameInstance()` | 完成状态立即持久化 | +| InteractableComponent::Consume | `GI->MarkItemCollected()` | 交互物消耗状态写入(由交互系统调用) | + +### v0.1 行为 + +- v0.1 为单场景原型,不存在跨场景切换 +- `UChildHoodGameInstance` 在 v0.1 中以空数据初始化,所有查询返回默认值 +- `LoadStateFromGameInstance()` 等效于初始化为 0 +- 后续多场景版本只需在 Project Settings 中将 GameInstance Class 设为 `UChildHoodGameInstance`,收集系统代码无需修改 +- 后续版本可通过 USaveGame 将 GameInstance 数据序列化到磁盘,实现跨会话持久化 From a4fd3d3092739c24a71e4c3616236668bebd78b7 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 10:52:16 +0800 Subject: [PATCH 3/7] =?UTF-8?q?chore(art):=20=E6=96=B0=E5=A2=9E=E6=B7=B1?= =?UTF-8?q?=E5=A4=8F=E6=97=B6=E5=85=89=E5=8F=82=E8=80=83=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=EF=BC=88=E5=9C=B0=E5=9B=BE=E3=80=81=E6=AD=A3=E5=8D=88=E7=B3=BB?= =?UTF-8?q?=E5=88=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai-art-research/参考图/游戏截图/深夏时光/深夏时光-地图.png | 3 +++ .../参考图/游戏截图/深夏时光/深夏时光-正午-人物.png | 3 +++ .../参考图/游戏截图/深夏时光/深夏时光-正午-室内.png | 3 +++ .../参考图/游戏截图/深夏时光/深夏时光-正午-室内2.png | 3 +++ .../参考图/游戏截图/深夏时光/深夏时光-正午-室内3.png | 3 +++ .../参考图/游戏截图/深夏时光/深夏时光-正午-街景.png | 3 +++ 6 files changed, 18 insertions(+) create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-地图.png create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-人物.png create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内.png create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内2.png create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内3.png create mode 100644 ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-街景.png diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-地图.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-地图.png new file mode 100644 index 0000000..8b2067a --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-地图.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:496104847e0577d62f7022bc35639b5af5caeb8d190269ae805eaace4d48c892 +size 2400592 diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-人物.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-人物.png new file mode 100644 index 0000000..940420e --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-人物.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d02961d68f68f4f40f012b933800208958eda758f3a2c0d5e58aeb62d4d5f9b0 +size 3130569 diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内.png new file mode 100644 index 0000000..05ac9de --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:359062ad78df90a2b893756700277736373f977b21b1b99606c6da9de2b0f742 +size 2377881 diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内2.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内2.png new file mode 100644 index 0000000..81ba3ac --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f543582eacd856fb0d57f42685cc7db3e54f676add652fc30192186d5b5e0b2 +size 1980015 diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内3.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内3.png new file mode 100644 index 0000000..46573d8 --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-室内3.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fc1548418eddbceb865d42f40fbb4909f179e6440171438265c00be58ad8ca2 +size 2661169 diff --git a/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-街景.png b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-街景.png new file mode 100644 index 0000000..4f19124 --- /dev/null +++ b/ai-art-research/参考图/游戏截图/深夏时光/深夏时光-正午-街景.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0d3624e65e7b9198be9fe1dd8701680f9b039c52beb057544f8a5c10604b230 +size 2454213 From 00ca7044c75ce669e495b6a5c37be3ed5269eeb5 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 11:25:15 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat(interaction):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E7=B3=BB=E7=BB=9F=E6=A0=B8=E5=BF=83C++?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=EF=BC=886a-6f=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Source/ChildHood/ChildHood.Build.cs | 1 + Source/ChildHood/ChildHoodGameInstance.cpp | 30 + Source/ChildHood/ChildHoodGameInstance.h | 56 ++ .../Interaction/InteractableActor.cpp | 47 + .../ChildHood/Interaction/InteractableActor.h | 69 ++ .../Interaction/InteractableComponent.cpp | 221 +++++ .../Interaction/InteractableComponent.h | 101 +++ .../Interaction/InteractionManager.cpp | 815 ++++++++++++++++++ .../Interaction/InteractionManager.h | 206 +++++ .../ChildHood/Interaction/InteractionTypes.h | 178 ++++ .../ChildHood/Interaction/PushCameraActor.cpp | 19 + .../ChildHood/Interaction/PushCameraActor.h | 33 + 12 files changed, 1776 insertions(+) create mode 100644 Source/ChildHood/ChildHoodGameInstance.cpp create mode 100644 Source/ChildHood/ChildHoodGameInstance.h create mode 100644 Source/ChildHood/Interaction/InteractableActor.cpp create mode 100644 Source/ChildHood/Interaction/InteractableActor.h create mode 100644 Source/ChildHood/Interaction/InteractableComponent.cpp create mode 100644 Source/ChildHood/Interaction/InteractableComponent.h create mode 100644 Source/ChildHood/Interaction/InteractionManager.cpp create mode 100644 Source/ChildHood/Interaction/InteractionManager.h create mode 100644 Source/ChildHood/Interaction/InteractionTypes.h create mode 100644 Source/ChildHood/Interaction/PushCameraActor.cpp create mode 100644 Source/ChildHood/Interaction/PushCameraActor.h 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; +}; From 5df451011c35ca5a41bc0b9533f41a14cc2c9246 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 11:25:30 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(interaction):=20Character=E5=92=8CPlay?= =?UTF-8?q?erController=E9=9B=86=E6=88=90=E4=BA=A4=E4=BA=92=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Source/ChildHood/ChildHoodCharacter.cpp | 38 +++++++-- Source/ChildHood/ChildHoodCharacter.h | 25 +++++- .../ChildHood/ChildHoodPlayerController.cpp | 79 ++++++++++++++++++- Source/ChildHood/ChildHoodPlayerController.h | 28 ++++++- 4 files changed, 159 insertions(+), 11 deletions(-) diff --git a/Source/ChildHood/ChildHoodCharacter.cpp b/Source/ChildHood/ChildHoodCharacter.cpp index 60e34b8..afdb06b 100644 --- a/Source/ChildHood/ChildHoodCharacter.cpp +++ b/Source/ChildHood/ChildHoodCharacter.cpp @@ -11,12 +11,13 @@ #include "EnhancedInputSubsystems.h" #include "InputActionValue.h" #include "ChildHood.h" +#include "Interaction/InteractionManager.h" AChildHoodCharacter::AChildHoodCharacter() { // Set size for collision capsule GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f); - + // Don't rotate when the controller rotates. Let that just affect the camera. bUseControllerRotationPitch = false; bUseControllerRotationYaw = false; @@ -46,15 +47,15 @@ AChildHoodCharacter::AChildHoodCharacter() FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); FollowCamera->bUsePawnControlRotation = false; - // Note: The skeletal mesh and anim blueprint references on the Mesh component (inherited from Character) - // are set in the derived blueprint asset named ThirdPersonCharacter (to avoid direct content references in C++) + // 创建交互管理器组件 + InteractionManager = CreateDefaultSubobject(TEXT("InteractionManager")); } void AChildHoodCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) { // Set up action bindings if (UEnhancedInputComponent* EnhancedInputComponent = Cast(PlayerInputComponent)) { - + // Jumping EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &ACharacter::Jump); EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping); @@ -92,6 +93,12 @@ void AChildHoodCharacter::Look(const FInputActionValue& Value) void AChildHoodCharacter::DoMove(float Right, float Forward) { + // 交互中屏蔽移动 + if (bIsInteracting) + { + return; + } + if (GetController() != nullptr) { // find out which way is forward @@ -101,10 +108,10 @@ void AChildHoodCharacter::DoMove(float Right, float Forward) // get forward vector const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X); - // get right vector + // get right vector const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y); - // add movement + // add movement AddMovementInput(ForwardDirection, Forward); AddMovementInput(RightDirection, Right); } @@ -131,3 +138,22 @@ void AChildHoodCharacter::DoJumpEnd() // signal the character to stop jumping StopJumping(); } + +// ============================================================================ +// 交互系统方法 +// ============================================================================ + +UInteractionManager* AChildHoodCharacter::GetInteractionManager() const +{ + return InteractionManager; +} + +void AChildHoodCharacter::SetInteracting(bool bValue) +{ + bIsInteracting = bValue; +} + +bool AChildHoodCharacter::IsInteracting() const +{ + return bIsInteracting; +} diff --git a/Source/ChildHood/ChildHoodCharacter.h b/Source/ChildHood/ChildHoodCharacter.h index 33f7414..b4b9abf 100644 --- a/Source/ChildHood/ChildHoodCharacter.h +++ b/Source/ChildHood/ChildHoodCharacter.h @@ -10,6 +10,7 @@ class USpringArmComponent; class UCameraComponent; class UInputAction; +class UInteractionManager; struct FInputActionValue; DECLARE_LOG_CATEGORY_EXTERN(LogTemplateCharacter, Log, All); @@ -30,7 +31,7 @@ class AChildHoodCharacter : public ACharacter /** Follow camera */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components", meta = (AllowPrivateAccess = "true")) UCameraComponent* FollowCamera; - + protected: /** Jump Input Action */ @@ -49,10 +50,29 @@ protected: UPROPERTY(EditAnywhere, Category="Input") UInputAction* MouseLookAction; + /** 交互管理器组件 */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Interaction", meta = (AllowPrivateAccess = "true")) + UInteractionManager* InteractionManager; + + /** 是否处于交互状态(交互期间屏蔽移动) */ + bool bIsInteracting = false; + public: /** Constructor */ - AChildHoodCharacter(); + AChildHoodCharacter(); + + /** 获取交互管理器组件 */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interaction") + UInteractionManager* GetInteractionManager() const; + + /** 设置交互状态(InteractionManager 调用,控制移动屏蔽) */ + UFUNCTION(BlueprintCallable, Category = "Interaction") + void SetInteracting(bool bValue); + + /** 查询当前是否在交互中 */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Interaction") + bool IsInteracting() const; protected: @@ -93,4 +113,3 @@ public: /** Returns FollowCamera subobject **/ FORCEINLINE class UCameraComponent* GetFollowCamera() const { return FollowCamera; } }; - diff --git a/Source/ChildHood/ChildHoodPlayerController.cpp b/Source/ChildHood/ChildHoodPlayerController.cpp index cadaba8..febaaf6 100644 --- a/Source/ChildHood/ChildHoodPlayerController.cpp +++ b/Source/ChildHood/ChildHoodPlayerController.cpp @@ -1,7 +1,9 @@ // Copyright Epic Games, Inc. All Rights Reserved. - #include "ChildHoodPlayerController.h" +#include "ChildHoodCharacter.h" +#include "Interaction/InteractionManager.h" +#include "EnhancedInputComponent.h" #include "EnhancedInputSubsystems.h" #include "Engine/LocalPlayer.h" #include "InputMappingContext.h" @@ -57,6 +59,28 @@ void AChildHoodPlayerController::SetupInputComponent() } } } + + // 绑定交互输入(Enhanced Input Action) + if (UEnhancedInputComponent* EnhancedInputComponent = Cast(InputComponent)) + { + // 交互(E 键) + if (InteractAction) + { + EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &AChildHoodPlayerController::OnInteractPressed); + } + + // 取消(ESC 键) + if (CancelAction) + { + EnhancedInputComponent->BindAction(CancelAction, ETriggerEvent::Started, this, &AChildHoodPlayerController::OnCancelPressed); + } + + // 切换交互目标(Tab/滚轮) + if (SwitchInteractAction) + { + EnhancedInputComponent->BindAction(SwitchInteractAction, ETriggerEvent::Triggered, this, &AChildHoodPlayerController::OnSwitchInteract); + } + } } } @@ -65,3 +89,56 @@ bool AChildHoodPlayerController::ShouldUseTouchControls() const // are we on a mobile platform? Should we force touch? return SVirtualJoystick::ShouldDisplayTouchInterface() || bForceTouchControls; } + +// ============================================================================ +// 交互输入回调 +// ============================================================================ + +void AChildHoodPlayerController::OnInteractPressed() +{ + if (APawn* MyPawn = GetPawn()) + { + if (AChildHoodCharacter* Char = Cast(MyPawn)) + { + if (UInteractionManager* IM = Char->GetInteractionManager()) + { + // E 键双重功能:Free 状态发起交互,Active 阶段结束交互 + // BeginInteract 在非 Free 状态会被拒绝(内部 return) + // CancelInteract 在非 Interacting 状态或 AnimLock 阶段会被拒绝 + IM->BeginInteract(); + IM->CancelInteract(); + } + } + } +} + +void AChildHoodPlayerController::OnCancelPressed() +{ + if (APawn* MyPawn = GetPawn()) + { + if (AChildHoodCharacter* Char = Cast(MyPawn)) + { + if (UInteractionManager* IM = Char->GetInteractionManager()) + { + IM->CancelInteract(); + } + } + } +} + +void AChildHoodPlayerController::OnSwitchInteract(const FInputActionValue& Value) +{ + if (APawn* MyPawn = GetPawn()) + { + if (AChildHoodCharacter* Char = Cast(MyPawn)) + { + if (UInteractionManager* IM = Char->GetInteractionManager()) + { + // 读取 float 值作为方向:正值=下一个,负值=上一个 + float DirectionValue = Value.Get(); + int32 Direction = (DirectionValue >= 0.f) ? 1 : -1; + IM->SwitchTarget(Direction); + } + } + } +} diff --git a/Source/ChildHood/ChildHoodPlayerController.h b/Source/ChildHood/ChildHoodPlayerController.h index ddd4bf6..28a635d 100644 --- a/Source/ChildHood/ChildHoodPlayerController.h +++ b/Source/ChildHood/ChildHoodPlayerController.h @@ -7,7 +7,9 @@ #include "ChildHoodPlayerController.generated.h" class UInputMappingContext; +class UInputAction; class UUserWidget; +struct FInputActionValue; /** * Basic PlayerController class for a third person game @@ -17,7 +19,7 @@ UCLASS(abstract) class AChildHoodPlayerController : public APlayerController { GENERATED_BODY() - + protected: /** Input Mapping Contexts */ @@ -40,6 +42,20 @@ protected: UPROPERTY(EditAnywhere, Config, Category = "Input|Touch Controls") bool bForceTouchControls = false; + // === 交互输入 === + + /** 交互输入动作(E 键) */ + UPROPERTY(EditAnywhere, Category = "Input|Interaction") + UInputAction* InteractAction; + + /** 取消输入动作(ESC 键) */ + UPROPERTY(EditAnywhere, Category = "Input|Interaction") + UInputAction* CancelAction; + + /** 切换交互目标输入动作(Tab/滚轮) */ + UPROPERTY(EditAnywhere, Category = "Input|Interaction") + UInputAction* SwitchInteractAction; + /** Gameplay initialization */ virtual void BeginPlay() override; @@ -49,4 +65,14 @@ protected: /** Returns true if the player should use UMG touch controls */ bool ShouldUseTouchControls() const; +private: + + /** E 键按下回调:发起交互 */ + void OnInteractPressed(); + + /** ESC 键按下回调:取消/结束交互 */ + void OnCancelPressed(); + + /** Tab/滚轮回调:切换交互目标 */ + void OnSwitchInteract(const FInputActionValue& Value); }; From 85c2062318b9b59cc0bf49c1ef38496f7982a171 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 11:43:10 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat(interaction):=20=E6=96=B0=E5=A2=9EUI?= =?UTF-8?q?=20Widget=E5=9F=BA=E7=B1=BB=E5=B9=B6=E9=9B=86=E6=88=90=E5=88=B0?= =?UTF-8?q?InteractionManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interaction/InteractionManager.cpp | 102 ++++++++++++++++-- .../Interaction/InteractionManager.h | 35 ++++-- .../Interaction/InteractionPromptWidget.cpp | 67 ++++++++++++ .../Interaction/InteractionPromptWidget.h | 65 +++++++++++ .../Interaction/NarrativeTextWidget.cpp | 57 ++++++++++ .../Interaction/NarrativeTextWidget.h | 66 ++++++++++++ 6 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 Source/ChildHood/Interaction/InteractionPromptWidget.cpp create mode 100644 Source/ChildHood/Interaction/InteractionPromptWidget.h create mode 100644 Source/ChildHood/Interaction/NarrativeTextWidget.cpp create mode 100644 Source/ChildHood/Interaction/NarrativeTextWidget.h diff --git a/Source/ChildHood/Interaction/InteractionManager.cpp b/Source/ChildHood/Interaction/InteractionManager.cpp index 5401a32..324919a 100644 --- a/Source/ChildHood/Interaction/InteractionManager.cpp +++ b/Source/ChildHood/Interaction/InteractionManager.cpp @@ -5,6 +5,8 @@ #include "Interaction/InteractableComponent.h" #include "Interaction/InteractableActor.h" #include "Interaction/PushCameraActor.h" +#include "Interaction/InteractionPromptWidget.h" +#include "Interaction/NarrativeTextWidget.h" #include "ChildHoodCharacter.h" #include "ChildHoodGameInstance.h" #include "ChildHood.h" @@ -17,6 +19,7 @@ #include "Engine/World.h" #include "TimerManager.h" #include "Kismet/GameplayStatics.h" +#include "Blueprint/WidgetBlueprintLibrary.h" // 定义交互系统日志类别 DECLARE_LOG_CATEGORY_EXTERN(LogInteraction, Log, All); @@ -56,6 +59,39 @@ void UInteractionManager::BeginPlay() UE_LOG(LogInteraction, Log, TEXT("InteractionManager: BeginPlay 完成, PushCameraActor=%s"), PushCameraActor ? *PushCameraActor->GetName() : TEXT("nullptr")); + + // --- 创建 UI Widget --- + APlayerController* PC = GetWorld()->GetFirstPlayerController(); + if (PC) + { + // 创建提示 UI + if (PromptWidgetClass) + { + PromptWidget = CreateWidget(PC, PromptWidgetClass); + if (PromptWidget) + { + PromptWidget->AddToViewport(10); + } + } + else + { + UE_LOG(LogInteraction, Warning, TEXT("InteractionManager: PromptWidgetClass 未设置,提示 UI 不可用")); + } + + // 创建文本底框 UI + if (NarrativeWidgetClass) + { + NarrativeWidget = CreateWidget(PC, NarrativeWidgetClass); + if (NarrativeWidget) + { + NarrativeWidget->AddToViewport(20); + } + } + else + { + UE_LOG(LogInteraction, Warning, TEXT("InteractionManager: NarrativeWidgetClass 未设置,文本 UI 不可用")); + } + } } // ============================================================================ @@ -66,9 +102,18 @@ void UInteractionManager::TickComponent(float DeltaTime, ELevelTick TickType, FA { Super::TickComponent(DeltaTime, TickType, ThisTickFunction); - // Free 状态不需要 Tick if (State == EInteractionState::Free) { + // Free 状态下更新提示 UI 的屏幕位置 + if (PromptWidget && PromptWidget->IsVisible() && + NearbyInteractables.Num() > 0 && NearbyInteractables.IsValidIndex(SelectedIndex)) + { + UInteractableComponent* Selected = NearbyInteractables[SelectedIndex]; + if (Selected) + { + UpdatePromptScreenPosition(Selected); + } + } return; } @@ -651,32 +696,49 @@ void UInteractionManager::UpdatePromptUI() return; } - // v0.1: 用日志输出替代 UI FString PromptText = Selected->GetPromptText(); - UE_LOG(LogInteraction, Log, TEXT("UpdatePromptUI: [提示] %s"), *PromptText); + + if (PromptWidget) + { + PromptWidget->SetPromptText(PromptText); + PromptWidget->Show(); + UpdatePromptScreenPosition(Selected); + } + + UE_LOG(LogInteraction, Verbose, TEXT("UpdatePromptUI: %s"), *PromptText); } void UInteractionManager::HidePrompt() { - // v0.1: 用日志输出替代 UI - UE_LOG(LogInteraction, Verbose, TEXT("HidePrompt: 隐藏交互提示")); + if (PromptWidget) + { + PromptWidget->Hide(); + } } void UInteractionManager::ShowNarrativeText(const FString& Text) { - // v0.1: 用日志输出替代 UI if (Text.IsEmpty()) { - UE_LOG(LogInteraction, Warning, TEXT("ShowNarrativeText: 文本为空")); + UE_LOG(LogInteraction, Warning, TEXT("ShowNarrativeText: text is empty")); return; } - UE_LOG(LogInteraction, Log, TEXT("ShowNarrativeText: [文本] %s"), *Text); + + if (NarrativeWidget) + { + NarrativeWidget->SetNarrativeText(Text); + NarrativeWidget->Show(); + } + + UE_LOG(LogInteraction, Verbose, TEXT("ShowNarrativeText: %s"), *Text); } void UInteractionManager::HideNarrativeText() { - // v0.1: 用日志输出替代 UI - UE_LOG(LogInteraction, Verbose, TEXT("HideNarrativeText: 隐藏叙事文本")); + if (NarrativeWidget) + { + NarrativeWidget->Hide(); + } } // ============================================================================ @@ -813,3 +875,23 @@ void UInteractionManager::OnExitTimerComplete() UpdatePromptUI(); } } + +// ============================================================================ +// 提示 UI 屏幕位置更新 +// ============================================================================ + +void UInteractionManager::UpdatePromptScreenPosition(UInteractableComponent* Interactable) +{ + if (!Interactable || !PromptWidget) return; + + APlayerController* PC = GetWorld()->GetFirstPlayerController(); + if (!PC) return; + + // 交互物世界位置 + 高度偏移 → 屏幕坐标 + FVector WorldLoc = Interactable->GetWorldLocation() + FVector(0.f, 0.f, PromptHeightOffset); + FVector2D ScreenPos; + if (UGameplayStatics::ProjectWorldToScreen(PC, WorldLoc, ScreenPos, true)) + { + PromptWidget->SetScreenPosition(ScreenPos); + } +} diff --git a/Source/ChildHood/Interaction/InteractionManager.h b/Source/ChildHood/Interaction/InteractionManager.h index a4fb918..433d21c 100644 --- a/Source/ChildHood/Interaction/InteractionManager.h +++ b/Source/ChildHood/Interaction/InteractionManager.h @@ -12,6 +12,8 @@ class UInteractableComponent; class APushCameraActor; class AChildHoodCharacter; +class UInteractionPromptWidget; +class UNarrativeTextWidget; /** * UInteractionManager @@ -136,15 +138,29 @@ private: /** 动画事件是否已触发 */ bool bAnimEventFired = false; - // === UI 引用(v0.1 后续任务创建) === + // === UI 配置 === - /** 交互提示 Widget(v0.1 用日志替代) */ - UPROPERTY() - TObjectPtr PromptWidget; + /** 提示 UI 蓝图类(需在编辑器中指定为 WBP_InteractionPrompt) */ + UPROPERTY(EditAnywhere, Category = "Interaction|UI") + TSubclassOf PromptWidgetClass; - /** 文本底框 Widget(v0.1 用日志替代) */ + /** 文本底框 UI 蓝图类(需在编辑器中指定为 WBP_NarrativeText) */ + UPROPERTY(EditAnywhere, Category = "Interaction|UI") + TSubclassOf NarrativeWidgetClass; + + /** 提示 UI 高度偏移(交互物上方的世界空间 Z 偏移,cm) */ + UPROPERTY(EditAnywhere, Category = "Interaction|UI") + float PromptHeightOffset = 80.0f; + + // === UI 实例 === + + /** 交互提示 Widget 实例 */ UPROPERTY() - TObjectPtr NarrativeWidget; + TObjectPtr PromptWidget; + + /** 文本底框 Widget 实例 */ + UPROPERTY() + TObjectPtr NarrativeWidget; // === 文本选择 === @@ -156,12 +172,15 @@ private: /** 更新选中交互物(排序后 SelectedIndex = 0) */ void UpdateSelection(); - /** 更新提示 UI(v0.1 用日志输出) */ + /** 更新提示 UI 内容和位置 */ void UpdatePromptUI(); - /** 隐藏提示 UI(v0.1 用日志输出) */ + /** 隐藏提示 UI */ void HidePrompt(); + /** 更新提示 UI 的屏幕空间位置(跟随交互物世界位置投影) */ + void UpdatePromptScreenPosition(UInteractableComponent* Interactable); + /** Enter 阶段 Tick:转向插值 + 推镜 + 等待 */ void TickEnterPhase(float DeltaTime); diff --git a/Source/ChildHood/Interaction/InteractionPromptWidget.cpp b/Source/ChildHood/Interaction/InteractionPromptWidget.cpp new file mode 100644 index 0000000..f7cb7af --- /dev/null +++ b/Source/ChildHood/Interaction/InteractionPromptWidget.cpp @@ -0,0 +1,67 @@ +// ChildHood Project - 交互提示 UI 基类 实现 + +#include "Interaction/InteractionPromptWidget.h" +#include "Components/TextBlock.h" + +void UInteractionPromptWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + + // 初始隐藏 + SetVisibility(ESlateVisibility::Hidden); +} + +void UInteractionPromptWidget::SetPromptText(const FString& Text) +{ + if (PromptTextBlock) + { + PromptTextBlock->SetText(FText::FromString(Text)); + } +} + +void UInteractionPromptWidget::SetScreenPosition(const FVector2D& Position) +{ + // 使用 RenderTranslation 偏移 Widget 位置 + // 蓝图中 CanvasPanel 的 Anchors 应设置为屏幕左上角 + // 此处直接设置 RenderTranslation 来移动位置 + SetRenderTranslation(Position); +} + +void UInteractionPromptWidget::Show() +{ + if (bIsVisible) return; + bIsVisible = true; + + SetVisibility(ESlateVisibility::HitTestInvisible); + + if (FadeInAnim) + { + PlayAnimation(FadeInAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + } +} + +void UInteractionPromptWidget::Hide() +{ + if (!bIsVisible) return; + + if (FadeOutAnim) + { + PlayAnimation(FadeOutAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + // 动画播完后隐藏(简单方案:直接设置定时器隐藏) + // 动画时长由蓝图中的 FadeOutAnim 决定,这里用固定 0.2s + FTimerHandle TempHandle; + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(TempHandle, [this]() + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + }, 0.2f, false); + } + } + else + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + } +} diff --git a/Source/ChildHood/Interaction/InteractionPromptWidget.h b/Source/ChildHood/Interaction/InteractionPromptWidget.h new file mode 100644 index 0000000..2b88ea1 --- /dev/null +++ b/Source/ChildHood/Interaction/InteractionPromptWidget.h @@ -0,0 +1,65 @@ +// ChildHood Project - 交互提示 UI 基类 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(提示UI显示/隐藏) +// 职责:屏幕空间悬浮提示 "[E] 动作词 名称",跟随交互物位置 + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "InteractionPromptWidget.generated.h" + +class UTextBlock; + +/** + * UInteractionPromptWidget + * + * 交互提示 UI 的 C++ 基类。蓝图子类(WBP_InteractionPrompt)继承此类, + * 在 UMG 编辑器中布局可视组件,并确保组件名与 BindWidget 匹配。 + * + * 绑定要求: + * - TextBlock 名为 "PromptTextBlock" — 显示提示文字 + * - WidgetAnimation 名为 "FadeInAnim" — 淡入动画(0.2s) + * - WidgetAnimation 名为 "FadeOutAnim" — 淡出动画(0.2s) + */ +UCLASS(Abstract, Blueprintable) +class UInteractionPromptWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + /** 设置提示文字内容 */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Prompt") + void SetPromptText(const FString& Text); + + /** 设置屏幕空间位置(由 InteractionManager 每帧调用) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Prompt") + void SetScreenPosition(const FVector2D& Position); + + /** 显示提示(播放淡入动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Prompt") + void Show(); + + /** 隐藏提示(播放淡出动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Prompt") + void Hide(); + +protected: + + /** 提示文字 TextBlock,蓝图子类必须有一个同名 TextBlock */ + UPROPERTY(meta = (BindWidget)) + UTextBlock* PromptTextBlock; + + /** 淡入动画(0.2s Opacity 0→1),蓝图子类必须有同名 WidgetAnimation */ + UPROPERTY(meta = (BindWidgetAnim), Transient) + UWidgetAnimation* FadeInAnim; + + /** 淡出动画(0.2s Opacity 1→0),蓝图子类必须有同名 WidgetAnimation */ + UPROPERTY(meta = (BindWidgetAnim), Transient) + UWidgetAnimation* FadeOutAnim; + + /** 是否正在显示中 */ + bool bIsVisible = false; + + virtual void NativeOnInitialized() override; +}; diff --git a/Source/ChildHood/Interaction/NarrativeTextWidget.cpp b/Source/ChildHood/Interaction/NarrativeTextWidget.cpp new file mode 100644 index 0000000..14cb308 --- /dev/null +++ b/Source/ChildHood/Interaction/NarrativeTextWidget.cpp @@ -0,0 +1,57 @@ +// ChildHood Project - 文本底框 UI 基类 实现 + +#include "Interaction/NarrativeTextWidget.h" +#include "Components/TextBlock.h" + +void UNarrativeTextWidget::NativeOnInitialized() +{ + Super::NativeOnInitialized(); + + // 初始隐藏 + SetVisibility(ESlateVisibility::Hidden); +} + +void UNarrativeTextWidget::SetNarrativeText(const FString& Text) +{ + if (NarrativeTextBlock) + { + NarrativeTextBlock->SetText(FText::FromString(Text)); + } +} + +void UNarrativeTextWidget::Show() +{ + if (bIsVisible) return; + bIsVisible = true; + + SetVisibility(ESlateVisibility::HitTestInvisible); + + if (FadeInAnim) + { + PlayAnimation(FadeInAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + } +} + +void UNarrativeTextWidget::Hide() +{ + if (!bIsVisible) return; + + if (FadeOutAnim) + { + PlayAnimation(FadeOutAnim, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f); + FTimerHandle TempHandle; + if (UWorld* World = GetWorld()) + { + World->GetTimerManager().SetTimer(TempHandle, [this]() + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + }, 0.2f, false); + } + } + else + { + bIsVisible = false; + SetVisibility(ESlateVisibility::Hidden); + } +} diff --git a/Source/ChildHood/Interaction/NarrativeTextWidget.h b/Source/ChildHood/Interaction/NarrativeTextWidget.h new file mode 100644 index 0000000..1cbead9 --- /dev/null +++ b/Source/ChildHood/Interaction/NarrativeTextWidget.h @@ -0,0 +1,66 @@ +// ChildHood Project - 文本底框 UI 基类 +// 对应技术方案: docs/technical/交互系统-技术方案.md 第6节(通用文本显示UI) +// 职责:半透明底框 + 一句话叙事/对话文本,带淡入淡出 + +#pragma once + +#include "CoreMinimal.h" +#include "Blueprint/UserWidget.h" +#include "NarrativeTextWidget.generated.h" + +class UTextBlock; + +/** + * UNarrativeTextWidget + * + * 文本底框 UI 的 C++ 基类。蓝图子类(WBP_NarrativeText)继承此类, + * 在 UMG 编辑器中布局可视组件,并确保组件名与 BindWidget 匹配。 + * + * 绑定要求: + * - TextBlock 名为 "NarrativeTextBlock" — 显示叙事/对话文字 + * - WidgetAnimation 名为 "FadeInAnim" — 淡入动画(0.2s) + * - WidgetAnimation 名为 "FadeOutAnim" — 淡出动画(0.2s) + * + * 布局要求: + * - Anchors: Horizontal Center, Vertical ~0.6(屏幕中心偏下) + * - Border: 半透明黑色背景(~60%),圆角,SizeToContent + * - TextBlock: 白色文字,居中,自动换行 + */ +UCLASS(Abstract, Blueprintable) +class UNarrativeTextWidget : public UUserWidget +{ + GENERATED_BODY() + +public: + + /** 设置叙事文本内容 */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Narrative") + void SetNarrativeText(const FString& Text); + + /** 显示文本(播放淡入动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Narrative") + void Show(); + + /** 隐藏文本(播放淡出动画) */ + UFUNCTION(BlueprintCallable, Category = "Interaction|Narrative") + void Hide(); + +protected: + + /** 叙事文字 TextBlock,蓝图子类必须有一个同名 TextBlock */ + UPROPERTY(meta = (BindWidget)) + UTextBlock* NarrativeTextBlock; + + /** 淡入动画(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; +}; From fd40217ab85b7acc54e6ab22722e0bbf3a5d747d Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Thu, 11 Jun 2026 14:22:57 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(interaction):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BA=A4=E4=BA=92InputAction=E8=B5=84=E4=BA=A7=E5=B9=B6?= =?UTF-8?q?=E9=85=8D=E7=BD=AEIMC=E5=92=8CBP=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Content/BP/Basic/BP_ChildHoodPlayerController.uasset | 4 ++-- Content/BP/Character/BP_ChildHoodCharacter.uasset | 4 ++-- Content/Input/Actions/IA_Cancel.uasset | 3 +++ Content/Input/Actions/IA_Interact.uasset | 3 +++ Content/Input/Actions/IA_SwitchInteract.uasset | 3 +++ Content/Input/IMC_Default.uasset | 4 ++-- 6 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 Content/Input/Actions/IA_Cancel.uasset create mode 100644 Content/Input/Actions/IA_Interact.uasset create mode 100644 Content/Input/Actions/IA_SwitchInteract.uasset diff --git a/Content/BP/Basic/BP_ChildHoodPlayerController.uasset b/Content/BP/Basic/BP_ChildHoodPlayerController.uasset index 089eb24..e668d48 100644 --- a/Content/BP/Basic/BP_ChildHoodPlayerController.uasset +++ b/Content/BP/Basic/BP_ChildHoodPlayerController.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8aa209924313a3476a030e81479a993ad1c7170b12c3797f0ac2dcd5c273a58e -size 20473 +oid sha256:a0c7966623a6ef2ecef5d7d97f08731457ed7092c909ab0441618b5be0aabd97 +size 21323 diff --git a/Content/BP/Character/BP_ChildHoodCharacter.uasset b/Content/BP/Character/BP_ChildHoodCharacter.uasset index c803635..f766e0d 100644 --- a/Content/BP/Character/BP_ChildHoodCharacter.uasset +++ b/Content/BP/Character/BP_ChildHoodCharacter.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ab30c1de6a469952710d2728899acbe1df72cb6c5f239d0b0352d5ade60d3423 -size 35388 +oid sha256:e3aa723818e2ad57f930663427ba4f083e7988cb963c3b24f59958b12fd1e58c +size 37787 diff --git a/Content/Input/Actions/IA_Cancel.uasset b/Content/Input/Actions/IA_Cancel.uasset new file mode 100644 index 0000000..76fac8d --- /dev/null +++ b/Content/Input/Actions/IA_Cancel.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e9fdba70434b9c7459a9849e8bb8f8ff02896419a5c264b2f8c125f64bc9640e +size 1356 diff --git a/Content/Input/Actions/IA_Interact.uasset b/Content/Input/Actions/IA_Interact.uasset new file mode 100644 index 0000000..e82eb6e --- /dev/null +++ b/Content/Input/Actions/IA_Interact.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8364eaebec48cca84045dfbe093a79c15c2e6bd19ab00c94f447dfdba20c7bf2 +size 1366 diff --git a/Content/Input/Actions/IA_SwitchInteract.uasset b/Content/Input/Actions/IA_SwitchInteract.uasset new file mode 100644 index 0000000..d5264f5 --- /dev/null +++ b/Content/Input/Actions/IA_SwitchInteract.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57d9d82572a58028ad742d5c6b3f9f8ca38ed2a64044d6134ce8c619831cf433 +size 1396 diff --git a/Content/Input/IMC_Default.uasset b/Content/Input/IMC_Default.uasset index b63309a..252a92c 100644 --- a/Content/Input/IMC_Default.uasset +++ b/Content/Input/IMC_Default.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:133bda6567aaeea1aae05c03597345e4b1d9ef24d868a0275203b3203a7aae98 -size 7787 +oid sha256:d44222add12917e337f0339c731a3c22b74d3c465a37045f20510e08dddadb95 +size 8444