斯坦福 UE4 C++ ActionRoguelike游戏实例教程 12.认识GamePlayTag, 实现技能的互斥

发布时间 2023-04-18 18:28:36作者: 仇白

斯坦福课程 UE4 C++ ActionRoguelike游戏实例教程 0.绪论

概述

本篇文章对应Lecture 17 - GameplayTags, 67、67节。本文将会讲述UE4中GameplayTag的概念,并使用GameplayTag系统实现不同技能的互斥效果。

具体来说,本文将解决在使用技能的时候会被其他技能打断的问题(例如疾跑的同时发射魔法子弹,攻击未结束的时候还能继续攻击等)。

目录

  1. 认识GameplayTag
  2. 添加GamePlayTag
  3. 实现技能间的互斥

认识GamePlayTag

在上一节课实现的Action System(能力系统)中遗留了一个问题,就是技能在释放的过程中可以同时释放其他技能,例如,按住shift进行疾跑的时候,我们可以同时点击鼠标左键进行攻击。

虽然在某些玩法层面上好像没什么问题,但是对于程序员来说,这是不可控的。我们现在希望,在技能释放的时候,不允许其他技能同时释放。除此以外,我们还希望有更多的自由度,可以选择哪些技能可以被“柔化”。就像某些格斗游戏(例如DNF)一样,普通攻击可以被一些灵活的小技能打断,而施法时间长的技能你只能眼睁睁的看着技能释放完毕,期间的任何操作都不会被响应。

img

图片来源DNF武神吧,一瞬间使用了三个技能

要实现这个功能,最简单的办法就是为你的角色添加一系列bool值,比如IsSprinting,IsAttacking之类的,在每次释放技能的时候检查是否正在释放其他技能。但是这个方法实在是过于原始,而且硬编码的方式也不适合将程序做大,当你有几十上百个技能时,处理每一个bool值的判断将会成为一个噩梦。

所幸,UE为我们提供了一套标签系统(GameplayTag),它是一种用于标记游戏对象属性信息的系统,这套系统可以在蓝图、代码和编辑器中使用。具体如何去使用标签系统,让我们边做边说。

课程中提到,在C++中,标签系统被叫做FGameplayTag(F开头表示是结构体),本质上是一个封装了FName的结构体,有以下几点特征:

  • FName wrapped in Struct: 用结构体封装的FName

  • Alternative for:bool,enum,FName: 用于替代bool、enum、FName等标识

  • created via Project Settings :通过项目设置创建

  • Editor support to easily select GameplayTags: 从编辑器可以轻易处理GameplayTags

  • FGameplayTagContainer(Wrapped TArray of GameplayTags):顾名思义,FGameplayTagContainer 是GameplayTag的容器,内部使用TArray实现,可以将其理解为GameplayTag的数组

  • Extensively used by GAS: 在GAS中被广泛使用

总的来说,GameplayTag是一个非常有用的东西,UE已经将其很好地封装,使用Tag我们可以轻易地获取对象的各种属性,并且处理这些属性之间的关系。

添加GamePlayTag

要开启GamePlayTag这个功能,首先我们到.Build.cs构建文件把GameplayTags模块包含到项目中,这样我们就能在C++中使用GameplayTags了。

image-20230416164300303

添加GamePlayTag

接下来我们要对上节课实现的ActionComponent进行扩展。如果没有看过这方面的教程,可以参考我的上一篇文章。

在当初设计ActionComponent的时候,我们就将其定义为角色身上专门用于管理各种能力的组件。为了协调好各个能力的运行,我们自然得为其加上一个GameplayTagContainer,专门用来存放目前已激活的能力标签。

FGameplayTagContainer以F作为开头,表示它是一个结构体,其功能也很明确,封装了FGameplayTag的TArray,作为保存各种标签的容器。并且定义了一些列查找检索相关的函数,这里让它作为标签的容器自然是尽到了他的本职工作。

这里我们不使用指针,所以必须包含该结构体的头文件(编译器需要知道该变量的大小),如果是指针,其变量大小是固定的,就无需包含头文件。

