diff --git a/docs/technical/交互系统-技术方案.md b/docs/technical/交互系统-技术方案.md index 7f1971f..7ae856d 100644 --- a/docs/technical/交互系统-技术方案.md +++ b/docs/technical/交互系统-技术方案.md @@ -2,7 +2,7 @@ > 对应执行案: `docs/design/交互系统-执行案.md` (v0.1.2) > 关联系统: 角色控制系统、物品系统、收集系统、GameInstance 持久化层 -> 日期: 2026-06-11(v1.0 全面重构:Effect/Action 数据驱动架构) +> 日期: 2026-06-11(2026-06-11 更新:审查修订 v1.1) --- @@ -12,6 +12,7 @@ |------|------|----------| | v0.1 | 2026-06-07 | 初版技术方案,基于类型分发的状态机架构 | | v1.0 | 2026-06-11 | 全面重构:Effect/Action 数据驱动架构;普通 C++ Manager 挂 AInfo;门派生类;持久化 Subsystem;逻辑/表现解耦 | +| v1.1 | 2026-06-11 | 审查修订:补充 ShowNarrative 的 bWaitForInput/bRandomSelect;补充 FSelectedInfo/FTurnState 定义;补充 CancelEffect 行为;修正模板 A/E 关闭流程;修正时序图标注 | --- @@ -238,6 +239,8 @@ classDiagram -float ElapsedTime -int32 NextActionIndex -bool bIsPlaying + -bool bIsWaitingForInput + -int32 LastNarrativeIndex -FTurnState TurnState -TFunction~void~ OnEffectComplete +PlayEffect(FInteractEffect, FEffectContext) void @@ -246,6 +249,9 @@ classDiagram +Update(float DeltaTime) void -ExecuteAction(FInteractAction, FEffectContext) void -FinishEffect() void + -ResumeFromWait() void + -PerformCleanup() void + -PickRandomNarrative(TArray~FString~) FString Note: 普通C++类,非UObject Note: 所有UObject引用用TWeakObjectPtr Note: Tick由Driver显式调用 @@ -387,7 +393,7 @@ sequenceDiagram autonumber actor Player as 玩家 participant Char as AChildHoodCharacter - participant Mgr as InteractionManager
(GameInstance) + participant Mgr as FInteractionManager
(ASystemDriver持有) participant Comp as UInteractableComponent participant EFM as EffectManager participant Persist as PersistenceSubsystem @@ -397,7 +403,7 @@ sequenceDiagram Note over Player,Driver: === Phase 0: 进入检测范围 === Player->>Char: 移动进入 TriggerRadius - Comp->>Mgr: OnOverlap → GetGI()->GetMgr()->RegisterInteractable(this) + Comp->>Mgr: OnOverlap → Driver->GetInteractionManager().RegisterInteractable(this) Mgr->>Mgr: Sort by Dist2D, SelectedIndex = 0 Note over Player,Driver: === Phase 1: Prompt 显示 (Pres 每帧 Polling) === @@ -411,7 +417,7 @@ sequenceDiagram Note over Player,Driver: === Phase 2: 按 E 触发 Effect === Player->>Char: 按 E - Char->>Mgr: GetGI()->GetMgr()->BeginInteract() + Char->>Mgr: Driver->GetInteractionManager().BeginInteract() Mgr->>Mgr: State = Executing Mgr->>Char: SetInteracting(true) Mgr->>EFM: PlayEffect(Comp.Data.InteractEffect, Context) @@ -481,6 +487,7 @@ enum class EInteractActionType : uint8 ShowActor UMETA(DisplayName = "显示交互物"), AddToBackpack UMETA(DisplayName = "物品进背包"), ShowNarrative UMETA(DisplayName = "显示叙事文本"), + HideNarrative UMETA(DisplayName = "隐藏叙事文本"), ShowPickupNotification UMETA(DisplayName = "显示拾取通知"), ToggleDoor UMETA(DisplayName = "切换门状态"), PlayDialogue UMETA(DisplayName = "播放对话"), @@ -537,6 +544,14 @@ struct FInteractAction // --- ShowNarrative --- UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides)) TArray NarrativeTexts; + + /** 是否等待用户按 E/ESC 后再继续后续 Action */ + UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides)) + bool bWaitForInput = false; + + /** 是否从 NarrativeTexts 中随机选取(不重复上一次) */ + UPROPERTY(EditAnywhere, meta = (EditCondition = "Type==EInteractActionType::ShowNarrative", EditConditionHides)) + bool bRandomSelect = true; }; ``` @@ -619,6 +634,80 @@ struct FEffectContext }; ``` +### FSelectedInfo — Presentation 层缓存数据 + +> Manager 在选择变化时从 Component 读一次并写入此结构体,Prompt 组件每帧只读此缓存,不直接访问 Component。 + +```cpp +struct FSelectedInfo +{ + /** 是否有有效选中 */ + bool bHasSelection = false; + + /** 选中交互物的提示文本(如 "[E] 查看旧冰箱") */ + FString PromptText; + + /** 选中交互物的世界坐标(用于屏幕空间投影) */ + FVector WorldPosition = FVector::ZeroVector; + + /** 选中交互物是否已消耗(消耗后不再显示提示) */ + bool bIsConsumed = false; + + void Reset() + { + bHasSelection = false; + PromptText.Empty(); + WorldPosition = FVector::ZeroVector; + bIsConsumed = false; + } +}; +``` + +**写入时机**:`RefreshCachedSelection()` — 在 `RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` 中调用。 + +**读取方**:`UInteractionPromptComponent::TickComponent` 每帧读取 `CachedSelected.PromptText` 和 `CachedSelected.WorldPosition`,不碰任何 Component。 + +### FTurnState — TurnToTarget 跨帧插值状态 + +> TurnToTarget Action 需要每帧 RInterpTo 驱动旋转,不能一帧完成。EffectManager 在 Update 中维护此状态。 + +```cpp +struct FTurnState +{ + /** 是否正在转向 */ + bool bIsActive = false; + + /** 转向起始旋转 */ + FRotator StartRotation = FRotator::ZeroRotator; + + /** 转向目标旋转(面朝交互物方向) */ + FRotator TargetRotation = FRotator::ZeroRotator; + + /** 已用时间(秒) */ + float ElapsedTime = 0.f; + + /** 转向总时长(秒) */ + float Duration = 0.2f; + + /** 是否已完成 */ + bool IsComplete() const { return ElapsedTime >= Duration; } + + /** 获取当前插值 Alpha [0,1] */ + float GetAlpha() const { return FMath::Clamp(ElapsedTime / Duration, 0.f, 1.f); } + + void Reset() + { + bIsActive = false; + StartRotation = FRotator::ZeroRotator; + TargetRotation = FRotator::ZeroRotator; + ElapsedTime = 0.f; + Duration = 0.2f; + } +}; +``` + +**生命周期**:`TurnToTarget` Action 触发时初始化 → `EffectManager::Update` 每帧驱动插值 → `IsComplete()` 后停止。 + ### 委托 ```cpp @@ -648,19 +737,23 @@ DECLARE_DELEGATE(FOnEffectComplete); ``` Effect: "观察物品" -TotalDuration: 3.0s, Cancellable: true +TotalDuration: 10.0s (安全上限,实际由 bWaitForInput 控制结束), Cancellable: true TriggerTime | Action | 参数 ----------- | ------------- | ---- 0.0s | TurnToTarget | TurnDuration=0.2 0.0s | CameraPush | PushDistance=90, PushDuration=0.5 -0.7s | ShowNarrative | Texts=["冰箱嗡嗡响。里面有冰棒!"] -0.7s | (等待用户输入,ShowNarrative 持续显示) +0.7s | ShowNarrative | Texts=["冰箱嗡嗡响。里面有冰棒!"], bWaitForInput=true, bRandomSelect=true +(用户按E/ESC后继续) +~0.7s+ | HideNarrative | (淡出 0.2s) +~0.7s+ | CameraReturn | ReturnDuration=0.3 ``` -> ShowNarrative Action 执行后进入"等待用户输入"状态,用户按 E/ESC 后 Effect 继续。 -> 此行为通过在 ShowNarrative Action 中标记 `bWaitForInput=true` 实现(后续版本扩展)。 -> v0.1 简化:ShowNarrative 不等待,用 Effect TotalDuration 控制总时长。 +> **关闭流程**(对应执行案 E6):ShowNarrative 标记 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户按 E/ESC。用户输入后 Effect 恢复,执行 HideNarrative(底框淡出 0.2s)+ CameraReturn(相机回位 0.3s ease-in),Effect 完成。 +> +> **随机文本选取**(对应执行案 E5):ShowNarrative 标记 `bRandomSelect=true`,执行时从 NarrativeTexts 列表中随机选取一条,不重复上一次的内容。若列表只有一条则每次显示同一条。 +> +> **可重复交互**(对应执行案 E5):观察类 Effect 的 bOneShot=false,不调用 Consume。每次交互重新执行 Effect,ShowNarrative 每次随机选取新文本。 #### 模板 B:拾取类 Effect @@ -721,20 +814,139 @@ TriggerTime | Action | 参数 ``` Effect: "NPC对话" -TotalDuration: 4.0s, Cancellable: true +TotalDuration: 10.0s (安全上限,实际由 bWaitForInput 控制结束), Cancellable: true TriggerTime | Action | 参数 ----------- | ------------- | ---- 0.0s | TurnToTarget | TurnDuration=0.2 0.0s | CameraPush | PushDistance=90, PushDuration=0.5 -0.7s | ShowNarrative | Texts=["zzz"] +0.7s | ShowNarrative | Texts=["zzz"], bWaitForInput=true, bRandomSelect=true +(用户按E/ESC后继续) +~0.7s+ | HideNarrative | (淡出 0.2s) +~0.7s+ | CameraReturn | ReturnDuration=0.3 ``` +> **关闭流程**(对应执行案 K6):与观察类完全一致。ShowNarrative 标记 `bWaitForInput=true`,用户按 E/ESC 后执行 HideNarrative + CameraReturn。 +> +> **随机对话选取**(对应执行案 K6):ShowNarrative 标记 `bRandomSelect=true`,从对话文本列表中随机选取,不重复上一次。若列表只有一条则每次相同。 + +### CancelEffect 行为定义 + +> 对应审查问题:执行案要求观察/对话类可按 E/ESC 中断,需明确定义 Effect 取消时的完整行为。 + +`EffectManager::CancelEffect()` 在用户按 E/ESC 且当前 Effect 的 `bCancellable=true` 时触发。完整行为如下: + +```cpp +void FEffectManager::CancelEffect() +{ + if (!bIsPlaying) return; + if (!CurrentEffect.bCancellable) return; + + // 1. 检查是否处于 WaitForInput 暂停态 + if (bIsWaitingForInput) + { + // 用户输入正是期望的恢复信号 → 走正常恢复流程 + // 执行 WaitForInput 之后的所有剩余 Action(HideNarrative, CameraReturn 等) + ResumeFromWait(); + return; + } + + // 2. 非暂停态的强制取消(如安全超时) + // 停止 Timeline + bIsPlaying = false; + + // 3. 执行收尾清理 + PerformCleanup(); +} +``` + +#### 收尾清理行为(PerformCleanup) + +无论 CancelEffect 走哪条路径,Effect 结束时必须确保以下清理: + +| 清理项 | 条件 | 行为 | +|--------|------|------| +| 文本底框 | ShowNarrative 曾显示但未 HideNarrative | 强制淡出(0.2s)并移除 Widget | +| 相机回位 | CameraPush 曾执行但未 CameraReturn | 强制回位(0.3s ease-in),`SetViewTargetWithBlend(Character)` | +| 蒙太奇停止 | PlayMontage 正在播放 | `Montage_Stop(0.2f)` 淡出停止 | +| 转向状态 | TurnToTarget 未完成 | 立即设为目标旋转(瞬移到最终位置) | +| Timeline 状态 | 任何 | 重置 ElapsedTime、NextActionIndex、TurnState | + +#### WaitForInput 暂停机制 + +当 ShowNarrative Action 标记 `bWaitForInput=true` 时: + +1. EffectManager 执行 ShowNarrative(显示文本底框) +2. 设置 `bIsWaitingForInput = true`,暂停 Timeline(不再推进 ElapsedTime) +3. 用户按 E/ESC → Character 输入回调 → `EffectManager.CancelEffect()` +4. CancelEffect 检测到 `bIsWaitingForInput == true` → 调用 `ResumeFromWait()` +5. ResumeFromWait 设置 `bIsWaitingForInput = false`,恢复 Timeline +6. 后续 Action(HideNarrative、CameraReturn)按 TriggerTime 正常执行 +7. Timeline 到达 TotalDuration → `FinishEffect()` → 回到 Free 状态 + +```cpp +void FEffectManager::ResumeFromWait() +{ + bIsWaitingForInput = false; + // 继续 Update 中正常触发后续 Action + // HideNarrative/CameraReturn 的 TriggerTime 应设在 ShowNarrative 之后 +} +``` + +> **设计要点**:WaitForInput 不是取消,而是"暂停等待"。用户按 E/ESC 是期望的恢复信号,不是强制中断。CancelEffect 检测到暂停态时走恢复路径,而非强制清理路径。 + +### ShowNarrative Action 执行逻辑 + +> 对应执行案:观察类「文本从列表中随机选取,不重复上一次」、对话类「每次触发随机选取,不重复上一次」。 + +```cpp +void FEffectManager::ExecuteShowNarrative(const FInteractAction& Action, const FEffectContext& Context) +{ + // 1. 选取文本 + FString SelectedText; + if (Action.bRandomSelect && Action.NarrativeTexts.Num() > 1) + { + SelectedText = PickRandomNarrative(Action.NarrativeTexts); + } + else if (Action.NarrativeTexts.Num() > 0) + { + SelectedText = Action.NarrativeTexts[0]; + } + + // 2. 显示文本底框 + // 通过 Context 中的 Instigator 获取 PC,显示 NarrativeWidget + ShowNarrativeWidget(SelectedText); + + // 3. 如果需要等待用户输入 + if (Action.bWaitForInput) + { + bIsWaitingForInput = true; + // Update() 中检测到 bIsWaitingForInput 后不再推进 ElapsedTime + } +} + +FString FEffectManager::PickRandomNarrative(const TArray& Texts) +{ + if (Texts.Num() == 0) return TEXT(""); + if (Texts.Num() == 1) return Texts[0]; + + int32 CandidateIndex; + do { + CandidateIndex = FMath::RandRange(0, Texts.Num() - 1); + } while (CandidateIndex == LastNarrativeIndex && Texts.Num() > 1); + + LastNarrativeIndex = CandidateIndex; + return Texts[CandidateIndex]; +} +``` + +> **LastNarrativeIndex** 是 FEffectManager 的成员变量,跨 Effect 调用保持(同一交互物的多次交互不重复)。当交互物切换时由 InteractionManager 重置。 + ### 通用规则实现 #### 规则 1: 触发条件 -**实现**:`UInteractableComponent::OnDetectionOverlapBegin` → `GetGI()->GetMgr()->RegisterInteractable(this)`。Manager 的 `BeginInteract()` 检查 State==Free、SelectedIndex 有效、!IsConsumed()。 +**实现**:`UInteractableComponent::OnDetectionOverlapBegin` → `ASystemDriver::Get(World)->GetInteractionManager().RegisterInteractable(this)`。Manager 的 `BeginInteract()` 检查 State==Free、SelectedIndex 有效、!IsConsumed()。 #### 规则 2-5: Prompt UI 显示/隐藏 @@ -749,7 +961,7 @@ Manager 在 `RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` #### 规则 6-10: 多交互物选择与切换 -**实现**:同 v0.1,但 Manager 在 GameInstance 中。`RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` / `UpdateSelection` 逻辑不变。 +**实现**:同 v0.1,但 Manager 在 ASystemDriver 中(非 GameInstance)。`RegisterInteractable` / `UnregisterInteractable` / `SwitchTarget` / `UpdateSelection` 逻辑不变。 #### 规则 11-14: 交互触发与锁定 @@ -777,7 +989,13 @@ void AChildHoodCharacter::OnInteract() } ``` -`BeginInteract()` 返回选中的 Component(或 nullptr 拒绝),设置 `State = Executing`。Effect 执行期间所有输入被忽略(CancelInteract 检查 bCancellable)。 +`BeginInteract()` 返回选中的 Component(或 nullptr 拒绝),设置 `State = Executing`。 + +**CancelInteract(E/ESC)行为**: +- 若 EffectManager 处于 `bIsWaitingForInput` 暂停态 → `ResumeFromWait()`(恢复 Timeline,执行关闭流程 Action) +- 若 Effect 的 `bCancellable=true` 但非暂停态 → `CancelEffect()` 强制取消 + `PerformCleanup()` 清理 +- 若 Effect 的 `bCancellable=false` → 忽略输入(拾取、门操作、翻找期间) +- Interacting 状态下 Tab/滚轮/新 E 键触发交互 → 全部忽略 Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectComplete → InteractionManager.OnInteractionFinished()`。 @@ -822,7 +1040,7 @@ Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectC | Consume 触发同步 EndOverlap | 🟡中 | Consume() 禁用碰撞同步触发 EndOverlap → UnregisterInteractable,可能清空 CurrentInteractable | Manager 的 UnregisterInteractable 在 Executing 状态下延迟清理 | | TurnToTarget 跨帧驱动 | 🟡中 | TurnToTarget Action 需要每帧 RInterpTo,不能一帧完成 | EffectManager.Update 中维护 TurnState,每帧驱动插值 | | EditCondition 折叠大量 Action 参数 | 🟢低 | FInteractAction 一个 struct 含所有 Action 类型参数,编辑器中按类型折叠 | 使用 EditConditionHides 自动隐藏不相关字段 | -| ShowNarrative 等待用户输入 | 🟡中 | 观察/对话类需要等用户按 E/ESC,不能靠 TotalDuration 硬控 | v0.1 用 TotalDuration 硬控;后续版本给 ShowNarrative 加 bWaitForInput 标记,Effect 暂停 Timeline 等待 | +| ShowNarrative 等待用户输入 | 🟡中 | 观察/对话类需要等用户按 E/ESC,不能靠 TotalDuration 硬控 | ShowNarrative Action 支持 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户输入后恢复,TotalDuration 仅作安全上限 | | AInfo 驱动器生命周期 | 🟢低 | 关卡加载/卸载时需正确 Spawn/销毁 | OnWorldReady 中 Spawn,关卡卸载时 World 销毁自动清理 | ### 注意事项 @@ -832,7 +1050,8 @@ Effect 完成通过 Driver 中 `std::function` 连线:`EffectManager.OnEffectC 3. **Manager 是普通 C++**:`FInteractionManager` / `FEffectManager` 不是 UObject,不参与 GC。所有 UObject 引用使用 `TWeakObjectPtr`,回调使用 `TFunction` 4. **InteractionManager 无 Tick**:所有操作由碰撞事件、输入回调、EffectManager 完成回调驱动,无每帧逻辑 5. **GameInstance 不持有交互系统**:只有 `UPersistenceSubsystem` 跨 World 存活,所有交互逻辑随 World 生灭 -4. **Prompt 组件独立**:不参与 Effect 执行,只负责 Free 状态下的 UI 追踪 +6. **Prompt 组件独立**:不参与 Effect 执行,只负责 Free 状态下的 UI 追踪 +7. **WaitForInput 暂停机制**:ShowNarrative Action 支持 `bWaitForInput=true`,EffectManager 暂停 Timeline 等待用户输入。用户按 E/ESC 走 ResumeFromWait(恢复后续 Action),而非强制取消 ---