尽管 C++ 本身视 classstruct 都为类类型,区别只在默认访问修饰符(class 默认为 private,struct 默认为 public)。但在虚幻引擎中却有所不同,UObject 使用 class 作为类类型的修饰符,而 UStruct 使用 struct 作为类类型的修饰符(请注意这里没有讨论 Slate 相关的类体系结构),就会在反射机制、网络复制方面产生不同的区分,本博文在构建道具类型的基础下,讨论以下内容:

  • 如何维护它们的生命周期;
  • 如何在网络中复制它们。

物品的信息

在绝大多数情况下,物品信息可以用一个结构体来定义(C++中需要派生自 FTableRowBase),然后定义基于该结构体的 DataTable,这种方式不仅可以实现统一的数据管理,还允许从外界来修改物品信息,在需要使用物品信息的位置处定义一个 FDataTableRowHandle 实例即可对 DataTable 中的行进行引用。但取而代之的是,引擎每次启动都会加载所有的物品信息,造成加载时间变长;DataTable 有一个独立于结构体的 RowName 列,因此开发者需要引入约定来确定 “空”是什么,是 RowName 为 Empty 还是结构体中其中一个作为标识符的成员为无效值;存储 FDataTableRowHandle 并不能保证得到的信息就是物品信息,因为它允许设置来自哪个数据表和行名,对于输出行只能假设为是物体信息有关的结构体(我们无法使用 Cast 的结果来判断是否为预期的类型)。

还有一个可选方案是使用 UPrimaryDataAsset 类型,定义的数据变量可以全部用于描述物品的信息(相比较 DataTable 多了 RowName),可以用常见约定 nullptr 为空对象。这种类型下,我们还允许通过配置 UPROPERTY 来使一些数据成员隐藏起来,例如在下面的代码中允许设计师设置物体使用 StaticMeshSkeletalMesh 网格资产,从而隐藏另一项以达到更加友好的编辑界面:

UENUM(BlueprintType)
enum class EMeshType : uint8
{
	Skeletal,
	Static
};

UCLASS()
class BOTA_API UBota_ItemDataAsset : public UPrimaryDataAsset
{
	GENERATED_BODY()
	
public:
	virtual FPrimaryAssetId GetPrimaryAssetId() const override { return FPrimaryAssetId("Bota_ItemDataAsset", GetFName());};

protected:
	virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;

public:

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Info")
	FText DisplayName;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Info")
	EMeshType MeshType;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Info", meta = (EditCondition = "MeshType == EMeshType::Skeletal", EditConditionHides))
	TSoftObjectPtr<USkeletalMesh> SkeletalMesh;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Info", meta = (EditCondition = "MeshType == EMeshType::Static", EditConditionHides))
	TSoftObjectPtr<UStaticMesh> StaticMesh;
};

// 当设计师选择一项时,将另一项的值清空。这样在创建物品 Actor 时就不需要检查 EMeshType 的值,直接对两种网格组件调用设置网格函数即可。
void UBota_ItemDataAsset::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
    Super::PostEditChangeProperty(PropertyChangedEvent);

    if (PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED(ThisClass, MeshType))
    {
        if (MeshType == EMeshType::Static)
        {
            SkeletalMesh = nullptr;
        }
        else
        {
            StaticMesh = nullptr;
        }
    }

将物品信息都为单独一个资产时,我们还可以进一步地允许将资产拖拽到场景中,创建使用这个道具信息的道具 Actor。只需要定义一个派生自 UActorFactory 的类,然后覆盖 CanCreateActorFromPostSpawnActor 函数即可,以下是一个示例:

UBota_ItemActorFactory::UBota_ItemActorFactory()
{
	NewActorClass = ABota_ItemActor::StaticClass();
}

bool UBota_ItemActorFactory::CanCreateActorFrom(const FAssetData& AssetData, FText& OutErrorMsg)
{
	if (!AssetData.IsValid())
		return false;

	if (!AssetData.GetClass()->IsChildOf(UBota_ItemDataAsset::StaticClass()))
	{
		OutErrorMsg = NSLOCTEXT("CanCreateActor", "WrongType", "Asset must be a UBota_ItemDataAsset.");
		return false;
	}
	return true;
}

void UBota_ItemActorFactory::PostSpawnActor(UObject* Asset, AActor* NewActor)
{
	const auto& Data = Cast<UBota_ItemDataAsset>(Asset);
	auto Actor = Cast<ABota_ItemActor>(NewActor);
	if (!Data || !Actor)
		return;

	Actor->DefaultItemDataAsset = Data;
}