//SurActionComponent.h
public: 
   //当前已拥有的Tag
   UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Tags")
   FGameplayTagContainer ActiveGameplayTags;
   

FGameplayTagContainer中可以加是否正在冲刺、是否正在攻击、是否被击倒的这些标签。然后我们就可以从这个容器中获取到这些信息,得知角色目前在做什么,然后以此为依据去做决定。

接着为SurAction添加以下代码。

其中,最重要的部分就是GrantsTags和 BlockedTags两个FGameplayTagContainer。

GrantsTags保存了当前Action拥有的Tag,你也可以将其理解为Action的属性,当这个Action被激活时,会将GrantsTags加入到ActiveGameplayTags中;Action结束时移除。

BlockTags保存了与当前Action冲突的Tag,Action开始时必须保证ActiveGameplayTags中没有BlockedTags。

然后修改StartAction_Implementation和StopAction_Implementation,在Action启用时,我们把GrantTags中的所有Tag都放入ActiveGameplayTags中;停用时全部移出。

//SurAction.h
protected:
	UFUNCTION(BlueprintCallable, Category = "Action")
	USurActionComponent* GetOwningComponent() const;
	
   //当这个Action被激活时,将GrantsTags加入到ActiveGameplayTags中;Action结束时移除
   UPROPERTY(EditDefaultsOnly, Category = "Tags")
   FGameplayTagContainer GrantsTags;

   //Action开始时必须保证ActiveGameplayTags中没有BlockedTags,换句话来说就是和本Action冲突的Tag
   UPROPERTY(EditAnywhere, Category = "Tags")
   FGameplayTagContainer BlockedTags;


//.cpp
USurActionComponent* USurAction::GetOwningComponent() const
{
	return Cast<USurActionComponent>(GetOuter());
}

void USurAction::StartAction_Implementation(AActor* Instigator)
{
	UE_LOG(LogTemp, Log, TEXT("Running: %s"), *GetNameSafe(this));
	USurActionComponent* Comp = GetOwningComponent();
	ensure(Comp);
	Comp->ActiveGameplayTags.AppendTags(GrantsTags);
	
}

void USurAction::StopAction_Implementation(AActor* Instigator)
{
	UE_LOG(LogTemp, Log, TEXT("Stoping: %s"), *GetNameSafe(this));
	USurActionComponent* Comp = GetOwningComponent();
	ensure(Comp);
	Comp->ActiveGameplayTags.RemoveTags(GrantsTags);
}

为了看到当前拥有那些Action,我们可以在SurActionComponent类中添加debug信息打印到屏幕上。

//SurActionComponent.cpp
void USurActionComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
   Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
   FString DebugMsg = GetNameSafe(GetOwner()) + " : " + ActiveGameplayTags.ToStringSimple();
   GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::White, DebugMsg);
}

编译完成后,进入UE编辑器,点开项目设置,可以看到有个GamePlayTags,进入后新建两个tag:"Action.Sprinting"、"Action.Attacking" 。

可以看到,UE中的Tag是有分级的,每个级别用.隔开,这为我们分类和查找Tag提供了很大的便利,

image-20230416172037768

在项目设置中添加标签

接下来要让之前的技能应用到我们新建的标签。打开SprintAction,可以看到细节面板多了两个成员。将其修改如下:

image-20230416172425696

修改Tags

可以看到,我们为SprintAction的GrantTags添加了Action.Sprinting,意思就是SprintAction拥有Action.Sprinting标签,正如每个人都或多或少地给别人贴过标签一样,你可以把这个标签理解为它自己的属性。

我们还未BlockedTags添加了Action.Attacking。在我们的设想中,当Sprint技能开始的时候,会先检查ActiveGameplayTags中有没有Action.Attacking标签(目前还没实现这部分逻辑),如果有,则不开始能力。如果没有,将Action.Sprinting标签加入到ActiveGameplayTags中,然后执行能力。

同样的,为我们的主要攻击(鼠标左键)修改相关标签。

image-20230416172723161

修改Tags

