TomLooman_ActionRoguelike_第四章接口和碰撞查詢
該專欄用于保存對TomLooman的ActionRoguelike項(xiàng)目的學(xué)習(xí)筆記,學(xué)習(xí)過程中的思考與記錄不一定準(zhǔn)確。
教程參考:https://github.com/tomlooman/ActionRoguelike
基于UE5.0的項(xiàng)目實(shí)現(xiàn):https://github.com/CarolBaggins2023/TomLooman_ActionRoguelike_Tutorial

2023_07_30
接口與碰撞查詢:C++接口(與Actor互動(dòng)),ActorComponent和碰撞痕跡,動(dòng)畫和計(jì)時(shí)器(改進(jìn)攻擊)
?
UE中的接口
官方大致解釋:接口能讓一組不相關(guān)的類實(shí)現(xiàn)一組通用的函數(shù)。某些游戲功能可能被大量復(fù)雜且不相關(guān)的類共享,這就是接口的出場之時(shí)。
例如,在游戲中,玩家進(jìn)入一個(gè)trigger區(qū)域后,陷阱會(huì)傷害玩家,敵人會(huì)做出反應(yīng),奪旗點(diǎn)會(huì)給予玩家點(diǎn)數(shù)獎(jiǎng)勵(lì)。它們都共享同一個(gè)功能“玩家進(jìn)入trigger區(qū)域,執(zhí)行某個(gè)動(dòng)作”。陷阱派生自AActor,敵人派生自ACharacter,獎(jiǎng)勵(lì)點(diǎn)數(shù)派生自UDataAsset,它們的唯一公共父類是UObject,但我們無法修改UObject,所以常用的通過在公共父類中聲明虛函數(shù),在子類中對虛函數(shù)進(jìn)行覆蓋,實(shí)現(xiàn)動(dòng)態(tài)綁定的做法行不通。在這種情況下,推薦使用接口。(實(shí)際上,接口的基本原理也類似于覆蓋公共父類的虛函數(shù),但這個(gè)公共父類不是本來就有的,而是我們后來加上去的)
因?yàn)槲覀円鰧毾浜歪t(yī)療包,兩者都要實(shí)現(xiàn)玩家與其互動(dòng)后執(zhí)行某個(gè)動(dòng)作的功能,所以我們要先構(gòu)建一個(gè)Interface類,來讓寶箱和醫(yī)療包的類繼承這個(gè)Interface類。

?
?
接口類的聲明如下
??? UINTERFACE(MinimalAPI, Blueprintable)??? class UReactToTriggerInterface : public UInterface??? {??????? GENERATED_BODY()??? };???? class IReactToTriggerInterface??? {??????? GENERATED_BODY()???? public:??????? /** 在此處添加接口函數(shù)聲明 */??? };
發(fā)現(xiàn)與我們之前聲明的C++不太一樣的是,Interface類有兩個(gè)類聲明,它們類名相同,但前綴不同,以此做出區(qū)分。
"前綴為U(U-prefixed)"的類是個(gè)空白類,不需要構(gòu)造函數(shù)或任何其他函數(shù),創(chuàng)建后不應(yīng)被修改。它并不是實(shí)際接口,只是向UE的反射系統(tǒng)確??梢娦?。
"前綴為I(I-prefixed)"的類是實(shí)際接口,將包含所有接口函數(shù),且此類實(shí)際上將被你的其他類繼承。我們的工作在這個(gè)類中進(jìn)行。
?
我們在ISGameplayInterface中聲明了接口函數(shù)如下
UFUNCTION(BlueprintCallable, BlueprintNativeEvent)
void Interact(APawn *InstigatorPawn);
但我們在源文件中并沒有相應(yīng)的實(shí)現(xiàn),所以目前Interface類的源文件是個(gè)空文件。根據(jù)自帶的注釋,我們可以發(fā)現(xiàn)Interface類實(shí)際上就類似于我們創(chuàng)造的,實(shí)現(xiàn)相同功能的類的公共父類,而如果我們不對Interface類的任何成員函數(shù)給予定義的話,該公共父類是個(gè)抽象基類。

