斯坦福 UE4 C++ ActionRoguelike游戏实例教程 10.控制台变量的用法 & 静态函数库 & 使用对象通道对碰撞进行控制

发布时间 2023-04-13 21:15:35作者: 仇白

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

概述

本文对应Lecture 15, 61 - Console Variables for debugging and game balancing。

本文将会教你如何在C++中编辑控制台变量的逻辑,通过在游戏中打开控制台,以修改控制台变量的方式来修改游戏里的各种参数;此外,还会使用自定义静态函数库类,将部分常用的功能封装成静态函数以供使用。

另外,在这篇文章将会简单介绍UE中的碰撞规则,以及如何创建和使用碰撞通道。

对尸体施加冲击力

目录

  1. 控制台变量
    1. 是否自动生成AI角色
    2. 设置伤害倍率
    3. debug信息显示
  2. 静态函数库

控制台变量

喜欢玩游戏的朋友们一定有使用控制台对游戏进行设置的经历,还是拿Minecraft举例,常用的 time set 100 命令就是使用控制台来修改游戏中的时间,其内部原理往往就是调整控制台变量。在UE中同样也支持这种做法,允许我们在游戏运行的时候通过控制台变量来修改游戏中的各种参数,具体该怎么修改,让我们边做边说。

控制是否自动生成AI角色

我们的第一个控制台变量是一个bool类型的值,读者可以直接将下面一行代码复制到SurGameModeBase.cpp文件中,虽然看着很长,但是理解起来并不费劲。

在这行代码中,实际上是创建了一个TAutoConsoleVariable<bool>类型的static全局变量,并且在创建的时候调用了带参数的构造函数,我们将这个变量命名为CVarSpawnBots。由于在前面使用static关键字修饰,因此在其他cpp文件中是不能使用这个变量的。

其中,构造函数第一个参数是指令,第二个参数是默认值,第三个参数是指令描述,第四个参数是变量的标识,我们要做的就是依葫芦画瓢,将相关的参数填进去。

值得一提的是,这里的指令我们使用了su.***的格式,这是为了让我们在控制台中能够更方便的使用命令提示,只需要输入su.即可跳出我们定义的相关指令,这也算一个小技巧吧。

//SurGameModeBase.cpp
//第一个参数是指令,第二个参数是默认值,第三个参数是指令描述,第四个参数是变量的标识
//标识符为ECVF_cheat的作用是这个变量将不会在发行版本中生效,仅用于开发人员调试用
static TAutoConsoleVariable<bool> CVarSpawnBots(TEXT("su.SpawnBots"),true,TEXT("Enable spawning of bots via timer."), ECVF_Cheat);

可以点开类的声明看看他的源码,我们可以看到他的本质上就是一个模板类,类型T的主要的作用就是为了为这个控制台变量赋初值。

// TAutoConsoleVariable的源码
template <class T>
class TAutoConsoleVariable : public FAutoConsoleObject
{
public:
	TAutoConsoleVariable(const TCHAR* Name, const T& DefaultValue, const TCHAR* Help, uint32 Flags = ECVF_Default);
}

如果要使用这个控制台变量,只需要在需要使用他的地方,像获取一个类的成员一样去获取他当前的值,如下所示

因为我们是在游戏里使用控制台,所以这里使用GetValueOnGameThread获取控制台变量。(顺带一提,编辑器也是有控制台的)

void ASurGameModeBase::SpawnBotTimerElapsed()
{
   //从游戏线程中获取控制台变量
   if(!CVarSpawnBots.GetValueOnGameThread())
   {
      UE_LOG(LogTemp, Warning, TEXT("Bot spawning disable via cvar 'CVarSpawnBots'."))
      return;
   }
    ....
}

设置伤害倍率

同样的,我们可以使用同样的方式设置子弹的伤害倍率。

当然,这里有一个小问题就是这个伤害倍率是不分敌我双方的,这里我想做个标注,因此我在注释里添加了@fixme这样的写法,为了让以后的自己能更快速的找到。我已经忘了这个写法是从哪里学来的了,但是这种写法在大项目中非常常见,而我们要找到也只需要对文件进行全局搜索即可。

//SurAttributeComponent.cpp
//伤害倍率
static TAutoConsoleVariable<float> CVarDamageMultiplier(TEXT("su.DamageMutiplier"), 1.f, TEXT("Global Damage Motifier for Attrivute Component."), ECVF_Cheat);

bool USurAttributeComponent::ApplyHealthChanges(AActor* InstigatorActor, float Delta)
{
	if(!GetOwner()->CanBeDamaged())
	{
		return false;
	}

	//@fixme: 这个伤害乘法不分敌我双方
	if(Delta < 0.f)
	{
		float DamageMutiplier = CVarDamageMultiplier.GetValueOnGameThread();
		Delta *= DamageMutiplier;
	}
    ....
}

交互debug显示

控制台变量还有一个很常用的功能就是是否显示Debug信息,我们同样可以在代码里进行修改,让debug信息在我们想看到的时候出现,而不是每次都要重新编译才能切换显示。

//SurInteractionComponent.cpp

//是否开启交互debug显示
static  TAutoConsoleVariable<bool> CVarDebugDrawInteraction(TEXT("su.InteractionDebugDeaw"), false, TEXT("Enable Debug Line for Interact Component."), ECVF_Cheat);


void USurInteractionComponent::PrimaryInteract()
{

   bool bDebugDraw = CVarDebugDrawInteraction.GetValueOnGameThread();
   
   ....
    if(bDebugDraw)
    {
       DrawDebugLine(GetWorld(), CtrlerLocation, End, FColor::Red, false, 3);
    }
}

接下来展示一下游戏里的画面,按下·键,就可以唤出控制台,输入su.,就可以看到我们自己定义的控制台变量:

image-20230324180404632

在后面输入值可以对控制台变量进行赋值

小TIPs:双击·键可以打开控制台详细面板,这里可以查看指令的输入历史以及最后一个执行的是谁

image-20230324180825629

查看控制台历史指令

现在,我们就可以通过使用控制台的方式调整游戏里的各种参数了。

静态函数库

在很多时候,我们并不想反反复复地获取一个类的对象,然后再调用他的成员。而静态函数为我们编程提供了便利,例如说我们之前用过的UGamePlayStatic类中定义了相当多的静态函数供我们调用,我们可以轻松的使用相当多的功能。同样的,我们可以自定义我们所需要的静态函数,我们通常采取定义一个函数库类的做法。在这个类中,你应该把所有的成员函数声明为static类型以供项目其他地方所用。

如下图所示,新建一个C++蓝图函数库,将其命名为SurGameplayFunctionLibrary

image-20230409155648593

这里我定义了两个静态函数。顾名思义,ApplyDamage的作用是使一个角色对另一个角色造成伤害,而 ApplyDirectionalDamage在前者的基础上添加了冲击力的设定。

UCLASS()
class FPSPROJECT_API USurGameplayFunctionLibrary : public UBlueprintFunctionLibrary
{
   GENERATED_BODY()
public:
   // 这个函数将被用于一个Actor试图伤害另一个Actor时
   UFUNCTION(BlueprintCallable, Category = "Gameplay")
   static bool ApplyDamage(AActor* DamageCauser, AActor* TargetActor, float DamageAmount);

   // 施加带有方向的伤害,目标角色将会受到一个冲击力
   UFUNCTION(BlueprintCallable, Category = "Gameplay")
   static bool ApplyDirectionalDamage(AActor* DamageCauser, AActor* TargetActor, float DamageAmount, const FHitResult& HitResult);
};

下面是函数的实现。相信ApplyDamage函数里面的内容大家都已经很熟悉了,就是获取属性组件然后调用ApplyHealthChanges那一套,我们将这些内容抽象成了一个静态函数,将来我们想做同样逻辑的时候可以直接调用它。

