docs(technical): 交互系统技术方案 v1.0 重写:Effect/Action 数据驱动架构

This commit is contained in:
2026-06-11 19:04:14 +08:00
parent ac7a3abda9
commit 2342b63fc2
4 changed files with 985 additions and 868 deletions
+1
View File
@@ -2,3 +2,4 @@
- [UE5.7 Python API](ue57-python-api.md) — 实测 API 差异(set_actor_rotation、new_level、CWD、import 路径、Rotator 关键字参数)
- [V01 Bugfix](level-builder-bugfix-v01.md) — 8个白模关卡问题诊断修复(搭建指南6+解析器双重解析+构建器静默fallback)
- [BSP Pivot Types](bsp-pivot-types.md) — 38种 BSP 资产实测锚点+自动补偿机制(角落/底边中心/底面中心)
- [交互系统架构](interaction-effect-architecture.md) — v1.0 Effect/Action 数据驱动架构(Manager在GI、AInfo Tick、门派生、Prompt独立)
@@ -0,0 +1,31 @@
---
name: interaction-effect-architecture
description: 交互系统 v1.0 Effect/Action 数据驱动架构决策记录
metadata:
type: project
---
## 交互系统架构决策(v1.0 重构,2026-06-11)
### 核心架构:Effect/Action 数据驱动
交互物不关心"交互后发生什么",只配置 Effect(由 Action 组成)交给 EffectManager 执行。
**Why:** v0.1 用 switch(Type) 硬编码,新增交互类型需改代码。v1.0 配数据即可。
### Manager 归属
- FInteractionManager / FEffectManager 是普通 C++ 类,不是 UObject
- 由 ASystemDriver (AInfo) 持有,随 World 生灭
- 不依赖 UE Subsystem 生命周期,显式控制创建顺序和 Tick 顺序
- 所有 UObject 引用用 TWeakObjectPtr,回调用 TFunction
- GameInstance 不持有任何交互系统对象,只有 UPersistenceSubsystem 跨 World
### 分层规则
- 表现层只读 Manager 缓存(getter),不碰 Component
- Manager 在选择变化时从 Component 读一次并缓存
- 逻辑事件仅用于 "A 影响 B"(如 Consume → Persistence)
### 协调方式
- Character 输入回调通过 ASystemDriver::Get(World) 访问两个 Manager
- Driver::BeginPlay 中 TFunction 连线 EffectManager.OnEffectComplete → Manager.OnInteractionFinished
- PromptComponent 通过 Driver 获取 Manager,只读缓存
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
# 交互系统 · 拾取流程时序图(v1.0 Effect 驱动版)
## 场景
玩家进入地上某个交互物的交互范围,按 E 交互后,人物做动作,交互物消失,背包加一个新物品,UI 显示捡到一个物品。
## 架构层级
| 层级 | 实体 | 所在位置 |
|------|------|---------|
| **Logic** | `FInteractionManager` | ASystemDriver 持有 (普通C++, 纯事件驱动, 缓存 Presentation 数据) |
| **Logic** | `FEffectManager` | ASystemDriver 持有 (普通C++, Driver 提供Tick) |
| **World** | `ASystemDriver` | AInfo 全局唯一 (持有所有Manager, 显式控制创建/Tick顺序) |
| **Data** | `UInteractableComponent` | 关卡实例 |
| **Data** | `UPersistenceSubsystem` | GameInstanceSubsystem |
| **Data** | `BackpackSystem` | GameInstance |
| **Presentation** | `UInteractionPromptComponent` | Character |
## 数据流规则
- 表现层直接读数据层(Manager state / Component data / Backpack data)
- 逻辑事件仅在 A 影响 B 时使用(如 Consume Action → Persistence 写入)
- 两个表现实体之间不直接访问
---
## Effect 配置
```
Effect: "拾取地面物品"
TotalDuration: 1.5s, Cancellable: false
TriggerTime | Action | 说明
----------- | --------------------- | ----
0.0s | TurnToTarget | TurnDuration=0.2,转向交互物
0.0s | PlayMontage | Montage=AM_Pickup,弯腰拾取动画
0.6s | Consume | 标记消耗,禁用检测球
0.6s | HideActor | 隐藏交互物 Actor
0.6s | AddToBackpack | 物品进背包 + 持久化
1.0s | ShowPickupNotification| 显示 "捡到了 [旧橡皮]!"
```
---
## 时序图
```mermaid
sequenceDiagram
autonumber
actor Player as 玩家
participant Char as AChildHoodCharacter
participant Mgr as FInteractionManager<br/>(普通C++ · ASystemDriver持有)
participant Comp as UInteractableComponent<br/>(Data · 关卡实例)
participant Actor as AInteractableActor
participant EFM as FEffectManager<br/>(普通C++ · ASystemDriver持有)
participant Persist as UPersistenceSubsystem<br/>(Data · GameInstance)
participant Bag as BackpackSystem
participant Pres as UInteractionPromptComponent<br/>(Presentation · Character)
participant Driver as ASystemDriver<br/>(AInfo · 持有所有Manager)
rect rgb(230, 245, 255)
Note over Player,Driver: ═══ Phase 0 · 进入检测范围 ═══
Player->>Char: WASD 移动进入 TriggerRadius
Comp->>Mgr: DetectionSphere OnOverlapBegin<br/>ASystemDriver::Get(World)->GetInteractionManager().RegisterInteractable(this)
Mgr->>Mgr: NearbyInteractables.Add(Comp)<br/>Sort by Dist2D → SelectedIndex = 0
Mgr->>Mgr: RefreshCachedSelection()<br/>缓存 PromptText + WorldPosition
end
rect rgb(255, 250, 230)
Note over Player,Driver: ═══ Phase 1 · Prompt 显示(Pres 每帧读 Manager 缓存)═══
loop 每帧 Pres TickComponent
Pres->>Mgr: GetState() → Free
Pres->>Mgr: HasSelection() → true
Pres->>Mgr: GetSelectedPromptText() → "[E] 拾取 旧橡皮"
Pres->>Mgr: GetSelectedWorldPosition() → FVector
Pres->>Pres: PromptWidget.SetText + SetScreenPosition
end
Note right of Pres: Pres 不碰 Component<br/>只读 Manager 缓存
end
rect rgb(230, 255, 230)
Note over Player,Driver: ═══ Phase 2 · 按 E,Character 通过 Driver 协调两个 Manager ═══
Player->>Char: 按 E (IA_Interact)
Char->>Mgr: Driver->GetInteractionManager().BeginInteract() → 返回 Comp
Mgr->>Mgr: State = Executing, CurrentInteractable = Comp
Char->>Char: SetInteracting(true) → 屏蔽移动
Char->>EFM: Driver->GetEffectManager().PlayEffect(Comp.Data.InteractEffect, Context)<br/>Context = {Char, Comp, Actor}
Note right of EFM: Effect "拾取地面物品"<br/>T=0.0 TurnToTarget + PlayMontage<br/>T=0.6 Consume+Hide+Backpack<br/>T=1.0 PickupNotification<br/>Total=1.5s
end
rect rgb(255, 240, 245)
Note over Player,Driver: ═══ Phase 3 · Presentation 检测状态变化 ═══
Pres->>Mgr: Tick: GetState() → Executing (变化!)
Pres->>Pres: PromptWidget.Hide()
end
rect rgb(240, 240, 255)
Note over Player,Driver: ═══ Phase 4 · Effect 执行(ASystemDriver Tick → EffectManager)═══
Note over Driver: T=0.0: TurnToTarget + PlayMontage
Driver->>EFM: Driver::Tick → EffectManager.Update(DeltaTime)
EFM->>EFM: ElapsedTime=0.0 → 触发 TurnToTarget Action
EFM->>Char: RInterpTo 转向 Comp(持续帧)
EFM->>EFM: 触发 PlayMontage Action
EFM->>Char: GetMesh()->GetAnimInstance()->Montage_Play(AM_Pickup)
Note right of Char: 播放弯腰拾取动画
Note over Driver: T=0.0→0.6: 持续转向 + 动画播放
loop 每帧 Driver Tick
Driver->>EFM: Tick → Update(DeltaTime)
EFM->>EFM: ElapsedTime += DeltaTime
EFM->>Char: TurnToTarget: RInterpTo 插值
end
Note over Driver: T=0.6: Consume + HideActor + AddToBackpack
Driver->>EFM: Tick → Update → ElapsedTime=0.6 触发
EFM->>Comp: Consume() — 数据变更
Comp-->>Comp: bIsConsumed=true<br/>DetectionSphere → NoCollision
Note right of Comp: 禁用碰撞同步触发 EndOverlap<br/>→ UnregisterInteractable<br/>Manager 标记延迟清理
EFM->>Persist: MarkItemCollected("rubber_01")
Persist-->>Persist: CollectedItemIDs.Add("rubber_01")
EFM->>Actor: SetActorHiddenInGame(true)
EFM->>Bag: AddItem("rubber_01", "旧橡皮")
Bag-->>Bag: Items.Add({Id:"rubber_01", Name:"旧橡皮"})
Pres->>Comp: Tick: IsConsumed() → true (变化!)
Pres->>Actor: SetActorHiddenInGame(true)
Note over Driver: T=1.0: ShowPickupNotification
Driver->>EFM: Tick → Update → ElapsedTime=1.0 触发
EFM->>Pres: 显示拾取通知 "捡到了 [旧橡皮]!"
end
rect rgb(230, 255, 255)
Note over Player,Driver: ═══ Phase 5 · Effect 完成,回到 Free ═══
Driver->>EFM: Tick → Update → ElapsedTime >= TotalDuration(1.5s)
EFM->>EFM: FinishEffect()
EFM->>Mgr: OnEffectComplete 回调
Mgr->>Mgr: State = Free, CurrentInteractable = nullptr
Mgr->>Char: SetInteracting(false) → 恢复移动
Pres->>Mgr: Tick: GetState() → Free (变化!)
Pres->>Mgr: GetSelectedInteractable() → null (已注销)
Note over Pres: 无其他交互物在范围,不显示 Prompt
Player->>Char: WASD 继续移动
end
```
---
## 关键数据流说明
### 表现层读数据(只读 Manager 缓存,不碰 Component)
| 读取时机 | PromptComponent 读谁 | 读什么 |
|----------|---------------------|--------|
| 每帧 Tick | Manager | `GetState()` |
| Free 状态 | Manager | `HasSelection()` |
| Free 状态 | Manager | `GetSelectedPromptText()` |
| Free 状态 | Manager | `GetSelectedWorldPosition()` |
### Manager 缓存更新(选择变化时从 Component 读一次)
| 触发时机 | Manager 方法 | 缓存内容 |
|----------|-------------|---------|
| RegisterInteractable | RefreshCachedSelection() | PromptText + WorldPosition |
| UnregisterInteractable | RefreshCachedSelection() | PromptText + WorldPosition |
| SwitchTarget | RefreshCachedSelection() | PromptText + WorldPosition |
### Effect 内 Action 执行(直接操作)
| Action | 操作对象 | 说明 |
|--------|---------|------|
| TurnToTarget | Char->SetActorRotation | RInterpTo 跨帧插值 |
| PlayMontage | Char->GetMesh()->GetAnimInstance() | 播放蒙太奇 |
| Consume | Component->Consume() | 标记消耗+禁用检测 |
| HideActor | Actor->SetActorHiddenInGame(true) | 隐藏 |
| AddToBackpack | PersistenceSubsystem + BackpackSystem | 写入持久层+背包 |
| ShowPickupNotification | PromptComponent / Widget | 显示通知 |
### 逻辑事件(仅 A 影响 B)
| 事件 | 触发者 | 订阅者 |
|------|--------|--------|
| OnItemConsumed | Manager(由 Consume Action 触发) | PersistenceSubsystem |
| OnDoorToggled | Manager(由 ToggleDoor Action 触发) | PersistenceSubsystem + DoorComponent |
---
## 注意事项
### EndOverlap 时序问题
Phase 4 中 `Consume()` 禁用 DetectionSphere 会**同步触发** `EndOverlap`,进而调用 `Manager->UnregisterInteractable()`。此时 Manager 处于 Executing 状态且 Effect 仍在使用 CurrentInteractable。
**解决方案**:Manager 的 `UnregisterInteractable` 中,当 `State == Executing` 且移除的正是 `CurrentInteractable` 时,仅从 NearbyInteractables 列表移除,**不清除 CurrentInteractable 引用**,等 Effect 完成回调中统一清理。
### ASystemDriver 生命周期
- `ASystemDriver` 是 AInfo 全局唯一,持有所有 World 级 Manager(普通 C++ 对象)
- 关卡加载完成 → `GameInstance` Spawn `ASystemDriver`
- `Driver::BeginPlay` 显式创建 `FInteractionManager` + `FEffectManager`,控制顺序
- `Driver::BeginPlay` 中用 `TFunction` 连线 `EffectManager.OnEffectComplete → Manager.OnInteractionFinished`
- `Driver::Tick` 显式调用 `EffectManager.Update(Delta)`,控制 Tick 顺序
- 关卡卸载 → World 销毁 → Driver 自动销毁 → Manager 随 Driver 析构
- 新关卡加载 → 重新 Spawn + 创建
### 系统协调关系
- `FInteractionManager` 和 `FEffectManager` 是普通 C++ 类,由 `ASystemDriver` 持有
- `AChildHoodCharacter` 输入回调通过 `ASystemDriver::Get(World)` 访问两个 Manager
- `Driver::BeginPlay` 中通过 `TFunction<void()>` 连接 EffectManager 完成回调到 Manager
- `FInteractionManager` 纯事件驱动,无 Tick/Update
- `UChildHoodGameInstance` 不持有任何交互系统对象,仅 `UPersistenceSubsystem` 跨 World 存活
### TurnToTarget 跨帧插值
TurnToTarget Action 不是瞬时完成的。EffectManager 在 Update 中维护 `FTurnState`(起始旋转、目标旋转、已用时间),每帧调用 `FMath::RInterpTo` 直到转向完成。多个 Action 可以并行执行。