?
?
根據(jù)覆蓋Interface類的成員函數(shù)(接口函數(shù))位置(C++/藍(lán)圖),UFUNCTION中應(yīng)使用不同的specifier。從而對該接口函數(shù)是否是虛擬函數(shù),以及如何覆蓋該函數(shù)提出不同要求。
在我們的聲明中,BlueprintCallable指該接口函數(shù)可被藍(lán)圖調(diào)用,同時(shí)要求再使用BlueprintNativeEvent或BlueprintImplementableEvent,且該函數(shù)不能是虛函數(shù)。(一開始認(rèn)為Interface類就是自己創(chuàng)建的公共父類,通過派生類覆蓋基類虛函數(shù),來實(shí)現(xiàn)不同功能。但這么一看可能不是這樣,只是想法有些類似。)(但是在僅C++的情況下,接口函數(shù)又必須是虛函數(shù)。)
BlueprintImplementableEvent指該函數(shù)只能在繼承該Interface的藍(lán)圖類中被覆蓋,BlueprintNativeEvent指該函數(shù)可以在C++中被覆蓋,但是覆蓋該函數(shù)的函數(shù)要在函數(shù)名末尾加后綴“_Implementation”,在我們的項(xiàng)目中如下。
(Interface類中的聲明)

(Interface類的派生類中的覆蓋)

?
我們通過繼承Interface類并覆蓋接口函數(shù)來使用接口。我們的寶箱類的聲明如下

注意這里繼承的是I開頭的Interface類,而不是U開頭的。而且由于接口函數(shù)UNFUNCTION中的Specifier是BlueprintNativeEvent,所以覆蓋時(shí)函數(shù)名要加后綴”_Implementation”。
?
覆蓋的接口函數(shù)如下

在這個(gè)函數(shù)中我們沒有使用到形參,只是進(jìn)行了UStaticMesh類的LidMesh的一個(gè)相對旋轉(zhuǎn)。
?
到目前為止,雖然我們定義了接口,也定義了使用接口的類,但是這個(gè)接口對應(yīng)的功能并不能被執(zhí)行,因?yàn)闆]有東西來觸發(fā)它。
我們直接可以想到的是,在ASCharacter類中定義一個(gè)函數(shù),這個(gè)函數(shù)在我們按下某個(gè)按鍵時(shí)執(zhí)行,觸發(fā)使用接口的類中的接口函數(shù)。
但是這樣的做法在長期看來會(huì)導(dǎo)致ASCharacter越來越臃腫。一個(gè)好的解決方法是自定義ActorComponent,就像UE自帶的碰撞組件、相機(jī)組件那樣。通過將可復(fù)用的模塊定義為組件,能實(shí)現(xiàn)系統(tǒng)模塊之間的解耦合。
?
之前我們創(chuàng)建了名為SGameplayInterface的Interface類,所以對應(yīng)的,我們創(chuàng)建名為SInteractionComponent的UActorComponent類,并在這個(gè)組件類中執(zhí)行接口函數(shù)。
該類的聲明如下,

?
ActorComponent是添加到Actor上的實(shí)現(xiàn)各種功能的組件的基類,其中帶有Transform的被稱為SceneComponent,可以渲染Actor的被稱為PrimitiveComponent。
?
UClass的Specifier中的ClassGroup控制該類在UE編輯器的瀏覽器中屬于的類別,ClassGroup的值不能隨意指定。Specifier中的meta是元數(shù)據(jù)說明符,表示類與引擎、編輯器的相處方式,它只存在于編輯器中,我們不能編寫能訪問到meta的游戲邏輯,其中的BlueprintSpawnableComponent說明該類可由藍(lán)圖生成,如下,

?
ActorComponent類與Actor類有一點(diǎn)不同是,Actor類中有一個(gè)Tick函數(shù),而ActorComponent中的函數(shù)叫TickComponent。因?yàn)檫@里并沒有使用,所以后面再講。
?
在SInteractionComponent類中聲明并定義如下函數(shù)