而ApplyDirectionalDamage函数在前者的基础上对目标Actor施加了冲击力。在这段代码中,他会尝试获取命中的组件,通常能够被碰撞的组件都是继承了UPrimitiveComponent类。之后判断命中的部位是否开启了物理模拟,由于我们想要命中的是骨骼组件,因此在判断的时候还传入了骨骼名作为参数。如果命中的是模拟了物理的骨骼组件,将会对这个组件施加一个冲击力。

bool USurGameplayFunctionLibrary::ApplyDamage(AActor* DamageCauser, AActor* TargetActor, float DamageAmount)
{
   USurAttributeComponent* AttributeComponent = USurAttributeComponent::GetAttributes(TargetActor);
   if(AttributeComponent)
   {
      return AttributeComponent->ApplyHealthChanges(DamageCauser, -DamageAmount);
   }
   return false;
}

bool USurGameplayFunctionLibrary::ApplyDirectionalDamage(AActor* DamageCauser, AActor* TargetActor, float DamageAmount,
   const FHitResult& HitResult)
{
   if(ApplyDamage(DamageCauser, TargetActor, DamageAmount))
   {
      UPrimitiveComponent* HitComp = HitResult.GetComponent();
      if(HitComp && HitComp->IsSimulatingPhysics(HitResult.BoneName))
      {
         HitComp->AddImpulseAtLocation(-HitResult.ImpactNormal * 300000.f, HitResult.ImpactPoint, HitResult.BoneName);
      }
      return true;
   }
   return false;
}

最后我们将以前写过的魔法子弹伤害部分的代码进行修改,直接调用静态函数库的ApplyDirectionalDamage,十分便捷。

void ASurMagicProjectile::OnOverlapBegin(UPrimitiveComponent* HitComp, AActor* OtherActor,
                                         UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
   if(OtherActor && OtherActor != GetInstigator())
   {
      if(USurGameplayFunctionLibrary::ApplyDirectionalDamage(GetInstigator(), OtherActor, DamageAmount, SweepResult))
      {
         Explode();
      }
   }
}

BUG的出现和解决:修改碰撞预设

实际运行的时候会发现子弹打到了角色身上,但是什么事情也没有发生。进行调试发现,子弹命中的是目标的胶囊网格体,并不是我们想要的骨骼网格体。胶囊网格体竟然为骨骼网格体挡枪,这我们自然是不能接受的。我们可以通过修改碰撞预设的方式,设置我们的子弹不会命中胶囊网格体,并且能够对骨骼网格体作出反应。

碰撞预设相关的知识点实际上是相当重要而且常见的,课程里针对子弹的碰撞类型修改了胶囊网格体的碰撞规则,实际上我们可以使用更聪明的办法该修改这个BUG,由于这部分内容会在课程后面提到,这里做一个概述,感兴趣的读者可以深入了解和学习。

在UE中,每一个能被“碰撞”的组件都有自己的通道(Object Channels),我们可以自定义不同的通道之间是如何进行交互的,交互模式分为三种,分别是忽略(ignore)、重叠(overlap)和阻挡(block)。

举个栗子,如果两个组件对对方的通道类型交互模式都是block,那么他们在接触的时候就会互相阻挡,并触发碰撞事件(OnHit),也就是类似我们现实中的物体碰撞;如果一个类型对另一个类型是ignore,那么他们就会互相穿过,并且不会产生任何事件。而重叠则是允许双方互相穿过,并且产生重叠事件。

在UE中已经自带了相当多的通道,比如我们常见的WorldStatic、pawn等。有时候我们也想自定义一个通道,比如说我们的魔法子弹,我们单独为他创建一个Object channel,起名为Projectile。只要将我们魔法子弹的球体组件设置为这个通道类型,那么这个球体组件就可以以projectile的身份与这个游戏里的其他碰撞类型进行交互。

