戰(zhàn):從上帝類到清晰架構(gòu))
如果你是從《虛幻引擎C開發(fā)學(xué)習(xí)二上》一路跟過(guò)來(lái)的那到這個(gè)階段應(yīng)該已經(jīng)能創(chuàng)建Actor、掛組件、處理基本輸入了。但說(shuō)實(shí)話很多人在這一步會(huì)卡住——不是不會(huì)寫C而是不知道“代碼該放哪里”移動(dòng)邏輯放Character里、開火邏輯也放Character里、換彈、技能、受擊、死亡全堆進(jìn)去沒(méi)幾天就變成一個(gè)幾百行甚至上千行的“上帝類”。這篇“二下”我就用一套第三人稱射擊Demo把Gameplay框架里的幾個(gè)關(guān)鍵類講透再把組件的拆法、C和藍(lán)圖的分工、射線與Projectile的處理講清楚。1. 組件劃分別把Pawn寫成上帝類1.1 一個(gè)反例所有邏輯都塞進(jìn)Character先說(shuō)一個(gè)大多數(shù)人都經(jīng)歷過(guò)的情況。剛開始寫UE C時(shí)最順手的方式就是在ACharacter子類里把所有東西都加上去UCLASS() class AMyCharacter : public ACharacter { GENERATED_BODY() public: virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override; void Fire(); void Reload(); void MeleeAttack(); protected: UPROPERTY(VisibleAnywhere) class UCameraComponent* CameraComp; };看起來(lái)沒(méi)什么問(wèn)題。但當(dāng)你把彈藥管理、傷害數(shù)值、動(dòng)畫通知、UI更新、敵人AI的調(diào)用都堆進(jìn)同一個(gè)類時(shí)問(wèn)題就來(lái)了改一下武器數(shù)值要翻半天換一個(gè)UI框架要把和邏輯耦合的代碼全拆一遍更別提后面如果要加雙武器、加技能系統(tǒng)Character會(huì)膨脹到什么程度。我見過(guò)一個(gè)做模擬項(xiàng)目的朋友Character類里單是開火就寫了三百行換彈邏輯又兩百行。每次他改需求都要在構(gòu)造函數(shù)和Tick之間反復(fù)橫跳。后來(lái)我們把它拆成組件工作量沒(méi)有減少但改起來(lái)明顯不慌——“代碼在哪兒”這件事本身比“代碼多不多”更重要。1.2 拆組件時(shí)的判斷標(biāo)準(zhǔn)拆分不是說(shuō)“把類變短就完事”而是讓每個(gè)類或者組件只干一件事。UE里最自然的拆分單位就是UActorComponent。以下是我在Demo里用的分組方式組件職責(zé)核心APIUCharacterMovementComponent角色移動(dòng)引擎內(nèi)置不要自己寫MoveForward / MoveRightUCameraComponent相機(jī)視角與后坐力表現(xiàn)SetRelativeRotation / FOV控制UWeaponComponent武器數(shù)據(jù)、開火、換彈、彈道Fire / Reload / GetMuzzleTransformUHealthComponent血量、傷害、死亡廣播ApplyDamage / OnDiedUInteractComponent場(chǎng)景交互門、開關(guān)TryInteract這里有個(gè)核心判斷標(biāo)準(zhǔn)某個(gè)行為如果只涉及“一個(gè)對(duì)象的內(nèi)部狀態(tài)”就放進(jìn)組件如果涉及“多個(gè)對(duì)象之間的協(xié)作”才考慮放到Character或GameMode里統(tǒng)一調(diào)度。比如換彈邏輯減彈藥、播動(dòng)畫、強(qiáng)制禁用開火這些都屬于武器組件自身狀態(tài)就應(yīng)該寫在UWeaponComponent里。但換彈結(jié)束后要不要讓UI更新、要不要讓角色進(jìn)入某個(gè)狀態(tài)這就是跨對(duì)象協(xié)作應(yīng)該通過(guò)事件或委托廣播出去而不是直接訪問(wèn)UI層。1.3 組件之間怎么通信拆好了以后緊跟著的問(wèn)題就是組件之間怎么互相喊話我見過(guò)很多新手把組件拆出來(lái)結(jié)果發(fā)現(xiàn)它們完全不會(huì)通信于是又回去寫上帝類。其實(shí)通信無(wú)非三種思路組件內(nèi)部先嘗試轉(zhuǎn)換Ownerif (AMyCharacter* OwnerCharacter CastAMyCharacter(GetOwner())) { OwnerCharacter-GetHealthComponent()-ApplyDamage(10.0f); }這是最簡(jiǎn)單的方法適用于組件之間確實(shí)強(qiáng)相關(guān)的情況。缺點(diǎn)是耦合比較直接不能隨便復(fù)用。用接口隔離實(shí)現(xiàn)細(xì)節(jié)。如果開火的時(shí)候不需要知道“誰(shuí)”在開火只需要回調(diào)一個(gè)“Hit目標(biāo)”就可以用一個(gè)自定義接口UINTERFACE(MinimalAPI) class UDamageable : public UInterface { GENERATED_BODY() }; class IDamageable { GENERATED_BODY() public: virtual float OnDamageTaken(float Damage) 0; };組件只跟這個(gè)接口打交道具體是誰(shuí)實(shí)現(xiàn)了這個(gè)接口、內(nèi)部邏輯是什么組件一概不管。這樣武器組件就能用在敵人、玩家、甚至可破壞場(chǎng)景物體上。用動(dòng)態(tài)多播委托做事件廣播。比如UHealthComponent血量歸零時(shí)廣播OnDied任何對(duì)死亡感興趣的對(duì)象自己來(lái)綁定這樣組件的發(fā)布方和訂閱方完全解耦。我個(gè)人的建議是能放接口的盡量放接口能用委托廣播的不要直接拿指針。你會(huì)發(fā)現(xiàn)后面做AI、做多人時(shí)這套“低耦合”設(shè)計(jì)會(huì)幫你省下大量重構(gòu)時(shí)間。2. Gameplay框架四件套GameMode、Controller、Pawn、State2.1 它們之間到底是誰(shuí)管誰(shuí)講完組件接下來(lái)是很多教程語(yǔ)焉不詳?shù)腉ameplay框架。我剛學(xué)的時(shí)候被這幾個(gè)類繞暈過(guò)后來(lái)用一個(gè)很粗淺但好用的比喻理解了GameMode是裁判PlayerController是玩家的大腦Pawn是玩家的身體PlayerState是比賽成績(jī)單。類作用范圍典型職責(zé)何時(shí)存在AGameMode全局規(guī)則定義出生點(diǎn)、玩家類、勝負(fù)條件服務(wù)器單機(jī)里就是當(dāng)前進(jìn)程APlayerController單個(gè)玩家接收輸入、控制Pawn、管理HUD從玩家加入一直存在APawn物理化身移動(dòng)、開火、表現(xiàn)被Controller控制時(shí)存在APlayerState跨重生數(shù)據(jù)分?jǐn)?shù)、擊殺數(shù)、隊(duì)伍信息與玩家會(huì)話綁定這段代碼演示了GameMode的配置方法UCLASS() class AMyGameMode : public AGameModeBase { GENERATED_BODY() public: AMyGameMode() { DefaultPawnClass AMyCharacter::StaticClass(); PlayerControllerClass AMyPlayerController::StaticClass(); GameStateClass AMyGameState::StaticClass(); PlayerStateClass AMyPlayerState::StaticClass(); } };2.2 Spawn流程到底怎么走Gameplay框架最勸退新手的一點(diǎn)是“創(chuàng)建順序”。在開始一個(gè)游戲時(shí)實(shí)際大致是這樣的順序服務(wù)器先創(chuàng)建GameMode和GameState然后等待玩家連入玩家連入后服務(wù)器創(chuàng)建PlayerControllerPlayerController成功初始化后服務(wù)器在選定的出生點(diǎn)創(chuàng)建DefaultPawnClass然后調(diào)用PlayerController的Possess()控制這個(gè)PawnPawn這時(shí)收到PossessedBy()可以在里面做一些控制權(quán)相關(guān)初始化。這里有兩個(gè)極其容易踩的點(diǎn)Possess不一定在Pawn的BeginPlay之后立刻發(fā)生。尤其在網(wǎng)絡(luò)環(huán)境中Possess的時(shí)機(jī)和Pawn的完整初始化可能不是同一幀。如果你在Pawn的BeginPlay里就去訪問(wèn)Controller很可能是空指針。不要在Pawn的構(gòu)造函數(shù)里訪問(wèn)World、創(chuàng)建其他Actor或依賴GameMode。構(gòu)造函數(shù)時(shí)對(duì)象還沒(méi)真正進(jìn)入世界你拿到的World可能是空的強(qiáng)行使用會(huì)直接崩潰或產(chǎn)生無(wú)法追蹤的詭異Bug。我自己的習(xí)慣是在構(gòu)造函數(shù)里只初始化純數(shù)值和創(chuàng)建組件真正需要依賴外部狀態(tài)的內(nèi)容放到BeginPlay或PossessedBy里做然后通過(guò)日志確認(rèn)調(diào)用順序。如果你也想知道某個(gè)項(xiàng)目的調(diào)用次序最快的方法是在每個(gè)類的構(gòu)造函數(shù)、BeginPlay、PossessedBy里加UE_LOG跑一下對(duì)比輸出比翻幾個(gè)小時(shí)源碼高效得多。2.3 誰(shuí)來(lái)做玩家分?jǐn)?shù)以Demo里“擊殺得分”為例很多人會(huì)把分?jǐn)?shù)存在Character或Controller里。問(wèn)題是角色死亡重生后數(shù)據(jù)就沒(méi)了Controller雖然存活但如果要保存跨局積分也不夠規(guī)范。正確做法是放進(jìn)PlayerState。它和玩家會(huì)話綁定角色重生時(shí)PlayerState仍然存在。寫起來(lái)大概是UPROPERTY(BlueprintReadOnly, ReplicatedUsing OnRep_Score) int32 Score; void AddScore(int32 Value);雖然這篇不展開服務(wù)器復(fù)制但我建議你從一開始就養(yǎng)成“該放PlayerState的數(shù)據(jù)不要放Pawn”的習(xí)慣。后面做多人時(shí)你會(huì)感謝這個(gè)習(xí)慣。另外GameState是用來(lái)放“所有玩家都能看到”的全局狀態(tài)比如游戲倒計(jì)時(shí)、當(dāng)前存活隊(duì)伍數(shù)。不要把單個(gè)玩家的東西放進(jìn)去否則又要多寫同步邏輯。3. C和藍(lán)圖的分工屬性與事件的設(shè)計(jì)邊界3.1 一個(gè)屢見不鮮的錯(cuò)誤把C類當(dāng)藍(lán)圖用很多從藍(lán)圖轉(zhuǎn)C的人會(huì)慣性把所有邏輯都往藍(lán)圖里放C只寫一個(gè)空殼而學(xué)過(guò)一點(diǎn)C的人又容易走極端恨不得連動(dòng)畫藍(lán)圖里的Notiify都寫在C里。兩種都不太對(duì)。我的參考標(biāo)準(zhǔn)很簡(jiǎn)單能用C保證的數(shù)據(jù)和流程就放C需要美術(shù)、策劃頻繁調(diào)整的數(shù)值和表現(xiàn)暴露給藍(lán)圖。比如武器組件里傷害、射速、彈匣容量這些數(shù)據(jù)就該用UPROPERTY暴露給藍(lán)圖去調(diào)UPROPERTY(EditDefaultsOnly, Category Weapon) float Damage 25.0f; UPROPERTY(EditDefaultsOnly, Category Weapon) float FireRate 0.1f; UPROPERTY(EditDefaultsOnly, Category Weapon) int32 MagazineCapacity 30;這里用EditDefaultsOnly而不是EditAnywhere是對(duì)默認(rèn)資產(chǎn)做配置。打開藍(lán)圖類時(shí)你可以在Class Defaults里改但實(shí)例里不能改。這個(gè)約束對(duì)“數(shù)值模板”來(lái)說(shuō)很合適。3.2 什么時(shí)候用BlueprintImplementableEvent需要把“函數(shù)的具體實(shí)現(xiàn)”留給藍(lán)圖去完成時(shí)用BlueprintImplementableEvent。典型場(chǎng)景是播放動(dòng)畫、音效、UI表現(xiàn)UFUNCTION(BlueprintImplementableEvent) void OnWeaponFired(const FVector ShotDirection);C負(fù)責(zé)在合適的時(shí)機(jī)調(diào)用這個(gè)事件藍(lán)圖負(fù)責(zé)在事件圖里生成彈殼特效、播槍口火焰。這樣C不會(huì)被表現(xiàn)層代碼污染藍(lán)圖美術(shù)也不需要理解C。需要同時(shí)給一個(gè)默認(rèn)實(shí)現(xiàn)但也允許藍(lán)圖覆蓋時(shí)用BlueprintNativeEvent。不過(guò)老實(shí)說(shuō)新手階段用ImplementableEvent就夠了NativeEvent少用它容易讓人寫出“藍(lán)圖里沒(méi)調(diào)Super就丟邏輯”的隱性Bug。3.3 從C側(cè)調(diào)用藍(lán)圖事件時(shí)的時(shí)機(jī)問(wèn)題這里有一個(gè)大坑我見過(guò)不少人中招**在C構(gòu)造函數(shù)里調(diào)用BlueprintImplementableEvent藍(lán)圖端幾乎永遠(yuǎn)收不到。**因?yàn)闃?gòu)造函數(shù)執(zhí)行時(shí)藍(lán)圖類可能還沒(méi)完全加載事件綁定還沒(méi)準(zhǔn)備好。正確時(shí)機(jī)是在BeginPlay之后。比如武器組件要在槍口生成特效先把BeginPlay里拿到Muzzle組件的Transform再在收到射擊請(qǐng)求時(shí)觸發(fā)OnWeaponFired事件。補(bǔ)一個(gè)數(shù)據(jù)驅(qū)動(dòng)的建議數(shù)值如果特別多別全部暴露成UPROPERTY堆在組件上可以把一組武器參數(shù)做成一個(gè)UDataAsset子類。做法大概是UCLASS() class UWeaponConfig : public UDataAsset { GENERATED_BODY() public: UPROPERTY(EditDefaultsOnly) float Damage; UPROPERTY(EditDefaultsOnly) float FireRate; UPROPERTY(EditDefaultsOnly) TSubclassOfclass AMyProjectile ProjectileClass; };然后武器組件里只需要一個(gè)UWeaponConfig* WeaponConfig切換武器時(shí)換一個(gè)DataAsset就行。這樣策劃配數(shù)據(jù)很直觀甚至不需要打開C。4. 戰(zhàn)斗邏輯實(shí)戰(zhàn)射線、Projectile與傷害接口4.1 先搞清楚用什么方式做命中檢測(cè)第三人稱射擊Demo里武器要么用射線Instant Hit要么生成飛行的Projectile。兩種沒(méi)有絕對(duì)優(yōu)劣主要看手感。射線命中反饋快適合步槍Projectile有飛行時(shí)間適合火箭筒、投擲物。我做Demo時(shí)先用射線把核心閉環(huán)跑通FVector Start WeaponMesh-GetSocketLocation(MuzzleSocket); FVector End Start CameraComp-GetForwardVector() * 10000.0f; FHitResult Hit; FCollisionQueryParams QueryParams; QueryParams.AddIgnoredActor(GetOwner()); QueryParams.AddIgnoredActor(this); bool bHit GetWorld()-LineTraceSingleByChannel(Hit, Start, End, ECC_Visibility, QueryParams);這里有兩個(gè)細(xì)節(jié)很容易被忽略Start不要從攝像機(jī)直接發(fā)要盡量從槍口Socket發(fā)出。不然近距離開火時(shí)子彈會(huì)打在攝像機(jī)前的遮擋物上而且武器直觀感會(huì)很差。用ECC_Visibility還是ECC_Camera要看你的碰撞設(shè)置。我習(xí)慣給“可被武器命中但不可被相機(jī)穿透”的物體單獨(dú)安排碰撞通道避免用Visibility通道把自己的角色也掃進(jìn)去。4.2 Projectile的移動(dòng)與生命周期如果要做投射物UE已經(jīng)準(zhǔn)備好UProjectileMovementComponent。我通常在構(gòu)造函數(shù)里這樣配置UProjectileMovementComponent* ProjectileComp CreateDefaultSubobjectUProjectileMovementComponent(TEXT(ProjectileComp)); ProjectileComp-InitialSpeed 3000.0f; ProjectileComp-MaxSpeed 5000.0f; ProjectileComp-ProjectileGravityScale 0.0f; ProjectileComp-SetAutoActivate(true);射線武器不需要考慮重力但火箭筒可能要做射后落地。這里ProjectileGravityScale看需求調(diào)整不是越大越真實(shí)太高反而難以瞄準(zhǔn)。投射物常見問(wèn)題是飛得太遠(yuǎn)舍不得銷毀、撞到墻上又沒(méi)反應(yīng)、或者卡在墻角反復(fù)觸發(fā)。我的處理方式是在Projectile的BeginPlay里設(shè)一個(gè)生命周期定時(shí)器超時(shí)自動(dòng)銷毀同時(shí)用OnComponentHit事件處理碰撞ProjectileComp-OnComponentHit.AddDynamic(this, AMyProjectile::OnHit);4.3 傷害流程接口加事件命中目標(biāo)后調(diào)用傷害。我先是直接對(duì)AMyCharacter進(jìn)行Cast后來(lái)發(fā)現(xiàn)敵人、車輛、可破壞物都要復(fù)用于是改成接口if (IDamageable* Damageable CastIDamageable(Hit.GetActor())) { Damageable-OnDamageTaken(Damage, GetOwner()); }在HealthComponent里OnDamageTaken只做兩件事扣減血量廣播OnDamaged/OnDied事件。角色死亡屬于表現(xiàn)層通過(guò)事件讓動(dòng)畫藍(lán)圖和UI自己去監(jiān)聽HealthComponent本身不需要關(guān)心。這里有一個(gè)很容易犯的錯(cuò)誤把一個(gè)組件里的函數(shù)做得太“聰明”比如在ApplyDamage里直接調(diào)用PlayDeathAnimation、SetActorHiddenInGame、丟棄武器。這些表現(xiàn)一旦被耦合進(jìn)來(lái)后期換表現(xiàn)方案時(shí)就會(huì)很痛。正確做法是HealthComponent只維護(hù)“數(shù)值與狀態(tài)”表現(xiàn)交給外部監(jiān)聽者。5. 這個(gè)階段最容易踩的坑5.1 指針隨手一存后來(lái)就崩了UE的Actor和Component可能在任何時(shí)候被銷毀最典型的是角色重生、關(guān)卡切換、銷毀投射物。如果你把某個(gè)Actor的裸指針存在另一個(gè)Actor里用完沒(méi)有判空大概率會(huì)在某個(gè)詭異時(shí)刻崩潰。我的應(yīng)對(duì)策略跨Actor引用盡量用TWeakObjectPtr至少不會(huì)因?yàn)槟阃浿每找爸羔樁罎?。在調(diào)用前做IsValid()判斷。不要在Tick里反復(fù)Cast獲取同一個(gè)對(duì)象盡量在BeginPlay時(shí)緩存并做好失效處理。5.2 別在Tick里做每幀分配新手在Tick里寫FString::Printf、TArray拼接、甚至每幀LineTrace是很常見的。每幀分配大量臨時(shí)對(duì)象會(huì)導(dǎo)致明顯的卡頓和GC壓力尤其移動(dòng)端更明顯。如果某段邏輯需要每條射線都檢測(cè)一次但又不是每幀都有輸入那就別放在Tick里去輪詢改成在開火函數(shù)里執(zhí)行一次。同理UI更新不要在Character的Tick里直接調(diào)用用事件驅(qū)動(dòng)。5.3 BeginPlay和Possess順序是“薛定諤的”前面說(shuō)過(guò)別假定BeginPlay里一定存在Controller。我在Demo里遇到過(guò)角色在地圖里放置而非通過(guò)GameMode Spawn時(shí)Possess觸發(fā)時(shí)機(jī)完全不一樣。如果你確實(shí)需要“一成為玩家就立刻初始化”更穩(wěn)妥的方法是在APlayerController的BeginPlay或OnPossess里發(fā)一個(gè)自定義事件。Character側(cè)只負(fù)責(zé)響應(yīng)這個(gè)事件不主動(dòng)去輪詢Controller是否存在。5.4 別過(guò)早陷入底層源碼最后說(shuō)一個(gè)學(xué)習(xí)心態(tài)上的坑。UE剛上手時(shí)很容易看了一堆源碼反而更焦慮。我自己早期打開CharacterMovementComponent源碼內(nèi)心直呼“這也太復(fù)雜了”然后陷入一種迷之低效狀態(tài)。學(xué)會(huì)“先用再拆最后改”的路徑會(huì)順很多。內(nèi)置的UCharacterMovementComponent已經(jīng)夠用了先跑通邏輯再去考慮自定義移動(dòng)模式。重寫引擎底層是大后期的事不是學(xué)習(xí)第二篇該做的事。6. 在實(shí)現(xiàn)教學(xué)和內(nèi)容審核上最后說(shuō)兩句很多做技術(shù)分享的人容易忽略寫博文時(shí)配的Demo項(xiàng)目名、類名都最好用“模擬項(xiàng)目X”“AMyCharacter”這類代稱既方便讀者對(duì)照也不會(huì)帶出敏感信息同時(shí)盡量別在文章里放帶真實(shí)版權(quán)的美術(shù)資源。我自己的習(xí)慣是全部用引擎自帶的基礎(chǔ)圖元和方塊材質(zhì)跑通邏輯最重要。這套第三人稱射擊Demo如果接著往下推下一步我會(huì)去做技能系統(tǒng)和簡(jiǎn)單的敵人AI。做得過(guò)程中你會(huì)慢慢體會(huì)到UE C的難點(diǎn)從來(lái)不是語(yǔ)法而是“在正確的時(shí)間、正確的對(duì)象上做正確的事”。這個(gè)感覺(jué)靠看文字很難建立一定要親自加日志、做實(shí)驗(yàn)、斷點(diǎn)查一下到底在哪里崩了。