在這個(gè)函數(shù)中,我們從SCharacter射出一道射線,在第一個(gè)碰撞到的物體上執(zhí)行接口函數(shù)。因此包含兩個(gè)主要步驟,檢測第一個(gè)碰撞物體,在碰撞物體上執(zhí)行接口函數(shù)。
?
檢測碰撞物體:
核心函數(shù)是GetWorld()->LineTraceSingleByObjectType(Hit, Start, End, ObjectQueryParams)。
GetWorld返回UWorld類的指針,UWorld類是一個(gè)Map中的頂級對象,Actor和Component都存在于其中。
LineTraceSingleByObjectType射出一道射線并返回第一個(gè)阻擋射線的對象,阻擋的依據(jù)是對象的類型。但是這里顯式的返回值為bool變量,表示是否有阻擋對象,真正的阻擋對象信息保存在參數(shù)中(C++中通過引用類型的參數(shù)能增加返回值的個(gè)數(shù))。
Hit是FHitResult類的對象,在這里作為返回值。FHitResult類保存了一次hit的信息,包括hit的對象,hit的位置等。
Start和End是FVector類的對象,表示射線的開始和結(jié)束位置,其中,GetOwner返回?fù)碛性揂ctorComponent的Actor的指針,GetActorEyesViewPoint用引用形參的形式返回Actor的“眼睛”(或者說視角)(注意不是攝像機(jī)的視角)的location和rotation。
ObjectQueryParams屬于FCollisionObjectQueryParams類,保存碰撞查詢中涉及的對象類型,這里我們執(zhí)行AddObjectTypesToQuery(ECC_WorldDynamic)
,表示碰撞中查詢WorldDynamic類型的對象。
?
在碰撞物體上執(zhí)行接口函數(shù):
其核心是ISGameplayInterface::Execute_Interact(HitActor, MyPawn);
先通過AActor *HitActor = Hit.GetActor();獲得hit到的Actor對象,然后進(jìn)行兩重判斷,保證該函數(shù)指針非空且實(shí)現(xiàn)了接口函數(shù)。
if (HitActor)判斷是否有hit到對象,這里特指WorldDynamic對象。
if (HitActor->Implements<USGameplayInterface>())
判斷HitActor是否實(shí)現(xiàn)了接口函數(shù),這里的接口函數(shù)就是我們在SGameplayInterface里聲明的Interact。需要注意的是,這里用的是SGameplayInterface聲明中U開頭的類,而不是I開頭的類,因?yàn)閁開頭的類才是Interface中實(shí)現(xiàn)UE反射的部分。(這里無需先判斷HitActor是否繼承了Interface類,再判斷是否實(shí)現(xiàn)了接口函數(shù))
因?yàn)槲覀兊慕涌诤瘮?shù)有BlueprintNativeEvent的Specifier,所以執(zhí)行時(shí)調(diào)用ISGameplayInterface::Execute_Interact,如果該接口函數(shù)只在C++中被覆蓋,則可以直接調(diào)用原函數(shù)名,不用加前綴“Execute_”(覆蓋時(shí)也不用加后綴“_Implement”)。
函數(shù)的參數(shù)包括(1)實(shí)現(xiàn)接口函數(shù)的對象(因?yàn)樯厦娼?jīng)過了if判斷,所以就是HitActor,比如我們的寶箱或醫(yī)療包)(2)接口函數(shù)聲明中的其它參數(shù)(這里是觸發(fā)接口函數(shù)的對象,也就是擁有該ActorComponent的Actor,也就是我們的SCharacter對象)
因?yàn)榻涌诤瘮?shù)聲明中,形參是表示觸發(fā)接口函數(shù)的APawn類對象,所以我們要把SCharacter類型的MyOwner對象進(jìn)行類型轉(zhuǎn)換。類型轉(zhuǎn)換通過Cast實(shí)現(xiàn),函數(shù)接口如下(這里執(zhí)行時(shí)只給出了模板參數(shù)中的To和形參Src,模板參數(shù)中的From由形參推導(dǎo)得到)

和原生C++不同,在UE中,這樣的轉(zhuǎn)換是安全的。
?
?
上面的碰撞物體檢測使用的是射線,如果我們想要交互的物體很小的話,用射線就不合理。我們可以做出如下改進(jìn),用圓柱體檢測碰撞物體(也可以看作是很粗的射線)。

這里使用的是SweepMultiByObjectType,需要注意的是此時(shí)除了檢測范圍變大外,函數(shù)不再返回第一個(gè)碰撞到的對象,而是返回所有碰撞到的對象,因此這里的返回結(jié)果變成了一個(gè)數(shù)組(TArray)。
?
SweepMultiByObjectType的形參與LineTraceSingleByObjectType類似,不同之處在于要指定檢測范圍的形狀(FCollisionShape Shape)和該形狀的旋轉(zhuǎn)(FQuat::Identity,不旋轉(zhuǎn),F(xiàn)Quat是UE中的四元組類)。

?
因?yàn)槲覀內(nèi)韵胍粓?zhí)行第一個(gè)碰撞到的對象中的接口函數(shù),所以在遇到實(shí)現(xiàn)該接口的對象,并執(zhí)行接口函數(shù)后,就break跳出循環(huán)。
?
這里還有兩個(gè)用于Debug的可視化函數(shù),
DrawDebugSphere(GetWorld(), Hit.ImpactPoint, Radius, 32, LineColor, false, 2.0f);
DrawDebugLine(GetWorld(), Start, End, LineColor, false, 2.0f, 0, 2.0f);
效果如下,



