1. 交互代码是怎么烂掉的:从三十个分支的 switch 说起
我接手的第一个中型项目,交互系统是这样的:ALyraCharacter上挂一个UInteractComponent,玩家按下交互键后,在 PlayerController 里做一次射线检测,拿到命中 Actor,然后调用Cast<IInteractable>(HitActor)->Execute_Interact(...)。最开始的三个交互物——门、箱子、NPC——的时候,这套代码干净得像教科书。等到第六个月,交互物涨到三十多个,Interact()的实现里出现了一串if (Actor->IsA(ADoor::IsA)) else if (...),加一个需要读条的保险箱,就得同时改接口、改 UI、改输入。更麻烦的是,UI 要显示“按 E 拾取”还是“按 E 打开”,得由 UI 自己去查场景里最近的东西是什么,逻辑和呈现彻底搅在一起。
UE5 的 Lyra 示例项目里,交互系统叫 Interaction,目录落在Source/LyraGame/Interaction/,外加一个Actors/LyraWorldCollectable作为最小可交互实体。它做的事情,本质上是把“一次交互”拆成了五个可以独立演化的部分:谁想交互(发起者)、能交互什么(目标)、交互是什么(数据提案)、交互怎么生效(技能)、交互怎么被看见(消息与 UI)。这五件事在 Lyra 里分别对应IInteractionInstigator、IInteractableTarget、FInteractionOption、UGameplayAbility和 GameplayMessage,彼此之间只有接口或标签耦合,没有Cast<>到具体类。
这篇东西写给已经能跑通 Lyra、想把它那套交互拆开揉碎搬到自家项目里,或者正在纠结“我的交互到底该不该上 GAS”的人。我会按“为什么这么设计 → 数据长什么样 → 扫描怎么跑 → 一次完整时序 → 资源回收的坑 → UI 解耦 → 怎么裁剪”这条线走,中间穿插我自己改这套代码时踩过的雷。需要说明的是,Lyra 各个版本之间字段名和默认值有过调整,下文提到的默认参数和结构体成员,请以你手上那份源码为准,逻辑骨架是不变的。
先看一眼两种方案的直观差异,这个表我当年在组内分享时画过,后来每次带新人都还能用上:
| 维度 | 传统接口 + 射线方案 | Lyra 的 Interaction 方案 |
|---|---|---|
| 交互行为存放位置 | Actor 的Interact()实现里 | 独立的 GameplayAbility |
| 新增一种交互 | 改接口、改 UI、改输入分支 | 加一个 GA + 一个 Option 提案 |
| 冷却/消耗/前摇 | 自己造轮子或用 Timers 硬凑 | 直接吃 GAS 的 Cost、Cooldown、Tag 阻断 |
| UI 怎么知道能交互啥 | UI 反查场景,重复一遍检测逻辑 | GA 广播选项列表,UI 只管画 |
| 多人环境 | 谁调谁执行,权威性靠人肉保证 | 技能自身的网络策略决定执行端 |
| 扩展方式 | 继承 Actor 或改中心类 | 实现接口,或挂一个 GameFeature 插件 |
表里最后一行才是这套架构真正的价值点:新增交互内容不需要改动任何已有代码。你的拾取、NPC 对话、载具上车的交互逻辑可以分属三个互不认识的 GameFeature 插件,主工程的交互 GA 一行不用动。
2. 目标的“提案权”:IInteractableTarget 与 FInteractionOption 的设计取舍
Lyra 把“我能被怎么交互”这件事,交给了目标自己去说,而不是让发起者去猜。核心接口是IInteractableTarget,它只有一个关键函数:
// 结构示意,签名以你手上的版本为准 class IInteractableTarget { public: virtual void GatherInteractionOptions(const FInteractionQuery& InteractQuery, FInteractionOptionBuilder& OptionBuilder) = 0; };注意这个函数的名字叫 Gather,不叫 CanInteract,也不叫 GetInteractionType。这个命名透露了设计者的意图:目标不是被动应答“你能不能交互我”,而是主动往一个收集器里“投递提案”。FInteractionOptionBuilder内部持有一个TArray<FInteractionOption>&,提供AddInteractionOption()把提案塞进去。为什么绕这一层?因为一个目标可能同时提供多种交互——一台载具可以“驾驶”,也可以“加油”,甚至后备箱可以“搜刮”;一个 NPC 可以“对话”,也可以“交易”。如果用GetInteractionType()返回单个枚举,这些场景就得再设计一套二级菜单。
FInteractionOption里装的东西,拎出来讲是这三个字段最要紧:
USTRUCT(BlueprintType) struct FInteractionOption { GENERATED_BODY() // 交互的身份标签,比如 Interaction.Collect、Interaction.Talk UPROPERTY(EditAnywhere, BlueprintReadWrite) FGameplayTag InteractionName; // 这个交互实际由哪个技能来执行 UPROPERTY(EditAnywhere, BlueprintReadWrite) TSubclassOf<UGameplayAbility> InteractionAbilityToGrant; // 提案的来源目标,弱引用语义,避免持有时序问题 TScriptInterface<IInteractableTarget> InteractionTarget; };InteractionName这个 GameplayTag 是整个系统的索引键。它既被用来在 ASC 的ActivatableAbilities里匹配已经授予的技能,也被用来在 UI 层做图标和文案映射。用 Tag 而不是枚举的好处很直接:Tag 可以在编辑器里配置层级、可以在 GameFeature 插件里新增子标签而不改主工程的头文件。InteractionAbilityToGrant则把“交互是什么”和“交互怎么执行”彻底分开了——Collectable 只需要知道自己能被“拾取”,至于拾取时播什么蒙太奇、加什么物品、要不要销毁自己,那是那个 GA 的事。
发起者这一侧,Lyra 留了一个反向接口IInteractionInstigator,目前主要提供HasMatchingGameplayTag()。它的作用是让目标在提案前能做一次“资格预审”——比如一个宝箱只在发起者带Status.Alive且不带Status.Combat的时候才允许被打开。这个接口很薄,但非常必要:如果把资格判断写进技能自己的CanActivateAbility,UI 就会先显示“按 E 打开”然后按下去毫无反应,体验非常糟糕。放在提案阶段过滤,玩家从头到尾就看不到这个选项。
我见过不少团队在抄这套的时候把GatherInteractionOptions写成了一个巨大的 if-else,把所有交互类型都塞进同一个 Actor:
// 反面示例:把提案函数写成了分支地狱 void AMyUniversalInteractable::GatherInteractionOptions(const FInteractionQuery& Query, FInteractionOptionBuilder& Builder) { if (bIsDoor) { /* 加开门的 Option */ } else if (bIsChest) { /* 加开箱的 Option */ } else if (bIsNPC) { /* 加对话的 Option */ } // ... 四十行之后 }这其实只是把原来的 switch 从 PlayerController 挪到了 Actor 里,耦合方式没变。正确的姿势是拆成不同的 Actor 或不同的组件,每种只实现自己那一种提案,让“多态”发生在类上而不是分支上。Lyra 自己的ALyraWorldCollectable就非常简单,整个GatherInteractionOptions只有几行:判断交互 GA 有没有配、发起者资格过不过、然后Builder.AddInteractionOption(...)一次,没有第二个分支。
还有一个容易被忽略的细节:FInteractionQuery里带的是AvatarActor而不是 Controller 或 PlayerState。原因是交互行为的执行端是 Pawn,动画、碰撞、位置都在 Pawn 上,用 Avatar 是最直接的口径。如果你的项目里有切换 Pawn 的机制(比如附身),一定要在每次扫描时重新从 ASC 拿 Avatar,而不是在 GA 激活时缓存一次——我在一个附身玩法里就因为这行缓存,导致换人之后交互提示永远指向旧身体的坐标。
3. AbilityTask_GrantNearbyInteraction:一个每 0.1 秒跑一次的扫描器
Lyra 里负责“发现附近能交互的东西”的,是一个 AbilityTask,叫UAbilityTask_GrantNearbyInteraction。它是从ULyraGameplayAbility_Interact(交互主技能)激活时创建并ReadyForActivation()的,之后就一直跟着这个技能实例活着,技能结束它自动销毁。这个生命周期设计很关键:它意味着你不需要自己管理 Timer 的创建与清理,技能被取消、Pawn 死亡、ASC 被销毁,Task 自然跟着走。
它的核心逻辑可以概括成三步,每 0.1 秒循环一次:
// 逻辑骨架,非源码照搬 void UAbilityTask_GrantNearbyInteraction::Tick(float DeltaTime) { // 1. 用球形 Overlap 收集范围内的目标 TArray<FOverlapResult> Overlaps; World->OverlapMultiByChannel(Overlaps, Origin, FQuat::Identity, FCollisionShape::MakeSphere(InteractionScanRange), InteractionTraceChannel); // 2. 让每个目标投递提案,汇总成当前时刻的选项列表 FInteractionQuery Query; Query.AvatarActor = GetAvatarActor(); TArray<FInteractionOption> Options; FInteractionOptionBuilder Builder(Options); for (const FOverlapResult& Result : Overlaps) { if (IInteractableTarget* Target = Cast<IInteractableTarget>(Result.GetActor())) { Target->GatherInteractionOptions(Query, Builder); } } // 3. 与已授予的技能做差集,多的授予、少了的回收 UpdateGrantedAbilities(Options); }三个公开参数控制它的行为,都在技能蓝图里可调:InteractionScanRate(扫描间隔,示例里默认 0.1 秒)、InteractionScanRange(球形半径)、InteractionTraceChannel(碰撞通道,默认走 WorldDynamic)。这三个值我建议在项目初期就定下来,别让它随缘:扫描范围决定玩家“感觉多远能摸到”,通常比武器射程略小;扫描通道决定哪些东西会被扫到,一定要单独开一个自定义通道,别复用 WorldDynamic,否则场景里的碎屑、特效碰撞体全都会进来,白白吃掉遍历开销。
这里有个设计问题值得展开说:为什么是 Overlap 而不是每帧 LineTrace,为什么是 0.1 秒而不是每帧?前者是因为 UI 需要的是“周围有哪些选项”,而不是“准星对着哪一个”。LineTrace 只能返回单个结果,做不出“左边一个箱子右边一扇门”同时提示的效果,而且玩家视角抖动会导致提示疯狂闪烁。后者是纯粹的性价比取舍:交互提示的变化频率远低于画面帧率,10Hz 已经足够顺滑,而每帧 Overlap 在有几十个动态物体的场景里是实打实的浪费。
至于为什么用 GameplayAbility 而不是直接调函数,我总结成四条,这四条基本能说服任何还在犹豫的同事:第一,技能能吃 Cooldown 和 Cost,做一个“每 3 秒只能开一次”的箱子不需要自己写计时器;第二,技能能被 GameplayTag 阻断,Status.Stunned一挂上去,所有交互自动失效,不需要在每个交互里判断;第三,技能能触发蒙太奇、等动画通知、走PlayMontageAndWait的前摇后摇流程;第四,技能的NetExecutionPolicy帮你处理了“这段逻辑到底在哪一端跑”的问题。这四件事如果自己实现,就是重写半个 GAS。
4. 一次拾取动作的完整链路:从按键到 Actor 被销毁
把上面几个零件串起来,一次完整的交互在 Lyra 里是这样流动的。我按时间顺序拆成六步,每一步都标注它发生在哪个对象上,方便你对照源码下断点。
第一步,输入到技能。玩家按下交互键,Enhanced Input 的IA_Interact触发,InputConfig 把它映射到Ability.Local.Interact这个输入标签,ASC 收到后激活ULyraGameplayAbility_Interact。这里依赖的是 Lyra 那套ULyraInputConfig+ AbilitySet 的机制,交互主技能通常由 PawnData 里的 AbilitySet 授予,而不是硬编码在角色类里。
第二步,主技能收集选项。主技能激活后走UpdateInteractions(),从 ASC 的ActivatableAbilities里遍历所有FGameplayAbilitySpec,用Spec.GetDynamicSpecSourceTags()去匹配交互标签,把匹配上的组装成CurrentOptions,然后通过OnInteractionChanged这个多播委托广播出去。注意,它匹配的是“已经被扫描器授予过的技能”,而不是重新去场景里查一遍——职责切得很清楚,扫描器负责发现,主技能负责呈现和转派。
第三步,UI 刷新。HUD 里的交互提示 Widget 在初始化时绑定OnInteractionChanged,收到新列表就更新文案和图标。“按 E 拾取”里的“拾取”两个字,来自InteractionName这个 Tag 的映射表,而不是硬编码的字符串。
第四步,玩家确认。玩家再按一次交互键,主技能走TriggerInteraction(),遍历CurrentOptions,找到对应的FGameplayAbilitySpecHandle,调用TryActivateAbility(Handle)。这里主技能自己并不执行任何实际效果,它只是一个“调度台”。这个分层是我最喜欢的一点:主技能永远只有一个,而实际交互技能可以有几十个,彼此互不干扰。
第五步,目标技能执行。被授予的拾取技能开始跑:播蒙太奇、播音效、往背包里加物品、发一条 GameplayMessage、最后Destroy掉自己附着的那个可拾取 Actor。这些操作全部发生在目标技能的ActivateAbility里,可拾取 Actor 自己完全不知道“我拾取之后要做什么”,它只知道“我能被拾取”。
第六步,清理。Actor 被销毁后,下一轮扫描(最多 0.1 秒后)发现它不在了,差集比对判定该技能应当回收,调用ClearAbility抹掉对应的 Spec,主技能再广播一次空的选项列表,UI 上的提示随之消失。
如果你在调试时看到“拾取完提示还在”,大概率是差集比对那步出了问题:要么扫描器缓存了 Overlap 结果没刷新,要么ClearAbility用的 handle 和GiveAbility返回的不是同一个。我建议在这条链路上打三个日志:授予时打印Handle和InteractionName,回收时打印Handle,主技能广播时打印当前CurrentOptions的长度。三个数字对不上,问题位置就一目了然。
另外提一句需要读条的交互(比如长按开保险箱)。Lyra 的做法不是在主技能里塞一个 Timer,而是让目标技能自己用UAbilityTask_WaitDelay或PlayMontageAndWait控制时长,同时通过 GameplayMessage 广播一个带 Duration 的消息,UI 订阅后画环形进度。中途松手、移动打断、受到伤害打断,全部由技能的CancelAbility统一处理。这个设计的好处是进度条和技能状态天生同步——技能被取消,消息那边收到中断信号,进度条自然就停了,不会出现“技能断了进度条还在转”的经典 bug。
5. SpecHandle 的账要记清楚:动态授予 Ability 的回收时机
这一段是我踩坑最多的地方,单独拎出来讲。动态授予技能(GiveAbility)在 ASC 里是有状态的,每次调用都会往ActivatableAbilities数组里塞一个FGameplayAbilitySpec,返回一个FGameplayAbilitySpecHandle。这个数组只增不减,除非你显式ClearAbility(Handle)。所以“授予”和“回收”必须是严格配对的账,漏一笔就泄漏一笔。
坑一:同一个交互被重复授予。如果某个 Actor 的GatherInteractionOptions在同一轮扫描里被调用两次(比如它身上挂了两个碰撞体,Overlap 返回了两条结果),你会在选项列表里看到两个一模一样的“拾取”。表现是 UI 上出现两行提示,按一次键只生效一次,剩下那个永远挂着。解决办法是在扫描器里对目标去重,或者更稳妥地,在UpdateGrantedAbilities里以InteractionName为键做一次 Map 去重,只保留第一个。
坑二:目标被销毁后没有回收。拾取完 Actor 直接Destroy(),如果技能清理逻辑没跟上,ASC 里会留下一个指向已销毁对象的 Spec。它的DynamicAbilityTags仍然带着交互标签,主技能每次遍历都会把它算进CurrentOptions,于是 UI 上永远有个幽灵提示,按下去还没反应。这种 bug 特别隐蔽,因为只在特定交互路径上出现。防御手段有两个:一是在回收逻辑里对Spec的来源对象做有效性检查,二是让可拾取物在销毁前主动广播一个“我要没了”的消息,触发一次强制刷新。
坑三:技能正在执行时目标消失了。长读条交互进行到一半,场景被卸载或者目标被别的玩家抢走,技能还在跑,动画还在播,最后一帧去访问目标时崩了。对策很朴素但有效:技能拿到目标引用后一律用TWeakObjectPtr持有,每个 Tick 或每次访问前IsValid()一次,失效就CancelAbility。
坑四:网络端的授予时机。GiveAbility到底该在哪一端执行,取决于技能本身的网络策略。像“扫描附近有什么”这种纯本地行为,授予的是本地表现层的技能,主技能通常设成本地执行,不需要走网络;而真正改变游戏状态的技能(加物品、开门)必须有服务端权威执行。我的做法是把“发现”和“执行”在策略上彻底分开:扫描器和主技能只管本地,实际效果技能的NetExecutionPolicy根据它是否影响状态单独配置。混在一起会让“客户端能不能预测”变成一个永远说不清的问题。
坑五:ASC 换人时的残留。如果你的玩法允许切换 Pawn(附身、载具上下车),旧的 ASC 上可能还挂着一堆动态授予的 Spec。切换时一定要显式清理,别指望 GC。我在一个载具玩法里就吃过这个亏:下车后重新上车,同一辆车能同时提供两份“驾驶”选项,找了两天才定位到是旧 ASC 没清干净。
管理这些账最省事的办法,是在扫描器内部维护一张TMap<FGameplayTag, FGameplayAbilitySpecHandle>,键是交互标签,值是 handle。每轮扫描算出“本轮应有的标签集合”,和 Map 里的做双向差集:新增的授予并写 Map,消失的ClearAbility并从 Map 删除。这张 Map 就是唯一真相源,任何时候想查“当前授予了哪些交互”,看它就行,不用去翻 ASC 的整个数组。
6. 进度条不该长在技能里:GameplayMessage 撑起的 UI 解耦
Lyra 的交互 UI 分两块:一块是“有什么可以交互”的提示列表,走主技能上的OnInteractionChanged委托;另一块是“这次交互还要多久”的读条进度,走 GameplayMessage。为什么两种场景用了两套机制?因为委托是一对一的强绑定,适合“主技能 → 本地 HUD”这种明确关系;而读条进度可能同时被 HUD、世界空间 Widget、音效系统三方订阅,用消息总线更合适。
具体做法是定义一条消息结构,里面至少带发起者和时长:
USTRUCT(BlueprintType) struct FLyraInteractionDurationMessage { GENERATED_BODY() UPROPERTY(BlueprintReadOnly) TObjectPtr<AActor> Instigator = nullptr; UPROPERTY(BlueprintReadOnly) float Duration = 0.0f; };技能在开始读条时,通过UGameplayMessageSubsystem往一个约定的 GameplayTag 频道上广播这条消息,UI 在NativeOnInitialized里订阅同一个标签,收到就开动画,技能取消或完成时再广播一次(Duration 传 0 或者发一条独立的中断消息)收尾。
这套机制真正的价值在于它允许 UI 完全不知道技能的存在。Widget 只知道“有人往这个频道发了消息,我按 Duration 画个圈”,它甚至可以是蓝图做的、可以被别的系统复用。反过来,技能也不需要Cast到某个具体的 HUD 类去调函数——这在多人游戏里尤其重要,因为你没法保证远端玩家的 HUD 结构和你本地一致。
用过之后我总结出两条纪律。第一,订阅和注销必须成对,且都在 Widget 自己的生命周期函数里做,不要在 Blueprint 里手动调用订阅。Widget 被移出视图或销毁时如果没注销,消息回调会打到已经失效的对象上,轻则日志刷屏,重则崩在动画更新里。第二,频道标签要有统一的命名规范,比如全部挂在Lyra.Interaction.*下面,这样在编辑器里搜标签的时候一眼能看全,也方便后面做“静音某类交互提示”这种开关。
顺带说一个相关的问题:交互提示文案的本地化。InteractionName是 Tag,UI 层需要一张 Tag → 文案/图标的映射表。这张表别写死在 Widget 里,做成 DataAsset 或者 DataTable,让策划能自己改。我在一个项目里就因为文案硬编码在 Widget 蓝图的Switch on GameplayTag里,加一种交互就得让程序改一次 UI,活生生把策划的活变成了程序的活。
7. 落地裁剪:三种规模的交互系统怎么选
Lyra 这套东西完整搬过来,文件不少,接口、结构体、Task、主技能、消息、UI 加起来十几个类。不是所有项目都需要这个量级。我按实际项目经验分成三档,你可以对着自己的需求对号入座。
| 档位 | 适用场景 | 组成 | 大致工作量 |
|---|---|---|---|
| 精简版 | 交互种类少于 5 种,单机或弱联机,无冷却消耗需求 | 一个IInteractable接口 + 一个交互 GA + 射线检测 | 半天到一天 |
| 标准版 | 交互种类 5~15 种,需要提示 UI,需要资格过滤 | 接口 +FInteractionOption/FInteractionQuery+ 定时 Overlap 扫描 + 主技能 | 两到三天 |
| 完整版 | 跨模块扩展、GameFeature 插件化、多人、需要读条与中断 | 全套,含 AbilityTask 动态授予、GameplayMessage、Widget 解耦 | 一到两周(含调试) |
精简版的思路很直白:一个ULyraGameplayAbility_Interact,激活时做一次射线,命中实现了接口的 Actor 就调它的Interact(),然后结束。它的问题和上一节说的传统方案一样,但胜在十分钟能跑起来,适合原型期。
标准版是我在大多数中型项目里推荐的落点。保留“选项提案”这个核心抽象,扫描逻辑不用 AbilityTask,直接在主技能里起一个 Timer,或者干脆在 PlayerController 的 Tick 里节流调用。动态授予那套也可以简化成“所有交互共用一个 GA,通过FInteractionOption里携带的参数区分行为”——省掉了 handle 管理这一大坨最容易出 bug 的部分。
完整版才需要AbilityTask_GrantNearbyInteraction那套动态授予。判断标准很简单:当你的交互类型会由不同团队、在不同插件里各自新增,而主工程不允许被改动时,你才真正需要“目标自己投递技能类”这种反转型设计。否则一个共享 GA 加参数足够了。
无论哪一档,有几个改造点我是强烈建议一开始就做的。第一,把 Overlap 的碰撞通道换成自定义通道,别用 WorldDynamic,否则你会收到大量碎屑和特效碰撞体,白白浪费遍历。第二,加一个视线遮挡过滤,Overlap 只看距离不看遮挡,隔着一堵墙提示“按 E 开门”是新手最容易犯的错,用一次LineOfSightTo或者简单的一次射线,成本极低。第三,给选项加一个优先级字段,多个目标重叠时(比如一个箱子放在一扇门前)能稳定选出主目标,而不是每次 Overlap 顺序变了提示就跟着变。这三件事加起来不到一百行代码,但能省掉后面一半的体验问题。
还有一点关于第一人称项目:Lyra 默认是第三人称,Overlap 球心在角色胶囊体上,扫描范围是个球形。第一人称更适合“锥形 + 射线”,把扫描换成以摄像机为起点、朝向为轴的扇形检测,或者保留 Overlap 但额外要求目标在视野角度内。这块没有标准答案,取决于你的交互密度——开放世界里球形更宽容,室内狭窄场景里锥形更精确。
8. 遮挡、排序、冷却态提示:那些官方示例没替你解决的问题
Lyra 给的是一个能跑通的最小闭环,不是一个能直接上线的产品级交互系统。下面这几个问题,几乎每个把它搬进真实项目的人都会遇到,我按遇到频率从高到低排。
问题一:多个目标重叠时的优先级。示例里没有排序逻辑,选项顺序完全取决于 Overlap 返回的顺序,而这个顺序在不同帧之间可能变化。表现是站在箱子旁边对着门,提示在“开门”和“开箱”之间随机跳动。解法是给FInteractionOption加一个Priority字段,在收集完之后按 Priority 降序排一次,Priority 相同再按距离排。距离排序需要目标提供位置,可以用TScriptInterface转成 Actor 拿坐标。
问题二:冷却中的交互要不要显示。技能在冷却中时,TryActivateAbility会直接失败,但选项本身还在CurrentOptions里,UI 照常显示“按 E 打开”,玩家按下去毫无反应,体感极差。我的做法是在主技能组装CurrentOptions时多做一步:对每个 spec 调用一次CanActivateAbility,把结果作为bAvailable一起广播出去,UI 根据这个标志把提示置灰或者干脆隐藏。这一步的成本不高,但体验提升非常明显。
问题三:交互范围与 UI 范围不一致。扫描范围 200,但玩家站在 300 的地方就能看见提示——这种情况通常是因为 UI 用的是自己的距离判断,而不是扫描结果。全部统一到扫描器的结果上,UI 只做展示,不做任何距离判断。反过来的错误也存在:扫描范围设得比玩家感知范围大,导致远处的东西明明看不清却提示能交互。这个值只能靠手感调,我一般从 150 开始试。
问题四:可交互物的注册开销。如果场景里有几百个可交互物,每次 Overlap 都要遍历全部候选,成本会上去。优化方向有三个:给可交互物单独设一个碰撞配置文件,减少无关物体的干扰;把InteractionScanRate从 0.1 放宽到 0.2~0.25,交互提示的变化肉眼几乎无感;对静态的可交互物(比如固定位置的门)在关卡加载时预注册一个空间索引,扫描时查索引而不是查物理世界。第三个方案改动量不小,一般场景量级在几十个以内不需要。
问题五:交互提示的图标与文案映射。前面提过一次,这里补充具体的落地方式:建一张UDataAsset,里面是TMap<FGameplayTag, FInteractionDisplayInfo>,FInteractionDisplayInfo里放显示名、图标、按键提示类型(单击/长按)。Widget 收到选项列表后查这张表。别用 DataTable,Tag 做行名在 DataTable 里查起来反而别扭。
问题六:调试可视化。我在项目里加了一个控制台变量,打开之后把所有扫描到的目标、状态、优先级用 DrawDebug 画出来,扫描范围内画球,每个命中目标画个标签写上它的交互名和优先级。这个工具加上去之后,组里关于“为什么提示不对”的扯皮少了一大半。做交互系统一定要有这种可视化手段,光看日志效率太低了。
最后分享一个我个人的使用体会:这套架构最值得学的其实不是那几个类怎么写,而是它对待“扩展点”的态度。每一个可能变化的维度——交互类型、交互行为、显示方式、资格条件——都被单独拎出来做成了扩展点,而扩展点之间的连接方式是标签和接口,不是继承和Cast。真正搬到自己项目里的时候,你可以只保留一半的类,但最好把这套“谁提案、谁执行、谁呈现”的分工保留下来,因为日后出问题的从来不是代码量,而是职责边界。