前言
在我设计对话系统的时候遇到了这样的一个问题,每个能够对话的NPC身上有一个DialogueComponent来负责处理对话相关的逻辑,当对话时NPC头上会有一个Bubble来展示对话的内容,这部分逻辑是由BubbleAnchorComponent来负责的;对于DialogueComponent来说显然BubbleAnchorComponent是一个必须的子组件,并且其中有一些关于Bubble位置、Bubble样式的属性需要配置。那么如何创建并初始化BubbleAnchorComponent就成为了一个主要的问题,我想到了几种解决方案
解决方案
方案一:运行时添加并配置
这个方案应该是最“经典”的方案,通过在Component::OnRegister中使用AddComponentByClass向Owner添加组件并设置Attach以及组件的其他属性。但这个方案有几个问题:
- 无法在蓝图或者场景的Actor上看到或编辑添加的组件(虽然OnRegister已经被调用),因为只有CreateDefaultSubobject创建的组件才会展示在编辑器中并同时将其属性暴露在其创建者(Outer)的Detail面板上,这就意味着无法直接编辑BubbleAnchorComponent的属性,必须把其所有需要的属性通过DialogueComponent暴露到编辑器中,由DialogueComponent在OnRegister中创建时去配置,如果属性多的话可能会很麻烦
- 如果在创建BubbleAnchorComponent时将其保存为UPROPERTY的话,运行时能够通过DialogueComponent上的对应指针查看它的属性来Debug,否则运行时也没办法看到这个添加的Component的任何信息
方案二:在构造时创建,编辑器中配置
这个方案也很简单,在DialogueComponent的构造函数中通过CreateDefaultSubobject创建BubbleAnchorComponent,然后直接在编辑器中去调整它的属性,这个方案看起来解决了方案一中的两大问题,但实际上有几个隐藏的问题可能远比方案一的缺点要严重的多,这也是为什么这种方案不受推荐的理由
- Unreal在设计时默认所有Component都应该Owned By Actor,这就会导致一些系统在工作时不会考虑被Component创建出的Component,最经典的例子应该是网络同步。即便你的Component勾选了IsReplicated,它也是以Actor为单位进行同步的,被Component创建的Component在同步时会出现各种奇怪的问题,虽然可以利用重写Component的ReplicateSubobject来同步创建出的子Component,但操作起来也会很麻烦;而且除了网络系统外其他系统在工作时也可能会因此抛出奇怪的问题,你将很难定位这些问题的原因。具体关于网络同步的问题可以查看这个帖子
- 关于子Component的Attach问题,当创建SceneComponent时Attach是一个比较特殊的属性,如果父不是SceneComponent或者子不应该被Attach到父上,那么就需要获取Owner来做Attach,这时就会有一个新的问题,在Component的构造函数中能够获取Owner吗?
如果你尝试的话你会发现编辑器崩溃了,因为Owner为nullptr,但ActorComponent中第一行就设置了Owner,怎么会为空呢?
UActorComponent::UActorComponent(const FObjectInitializer& ObjectInitializer /*= FObjectInitializer::Get()*/)
: Super(ObjectInitializer)
{
OwnerPrivate = GetTypedOuter<AActor>();
}
首先Owner其实就是Outer,只不过将其限定为Actor类型作为Component的一类特殊属性而已,这也侧面说明了任何Component的Outer都应该是Actor,但ActorComponent作为父类的构造函数肯定先被调用到了,那怎么Owner还会为空呢,我们做几个快速的测试
UTestComponent::UTestComponent()
{
FString s = GetOuter()->GetName();
UObject* O = GetOuter();
if(Cast<AActor>(O))
{
AudioComponent = CreateDefaultSubobject<UAudioComponent>("AudioComp");
AudioComponent->SetupAttachment(GetOwner()->GetRootComponent());
}
}
这是一个空的项目,我们有一个TestComponent,TestCharacter中创建了这个组件,并且TestCharacter有一个派生的蓝图类但没有做任何操作,场景中也没有放置TestCharacter,我们看看TestComponent的构造函数被调用了几次以及Outer分别是什么
L"/Script/TestProject"
L"Default__TestProjectCharacter" //CDO
L"Default__BP_ThirdPersonCharacter_C" //CDO
L"Default__SKEL_BP_ThirdPersonCharacter_C" //CDO
//当我打开TestCharacter蓝图或将其加入场景中时
L"BP_ThirdPersonCharacter_C_0" //Instance
首先Outer从来没有为nullptr(这个其实也能猜到),除了第一个之外其余全部是Actor,分别是三个ClassDefaultObject和一个实例对象;问题就出现在第一个上面,看起来像是模块或者是类(UClass)之类的东西作为了Outer,我猜可能是构建类似StaticClass之类的UClass对象时候调用到了构造函数
所以回到问题本身,如果在Component的构造函数中调用Owner就必须要先判断Owner是否为空,并且要在Actor中优先创建Rootcomponent或者要Attach的Component才能在Component的构造函数中做Attach
这里还有个小坑,SceneComponent如果不做Attach的话,在蓝图或者编辑器界面会看到它自动Attach到Owner的RootComponent上,但实际上Play后就会脱离,直接Attach到场景上
方案三:相信自己所处的环境
方案三应该是最无脑的一种方案,虽然BubbleAnchorComponent是DialogueComponent运行所必须的组件,但并不意味着必须由DialogueComponent来创建,我大可以相信我的Owner已经为我准备好了我需要的一切,所以我可以直接在BeginPlay中通过GetComponentByClass来获取我需要的其他组件,如果没有找到的话直接抛出错误或者不执行对应模块的逻辑,这样既符合Unreal的设计来让Actor创建Component又可以在编辑器中直接配置对应的组件,完美的方案!
但事实真的如此吗,当你的游戏变得庞大的时候,任何一个小的功能组件都需要在构造函数中被直接添加到你的PlayerCharacter上;
当你的其他组件都是什么AI寻路、背包系统这样的大型模块时,一个BubbleAnchorComponent真的会给你一种Out of nowhere的感觉;
当多个组件需要某一个基础组件时这个组件的属性又该怎么配置,是否需要创建多个基础组件呢?
总结
显然方案二是最差的,除非有特殊情况否则应该尽量不使用方案二去创建Component
在一些确定的情况下,比如DialogueComponent以来于音频组件,但每个Character拥有音频组件是很合理常见的,那么可以使用方案三去创建依赖的Component,这点Unreal应该向Unity学习,提供一些支持依赖的宏来在编辑器界面提示这个组件需要哪些组件,就像Unity支持在Attribute中设置RequiredComponent来自动添加需要的组件
方案一应当是多数情况下的首选方案,虽然可能要多写几个属性,但总体来说是最合理的