该专栏用于保存对TomLooman的ActionRoguelike项目的学习笔记,学习过程中的思考与记录不一定准确。
教程参考:/tomlooman/ActionRoguelike
基于的项目实现:/CarolBaggins2023/TomLooman_ActionRoguelike_Tutorial
(相关资料图)
2023_07_30
接口与碰撞查询:C++接口(与Actor互动),ActorComponent和碰撞痕迹,动画和计时器(改进攻击)
UE中的接口
官方大致解释:接口能让一组不相关的类实现一组通用的函数。某些游戏功能可能被大量复杂且不相关的类共享,这就是接口的出场之时。
例如,在游戏中,玩家进入一个trigger区域后,陷阱会伤害玩家,敌人会做出反应,夺旗点会给予玩家点数奖励。它们都共享同一个功能“玩家进入trigger区域,执行某个动作”。陷阱派生自AActor,敌人派生自ACharacter,奖励点数派生自UDataAsset,它们的唯一公共父类是UObject,但我们无法修改UObject,所以常用的通过在公共父类中声明虚函数,在子类中对虚函数进行覆盖,实现动态绑定的做法行不通。在这种情况下,推荐使用接口。(实际上,接口的基本原理也类似于覆盖公共父类的虚函数,但这个公共父类不是本来就有的,而是我们后来加上去的)
因为我们要做宝箱和医疗包,两者都要实现玩家与其互动后执行某个动作的功能,所以我们要先构建一个Interface类,来让宝箱和医疗包的类继承这个Interface类。
接口类的声明如下
UINTERFACE(MinimalAPI, Blueprintable) classUReactToTriggerInterface : publicUInterface { GENERATED_BODY() }; classIReactToTriggerInterface { GENERATED_BODY() public: /** 在此处添加接口函数声明 */ };
发现与我们之前声明的C++不太一样的是,Interface类有两个类声明,它们类名相同,但前缀不同,以此做出区分。
"前缀为U(U-prefixed)"的类是个空白类,不需要构造函数或任何其他函数,创建后不应被修改。它并不是实际接口,只是向UE的反射系统确保可见性。
"前缀为I(I-prefixed)"的类是实际接口,将包含所有接口函数,且此类实际上将被你的其他类继承。我们的工作在这个类中进行。
我们在ISGameplayInterface中声明了接口函数如下
UFUNCTION(BlueprintCallable, BlueprintNativeEvent) void Interact(APawn *InstigatorPawn);
但我们在源文件中并没有相应的实现,所以目前Interface类的源文件是个空文件。根据自带的注释,我们可以发现Interface类实际上就类似于我们创造的,实现相同功能的类的公共父类,而如果我们不对Interface类的任何成员函数给予定义的话,该公共父类是个抽象基类。
根据覆盖Interface类的成员函数(接口函数)位置(C++/蓝图),UFUNCTION中应使用不同的specifier。从而对该接口函数是否是虚拟函数,以及如何覆盖该函数提出不同要求。
在我们的声明中,BlueprintCallable指该接口函数可被蓝图调用,同时要求再使用BlueprintNativeEvent或BlueprintImplementableEvent,且该函数不能是虚函数。(一开始认为Interface类就是自己创建的公共父类,通过派生类覆盖基类虚函数,来实现不同功能。但这么一看可能不是这样,只是想法有些类似。)(但是在仅C++的情况下,接口函数又必须是虚函数。)
BlueprintImplementableEvent指该函数只能在继承该Interface的蓝图类中被覆盖,BlueprintNativeEvent指该函数可以在C++中被覆盖,但是覆盖该函数的函数要在函数名末尾加后缀“_Implementation”,在我们的项目中如下。
(Interface类中的声明)
(Interface类的派生类中的覆盖)
我们通过继承Interface类并覆盖接口函数来使用接口。我们的宝箱类的声明如下
注意这里继承的是I开头的Interface类,而不是U开头的。而且由于接口函数UNFUNCTION中的Specifier是BlueprintNativeEvent,所以覆盖时函数名要加后缀”_Implementation”。
覆盖的接口函数如下
在这个函数中我们没有使用到形参,只是进行了UStaticMesh类的LidMesh的一个相对旋转。
到目前为止,虽然我们定义了接口,也定义了使用接口的类,但是这个接口对应的功能并不能被执行,因为没有东西来触发它。
我们直接可以想到的是,在ASCharacter类中定义一个函数,这个函数在我们按下某个按键时执行,触发使用接口的类中的接口函数。
但是这样的做法在长期看来会导致ASCharacter越来越臃肿。一个好的解决方法是自定义ActorComponent,就像UE自带的碰撞组件、相机组件那样。通过将可复用的模块定义为组件,能实现系统模块之间的解耦合。
之前我们创建了名为SGameplayInterface的Interface类,所以对应的,我们创建名为SInteractionComponent的UActorComponent类,并在这个组件类中执行接口函数。
该类的声明如下,
ActorComponent是添加到Actor上的实现各种功能的组件的基类,其中带有Transform的被称为SceneComponent,可以渲染Actor的被称为PrimitiveComponent。
UClass的Specifier中的ClassGroup控制该类在UE编辑器的浏览器中属于的类别,ClassGroup的值不能随意指定。Specifier中的meta是元数据说明符,表示类与引擎、编辑器的相处方式,它只存在于编辑器中,我们不能编写能访问到meta的游戏逻辑,其中的BlueprintSpawnableComponent说明该类可由蓝图生成,如下,
ActorComponent类与Actor类有一点不同是,Actor类中有一个Tick函数,而ActorComponent中的函数叫TickComponent。因为这里并没有使用,所以后面再讲。
在SInteractionComponent类中声明并定义如下函数
在这个函数中,我们从SCharacter射出一道射线,在第一个碰撞到的物体上执行接口函数。因此包含两个主要步骤,检测第一个碰撞物体,在碰撞物体上执行接口函数。
检测碰撞物体:
核心函数是GetWorld()->LineTraceSingleByObjectType(Hit, Start, End, ObjectQueryParams)。
GetWorld返回UWorld类的指针,UWorld类是一个Map中的顶级对象,Actor和Component都存在于其中。
LineTraceSingleByObjectType射出一道射线并返回第一个阻挡射线的对象,阻挡的依据是对象的类型。但是这里显式的返回值为bool变量,表示是否有阻挡对象,真正的阻挡对象信息保存在参数中(C++中通过引用类型的参数能增加返回值的个数)。
Hit是FHitResult类的对象,在这里作为返回值。FHitResult类保存了一次hit的信息,包括hit的对象,hit的位置等。
Start和End是FVector类的对象,表示射线的开始和结束位置,其中,GetOwner返回拥有该ActorComponent的Actor的指针,GetActorEyesViewPoint用引用形参的形式返回Actor的“眼睛”(或者说视角)(注意不是摄像机的视角)的location和rotation。
ObjectQueryParams属于FCollisionObjectQueryParams类,保存碰撞查询中涉及的对象类型,这里我们执行AddObjectTypesToQuery(ECC_WorldDynamic)
,表示碰撞中查询WorldDynamic类型的对象。
在碰撞物体上执行接口函数:
其核心是ISGameplayInterface::Execute_Interact(HitActor, MyPawn);
先通过AActor *HitActor = ();获得hit到的Actor对象,然后进行两重判断,保证该函数指针非空且实现了接口函数。
if (HitActor)判断是否有hit到对象,这里特指WorldDynamic对象。
if (HitActor->Implements<USGameplayInterface>())
判断HitActor是否实现了接口函数,这里的接口函数就是我们在SGameplayInterface里声明的Interact。需要注意的是,这里用的是SGameplayInterface声明中U开头的类,而不是I开头的类,因为U开头的类才是Interface中实现UE反射的部分。(这里无需先判断HitActor是否继承了Interface类,再判断是否实现了接口函数)
因为我们的接口函数有BlueprintNativeEvent的Specifier,所以执行时调用ISGameplayInterface::Execute_Interact,如果该接口函数只在C++中被覆盖,则可以直接调用原函数名,不用加前缀“Execute_”(覆盖时也不用加后缀“_Implement”)。
函数的参数包括(1)实现接口函数的对象(因为上面经过了if判断,所以就是HitActor,比如我们的宝箱或医疗包)(2)接口函数声明中的其它参数(这里是触发接口函数的对象,也就是拥有该ActorComponent的Actor,也就是我们的SCharacter对象)
因为接口函数声明中,形参是表示触发接口函数的APawn类对象,所以我们要把SCharacter类型的MyOwner对象进行类型转换。类型转换通过Cast实现,函数接口如下(这里执行时只给出了模板参数中的To和形参Src,模板参数中的From由形参推导得到)
和原生C++不同,在UE中,这样的转换是安全的。
上面的碰撞物体检测使用的是射线,如果我们想要交互的物体很小的话,用射线就不合理。我们可以做出如下改进,用圆柱体检测碰撞物体(也可以看作是很粗的射线)。
这里使用的是SweepMultiByObjectType,需要注意的是此时除了检测范围变大外,函数不再返回第一个碰撞到的对象,而是返回所有碰撞到的对象,因此这里的返回结果变成了一个数组(TArray)。
SweepMultiByObjectType的形参与LineTraceSingleByObjectType类似,不同之处在于要指定检测范围的形状(FCollisionShape Shape)和该形状的旋转(FQuat::Identity,不旋转,FQuat是UE中的四元组类)。
因为我们仍想要只执行第一个碰撞到的对象中的接口函数,所以在遇到实现该接口的对象,并执行接口函数后,就break跳出循环。
这里还有两个用于Debug的可视化函数,
DrawDebugSphere(GetWorld(), Hit.ImpactPoint, Radius, 32, LineColor, false, );
DrawDebugLine(GetWorld(), Start, End, LineColor, false, , 0, );
效果如下,
这里第一个方块不是WorldDynamic,第二个方块是WorldDynamic,宝箱上没有第二个方块那样的球体是因为DrawDebugSphere在break之后。
最终我们要将上面实现的ActorComponent添加到SCharacter中,以下是声明和构造函数中的实例化,
在ASCharacter::SetupPlayerInputComponent中将触发接口函数的成员函数与某个事件绑定,并声明、实现该函数
在该函数的实现中,我们直接调用ActorComponent中执行接口函数的成员函数。
总结一下通过Interface和ActorComponent实现Character与Actor交互的过程:
(1)定义一个Interface类,并在该Interface中声明一个接口函数
(2)定义一个Actor类并继承Interface类,并在该Actor类中覆盖Interface类中的接口函数
(3)定义一个ActorComponent类,并在该ActorComponent类中定义一个函数,该函数涉及(a)判断某个对象是否实现了某个Interface类(2)执行该对象上Interface类部分的某个接口函数
(4)定义一个Character类,并定义一个ActorComponent类的对象为成员变量,绑定外部输入、事件和该类的某个成员函数,在该成员函数中调用ActorComponent类成员变量的特定函数
发生交互的过程如下:
(1)外部输入触发Character的某个事件,执行与该事件绑定的成员函数,在该成员函数中调用ActorComponent成员变量的某个函数
(2)在ActorComponent对象的成员函数中,判断Character的交互对象是否实现了Interface类,若实现了,则调用该交互对象实现的Interface类的接口函数
(3)调用交互对象覆盖的Interface类的接口函数
虽然动画通常在蓝图中完成,但是在C++中也可以。我们首先在SCharacter中创建UAnimMontage类的成员变量如下,为了在编辑器使用方便,我们将飞弹动画归为同一类,
在内容浏览器中筛选AnimationMontage类型,将AnimationMontage对象赋给Character的成员变量。
我们在PrimaryAttack中调用该对象如下
其中,PlayAnimMontage在Character的Mesh上播放Montage动画,返回动画的时间。
加入攻击抬手的动画后,如果不做其它调整,我们会发现,由于魔法飞弹spawn在我们按键时Character手的位置,而不是动画中Character伸手的位置,所以会出现角色抬手,但飞弹在下面生成的情况,如下
因此我们要对PrimaryAttack增加延时,这可以通过设置定时器实现。
用下面的语句给原本的PrimaryAttack增加了延时,
GetWorldTimerManager().SetTimer(TimerHandle_PrimaryAttack, this, &ASCharacter::PrimaryAttack_TimeElapsed, );
其中,GetTimerManager() 获取 World 中保存的定时器的管理器 TimerManager。SetTimer设置一个定时器,每个一段时间调用给定函数。TimerHandle_PrimaryAttack是FTimerHandle (定时器句柄)类型的成员变量,在头文件中声明如下
this是调用执行函数的对象。&ASCharacter::PrimaryAttack_TimeElapsed
是待执行的函数,在这里也就是我们原本的PrimaryAttack成员函数。是函数执行的时间间隔。
关键词: