在很多提供建造玩法的项目案例中,往往是定义 ActorComponent 类并在那里实现启用建造功能、选择建造位置、轻微调整建筑物、生成建筑物等功能,随后角色类会使用该 ActorComponent 的实例,从而获得建造建筑的能力。这需要开发者处理玩家能否启用建造功能,例如当玩家获得阵亡、沉睡、眩晕等状态时需要阻止建筑功能逻辑的执行,并且停止正在进行的建造进程。
除此之外,很多解决方案还采取了引入若干个碰撞频道来实现建筑物对齐功能,这会至少引发三个问题:1. 迁移代码到其他项目时总是需要添加这些额外的碰撞频道;2. 每次引入新的碰撞频道时,需要检查之前的建筑物是否已经忽略了新频道,避免出现错误的对齐;3. 开发者需要检查每个 BoxComponent 是否正确阻挡了对应的频道,增加了因开发者分心、遗忘所造成的程序错误逻辑概率。所以这篇博客集中于以下几点实现建造功能:
- 将建造功能作为一项 Ability 来实现,后期玩家获得的状态影响着玩家是否能够激活该技能;
- 设计师只需要按照命名规范为 BoxComponent 起合适的名字,并调整对齐位置点即可。
建筑物的识别与对齐
在玩家尝试进行一次建造物的生成时,通常更愿意将其摆放在已存在建筑物的附近,因此为玩家提供辅助对齐的机制尤为重要。在建筑物类内部可以看到很多 BoxComponent 出现在 StaticMeshComponent 的附近,它们用于指定可以摆放在那里的建筑类类型。在下图中,可以看到地基类建筑可以设置其他地基、墙壁可以对齐的位置。
在玩家挪动预览建筑物的位置时,射线追踪的结果会尝试寻找匹配的 BoxComponent,如果建筑物类型匹配则设置预览建筑物的变换到该 BoxComponent 的。具体的逻辑实现在下一节中展开详述,本节讨论如何为 BoxComponent 附上合适的建筑物类型。
我个人比较喜欢的是让每一个行为都有意义,包括为 BoxComponent 实例命名。在上图左上角可以看到FoundationX
、WallX
格式的名字,这些数字(X)前面的字符串就为 BoxComponent 定义了类型。这样名字不仅帮助设计师区分不同组件,还进一步确定了它可以对齐的建筑物类型(如建筑物类型为 Wall 的实例只能对齐到 Wall 开头的 BoxComponent 中)。为实现这种名称决定类型的功能,我们需要一个枚举类和 BoxComponent 派生类,具体实现如下:
enum class EStructureType : uint8
{
None,
Foundation,
Wall,
// other stuff
};
class UBota_BoxComponent : public UBoxComponent;
EStructureType UBota_BoxComponent::GetCollisionType() const
{
const auto& EnumPtr = FindObject<UEnum>(ANY_PACKAGE, TEXT("EStructureType"), true);
for (int32 i = 0; i < EnumPtr->NumEnums(); ++i)
{
if (GetNameSafe(this).StartsWith(EnumPtr->GetNameStringByIndex(i)))
{
return static_cast<EStructureType>(EnumPtr->GetValueByIndex(i));
}
}
return EStructureType::None;
}
在上述代码中EStructureType
决定了当前项目中有哪些建筑物类型。自定义的UBota_BoxComponent
提供了返回建筑类型的函数,在函数体内会根据自己的名字找到匹配的建筑枚举项并返回。这种实现方式使用到了虚幻引擎提供的反射机制,设计师也可以随时在EStructureType
添加新的建筑物类型,而无需修改UBota_BoxComponent
的代码。
在很多解决方案中,摧毁一个建筑物可能会引发其他建筑物的一同销毁,例如一块地基建筑物被销毁时会连同周围的墙壁、墙壁附近的物品一起销毁,但不会影响其他地基建筑物的销毁,此时可以遍历 UBota_BoxComponent
组件从而筛选出满足条件的建筑物。
实现技能前的准备工作
在 GAS 插件下,需要玩家挑选和确认施法位置时会用到继承自AGameplayAbilityTargetActor
的类实例(以下简称 TargetActor)。除了作为一个 Actor 存在,它还封装了一个FGameplayAbilityTargetData
(以下简称 TargetData),在玩家发出确认目标的请求时就会将处理后的信息封装成FGameplayAbilityTargetDataHandle
(以下简称 TargetDataHandle)然后以委托的形式传递出去,这是一种在 Ability 类中客户端向服务器通信发送数据的方式。
虚幻官方提供了如 GroundTace、SingleLineTrace 等派生类供开发者直接使用,这些派生类会在射线追踪中将FGameplayAbilityTargetingLocationInfo
的实例传递出去,在一些仅需要位置信息的技能中很有用。然而对于建造技能来说,除了位置信息我们还需要知道玩家设置的旋转信息,以便于建筑物拥有玩家预期的变换。因此需要:
- 能够传递位置和旋转信息(这里假设了缩放大小保持在 1);
- 作为预览建筑物的 TargetActor 能尝试寻找
UBota_BoxComponent
实例并对齐; - 能在自定义的 TargetData 和 TargetDataHandle 之间来回转换。
自定义 TargetData 中最关键的要求就是变量类型实现了网络序列化(有NetSerialize
函数可用),在本案例中,FVector_NetQuantize100
和FRotator
都被虚幻引擎官方实现了网络序列化,故整个工作流程就只是使用如同下面的模板来定义一个 TargetData:
USTRUCT(BlueprintType)
struct BOTA_API FGameplayAbilityTargetData_LocationAndRotation : public FGameplayAbilityTargetData
{
GENERATED_BODY()
FGameplayAbilityTargetData_LocationAndRotation() = default;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FVector_NetQuantize100 Location;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FRotator Rotation;
virtual UScriptStruct* GetScriptStruct() const override
{
return FGameplayAbilityTargetData_LocationAndRotation::StaticStruct();
}
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
Location.NetSerialize(Ar, Map, bOutSuccess);
Rotation.NetSerialize(Ar, Map, bOutSuccess);
bOutSuccess = true;
return true;
}
};
template<>
struct TStructOpsTypeTraits<FGameplayAbilityTargetData_LocationAndRotation> : public TStructOpsTypeTraitsBase2<FGameplayAbilityTargetData_LocationAndRotation>
{
enum
{
WithNetSerializer = true
};
};
接下来的任务就是定义 TargetActor,作为预览建筑物辅助玩家选择要将建筑物放置在哪里。TargetActor 有几个信息可以先了解一下(函数无签名信息):
StartTargeting()
:初始化函数,可以得到 Ability 的信息,进而获得 Avatar、AbilitySystemComponent (以下简称 ASC)等信息。从而在Tick()
函数中找到玩家的位置和看向的方向,确定射线追踪的参数。ConfirmTargetingAndContinue()
:玩家对 ASC 调用了TargetConfirm()
函数,代表玩家此时决定在此处生成 TargetData。在本例中代表玩家已经确定了新建筑物要放置在哪里。IsConfirmTargetingAllowed()
:通常在上一条函数中调用,有时 TargetActor 所掌握的信息不能生成 TargetData,返回一个布尔值来信息是否满足条件。在本例中,墙壁类建筑物不允许随意放置,只允许放置在 UBota_BoxComponent 的类型是 Wall 之中,具体来说必须在地基或者其他墙壁之上,所以出现在其他地方时阻止玩家生成建筑物实例。StartLocation
:一个 FGameplayAbilityTargetingLocationInfo 类型的实例,包含一个位置信息可以广播。
本例定义了一个派生自AGameplayAbilityTargetActor_SingleLineTrace
的新类,覆盖了其Tick()
、ConfirmTargetingAndContinue()
和IsConfirmTargetingAllowed()
函数。
在 Tick 函数会展开射线追踪检测,得到 FHitResult 实例后判断 Component 是否为 Bota_BoxComponent,如果是就设置 TargetActor 为 Component 的变换,否则就使用射线检测的 TraceEnd 作为 TargetActor 的位置。由于基类假设的是玩家并不会移动,但我们希望玩家可以边移动边选择放置处,这意味着需要更新StartLocation
的信息:
void ABota_PreviewTargetActor::Tick(float DeltaSeconds)
{
StartLocation.LiteralTransform.SetLocation(SourceActor->GetActorLocation());
auto HitResult = PerformTrace(SourceActor);
BuildTransform.SetLocation(HitResult.Component.IsValid() ? HitResult.ImpactPoint : HitResult.TraceEnd);
bCanBuild = bFreePlacement;
if (const auto& Component = Cast<UBota_BoxComponent>(HitResult.Component))
{
if (Component->GetCollisionType() == StructureType)
{
BuildTransform.SetLocation(Component->GetComponentLocation());
BuildTransform.SetRotation(Component->GetComponentRotation().Quaternion());
bCanBuild = true;
}
}
SetActorTransform(BuildTransform);
}
通常在建造游戏中玩家可以使用Q
E
或者鼠标滚轮实现预览建筑物在 Yaw 轴上进行旋转,为了实现这个功能,可以引入两个GameplayTag
并在对应按键按下和释放时分别对ASC
调用AddLooseGameplayTag()
和RemoveLooseGameplayTag()
,使得 TargetActor 和玩家之间通过标签来通信,具体实现是:
// Bota_GameplayTags.cpp
UE_DEFINE_GAMEPLAY_TAG(Tag_Input_Rotate_Up, "Input.Rotate.Up");
UE_DEFINE_GAMEPLAY_TAG(Tag_Input_Rotate_Down, "Input.Rotate.Down");
// Bota_PlayerCharacter.cpp 仅展示了 Rotate_Down 标签
void ABota_PlayerCharacter::OnRotateBuildingPressed()
{
if (const auto& ASC = GetAbilitySystemComponent())
{
ASC->AddLooseGameplayTag(Tag_Input_Rotate_Down);
}
}
void ABota_PlayerCharacter::OnRotateBuildingReleased()
{
if (const auto& ASC = GetAbilitySystemComponent())
{
ASC->RemoveLooseGameplayTag(Tag_Input_Rotate_Down);
}
}
// Bota_PreviewTargetActor.cpp
void ABota_PreviewTargetActor::Tick(float DeltaSeconds)
{
// ... 与之前相同
if (const auto& ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(SourceActor))
{
auto NewRotation = BuildTransform.Rotator();
if (ASC->GetGameplayTagCount(Tag_Input_Rotate_Down))
{
NewRotation = UKismetMathLibrary::ComposeRotators(NewRotation, FRotator(0, 5, 0));
}
if (ASC->GetGameplayTagCount(Tag_Input_Rotate_Up))
{
NewRotation = UKismetMathLibrary::ComposeRotators(NewRotation, FRotator(0, -5, 0));
}
BuildTransform.SetRotation(NewRotation.Quaternion());
}
// ... 与之前相同
}
接下来需要考虑实现如何将自定义的 TargetData 封装为 TargetDataHandle,并在ConfirmTargetingAndContinue()
中广播出去。下面的代码是比较模板化的代码,结构体实例也通常使用 C++ 的new
关键字来创建实例并对实例的数据成员赋值,最后新定义一个 TargetDataHandle 并将刚才的实例添加进去并返回。
FGameplayAbilityTargetDataHandle ABota_PreviewTargetActor::MakeTargetData_LocationAndRotation()
{
auto Data = new FGameplayAbilityTargetData_LocationAndRotation();
Data->Location = GetActorLocation();
Data->Rotation = GetActorRotation();
auto Handle = FGameplayAbilityTargetDataHandle();
Handle.Add(Data);
return Handle;
}
void ABota_PreviewTargetActor::ConfirmTargetingAndContinue()
{
check(ShouldProduceTargetData());
if (IsConfirmTargetingAllowed())
{
bDebug = false;
FGameplayAbilityTargetDataHandle Handle = MakeTargetData_LocationAndRotation();
TargetDataReadyDelegate.Broadcast(Handle);
}
}
在蓝图中,自定义的 TargetData 并没有直接的方法来获得里面存储的位置和旋转信息,因此需要实现一个单独的函数来将 TargetDataHandle 里面存储的自定义 TargetData 读取出来。这个函数可以是静态的,可以定义在 UGameplayAbility
或者 UBlueprintFunctionLibrary
中,本例中实现在了 GA 下:
void UBota_GameplayAbility::GetTransformFromTargetData(FGameplayAbilityTargetDataHandle Handle, const int Index, FTransform& Transform)
{
FGameplayAbilityTargetData* Data = Handle.Get(Index);
if (!Data)
{
return;
}
if (Data->GetScriptStruct() == FGameplayAbilityTargetData_LocationAndRotation::StaticStruct())
{
auto MyData = static_cast<FGameplayAbilityTargetData_LocationAndRotation*>(Data);
Transform.SetLocation(MyData->Location);
Transform.SetRotation(MyData->Rotation.Quaternion());
Transform.SetScale3D(FVector(1, 1, 1));
}
}
实现技能逻辑
在本例中将技能逻辑的实现放在了蓝图,这是因为预览建筑物类中设置了一些Expose On Spawn
的变量,我暂时在 C++ 中没有找到在技能任务执行之前,对这些变量初始化的办法。在蓝图中,我将技能的触发器设置为由 GameplayEvent
触发并且约定Optional Object
中存储着建筑类的信息。随后调用WaitTargetData
任务并选择之前定义的预览建筑物 TargetActor 并设置好建筑物信息,在Valid Data
执行中让服务器生成具体的 Actor 实例,进一步设置Owner
为玩家,这样就可以在建筑物类中定义RPC
并不会报没有网络链路的错误了,在本例中开关门的动作就是调用 Server 函数,随后 NetMulticast 执行开关门的动画。蓝图节点的总览如下:
触发技能的执行
在本例中,我使用一个 class UBota_StructureDataAsset : public UPrimaryDataAsset
来存储着建筑物的信息,有些如建筑物的名字、缩略图等信息定义在此处,而具体的网格资产和逻辑都是在 Actor 类中选择。在预览建筑物 TargetActor 中找到建筑物类的类默认对象,从而读取网格资产信息。这样就不用在DataAsset
和建筑物类中设置两次网格资产等其他信息,减少重复性行为。
// Bota_PreviewTargetActor.cpp
const auto& DefaultStructureActor = GetDefault<ABota_StructureActor>(StructureDataAsset->ActorToSpawn);
StructureType = DefaultStructureActor->StructureType;
const auto& Mesh = DefaultStructureActor->StaticMeshComponent->GetStaticMesh();
StaticMeshComponent->SetStaticMesh(Mesh);
bFreePlacement = DefaultStructureActor->bFreePlacement;
在AssetManager
中添加UBota_StructureDataAsset
后就可以使用 Get Primary Asset Id List
和 Async Load Primary Asset List
节点来加载所有建筑物了。选择需要建造的建筑物后,就可以调用 Send Gameplay Event To Actor
来触发技能的执行了。