這里第一個(gè)方塊不是WorldDynamic,第二個(gè)方塊是WorldDynamic,寶箱上沒有第二個(gè)方塊那樣的球體是因?yàn)镈rawDebugSphere在break之后。
?
最終我們要將上面實(shí)現(xiàn)的ActorComponent添加到SCharacter中,以下是聲明和構(gòu)造函數(shù)中的實(shí)例化,


在ASCharacter::SetupPlayerInputComponent中將觸發(fā)接口函數(shù)的成員函數(shù)與某個(gè)事件綁定,并聲明、實(shí)現(xiàn)該函數(shù)



在該函數(shù)的實(shí)現(xiàn)中,我們直接調(diào)用ActorComponent中執(zhí)行接口函數(shù)的成員函數(shù)。
?
總結(jié)一下通過Interface和ActorComponent實(shí)現(xiàn)Character與Actor交互的過程:
(1)定義一個(gè)Interface類,并在該Interface中聲明一個(gè)接口函數(shù)
(2)定義一個(gè)Actor類并繼承Interface類,并在該Actor類中覆蓋Interface類中的接口函數(shù)
(3)定義一個(gè)ActorComponent類,并在該ActorComponent類中定義一個(gè)函數(shù),該函數(shù)涉及(a)判斷某個(gè)對象是否實(shí)現(xiàn)了某個(gè)Interface類(2)執(zhí)行該對象上Interface類部分的某個(gè)接口函數(shù)
(4)定義一個(gè)Character類,并定義一個(gè)ActorComponent類的對象為成員變量,綁定外部輸入、事件和該類的某個(gè)成員函數(shù),在該成員函數(shù)中調(diào)用ActorComponent類成員變量的特定函數(shù)
?
發(fā)生交互的過程如下:
(1)外部輸入觸發(fā)Character的某個(gè)事件,執(zhí)行與該事件綁定的成員函數(shù),在該成員函數(shù)中調(diào)用ActorComponent成員變量的某個(gè)函數(shù)
(2)在ActorComponent對象的成員函數(shù)中,判斷Character的交互對象是否實(shí)現(xiàn)了Interface類,若實(shí)現(xiàn)了,則調(diào)用該交互對象實(shí)現(xiàn)的Interface類的接口函數(shù)
(3)調(diào)用交互對象覆蓋的Interface類的接口函數(shù)
?
雖然動(dòng)畫通常在藍(lán)圖中完成,但是在C++中也可以。我們首先在SCharacter中創(chuàng)建UAnimMontage類的成員變量如下,為了在編輯器使用方便,我們將飛彈動(dòng)畫歸為同一類,



在內(nèi)容瀏覽器中篩選AnimationMontage類型,將AnimationMontage對象賦給Character的成員變量。
?
我們在PrimaryAttack中調(diào)用該對象如下

其中,PlayAnimMontage在Character的Mesh上播放Montage動(dòng)畫,返回動(dòng)畫的時(shí)間。
?
加入攻擊抬手的動(dòng)畫后,如果不做其它調(diào)整,我們會(huì)發(fā)現(xiàn),由于魔法飛彈spawn在我們按鍵時(shí)Character手的位置,而不是動(dòng)畫中Character伸手的位置,所以會(huì)出現(xiàn)角色抬手,但飛彈在下面生成的情況,如下

因此我們要對PrimaryAttack增加延時(shí),這可以通過設(shè)置定時(shí)器實(shí)現(xiàn)。
用下面的語句給原本的PrimaryAttack增加了延時(shí),
GetWorldTimerManager().SetTimer(TimerHandle_PrimaryAttack, this, &ASCharacter::PrimaryAttack_TimeElapsed, 0.2f);
其中,GetTimerManager() 獲取 World 中保存的定時(shí)器的管理器 TimerManager。SetTimer設(shè)置一個(gè)定時(shí)器,每個(gè)一段時(shí)間調(diào)用給定函數(shù)。TimerHandle_PrimaryAttack是FTimerHandle (定時(shí)器句柄)類型的成員變量,在頭文件中聲明如下

this是調(diào)用執(zhí)行函數(shù)的對象。&ASCharacter::PrimaryAttack_TimeElapsed
是待執(zhí)行的函數(shù),在這里也就是我們原本的PrimaryAttack成員函數(shù)。0.2f是函數(shù)執(zhí)行的時(shí)間間隔。