物品的实例

讨论完物品信息的存储方式后,该讨论讨论如何存储物品的实例了,在实例中除了包含物品信息外,还有例如物品数量、物品耐久度、弹药量等数据成员。目前,绝大部分的实现中也是使用结构体来存储,在这种设计中结构体扮演着“大师(Master)”的身份,所有的道具都使用全部的数据成员,比如说资源类道具(例如木头、石头)也会拥有弹药量信息,只是它不会使用而已。虚幻引擎中 UStruct 使用另一套垃圾回收机制,我们得使用 TSharedPtrTSharedRef 等智能指针来维护它们。但这里需要考虑的是,是不是值得构建一个继承体系来适应不同的物品实例信息,只为了少存储几个字节,反而会增加类型识别和转换的工作(也会增加程序错误的概率)。在这里,我更倾向于物品实例类型是一个“大师”类型,即它存有物品所用到的所有信息。

通常来说使用了结构体后,只要标记为 Replicated 就可以保证网络复制了,因为无论是角色类还是物品栏的容器中,我们存储的都是结构体实例,而不是指向或绑定到结构体的指针或引用,只要保证远程过程调用中确保传递是绑定到结构体实例的引用(例如 const FBota_ItemInstance&),以及确保移动时在原位置处进行清除的工作即可。

如果一定要构建继承体系来维护道具实例,使用自定义的 UObject 会更好,因为我们可以使用相同的反射机制来维护道具实例。但这里需要注意的是,虚幻引擎中并不是所有的 UObject 都支持进行网络复制,想要自定义的 UObject 支持网络复制在类本身中表示能支持网络功能,还像在组件或 Actor 那样覆盖 GetLifetimeReplicatedProps 函数,以下是一个示例:

// header file
UCLASS(BlueprintType)
class BOTA_API UBota_ItemSpec : public UObject
{
	GENERATED_BODY()

public:
	UBota_ItemSpec() = default;
	virtual bool IsSupportedForNetworking() const override;
	virtual void GetLifetimeReplicatedProps(TArray< class FLifetimeProperty >& OutLifetimeProps) const override;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated)
	TObjectPtr<UBota_ItemDataAsset> ItemDataAsset;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated)
	int32 ItemAmount = 0;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated)
	bool bIsEquipped = false;
};
// cpp file
bool UBota_ItemSpec::IsSupportedForNetworking() const
{
    return true;
}

void UBota_ItemSpec::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(ThisClass, ItemDataAsset);
    DOREPLIFETIME(ThisClass, ItemAmount);
    DOREPLIFETIME(ThisClass, bIsEquipped);
}

完成以上这些还不能实现将物品实例进行网络复制,我们需要借助 Actor 的网络通道来复制 UObject 才可以。这意味着,UBota_ItemSpec 出现的任何位置(Actor 或 ActorComponent )都需要进行编写类似以下的代码:

// ABota_ItemActor 存储着 TObjectPtr<UBota_ItemSpec> ItemSpec,并标记了 Replicated。
void ABota_ItemActor::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, ItemSpec);
}
bool ABota_ItemActor::ReplicateSubobjects(UActorChannel* Channel, FOutBunch* Bunch, FReplicationFlags* RepFlags)
{
	bool bWroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);
	if (ItemSpec)
	{
		bWroteSomething |= Channel->ReplicateSubobject(ItemSpec, *Bunch, *RepFlags);
	}
	return bWroteSomething;
}

// UBota_InventoryComponent 存储着 TArray<UBota_ItemSpec> InventoryContainer,并标记了 Replicated。
void UBota_InventoryComponent::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);

	DOREPLIFETIME(ThisClass, InventoryContainer);
}
bool UBota_InventoryComponent::ReplicateSubobjects(UActorChannel* Channel, FOutBunch* Bunch, FReplicationFlags* RepFlags)
{
	bool bWroteSomething = Super::ReplicateSubobjects(Channel, Bunch, RepFlags);

	for (auto& Item : InventoryContainer->Items)
	{
		bWroteSomething |= Channel->ReplicateSubobject(Item, *Bunch, *RepFlags);
	}

	return bWroteSomething;
}

直到这里,我们算是实现了自定义的 UObject 的网络复制。但我目前遇到的问题不清楚是故意设计还是引擎问题,就是 UBota_ItemSpec 定义蓝图派生类,在蓝图中定义的数据成员就算启用了网络复制也无法进行网络复制,换句话说,UBota_ItemSpec 的派生体系只能在 C++ 端完成。