Verse Wiki — 写给蓝图作者的 Verse 手册
拔高 · EXTRA

组件之间怎么互相找到:同一个 entity 内的组件通信

继承式架构里,子类想用父类的能力,直接调用就行——编译器替你担保它存在。组合式架构没有这个担保:你的组件想用网格,得先到网格,而找不到是完全合法的结果。这一页讲怎么找、什么时候找,以及为什么「找」这件事比「调用」危险得多。

一、GetComponent:按类型找一个

最常用的入口是 entity 上的 GetComponent:给它一个组件类型,它把这个 entity 上该类型的组件交给你。官方 API 对它的描述是:如果存在、且在当前调用上下文里可访问,就成功并返回该类型的组件

注意「如果」两个字——它是一个带 <decides> 效果的函数,也就是可能失败的表达式,所以调用时用方括号,而且必须写在失败上下文里:

find_mesh.verse
# ✓ 正确:方括号 + 包在 if 里
if (Mesh := Entity.GetComponent[mesh_component]):
    Mesh.Disable()

# ✗ 错误:裸调用,Compile 报「decides 效果不允许出现在这个上下文」
# Mesh := Entity.GetComponent[mesh_component]

这套写法你在第 12、18 课已经练过无数遍了:可能失败的动作必须住在允许失败的地方。区别只在于,这次失败的含义是「这个 entity 上没装那个组件」。

官方 API 还写了一条容易被略过、但很重要的保证:在 AddedToScene 或 BeginSimulation 阶段调用它时,它会确保返回的那个组件也已经到达对应的阶段。翻译成人话:你在自己的 OnBeginSimulation 里找到的组件,一定也已经开始模拟了,不会拿到一个「还没醒」的半成品。这条保证是 Scene Graph 替你解决初始化顺序问题的方式——比蓝图里各个 Component 的 BeginPlay 谁先谁后要明确得多。

二、找一批:GetComponents 与往上往下找

除了按类型精确取一个,还有三条更宽的路子:

方法 拿到什么 典型用途
Entity.GetComponents() 这个 entity 上的全部组件 不知道对方是什么类型时,逐个转型试探
FindDescendantComponents 往下:后代 entity 上指定类型的组件 父组件统一指挥一堆子部件(灯、粒子、声音)
FindAncestorComponents 往上:祖先 entity 上指定类型的组件 子部件回头找到「我属于哪个整体」

GetComponents() 那条比较特别:因为你的代码事先不知道拿到的都是些什么,官方给的做法是用转型逐个试——转型成功,就说明这个组件具备你要的那种能力(比如实现了「能开关」的那个接口),于是可以统一处理。这在蓝图里对应的是「拿到一串 Component,逐个 Cast To 某个类」的老套路。

另外还有一条更省事的路子:标签。entity 上有 AddTag / ContainsTag 这类方法,官方推荐用它来挑出你关心的那些 entity,而不是依赖「它身上有哪些组件」或者「它在场景里的哪个位置」——因为后两者随时会变。这和蓝图里给 Actor 打 Tag、再用 Get All Actors With Tag 的思路完全一致。

三、官方纪律:找一次,然后别再找了

Epic 的《Scene Graph Best Practices》把组件通信的最佳实践压成了一条几乎是命令式的建议:在 begin simulation 阶段把需要的子组件一次找齐,把引用存下来、或者订阅它们的事件;之后靠事件驱动,不要每次都重新在图里搜一遍

落到代码上就是两种形态。第一种是订阅——找到一次,把回调挂上去,以后被动等着被叫:

subscribe_once.verse
watcher_component<public> := class<final_super>(component):

    OnBeginSimulation<override>():void =
        (super:)OnBeginSimulation()
        # 只在这里找一次,然后把回调挂上
        if (Mesh := Entity.GetComponent[mesh_component]):
            Mesh.EntityEnteredEvent.Subscribe(OnEntityEntered)

    OnEntityEntered(Other:entity):void =
        Print("有东西碰到我了")

第二种是缓存——找到一次,存进自己的字段,以后直接用:

cache_once.verse
blinker_component<public> := class<final_super>(component):

    # 空箱子备着(第 18 课的 option)
    var CachedMesh:?mesh_component = false

    OnBeginSimulation<override>():void =
        (super:)OnBeginSimulation()
        if (Mesh := Entity.GetComponent[mesh_component]):
            set CachedMesh = option{Mesh}

    OnSimulate<override>()<suspends>:void =
        loop:
            Sleep(1.0)
            # 直接用缓存,不再搜图
            if (Mesh := CachedMesh?):
                Mesh.Disable()
            Sleep(1.0)
            if (Mesh := CachedMesh?):
                Mesh.Enable()

为什么这么较真?因为「在图里搜一遍」的代价随项目规模增长。官方还专门提醒:能用局部方案解决时,不要把事件往整个 Scene Graph 的大范围里发;能靠预制体结构和标签把搜索范围缩小,就别做全图扫描。这些建议现在读着像是过度优化,等你的场景里有两千个 entity 时,它就是能不能跑得动的分界线。

还有一条更轻的通信方式值得知道:场景事件。entity 上有 SendUp / SendDown,可以把一条消息沿层级往上或往下传;组件这边覆写 OnReceive 来响应,返回真表示「这条消息我消费掉了,不用再往下传」。这条路子最适合「父层通知一批子部件干活」的场景——你甚至不需要知道下面到底挂了哪些组件,谁想接谁接。

四、为什么「找」比「调用」危险

现在回答标题里那个问题。在蓝图的继承式世界里,你在一个 Character 子类里调用 Jump,编译器替你担保它存在——它写在父类里,跑不掉。而在组合式世界里,你的组件调用的是「同一个 entity 上的另一个组件」,而那个组件是别人在面板里装上去的。这带来三类新风险:

把这三条对着蓝图的 GetComponentByClass 看一遍,你会发现风险其实一模一样——蓝图里也可能拿到 None、也可能有初始化顺序问题、也有「这个蓝图要求你必须挂某个组件」的隐性契约。区别在于:蓝图允许你把这些问题拖到运行时才发现(经典的 Accessed None 警告),而 Verse 在编译期就逼你把「没找到怎么办」写出来。代价是多写几行,收益是少一整类线上事故。

最后给一条能直接用的选型建议:能用事件订阅就不要用轮询查找,能用父层统一指挥就不要让子组件互相找,能把依赖固化成预制体就不要让人在面板上手工拼。组合式架构的自由度是它最大的优点,也是它最大的坑——你的工程纪律要主动补上编译器不再提供的那部分担保。

按官方最佳实践,一个组件需要长期使用同 entity 上的另一个组件,正确做法是?

来源

本文整理自 Epic 官方文档与 Verse API 参考:

Scene Graph 的 API 仍在演进,具体函数签名与可用事件以当时的官方 API 参考为准。