进入游戏,可以看到左上角显示了刚才定义的Debug信息,当我们点击左键进行攻击时,由于Action.Attacking被添加进了ActiveGameplayTags中,这时Debug信息就会忠实地输出ActiveGameplayTags中的所有标签。

image-20230416173010805

左上角Debug信息(为了方便显示,将血条UI移动到了左下角)

实现技能之间的冲突

前面实现了将GrantTags添加到ActiveGameplayTags的功能,现在我们每个能力都拥有了自己的标签。接下来我们要实现技能之间的互斥,也就是不能在疾跑的时候攻击,也不能在攻击的时候疾跑。

首先为Action基类添加一个CanStart函数,它会检索ActiveGameplayTags中是否有冲突的Tag(BlockedTags)。

注意这里使用了GameplayTagContainer的HasAny函数,只要ActiveGameplayTags含有BlockedTags中的任意一个Tag,都会返回true。顺带一提,之前提到Tag是FName的封装,底层实现还是hash,因此检索的效率是相当高的。

// SurAction.h
public:
UFUNCTION(BlueprintNativeEvent,Category = "Action")
bool CanStart(AActor* Instigator);


//,cpp
bool USurAction::CanStart_Implementation(AActor* Instigator)
{
	USurActionComponent* Comp = GetOwningComponent();
	if(Comp->ActiveGameplayTags.HasAny(BlockedTags))
	{
		return false;
	}
	return true;
}

修改USurActionComponent::StartActionByName,在每次试图运行能力时,都要判断当前能力能否运行。

//SurActionComponent.cpp
bool USurActionComponent::StartActionByName(AActor* Instigator, FName ActionName)
{
   for(USurAction* Action:Actions)
   {
      if(Action && Action->ActionName == ActionName)
      {
         if(!Action->CanStart(Instigator))
         {
             //打印debug信息
            FString FailedMsg = FString::Printf(TEXT("Failed to run: %s"), *ActionName.ToString());
			GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Red, FailedMsg);
            //考虑到可能有同名能力,所以不使用break
            continue;;
         }
         Action->StartAction(Instigator);
         return true;
      }
   }
   return false;
}

此处存在一个隐藏的BUG:如果存在不能开始的Action,比如说我们按下shift键,由于Action冲突,所以没有StartAction。但是松开shift键的时候,因为绑定了按键事件,我们还是会调用StopAction。但存在不能开始的Action的话,我们就没有添加过tag,执行stopAction移除tag会出现错误。总之,会出现一些无法预料到的问题。

所以我们在SurAction添加一个bool变量bIsRunning,和获取它的函数,用来判断这个Action是否正在运行:

//SurAction.cpp
void USurAction::StartAction_Implementation(AActor* Instigator)
{
   ...
   bIsRunning = true;
}

void USurAction::StopAction_Implementation(AActor* Instigator)
{
    UE_LOG(LogTemp, Log, TEXT("Stoping: %s"), *GetNameSafe(this));
	//如果没有运行就stop,程序一定哪里出现了问题
    ensureAlways(bIsRunning);
    ...
    bIsRunning = false;
}
bool USurAction::IsRunning() const
{
	return bIsRunning;
}

在StopActionByName处判断Action是否在运行,如果没有运行,则忽略不计。

//SurActionComponent,cpp
bool USurActionComponent::StopActionByName(AActor* Instigator, FName ActionName)
{
   for(USurAction* Action:Actions)
   {
      if(Action && Action->ActionName == ActionName)
      {
         if(Action->IsRunning())
         {
            Action->StopAction(Instigator);
            return true;
         }
      }
   }
   return false;
}

值得一提的是,我们不应该把判断是否在运行的逻辑 或者判断CanStart的逻辑写在USurAction::StopAction_Implementation和StartAction_Implementation里,因为这个函数是可以在蓝图中被重载的,在这种实现方法中,你每次重载函数,都不得不惦记着要判断Action是否在运行,这样无疑会加大程序员的心智负担和提高出现错误的概率。所以,在USurAction::StopAction的默认实现中,我们不需要做什么多余的操作,添加一个断言语句就可以了。