image-20230409171814521

创建通道

在创建通道下方有一个Preset,意思是预设,在这里可以编辑一个拥有碰撞属性的组件是应该拥有什么样的对象类型,并且如何对其他对象类型作出反应。如下图所示,我创建了一个预设,起名叫做Projectile。在这里,我规定使用了这个预设的组件的对象类型为Projectile,并且对WorldStatic、Vehicle、Destructible等类型为阻挡模式,对Pawn, WorldDynamic等类型为重叠模式。意思就是,当使用了这个预设的组件碰到了Pawn类型的组件后,会直接穿过,并触发重叠相关事件(前提是勾选触发重叠事件)。

image-20230409171851930

设置碰撞预设

回到我们的项目中来,打开AICharacter的胶囊体设置,找到碰撞相关的菜单。我们不想让Projectile类型的组件与胶囊体产生交互,因此我们将Projectile的交互策略改为忽略。这样,类型为Projectile的魔法子弹(准确的说是魔法子弹的球体组件)可以穿过胶囊体组件,并且不触发任何事件。

image-20230409170854640

胶囊体的碰撞设置

打开AICharacter的骨骼网格体设置,注意到他的对象类型是Pawn,我们不需要修改它,只需要将生成重叠事件勾选上,这样在它与其他物体重叠的时候才可以触发重叠事件。

image-20230409170941833

骨骼网格体的碰撞设置,注意要勾选生成重叠事件

此外,要记得将我们的魔法子弹的球体组件的预设设置为Projectile。

我们注意到球体组件对Pawn类型的策略是重叠,而骨骼网格体对Projectile类型的策略是阻挡,这两个策略相遇的话,会直接视为重叠,也就是会互相穿过,并触发重叠事件。

image-20230409220458653

设置魔法子弹的碰撞预设

到这里为止,我们的设置就完成了,现在,我们的子弹可以穿过胶囊体组件,并命中网格体组件,触发重叠事件了。

碰撞设置这里的坑挺多的,理解起来也需要花费一定时间,希望读者可以多多实验和查阅资料。

Tips:控制台指令中自带一个God命令,使用了这个命令后,玩家控制的角色将会标记为不可被伤害。在代码中我们可以使用AActor::CanBeDamaged()来判断是否使用了god命令,然后进一步的编辑伤害的逻辑。

很有冲击力

还有一个小问题,注意到敌对小兵死后,虽然网格体飞出去很远,但是胶囊体组件还留在原地,如果我们试图经过的话,会被透明的胶囊体组件挡住(这也是因为双方的碰撞设置都为block)。

使用show collision控制台命令可以查看碰撞体。

image-20230409172807525

解决这个问题很简单,只需要在敌对小兵死亡后,将其设置为无碰撞即可。从此,死去的小兵再也与世界无任何瓜葛,也再也没有人能触碰到它,令人唏嘘。

//ASurAiCharacter::OnHealthChanged
if(NewHealth <= 0.f)
{
   ......
   //设置为无碰撞
   GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
   GetCharacterMovement()->DisableMovement();
   SetLifeSpan(10.f);
}

总结

本篇文章揭秘了控制台变量是个什么玩意,作为游戏制作人的我们应该如何去使用它;介绍了静态函数库是个什么东西;最后结合产生的BUG介绍了游戏中碰撞通道和预设的概念。

关于碰撞这部分的内容,涉及的东西确实多,笔者确实有很多东西想讲,但精力确实也不太够,只能围绕案例做一点简单的解释,希望看到这里的读者踊跃发言,积极查阅资料。

最后的最后,这篇文章是课程第15课的最后一篇文章,这意味着我们已经学完了全部课程的一半,我们应该将这视为我们学习过程的一个里程碑,让我们小小的鼓(奖)励自己一下吧~

参考链接

UE4碰撞规则详解https://blog.csdn.net/zhangxsv123/article/details/79360025