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