1. 先看清楚:Lyra 的交互不是“按一下 E 开个箱子”
第一次翻 Lyra 的交互相关代码,很多人会失望:以为会看到一个几百行的InteractionComponent,结果发现核心逻辑散在接口、能力任务、指示器系统三处,中间还夹着一层 UI 扩展。但把它跑起来之后你会发现,这套拆法解决的是“一个功能到底该由谁负责”的问题——交互目标的判定属于谁、交互能力的授予时机归谁、屏幕上那个提示框的位置由谁计算,三件事被彻底分开了。UE5 Lyra 交互系统真正值得学的不是某一行代码,而是这种职责切分方式:IInteractableTarget只描述“我能被怎么交互”,GameplayAbility_Interact负责“我现在能不能交互、交互哪一个”,IndicatorSystem负责“把可交互信息画到屏幕上正确的位置”。这套结构让交互变成一种可扩展的通用能力,而不是某个 Actor 蓝图里的一段 Overlap 事件。
这篇内容适合三类人:正在用 UE5 做项目、想搞明白 Lyra 示例工程里交互链路怎么走通的开发者;已经把 GAS 用起来但交互还在用OnComponentBeginOverlap硬写的团队;以及想把这套机制抽出来做成自己项目通用交互框架的人。下面我会按“链路拆解—分层解剖—实现细节—动手接入—排查实录—二次设计”的顺序展开,中间会给出可直接对照的代码骨架和参数取值,也会说说我踩过的坑。
1.1 从玩家按下交互键那一刻倒推链路
先把时序倒着理一遍,比顺着看代码有效得多。玩家按下交互键,输入系统把InputTag.Interact交给 ASC,ASC 激活GA_Interact;GA_Interact在激活时已经通过一个后台任务,把附近所有可交互目标收集成了FInteractionOption列表,于是它直接拿列表里当前选中的那个选项,构造一份FGameplayEventData,把交互目标塞进Target,把交互需要的自定义数据塞进OptionalObject,然后向目标发送一个带标签的游戏事件;目标身上挂着一个等待该事件的AbilityTask_WaitGameplayEvent,收到事件后执行真正的业务逻辑,比如开门、拾取、启动机关。
反向看这条链路,有几个设计意图很明显:交互的“决策”发生在交互者身上,交互的“执行”发生在目标身上,两者只通过一个事件和一个数据结构耦合。这意味着目标不需要知道交互者是谁、拿的什么武器、有没有权限,它只需要在收到事件时检查EventData里的上下文。同样,交互者也不需要知道目标内部怎么实现,它只关心接口返回的选项是否可执行。
注意:这条链路里没有“交互组件”这个中间人。Lyra 把能力授予、选项收集、UI 刷新全部收进
GA_Interact内部,代价是这个能力类会比较重,收益是不需要为一个可能只有部分角色拥有的功能常驻一个组件。
1.2 为什么不用 Actor 蓝图里的简单 Overlap 去写
小 Demo 里用BoxComponent的BeginOverlap显示一个 Widget,按 E 触发,二十分钟能跑通。问题会在三个地方集中爆发。
第一是判定精度的失控。Overlap 是体积判定,玩家站在箱子边缘、隔着墙、在斜坡下方都可能触发;而 Lyra 的扫描任务是按固定频率做球形或胶囊扫描,并且把“视线检测”“距离衰减”“朝向加权”留给具体项目在GatherInteractionOptions里自己决定,判定策略是可替换的,不是写死在组件里的。
第二是多目标时的选择逻辑。一个房间里三个可交互物,Overlap 会同时触发三个提示,玩家按 E 到底交互哪个?Lyra 的选项列表带优先级,指示器层还做了聚簇处理,同屏元素过多时会合并显示,这套东西靠蓝图里的几个 Branch 是堆不出来的。
第三是网络与预测。交互事件走 GAS 的事件通道,天然带预测键和 ASC 的所有权关系,目标端可以用WaitGameplayEvent在自己的能力里处理,客户端预测、服务器校验这条线是通的。蓝图里直接CallServer的写法,一旦遇到断线重连、延迟补偿就会露馅。
所以我的建议很直接:项目规模一旦超过“单机小玩具”,交互就该按 Lyra 这套分层来做,哪怕你不上 GAS,也至少把“接口收集选项”和“表现层绘制”拆开。
2. 架构解剖:三个层次各管什么
2.1 接口层:InteractableTarget 只回答“我能被怎么交互”
IInteractableTarget是个纯接口,核心只有两个函数:一个用来收集交互选项,一个用来在事件发出前修改事件数据。前者接收一个查询结构体,输出一个选项结构体;后者接收事件标签和事件数据引用,让目标有机会往里面补上下文。这个接口的设计非常克制——它不返回布尔值表示“能不能交互”,而是返回一个选项结构,把“能不能”编码进选项的有效性里。这样做的好处是调用方不需要写两套分支,收集到有效选项就是能交互,收集不到就是不能。
接口里还有一个容易忽略的点:收集函数是在交互者侧调用的,不是在目标侧。也就是说,目标只是被动地被问“你有什么交互选项”,它不知道问的人是谁的全部状态,只知道查询结构体里带的 Avatar、PlayerController 和交互源组件。这种“拉模式”比“推模式”更好调试,因为任何时刻你都可以手动调用一次收集函数,看看当前返回什么,而不需要等目标主动发通知。
我见过一些项目把接口改成返回bool加一个输出参数,结果调用方为了拿显示文字还得再调一次。Lyra 这种一次调用返回完整选项的写法,虽然结构体字段多一点,但调用点干净。
2.2 能力层:Nearby 扫描与能力授予
GA_Interact激活时会创建一个后台能力任务,任务按固定间隔做一次范围扫描,把扫描到的 Actor 逐个询问接口,收集成功的选项存进列表。这个任务同时负责能力的动态授予与回收:当附近首次出现可交互目标时,把交互能力授予给 ASC;当附近彻底没有可交互目标时,把能力撤销。这个机制解决了一个很实际的问题——不是所有角色都需要交互能力,站着不动的 NPC、已经死亡的尸体、观战状态的玩家,都不该持有这个能力。
扫描频率是个需要认真对待的参数。设得太高,每帧一次扫描在多人场景里开销可观;设得太低,玩家跑到箱子前要等半秒才出提示。我的经验值是 0.15 到 0.25 秒之间,配合“玩家移动速度”做一点动态调整:静止时降到 0.3 秒,奔跑时提到 0.1 秒。这个逻辑不复杂,在任务里加一个基于速度的累加器就行。
另一个关键点是扫描范围与交互范围要分开。扫描范围决定“什么时候把提示显示出来”,交互范围决定“按下去能不能成功”。Lyra 的默认值里扫描范围略大于交互范围,这个差值就是留给玩家的容错空间。如果你把两个范围设成一样,玩家会在边缘反复横跳,提示一闪一闪,体验很差。我一般让扫描范围比交互范围大 15% 到 20%。
2.3 表现层:Indicator 系统与 Slate 绘制
交互提示不是普通的 UMG Widget,而是走了一套独立的指示器系统。核心对象是描述符、图层和管理器三件套:描述符描述“这个提示要跟着哪个场景组件、偏移多少、优先级多少、显示什么内容”;图层是一个绘制容器,内部用 Slate 画布做世界坐标到屏幕坐标的投影;管理器挂在 PlayerController 上,负责把描述符加到对应图层,并在销毁时清理。
为什么不用 UMG?因为 UMG 的 Widget 每个都是独立的 Slate 元素,几十个提示同屏时布局计算和绘制批次会很难看。指示器系统把所有提示画在同一个画布里,自己做投影、自己做排序、自己做屏幕边缘裁切,性能和控制力都更强。而且它能做一件 UMG 很难做的事:当两个提示的屏幕位置重叠时,按优先级合并成一个,显示“3 个可交互物”之类的聚合提示。
指示器的投影有个细节:它不是简单地把世界坐标转屏幕坐标,而是先取场景组件的世界位置加上偏移,再用玩家相机的投影矩阵换算,最后根据距离做缩放和透明度衰减。这意味着远处的提示会自然变小变淡,不需要你手动写距离判断。
2.4 数据契约:查询结构与选项结构
两个结构体的字段设计决定了这套系统的扩展上限。查询结构体里带的是“谁在问”:Avatar、Controller、交互源组件。选项结构体里带的是“能做什么”:交互能力类、交互目标的弱引用、可选的业务数据对象、显示文本、优先级。
这里有个我认为很聪明的设计:选项里带的是交互能力类,而不是一个回调。目标告诉交互者“如果你想跟我交互,请用这个能力类”,交互者在触发时动态授予并激活这个能力。这样一来,同一个目标可以根据不同条件返回不同的能力类——满血时返回“治疗”,残血时返回“献祭”,不需要在目标内部写一堆 if,也不需要交互者知道这些分支的存在。
业务数据对象是另一个扩展点。它是个UObject指针,目标可以把任何需要传给执行端的数据塞进去,比如宝箱的掉落表、门的开锁难度、机关的启动序列。执行端在能力里把它取出来强转成具体类型。这种弱类型的做法牺牲了一点类型安全,换来的是接口的通用性,我认为在这个场景下是划算的。
3. 核心实现细节:几个容易看漏的地方
3.1 扫描任务的节流与生命周期
能力任务在 Lyra 里的实现有个容易被忽略的细节:它不是每次 Tick 都做完整扫描,而是用一个累加器控制扫描间隔,这让任务本身的开销非常低。但更重要的是它的清理逻辑——任务结束时必须把所有动态授予的能力撤销掉,否则玩家离开交互范围后能力还挂在 ASC 上,输入绑定会一直存在,按交互键没反应但也不报错,排查起来很费时间。
我建议在撤销能力时加一行日志,把能力句柄和目标数量打出来。这类“幽灵能力”问题在联机环境下尤其难查,因为服务器和客户端的授予时机可能不一致。另外,如果项目里有多个交互源(比如角色身上和载具上各一个),要给每个源单独维护一份扫描结果,不能共用一个列表,否则载具上的玩家会看到角色身边的提示。
3.2 指示器的锚点与投影
描述符绑定的是场景组件加一个局部偏移,不是直接给一个世界坐标。这个选择的意义在于:当目标移动或有骨骼动画时,提示会自动跟随,不需要每帧手动更新位置。偏移量的设置要跟着模型走,人形角色的头顶提示一般设在胶囊体半高再加 20 到 30 厘米的位置,太大了会飘,太小了会糊在模型上。
投影失败是个必须处理的分支。当目标在相机背后、被完全遮挡、或者超出最大投影距离时,投影结果无效,这时候要么隐藏提示,要么把它压到屏幕边缘显示方向箭头。Lyra 的做法是直接隐藏,我倾向于对任务目标类的交互物显示边缘箭头,因为玩家需要知道“东西在那个方向”。这个改动不大,在画布的绘制循环里加一个分支就行。
3.3 事件数据如何在目标与交互者之间传递
事件数据的构造有几个必填项容易漏。Target必须设成交互目标的 Actor,否则目标侧的等待任务收不到事件;Instigator要设成交互者的 Actor,方便目标做归属判断;OptionalObject是可选的业务数据。还有一点,事件标签要跟目标侧等待的标签完全一致,这个标签最好用常量或者配置表统一管理,我见过因为一个字母大小写不一致导致事件发出去没人接的情况,查了两小时。
目标侧收到事件后,通常会在自己的能力里处理业务逻辑,而不是直接在 Actor 里写。这样做的理由是能力可以拿到完整的 ASC 上下文,能做消耗、冷却、动画蒙太奇、 GameplayCue。如果目标是个简单道具,没有 ASC,那就退化成在 Actor 上挂一个等待任务,效果一样,只是少了能力系统的约束。
3.4 多选项的排序与合并
当多个目标的提示在屏幕上靠得很近时,指示器层会做聚簇:距离小于阈值的提示合并成一个,显示数量。合并阈值按屏幕像素算比较稳,我一般设 40 到 60 像素。合并之后点击或者按键切换目标,需要有“循环切换”的逻辑,这个逻辑放在交互能力里,用索引轮转实现。
排序规则是个业务问题。我的做法是三级排序:先按优先级字段,再按距离,最后按目标唯一 ID 保证稳定。为什么要加唯一 ID?因为距离相同的两个目标在排序时结果不稳定,每帧顺序可能变,玩家会看到提示内容闪烁。加了 ID 兜底之后顺序就固定了。
| 排序维度 | 建议取值方式 | 解决的问题 |
|---|---|---|
| 优先级 | 目标在收集选项时自行填写,0 到 100 | 任务目标优先于环境装饰 |
| 距离 | 交互源到目标的平方距离 | 近距离优先 |
| 唯一 ID | Actor 的持久标识 | 排序稳定性 |
| 可见性 | 视线检测结果 | 隔墙目标降权 |
4. 动手接入:给一个自定义宝箱加交互
4.1 目标侧实现
假设我们有一个宝箱类,要实现交互接口。核心是在收集函数里判断当前能不能开、开了没有、玩家有没有钥匙,然后填选项。下面是骨架,字段命名按常见工程习惯整理,实际项目里按自己的命名规范调整即可。
// 收集交互选项:被交互者侧调用 void ATreasureChest::GatherInteractionOptions( const FInteractionQuery& Query, FInteractionOption& OutOption) { // 已经开过就不再提供选项 if (bOpened) { return; } // 距离与视线二次校验,扫描只是粗筛 if (!IsWithinInteractionRange(Query.AvatarActor)) { return; } OutOption.InteractionAbilityClass = OpenChestAbilityClass; OutOption.TargetActor = this; OutOption.DisplayText = NSLOCTEXT("Chest", "OpenChest", "开启宝箱"); OutOption.Priority = 50; // 业务数据:把掉落表传过去,执行端不用再查表 UTreasurePayload* Payload = NewObject<UTreasurePayload>(this); Payload->LootTable = LootTable; OutOption.OptionalObject = Payload; } // 事件发出前,目标有机会补充上下文 void ATreasureChest::CustomizeInteractionEventData( const FGameplayTag& EventTag, FGameplayEventData& EventData) { EventData.EventMagnitude = RequiredKeyLevel; }关键点在最后那个业务数据对象。把它设成NewObject并且指定this为 Outer,生命周期就跟宝箱绑定,不用担心泄漏。事件数据里的EventMagnitude是个很好用的浮点槽位,我经常用它传难度、等级、数量这类简单数值,省得为每个小数据都建一个 UObject。
4.2 交互能力与输入标签
执行端的交互能力要做三件事:拿到事件数据里的目标、判断能不能执行、执行并广播结果。输入绑定这块,交互标签要加到输入配置的映射里,同时在角色的输入组件里把按下事件转发给 ASC。这里有个常见错误:能力授予了但输入标签没配,按下去完全没反应,日志里也看不出来。我的排查习惯是先打印当前 ASC 上所有能力的标签,确认能力在、标签对,再查输入映射。
// 激活时先做一次预检查,避免无效的预测 bool UOpenChestAbility::CanActivateAbility( const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayTagContainer* SourceTags, const FGameplayTagContainer* TargetTags, FGameplayTagContainer* OptionalRelevantTags) const { if (!Super::CanActivateAbility(Handle, ActorInfo, SourceTags, TargetTags, OptionalRelevantTags)) { return false; } // 检查交互者状态,例如是否正在施法、是否被禁锢 return !ActorInfo->AbilitySystemComponent->HasMatchingGameplayTag( StateTag_Blocked); }参数选择上,冷却我建议设成 0.2 秒而不是 0,理由很实际:防止玩家手抖连按触发两次事件,目标和服务器都要处理重复请求,得不偿失。消耗可以根据宝箱类型配,普通箱子不消耗,需要钥匙的箱子在能力里扣。
4.3 HUD 指示器层配置
指示器层要在 HUD 初始化时添加到管理器,并且指定一个绘制优先级。我建议交互提示单独占一层,不要跟血条、任务标记混在一起,因为它们的刷新频率和合并策略不一样。层里要配最大显示数量,超过就按优先级裁剪,全屏二十个提示会糊成一团。
具体的参数我一般这样起步:最大显示数量 8 个,合并阈值 48 像素,最大投影距离 3000 单位,距离衰减起始 800 单位。这几个值不需要一次调准,先跑起来看效果,觉得远提示太小就加衰减起始,觉得提示太多就减显示数量。
4.4 联调与调试命令
联调阶段最有效的手段是可视化。扫描范围用球形或者胶囊的调试绘制,把扫描到的目标用不同的颜色标出来,收集成功的画绿色,失败的画红色,一眼就能看出是扫描没扫到还是收集函数返回了空。指示器的投影位置也可以画一个调试点,确认它跟模型对得上。
我常用的做法是给交互任务加一个控制台变量,开关调试绘制,同时在收集函数返回值的地方打一行 Verbose 级别日志,记录目标名、选项数量、失败原因。线上出问题时把这几个变量打开,比盲猜快得多。
5. 排查实录:交互不显示、按了没反应、多人不同步
5.1 指示器不出现
这是最高频的问题,原因可以按顺序排查。第一看接口是否真的被调用了,如果没有,问题在扫描任务——可能范围太小、可能扫描通道不对、可能任务根本没启动。第二看收集函数是否返回了有效选项,如果返回空,通常是距离校验或状态校验没过。第三看描述符是否被加到了管理器,管理器是否存在于当前 PlayerController 上。第四看图层的绘制条件,比如最大投影距离、是否被遮挡剔除。
| 现象 | 常见原因 | 快速验证 |
|---|---|---|
| 完全无提示 | 扫描任务未启动 | 打开调试绘制看有无扫描球 |
| 靠近才出现,稍远就没有 | 扫描范围小于交互范围 | 打印两者数值 |
| 有提示但位置乱飘 | 锚点组件设错或偏移过大 | 关闭偏移看是否贴合 |
| 提示一闪一闪 | 两个范围值过于接近 | 拉开 15% 以上差值 |
| 多人时只有主机能看到 | 描述符只在服务器创建 | 检查创建端的网络角色 |
5.2 按键无响应
确认能力在 ASC 上之后,查三件事:输入标签是否匹配、能力是否被阻塞标签挡住、能力是否已经在激活中。最后这条最隐蔽,因为有些项目把交互能力设成不可重入,第一次激活后没有正确结束,后续全部无效。我的做法是在能力结束时强制打印状态,确认EndAbility被调到。
5.3 网络与预测
交互在联机下最容易出问题的是“客户端看到提示但服务器不认”。原因是客户端基于本地状态收集了选项,服务器那边的状态已经变了。解决办法是在执行端再做一次校验,服务器拒绝时给客户端一个失败反馈,同时刷新选项列表。别指望客户端预测能解决所有问题,交互这种会改变世界状态的操作,最终裁决必须在服务器。
5.4 性能
扫描任务本身开销不大,真正吃性能的是指示器的绘制。每帧要对每个描述符做投影计算,描述符数量上到三四十个时会有可感知的开销。优化手段是按距离分帧更新远处描述符的屏幕位置,近处的每帧更新,远处的两帧或三帧更新一次。视觉上几乎看不出差别,开销能降一半。
6. 二次设计:把它改造成可复用的交互框架
6.1 长按与进度
按住交互是很多项目的刚需,比如长按开启重物、长按破解终端。实现方式是在交互能力里加一个等待任务,等待输入释放或者达到持续时间。持续时间可以用能力参数配,也可以从选项的业务数据里读,后者更灵活,因为不同目标需要的时间不一样。进度条不要直接绑在能力上,而是通过 UI 扩展把进度值传给指示器描述符,让指示器层去画,这样表现和逻辑还是分开的。
6.2 条件过滤与权限
权限判断应该放在收集函数里,而不是执行函数里。原因是收集阶段就能把不可用的选项过滤掉,玩家根本看不到,体验更干净。执行阶段的校验只作为最后一道保险。这条经验来自一次返工:早期我们把权限判断全放在执行端,结果玩家能看到提示、按下去才被告知“你没有钥匙”,非常挫败。改成收集阶段过滤后,提示直接不出现,反而更符合直觉。
6.3 与插件化模块解耦
如果项目用了模块化或者插件化的组织方式,交互系统的接口应该放在基础模块,具体的交互能力放在内容模块。这样基础模块不依赖任何具体玩法,内容模块可以自由增删。我见过把交互接口写在射击模块里的项目,结果做载具模块时为了引用接口不得不依赖射击模块,依赖关系一下就乱了。
6.4 一些实操体会
最后说几个我认为值得记下来的点。选项结构体里的目标引用用弱引用,别用裸指针,目标被销毁后提示还挂着的 bug 很难查。事件标签集中管理,别散落在各个类里。描述符的销毁一定要跟能力的结束绑定,用EndAbility或者 RAII 式的清理对象,别指望 GC。扫描任务的频率做成可配置,不同平台不同画质给不同值,主机和低端机用同一套参数是给自己找麻烦。
我在实际项目里把这套结构搬过去之后,最大的感受是交互终于变成了“可以讨论的设计”,而不是“某个蓝图里的黑盒”。策划想加一种新交互,只需要说清楚收集条件和执行效果,代码上的改动是新增一个能力类加一段接口实现,不会牵动其他任何地方。这种可预测性,比省下来的那点开发时间值钱得多。