PS: 其实按这个思路, 前面Action的Comp->ActiveGameplayTags.AppendTags(GrantsTags)语句也应该加在StartActionByName中。也许是课程作者疏忽,也许是有其他考虑,这里为了保持统一,笔者保留了自己的意见,还是以课程为准。

如果你实在不清楚应该在哪写判断的逻辑,至少保证你的程序不会轻易的崩溃,在相关的地方全部加上一句判断(比如在Component和Action里都写上判断),虽然代码看着略有些繁琐,但是也少了很多担惊受怕。

进入游戏,现在就可以验证我们写好的技能互斥逻辑了。

image-20230416181809998

角色在攻击的时候无法冲刺

还有一个BUG,当我们快速进行左键攻击的时候,编译器会在USurAction_ProjectileAttack::AttackDelay_Elapsed->StopAction处报错:

image-20230416195634518

在StopAction处出现错误

Rider编辑器指出错误的地方很奇怪,和视频教程里的并不一样,笔者在跟进调试的时候发现在某个地方传参出现了问题,目前无法理解这种情况,姑且认为是编辑器debug的问题。但是都有一个共同点,那就是在StopAction处出现了错误。测试发现,问题就出在前面我给StopAction_Implementation加上的一句断言上。当我把ensure去掉后,debug这里就不报错了。所以就认为是rider编辑器的bug吧。

不管上面出现的奇怪问题了。总之结论是一样的,都是在断言处出现了问题,也就是ensurealways(isRunning)处发生了中断。也就是说,当前还是有Action没有运行,但还是执行了StopAction的情况。

image-20230416200430107

课程里的截图

为什么会出现这个BUG呢?这得回到发射子弹这个技能的实现上来。我们使用了一个定时器,让这个技能有0.2s的释放时间。如果在这个0.2s期间,我们又使用了发射子弹这个功能,由于实现里每一个定时器都是独立的,那么就会再创建一个定时器。

现在创建了两个定时器,在定时器绑定的事件中,我们最后调用了StopAction。让我们沿着时间推进,现在第一个定时器时间到了,调用了StopAction,将bIsRunning置为false。又过去一段时间啊,第二个定时器时间到了,又调用了一次StopAction,由于当前的bIsRunning是false,因此触发断言,程序就报错了。

这个BUG很好理解,解决的办法很简单,就是在CanStart中添加一个判断即可。如果当前能力正在运行,就直接返回false。

bool USurAction::CanStart_Implementation(AActor* Instigator)
{
   if(IsRunning())
   {
      return false;
   }
...
}

进入游戏,查看实现的结果。可以看到,如果试图快速连点攻击,就会一直触发之前设置的Debug信息,表示不能运行能力。

狂点攻击键也不会那么鬼畜了

不过笔者认为这并不是最好的解决办法,因为这在一定程度上降低了游戏设计的自由度,这样修改意味着我们再也无法实现能够自己打断自己的技能了,虽然很少有机会能设计这种技能,但是多多少少还是有一些遗憾的。

其实这个问题还有一个解决方法,就是将我们普通攻击本身作为一个block tag添加到BlockTags中,这样当试图再次普攻时,由于检测到激活的Tag中有BlockTags中的成员,所以本次攻击就不会触发了。

同样的思路可以用在黑洞攻击、瞬移攻击上,因为他们用的都是同一套攻击动作,用的也是同一个tag,我们又不希望在普攻的同时能够放出黑洞,那我们就将普攻的tag添加到BlockTags中即可。

image-20230416201638134

所有普攻的动作都可以这么设置

最终效果&总结

最后看看最终结果,如图所示,我三个技能一顿乱按,由于技能释放的0.2s内是不允许使用其他技能的,左上角不断跳出能力运行失败的提示,但是每一个技能都完整地释放了出去。

一顿乱按

在本篇文章中,我们使用GameplayTag系统优化了上节课实现的能力系统,做到了技能之间的互斥,并掌握了使用标签获取对象属性的技巧。在接下来的文章中,我会基于标签系统实现几个非常常见的功能。

参考链接