《Exploring in UE4》移動組件詳解[原理分析]
前言
這篇文章對UE4的移動組件做了非常詳細的分析。主要從移動框架與實現原理,移動的網路同步,移動組件的改造三個方面來寫。
目錄
一.深刻理解移動組件的意義
二.移動實現的基本原理 2.1 移動組件與玩家角色 2.2 移動組件繼承樹2.3 移動組件相關類關係簡析
三.各個移動狀態的細節處理 3.1 Walking 3.2 Falling 3.2.1 Jump 3.3 Swimming 3.4 Flying 3.5 FScopedMovementUpdate延遲更新四.移動同步解決方案 4.1 伺服器角色正常的移動流程4.2 Autonomous角色
4.2.1 SavedMoves與移動合併 4.3 Simulate角色 4.4 關於物理託管後的移動五.特殊移動模式的實現思路 5.1 二段跳,多段跳的實現 5.2 噴氣式背包的實現 5.3 爬牆的實現 5.4 爬梯子的實現
一.深刻理解移動組件的意義
在大部分遊戲中,玩家的移動是最最核心的一個基本操作。UE提供的GamePlay框架就給開發者提供了一個比較完美的移動解決方案。由於UE採用了組件化的設計思路,所以這個移動解決方案的核心功能就都交給了移動組件來完成。移動可能根據遊戲的複雜程度有不同的處理,如果是一個簡單的俯視視角RTS類型的遊戲,可能只提供基本的坐標移動就可以了;而對於第一人稱的RPG遊戲,玩家可能上天入地,潛水飛行,那需要的移動就要更複雜一些。但是不管是哪一種,UE都基本上幫我們實現了,這也得益於其早期的FPS遊戲的開發經驗。
然而,引擎提供的基本移動並不一定能完成我們的目標,我們也不應該因此局限我們的設計。比如輕功的飛檐走壁,魔法飛船的超重力,彈簧鞋,噴氣背包飛行控制,這些效果都需要我們自己去進一步的處理移動邏輯,我們可以在其基礎上修改,也可以自定義自己的移動模式。不管怎麼樣,這些操作都需要對移動組件進行細緻入微的調整,所以我們就必須要深刻理解移動組件的實現原理。
再者,在一個網路遊戲中,我們對移動的處理會更加的複雜。如何讓不同客戶端的玩家都體驗到流暢的移動表現?如何保證角色不會由於一點點的延遲而產生「瞬移」?UE對這方面的處理都值得我們去學習與思考。
移動組件看起來只是一個和移動相關的組件,但其本身涉及到狀態機,同步解決方案,物理模塊,不同移動狀態的細節處理,動畫以及與其他組件(Actor)之間的調用關係等相關內容,足夠花上一段時間去好好研究。這篇文章會從移動的基本原理,移動狀態的細節處理,移動同步的解決方案幾個角度儘可能詳細的分析其實現原理,然後幫助大家快速理解並更好的使用移動組件。最後,給出幾個特殊移動模式的實現思路供大家參考。
二.移動實現的基本原理
2.1 移動組件與玩家角色
角色的移動本質上就是合理的改變坐標位置,在UE裡面角色移動的本質就是修改某個特定組件的坐標位置。圖2-1是我們常見的一個Character的組件構成情況,可以看到我們通常將CapsuleComponent(膠囊體)作為自己的根組件,而Character的坐標本質上就是其RootComponent的坐標,Mesh網格等其他組件都會跟隨膠囊體而移動。移動組件在初始化的時候會把膠囊體設置為移動基礎組件UpdateComponent,隨後的操作都是在計算UpdateComponent的位置。
當然,我們也並不是一定要設置膠囊體為UpdateComponent,對於DefaultPawn(觀察者)會把他的SphereComponent作為UpdateComponent,對於交通工具對象AWheeledVehicle會默認把他的Mesh網格組件作為UpdateComponent。你可以自己定義你的UpdateComponent,但是你的自定義組件必須要繼承USceneComponent(換句話說就是組件得有世界坐標信息),這樣他才能正常的實現其移動的邏輯。
2.2 移動組件繼承樹
移動組件類並不是只有一個,他通過一個繼承樹,逐漸擴展了移動組件的能力。從最簡單的提供移動功能,到可以正確模擬不同移動狀態的移動效果。如圖2-2所示
移動組件類一共四個。首先是UMovementComponent,作為移動組件的基類實現了基本的移動介面SafeMovementUpdatedComponent(),可以調用UpdateComponent組件的介面函數來更新其位置。
bool UMovementComponent::MoveUpdatedComponentImpl( const FVector& Delta, const FQuat& NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport){ if (UpdatedComponent) { const FVector NewDelta = ConstrainDirectionToPlane(Delta); return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport); } return false;}
通過上圖可以看到UpdateComponent的類型是UScenceComponent,UScenceComponent類型的組件提供了基本的位置信息——ComponentToWorld,同時也提供了改變自身以及其子組件的位置的介面InternalSetWorldLocationAndRotation()。而UPrimitiveComponent又繼承於UScenceComponent,增加了渲染以及物理方面的信息。我們常見的Mesh組件以及膠囊體都是繼承自UPrimitiveComponent,因為想要實現一個真實的移動效果,我們時刻都可能與物理世界的某一個Actor接觸著,而且移動的同時還需要渲染出我們移動的動畫來表現給玩家看。
下一個組件是UNavMovementComponent,該組件更多的是提供給AI尋路的能力,同時包括基本的移動狀態,比如是否能游泳,是否能飛行等。
UPawnMovementComponent組件開始變得可以和玩家交互了,前面都是基本的移動介面,不手動調用根本無法實現玩家操作。UPawnMovementComponent提供了AddInputVector(),可以實現接收玩家的輸入並根據輸入值修改所控制Pawn的位置。要注意的是,在UE中,Pawn是一個可控制的遊戲角色(也可以是被AI控制),他的移動必須與UPawnMovementComponent配合才行,所以這也是名字的由來吧。一般的操作流程是,玩家通過InputComponent組件綁定一個按鍵操作,然後在按鍵響應時調用Pawn的AddMovementInput介面,進而調用移動組件的AddInputVector(),調用結束後會通過ConsumeMovementInputVector()介面消耗掉該次操作的輸入數值,完成一次移動操作。
最後到了移動組件的重頭了UCharacterMovementComponent,該組件可以說是Epic做了多年遊戲的經驗集成了,裡面非常精確的處理了各種常見的移動狀態細節,實現了比較流暢的同步解決方案。各種位置校正,平滑處理才達到了目前的移動效果,而且我們不需要自己寫代碼就會使用這個完成度的相當高的移動組件,可以說確實很適合做第一,第三人稱的RPG遊戲了。
其實還有一個比較常用的移動組件,UProjectileMovementComponent ,一般用來模擬弓箭,子彈等拋射物的運動狀態。不過,這篇文章不將重點放在這裡。
2.3 移動組件相關類關係簡析
前面主要針對移動組件本身進行了分析,這裡更全面的概括一下移動的整個框架。(參考圖2-3)
在一個普通的三維空間里,最簡單的移動就是直接修改角色的坐標。所以,我們的角色只要有一個包含坐標信息的組件,就可以通過基本的移動組件完成移動。但是隨著遊戲世界的複雜程度加深,我們在遊戲裡面添加了可行走的地面,可以探索的海洋。我們發現移動就變得複雜起來,玩家的腳下有地面才能行走,那就需要不停的檢測地面碰撞信息(FFindFloorResult,FBasedMovementInfo);玩家想進入水中游泳,那就需要檢測到水的體積(GetPhysicsVolume(),Overlap事件,同樣需要物理);水中的速度與效果與陸地上差別很大,那就把兩個狀態分開寫(PhysSwimming,PhysWalking);移動的時候動畫動作得匹配上啊,那就在更新位置的時候,更新動畫(TickCharacterPose);移動的時候碰到障礙物怎麼辦,被其他玩家推怎麼處理(MoveAlongFloor里有相關處理);遊戲內容太少,想增加一些可以自己尋路的NPC,又需要設置導航網格(涉及到FNavAgentProperties);一個玩家太無聊,那就讓大家一起聯機玩(模擬移動同步FRepMovement,客戶端移動修正ClientUpdatePositionAfterServerUpdate)。
這麼一看,做一個優秀移動組件還真不簡單。但是不管怎麼樣,UE基本上都幫你實現了。通過上面的描述,你現在也大體上了解了移動組件在各個方面的處理,不過遇到具體的問題也許還是無從下手,所以咱們繼續往下分析。
三.各個移動狀態的細節處理
這一節我們把焦點集中在UCharacterMovementComponent組件上,來詳細的分析一下他是如何處理各種移動狀態下的玩家角色的。首先肯定是從Tick開始,每幀都要進行狀態的檢測與處理,狀態通過一個移動模式MovementMode來區分,在合適的時候修改為正確的移動模式。移動模式默認有6種,基本常用的模式有行走、游泳、下落、飛行四種,有一種給AI代理提供的行走模式,最後還有一個自定義移動模式。
3.1 Walking
行走模式可以說是所有移動模式的基礎,也是各個移動模式裡面最為複雜的一個。為了模擬出出真實世界的移動效果,玩家的腳下必須要有一個可以支撐不會掉落的物理對象,就好像地面一樣。在移動組件裡面,這個地面通過成員變數FFindFloorResult CurrentFloor來記錄。在遊戲一開始的時候,移動組件就會根據配置設置默認的MovementMode,如果是Walking,就會通過FindFloor操作來找到當前的地面,CurrentFloor的初始化堆棧如下圖3-2(Character Restart()的會覆蓋Pawn的Restart()):
下面先分析一下FindFloor的流程,FindFloor本質上就是通過膠囊體的Sweep檢測來找到腳下的地面,所以地面必須要有物理數據,而且通道類型要設置與玩家的Pawn有Block響應。這裡還有一些小的細節,比如我們在尋找地面的時候,只考慮腳下位置附近的,而忽略掉腰部附近的物體;Sweep用的是膠囊體而不是射線檢測,方便處理斜面移動,計算可站立半徑等(參考圖3-3,HitResult裡面的Normal與ImpactNormal在膠囊體Sweep檢測時不一定相同)。另外,目前Character的移動是基於膠囊體實現的,所以一個不帶膠囊體組件的Actor是無法正常使用UCharacterMovementComponent的。
找到地面玩家就可以站立住么?不一定。這又涉及到一個新的概念PerchRadiusThreshold,我稱他為可棲息範圍半徑,也就是可站立半徑。默認這個值為0,移動組件會忽略這個可站立半徑的相關計算,一旦這個值大於0.15,就會做進一步的判斷看看當前的地面空間是否足夠讓玩家站立在上面。
前面的準備工作完成了,現在正式進入Walking的位移計算,這一段代碼都是在PhysWalking裡面計算的。為了表現的更為平滑流暢,UE4把一個Tick的移動分成了N段處理(每段的時間不能超過MaxSimulationTimeStep)。在處理每段時,首先把當前的位置信息,地面信息記錄下來。在TickComponent的時候根據玩家的按鍵時長,計算出當前的加速度。隨後在CalcVelocity()根據加速度計算速度,同時還會考慮地面摩擦,是否在水中等情況。
// apply input to accelerationAcceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
算出速度之後,調用函數MoveAlongFloor()改變當前對象的坐標位置。在真正調用移動介面SafeMoveUpdatedComponent()前還會簡單處理一種特殊的情況——玩家沿著斜面行走。正常在walking狀態下,玩家只會前後左右移動,不會有Z方向的移動速度。如果遇到斜坡怎麼辦?如果這個斜坡可以行走,就會調用ComputeGroundMovementDelta()函數去根據當前的水平速度計算出一個新的平行與斜面的速度,這樣可以簡單模擬一個沿著斜面行走的效果,而且一般來說上坡的時候玩家的水平速度應該減小,通過設置bMaintainHorizontalGroundVelocity為false可以自動處理這種情況。
現在看起來我們已經可以比較完美的模擬一個移動的流程了,不過仔細想一下還有一種情況沒考慮到。那就是遇到障礙的情況怎麼處理?根據我們平時遊戲經驗,遇到障礙肯定是移動失敗,還可能沿著牆面滑動一點。UE裡面確實也就是這麼處理的,在角色移動的過程中(SafeMoveUpdatedComponent),會有一個碰撞檢測流程。由於UPrimitiveComponent組件才擁有物理數據,所以這個操作是在函數UPrimitiveComponent::MoveComponentImpl裡面處理的。下面的代碼會檢測移動過程中是否遇到了障礙,如果遇到了障礙會把HitResult返回。
FComponentQueryParams Params(PrimitiveComponentStatics::MoveComponentName, Actor);FCollisionResponseParams ResponseParam;InitSweepCollisionParams(Params, ResponseParam);bool const bHadBlockingHit = MyWorld->ComponentSweepMulti(Hits, this, TraceStart, TraceEnd, InitialRotationQuat, Params);
在接收到SafeMoveUpdatedComponent()返回的HitResult後,會在下面的代碼裡面處理碰撞障礙的情況。
- 如果Hit.Normal在Z方向上有值而且還可以行走,那說明這是一個可以移動上去的斜面,隨後讓玩家沿著斜面移動
- 判斷當前的碰撞體是否可以踩上去,如果可以的話就試著踩上去,如果過程中發現沒有踩上去,也會調用SlideAlongSurface()沿著碰撞滑動。
// UCharacterMovementComponent::PhysWalkingelse if (Hit.IsValidBlockingHit()){ // We impacted something (most likely another ramp, but possibly a barrier). float PercentTimeApplied = Hit.Time; if ((Hit.Time > 0.f) && (Hit.Normal.Z > KINDA_SMALL_NUMBER) && IsWalkable(Hit)) { // Another walkable ramp. const float InitialPercentRemaining = 1.f - PercentTimeApplied; RampVector = ComputeGroundMovementDelta(Delta * InitialPercentRemaining, Hit, false); LastMoveTimeSlice = InitialPercentRemaining * LastMoveTimeSlice; SafeMoveUpdatedComponent(RampVector, UpdatedComponent->GetComponentQuat(), true, Hit); const float SecondHitPercent = Hit.Time * InitialPercentRemaining; PercentTimeApplied = FMath::Clamp(PercentTimeApplied + SecondHitPercent, 0.f, 1.f); } if (Hit.IsValidBlockingHit()) { if (CanStepUp(Hit) || (CharacterOwner->GetMovementBase() != NULL && CharacterOwner->GetMovementBase()->GetOwner() == Hit.GetActor())) { // hit a barrier, try to step up const FVector GravDir(0.f, 0.f, -1.f); if (!StepUp(GravDir, Delta * (1.f - PercentTimeApplied), Hit, OutStepDownResult)) { UE_LOG(LogCharacterMovement, Verbose, TEXT("- StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString()); HandleImpact(Hit, LastMoveTimeSlice, RampVector); SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true); } else { // Dont recalculate velocity based on this height adjustment, if considering vertical adjustments. UE_LOG(LogCharacterMovement, Verbose, TEXT("+ StepUp (ImpactNormal %s, Normal %s"), *Hit.ImpactNormal.ToString(), *Hit.Normal.ToString()); bJustTeleported |= !bMaintainHorizontalGroundVelocity; } } else if ( Hit.Component.IsValid() && !Hit.Component.Get()->CanCharacterStepUp(CharacterOwner) ) { HandleImpact(Hit, LastMoveTimeSlice, RampVector); SlideAlongSurface(Delta, 1.f - PercentTimeApplied, Hit.Normal, Hit, true); } }}
基本上的移動處理就完成了,移動後還會立刻判斷玩家是否進入水中,或者進入Falling狀態,如果是的話立刻切換到新的狀態。
由於玩家在一幀裡面可能會從Walking,Swiming,Falling的等狀態不斷的切換,所以在每次執行移動前都會有一個iteration記錄當前幀的移動次數,如果超過限制就會取消本次的移動模擬行為。3.2 Falling
Falling狀態也算是處理Walking以外最常見的狀態,只要玩家在空中(無論是跳起還是下落),玩家都會處於Falling狀態。與Walking相似,為了表現的更為平滑流暢,Falling的計算也把一個Tick的移動分成了N段處理(每段的時間不能超過MaxSimulationTimeStep)。在處理每段時,首先計算玩家通過輸入控制的水平速度,因為玩家在空中也可以受到玩家控制的影響。隨後,獲取重力計算速度。重力的獲取有點意思,你會發現他是通過Volume體積獲取的,
float UMovementComponent::GetGravityZ() const{ return GetPhysicsVolume()->GetGravityZ();}APhysicsVolume* UMovementComponent::GetPhysicsVolume() const{ if (UpdatedComponent) { return UpdatedComponent->GetPhysicsVolume(); } return GetWorld()->GetDefaultPhysicsVolume();}
Volume裡面會取WorldSetting裡面的GlobalGravityZ,這裡給我們一個提示,我們可以通過修改代碼實現不同Volume的重力不同,實現自定義的玩法。注意,即使我們沒有處在任何一個體積裡面,他也會給我們的UpdateComponent綁定一個默認的DefaultVolume。那為什麼要有一個DefaultVolume?因為在很多邏輯處理上都需要獲取DefaultVolume以及裡面的相關的數據。比如,DefaultVolume有一個TerminalLimit,在通過重力計算下降速度的時候不可以超過這個設置的速度,我們可以通過修改該值來改變速度的限制。默認情況下,DefaultVolume裡面的很多屬性都是通過ProjectSetting裡面的Physics相關配置來初始化的。參考圖3-4,
通過獲取到的Gravity計算出當前新的FallSpeed(NewFallVelocity裡面計算,計算規則很簡單,就是單純的用當前速度-Gravity*deltaTime)。隨後再根據當前以及上一幀的速度計算出位移並進行移動,公式如下
FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick; SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);
前面我們計算完速度並移動玩家後,也一樣要考慮到移動碰撞問題。
第一種情況就是正常落地,如果玩家計算後發現碰撞到一個可以站立的地形,那直接調用ProcessLanded進行落地操作(這個判斷主要是根據碰撞點的高度來的,可以篩選掉牆面)。
第二種情況就是跳的過程中遇到一個平台,然後檢測玩家的坐標與當前碰撞點是否在一個可接受的範圍(IsWithinEdgeTolerance),是的話就執行FindFloor重新檢測一遍地面,檢測到的話就執行落地流程。
第三種情況是就是牆面等一些不可踩上去的,下落過程如果碰到障礙,首先會執行HandleImpact給碰到的對象一個力。隨後調用ComputeSlideVector計算一下滑動的位移,由於碰撞到障礙後,玩家的速度會有變化,這時候重新計算一下速度,再次調整玩家的位置與方向。如果玩家這時候有水平方向上的位移,還會通過LimitAirControl來限制玩家的速度,畢竟玩家在空中是無法自由控制角色的。對第三種情況做進一步的延伸,可能會出現碰撞調整後又碰到了另一個牆面,這裡Falling的處理可以讓玩家在兩個牆面找到一個合適的位置。但是仍然不能解決玩家被夾在兩個斜面但是卻無法落地的情況(或者在Waling和Falling中不斷切換)。如果有時間,我們後面可以嘗試解決這個問題,解決思路可以從FindFloor下的ComputeFloorDist函數入手,目的就是讓這個情況下玩家可以找到一個可行走的地面。
3.2.1 Jump
提到Falling,不得不提跳躍這一基本操作。下面大致描述了跳躍響應的基本流程,
1. 綁定觸發響應事件
void APrimalCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent){ // Set up gameplay key bindings check(PlayerInputComponent); PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump); PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);}void ACharacter::Jump(){ bPressedJump = true; JumpKeyHoldTime = 0.0f;}void ACharacter::StopJumping(){ bPressedJump = false; ResetJumpState();}
2.一旦按鍵響應立刻設置bPressedJump為true。TickComponent的幀循環調用ACharacter::CheckJumpInput來立刻檢測到是否執行跳躍操作
- ①執行CanJump()函數,處理藍圖裡面的相關限制邏輯。如果藍圖裡面不重寫該函數,就會默認執行ACharacter::CanJumpInternal_Implementation()。這裡面是控制玩家能否跳躍的依據,比如蹲伏狀態不能跳躍,游泳狀態不能跳躍。另外,有一個JumpMaxHoldTime表示玩家按鍵超過這個值後不會觸發跳躍。JumpMaxCount表示玩家可以執行跳躍的段數。(比如二段跳)
- ②執行CharacterMovement->DoJump(bClientUpdating)函數,執行跳躍操作,進入Falling,設置跳躍速度為JumpZVelocity,這個值不能小於0。
- ③ 判斷const bool bDidJump = canJump && CharacterMovement && DoJump;是否為真。做一些其他的相關操作。
const bool bDidJump = CanJump() && CharacterMovement->DoJump(bClientUpdating);if (!bWasJumping && bDidJump){ JumpCurrentCount++; OnJumped();}
3.在一次PerformMovement結束後,就會執行ClearJumpInput,設置設置bPressedJump為false。但是不會清除JumpCurrentCount這樣可以繼續處理多段跳。
4.玩家鬆開按鍵p也會設置bPressedJump為false,清空相關狀態。如果玩家仍在空中,那也不會清除JumpCurrentCount。一旦bPressedJump為false,就不會處理任何跳躍操作了。
5.如果玩家在空中按下跳躍鍵,他也會進入ACharacter::CheckJumpInput,如果JumpCurrentCount小於JumpMaxCount,玩家就可以繼續執行跳躍操作了。
3.3 Swiming
各個狀態的差異本質有三個點:
1.速度的不同2.受重力影響的程度3.慣性大小
游泳狀態表現上來看是一個有移動慣性(鬆手後不會立刻停止),受重力影響小(在水中會慢慢下落或者不動),移動速度比平時慢(表現水有阻力)的狀態。而玩家是否在水中的默認檢測邏輯也比較簡單,就是判斷當前的updateComponent所在的Volume是否是WaterVolume。(在編輯器裡面拉一個PhysicsVolume,修改屬性WaterVolume即可)
CharacterMovement組件裡面有浮力大小配置Buoyancy,根據玩家潛入水中的程度(ImmersionDepth返回0-1)可計算最終的浮力。隨後,開始要計算速度了,這時候我們需要獲取Volume裡面的摩擦力Friction,然後傳入CalcVelocity裡面,這體現出玩家在水中移動變慢的效果。隨後在Z方向通過計算浮力大小該計算該方向的速度,隨著玩家潛水的程度,你會發現玩家在Z方向的速度越來越小,一旦全身都浸入了水中,在Z軸方向的重力速度就會被完全忽略。
// UCharacterMovementComponent::PhysSwimmingconst float Friction = 0.5f * GetPhysicsVolume()->FluidFriction * Depth;CalcVelocity(deltaTime, Friction, true, BrakingDecelerationSwimming);Velocity.Z += GetGravityZ() * deltaTime * (1.f - NetBuoyancy);// UCharacterMovementComponent::CalcVelocity Apply fluid frictionif (bFluid){ Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));}
速度計算後,玩家就可以移動了。這裡UE單獨寫了一個介面Swim來執行移動操作,同時他考慮到如果移動後玩家離開了水體積而且超出水面過大,他機會強制把玩家調整到水面位置,表現會更好一些。
接下來還要什麼,那大家可能也猜出來了,就是處理移動中檢測到碰撞障礙的情況。基本上和之前的邏輯差不多,如果可以踩上去(StepUp())就調整玩家位置踩上去,如果踩不上去就給障礙一個力,然後順著障礙表面滑動一段距離(HandleImpact,SlideAlongSurface)。
那水中移動的慣性表現是怎麼處理的呢?其實並不是水中做了什麼特殊處理,而是計算速度時有兩個傳入的參數與Walking不同。一個是Friction表示摩擦力,另一個是BrakingDeceleration表示剎車的反向速度。
在加速度為0的時候(表示玩家的輸入已經被清空),水中的傳入的摩擦力要遠比地面摩擦里小(0.15:8),而剎車速度為0(Walking為2048),所以ApplyVelocityBraking在處理的時候在Walking表現的好像立刻剎車一樣,而在Swim和fly等情況下就好像有移動慣性一樣。// Only apply braking if there is no acceleration, or we are over our max speed and need to slow down to it.if ((bZeroAcceleration && bZeroRequestedAcceleration) || bVelocityOverMax){ const FVector OldVelocity = Velocity; const float ActualBrakingFriction = (bUseSeparateBrakingFriction ? BrakingFriction : Friction); ApplyVelocityBraking(DeltaTime, ActualBrakingFriction, BrakingDeceleration); //Dont allow braking to lower us below max speed if we started above it. if (bVelocityOverMax && Velocity.SizeSquared() < FMath::Square(MaxSpeed) && FVector::DotProduct(Acceleration, OldVelocity) > 0.0f) { Velocity = OldVelocity.GetSafeNormal() * MaxSpeed; }}
3.4 Flying
終於講到了最後一個移動狀態了,如果你想調試該狀態的話,在角色的移動組件裡面修改DefaultLandMovementMode為Flying即可。
Flying和其他狀態套路差不多,而且相對更簡單一些,首先根據前面輸入計算Acceleration,然後根據摩擦力開始計算當前的速度。速度計算後調用SafeMoveUpdatedComponent進行移動。如果碰到障礙,就先看能不能踩上去,不能的話處理碰撞,沿著障礙表面滑動。//UCharacterMovementComponent::PhysFlying//RootMotion RelativeRestorePreAdditiveRootMotionVelocity();if( !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity() ){ if( bCheatFlying && Acceleration.IsZero() ) { Velocity = FVector::ZeroVector; } const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction; CalcVelocity(deltaTime, Friction, true, BrakingDecelerationFlying);}//RootMotion RelativeApplyRootMotionToVelocity(deltaTime);
有一個關於Flying狀態的現象大家可能會產生疑問,當我設置默認移動方式為Flying的時候,玩家可以在鬆開鍵盤後進行滑行一段距離(有慣性)。但是使用GM命令的時候,為什麼就像Walking狀態一樣,鬆開按鍵後立刻停止?
其實時代碼對cheat Flying做了特殊處理,玩家鬆開按鍵後,加速度變為0,這時候強制設置玩家速度為0。所以使用GM的表現與實際上的不太一樣。3.5 FScopedMovementUpdate延遲更新
FScopedMovementUpdate並不是一種狀態,而是一種優化移動方案。因為大家在查看引擎代碼時,可能會看到在執行移動前會有下面這樣的代碼:
// Scoped updates can improve performance of multiple MoveComponent calls.{ FScopedMovementUpdate ScopedMovementUpdate(UpdatedComponent, bEnableScopedMovementUpdates ? EScopedUpdate::DeferredUpdates : EScopedUpdate::ImmediateUpdates); MaybeUpdateBasedMovement(DeltaSeconds); //......其他邏輯處理,這裡不給出具體代碼 // Clear jump input now, to allow movement events to trigger it for next update. CharacterOwner->ClearJumpInput(); // change position StartNewPhysics(DeltaSeconds, 0); //......其他邏輯處理,這裡不給出具體代碼 OnMovementUpdated(DeltaSeconds, OldLocation, OldVelocity);} // End scoped movement update
為什麼要把移動的代碼放到這個大括弧裡面,FScopedMovementUpdate又是什麼東西?仔細回想一下我們前面具體的移動處理邏輯,在一個幀裡面,我們由於移動的不合法,碰到障礙等可能會多次重置或者修改我們的移動。如果只是簡單修改膠囊體的位置,其實沒什麼,不過實際上我們還要同時修改子組件的位置,更新物理體積,更新物理位置等等,而計算過程中的那些移動數據其實是沒有用的,我們只需要最後的那個移動數據。
因此使用FScopedMovementUpdate可以在其作用域範圍內,先鎖定不更新物理等對象的移動,等這次移動真正的完成後再去更新。(等到FScopedMovementUpdate析構的時候再處理)
四.移動同步解決方案
前面關於移動邏輯的細節處理都是在PerformMovement裡面實現的,我們可以把函數PerformMovement當成一個完整的移動處理流程。這個流程無論是在客戶端還是在伺服器都必須要執行,或者作為一個單機遊戲,這一個介面基本上可以滿足我們的正常移動了。不過,在網路遊戲中,為了讓所有的玩家體驗一個幾乎相同的世界,需要保證一個具有絕對權威的伺服器,這個伺服器可以修正客戶端的不正常移動行為,保證各個客戶端的一致性。相關同步的操作都是基於UCharacterMovement組件實現的,所以我們的角色必須要使用這個移動組件。
移動組件的同步全都是基於RPC不可靠傳輸的,你會在UCharacterMovement頭文件裡面看到多個以Server或者Client開頭的RPC函數。
關於移動組件的同步思路,建議選閱讀一下官方文檔的內容,https://docs.unrealengine.com/latest/CHN/Gameplay/Networking/CharacterMovementComponent/index.html 回頭看可能更為清晰一點。現在我們把整個移動細節作為一個介面封裝起來,宏觀的研究移動組件的同步細節。
另外,如果還沒有完全搞清ROLE_Authority,ROLE_AutonomousProxy,ROLE_SimulatedProxy的概念,請參考 UE4網路同步詳解(一)——理解同步規則。這裡舉個例子,一個伺服器上有一個玩家ServerA和一個NPC ServerB,客戶端上擁有從伺服器複製過來的這個玩家ClientA與NPC ClientB。由於ServerA與ServerB都是在伺服器上生成的,所以他們兩在伺服器上的所有權Role都是ROLE_Authority。ClientA在客戶端上由於被玩家控制,他的Role是ROLE_AutonomousProxy。ClientB在客戶端是完全通過伺服器同步來控制的,他的Role就是ROLE_SimulatedProxy。
4.1 伺服器角色正常的移動流程
第三章節裡面的圖3-1就是單機或者ListenServer伺服器執行的移動流程。作為一個本地控制的角色,他只需要認真的執行正常的移動(PerformMovement)邏輯處理即可,所以ListenServer伺服器移動不再贅述。
但是對於DedicateServer,他的本地沒有控制的角色,對移動的處理就有差異了。分為兩種情況:
- 該角色在客戶端是模擬(Simulate)角色,移動完全由伺服器同步過去,如各類AI角色。這類移動一般是伺服器上行為樹主動觸發的
- 該角色在客戶端是擁有自治(Autonomous)權利的Character,如玩家控制的主角。這類移動一般是客戶端接收玩家輸入數據本地模擬後,再通過RPC發給伺服器進行模擬的
從下面的代碼可以了解到這兩種情況的處理(注意注釋):
// UCharacterMovementComponent:: TickComponent// simulate的角色在伺服器執行IsLocallyControlled也會返回true// Allow root motion to move characters that have no controller.if( CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()) ){ { SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration); // We need to check the jump state before adjusting input acceleration, to minimize latency // and to make sure acceleration respects our potentially new falling state. CharacterOwner->CheckJumpInput(DeltaTime); // apply input to acceleration Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector)); AnalogInputModifier = ComputeAnalogInputModifier(); } if (CharacterOwner->Role == ROLE_Authority) { // 單機或者DedicateServer控制simulate角色移動 PerformMovement(DeltaTime); } else if (bIsClient) { ReplicateMoveToServer(DeltaTime, Acceleration); }}else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy){ //DedicateServer控制自治客戶端角色移動 // Server ticking for remote client. // Between net updates from the client we need to update position if based on another object, // otherwise the object will move on intermediate frames and we wont follow it. MaybeUpdateBasedMovement(DeltaTime); MaybeSaveBaseLocation(); // Smooth on listen server for local view of remote clients. We may receive updates at a rate different than our own tick rate. if (CharacterMovementCVars::NetEnableListenServerSmoothing && !bNetworkSmoothingComplete && IsNetMode(NM_ListenServer)) { SmoothClientPosition(DeltaTime); }}
這兩種情況詳細的流程我們在下面兩個小結分析。
4.2 Autonomous角色
一個客戶端的角色是完全通過伺服器同步過來的,他身上的移動組件也一樣是被同步過來的,所以遊戲一開始客戶端的角色與伺服器的數據是完全相同的。對於Autonomous角色,大致的實現思路如下:
客戶端通過接收玩家的Input輸入,開始進行本地的移動模擬流程,移動前首先創建一個移動預測數據結構FNetworkPredictionData_Client_Character,執行PerformMovement移動,隨後保存當前的移動數據(速度,旋轉,時間戳以及移動結束後的位置等信息)到前面的FNetworkPredictionData裡面的SavedMoves列表裡面,並通過RPC將當前的Move數據發送該數據到伺服器。然後繼續進行TickComponent操作,重複這個流程。
客戶端在發送給伺服器RPC消息的同時,本地還會不斷的執行移動模擬。SavedMoves列表裡面的數據也就越來越多。如果這時候收到了一個ClientAckGoodMove調用,那麼表示伺服器接收了對應時間戳的客戶端移動,客戶端就將這個時間戳之前的SavedMoves全部移除。如果客戶端收到了ClientAdjustPosition調用,那麼表示對應這個時間戳的移動有問題,客戶端需要修改成伺服器傳過來的位置,並重新播放那些還沒被確認的SaveMoves列表裡面的移動。
整個流程如下圖所示:
4.2.1 SavedMoves與移動合併
仔細閱讀源碼的朋友對上面給出的流程可能並不是很滿意,因為除了ServerMove你可能還看到了ServerMoveDual以及ServerMoveOld等函數介面。而且除了SavedMoves列表,還有PendingMove,FreeMove這些移動列表。他們都是做什麼的?
簡單來講,這屬於移動帶寬優化的一個方式,將沒有意義的移動合併,減少消息的發送量。
當客戶端執行完本次移動後,都會把當前的移動數據以一個結構體保存到SavedMove列表,然後會判斷當前的這個移動是否可以被延遲發送(CanDelaySendingMove(),默認為true),如果可以就會繼續判斷當前的客戶端網路速度如何。如果當前的速度有一點慢或者上次更新的時間很短,移動組件就會將當前的移動賦值給PendingMove(表示將要執行的移動)並取消本次給伺服器消息的發送。
const bool bCanDelayMove = (CharacterMovementCVars::NetEnableMoveCombining != 0) && CanDelaySendingMove(NewMove);if (bCanDelayMove && ClientData->PendingMove.IsValid() == false){ // Decide whether to hold off on move // send moves more frequently in small games where server isnt likely to be saturated float NetMoveDelta; UPlayer* Player = (PC ? PC->Player : nullptr); AGameStateBase const* const GameState = GetWorld()->GetGameState(); if (Player && (Player->CurrentNetSpeed > 10000) && (GameState != nullptr) && (GameState->PlayerArray.Num() <= 10)) { NetMoveDelta = 0.011f; } else if (Player && CharacterOwner->GetWorldSettings()->GameNetworkManagerClass) { //這裡會根據網路管理的配置以及客戶端網路速度來決定是否延遲發送 NetMoveDelta = FMath::Max(0.0222f,2 * GetDefault<AGameNetworkManager>(CharacterOwner->GetWorldSettings()->GameNetworkManagerClass)->MoveRepSize/Player->CurrentNetSpeed); } else { NetMoveDelta = 0.011f; } if ((GetWorld()->TimeSeconds - ClientData->ClientUpdateTime) * CharacterOwner->GetWorldSettings()->GetEffectiveTimeDilation() < NetMoveDelta) { // Delay sending this move. ClientData->PendingMove = NewMove; return; }}
當客戶端進去下次Tick的時候,就會判斷當前的新的移動是否能與上次保存的PendingMove合併。如果可以,就可以減少一次消息的發送。如果不能合併,那麼在本次移動結束後給伺服器發送一個兩次移動(ServerMoveDual),就是單純的執行兩次ServerMove。
伺服器在受到兩次移動的時候對第一次移動不進行任何校驗,只對第二個移動進行正常的校驗,判斷是否是第一次的標準就是ClientPosition是不是FVector(1.f,2.f,3.f)。通過下面的代碼就可以了解了
void UCharacterMovementComponent::ServerMoveDual_Implementation( float TimeStamp0, FVector_NetQuantize10 InAccel0, uint8 PendingFlags, uint32 View0, float TimeStamp, FVector_NetQuantize10 InAccel, FVector_NetQuantize100 ClientLoc, uint8 NewFlags, uint8 ClientRoll, uint32 View, UPrimitiveComponent* ClientMovementBase, FName ClientBaseBone, uint8 ClientMovementMode){ ServerMove_Implementation(TimeStamp0, InAccel0, FVector(1.f,2.f,3.f), PendingFlags, ClientRoll, View0, ClientMovementBase, ClientBaseBone, ClientMovementMode); ServerMove_Implementation(TimeStamp, InAccel, ClientLoc, NewFlags, ClientRoll, View, ClientMovementBase, ClientBaseBone, ClientMovementMode);}
其實,UE的思想就是,將所有的移動的關鍵信息都數據化,這樣移動就可以自由的存儲和回放。為了節省帶寬,提高效率,我們也就可以想出各種辦法來減少發送不必要的消息,對於一個沒有移動過的玩家,理論上我們甚至都可以不去同步他的移動信息。
4.3 Simulate角色
首先看一下官方文檔對Simulate角色移動的描述:
對於那些不由人類控制的人物,其動作往往會通過正常的 PerformMovement() 代碼在伺服器(此時充當了主控者)上進行更新。Actor 的狀態,如方位、旋轉、速率和其他一些選定的人物特有狀態(如跳躍)都會通過正常的複製機制複製到其他機器,因此,它們不必在每一幀都經由網路傳送。為了在遠程客戶端上針對這些人物提供更流暢的視覺呈現,該客戶端機器將在每一幀為模擬代理執行一次模擬更新,直到新的數據(由伺服器主控)到來。本地客戶端查看其他遠程人類玩家時也是如此;遠程玩家將其更新發送給伺服器,後者為該玩家執行一次完整的動作更新,然後定期複製數據給所有其他玩家。 這個更新的作用是根據複製的狀態來模擬預期的動作結果,以便在下一次更新前「填補空缺」。所以,客戶端並沒有在新的位置放置由伺服器發送的代理,然後將它們保留到下次更新到來(可能是幾個後續幀),而是通過應用速率和移動規則,在每一幀模擬出一次更新。在另一次更新到來時,客戶端將重置本地模擬並開始新一次模擬。
簡單來說,Simulate角色的在伺服器上的移動就是正常的PerformMovement流程。而在客戶端上,該角色的移動分成兩個步驟來處理——收到伺服器的同步數據時就直接進行設置。在沒有收到伺服器消息的時候根據上一次伺服器傳過來的數據(包括速度與旋轉等)在本地執行Simulate模擬,等著下一個同步數據到來。Simulate角色採用這樣的機制,本質上是為了減小同步帶來的開銷。下面代碼展示了所有Character的同步屬性
void ACharacter::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const { Super::GetLifetimeReplicatedProps( OutLifetimeProps ); DOREPLIFETIME_CONDITION( ACharacter, RepRootMotion,COND_SimulatedOnlyNoReplay); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedBasedMovement, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedServerLastTransformUpdateTimeStamp, COND_SimulatedOnlyNoReplay); DOREPLIFETIME_CONDITION( ACharacter, ReplicatedMovementMode, COND_SimulatedOnly ); DOREPLIFETIME_CONDITION( ACharacter, bIsCrouched, COND_SimulatedOnly ); // Change the condition of the replicated movement property to not replicate in replays since we handle this specifically via saving this out in external replay data DOREPLIFETIME_CHANGE_CONDITION(AActor,ReplicatedMovement,COND_SimulatedOrPhysicsNoReplay); }
ReplicatedMovement記錄了當前Character的位置旋轉,速度等重要的移動數據,這個成員(包括其他屬性)在Simulate或者開啟物理模擬的客戶端才執行(可以先忽略NoReplay,這個和回放功能有關)。同時,我們可以看到Character大部分的同步屬性都是與移動同步有關,而且基本都是SimulatedOnly,這表示這些屬性只在模擬客戶端才會進行同步。除了ReplicatedMovement屬性以外,ReplicatedMovementMode同步了當前的移動模式,ReplicatedBasedMovement同步了角色所站在的Component的相關數據,ReplicatedServerLastTransformUpdateTimeStamp同步了最新的伺服器移動更新幀,也就相當於最後一次伺服器更新移動的時間(在ACharacter::PreReplication里會將伺服器當前的移動數據賦值給ReplicatedServerLastTransformUpdateTimeStamp然後進行同步)。
了解了這些同步的數據後,我們開始分析其移動流程。流程如下圖所示(RootMotion的情況我在上一章節已經描述,這裡不再贅述)。其實其基本思路與普通的移動處理相似,只不過是調用SimulateTick去根據當前的速度等條件模擬客戶端移動,但是有一點非常重要的差異就是Simulate的角色的膠囊體移動與Mesh移動是分開進行的。這麼做的原因是什麼呢?我們稍後再解釋。
客戶端的模擬我們大致了解了流程,那麼接收伺服器數據並修正是在哪裡處理的呢?答案是AActor::OnRep_ReplicatedMovement。客戶端在接收到伺服器同步的ReplicatedMovement時,會產生回調函數觸發SmoothCorrection的執行,從當前客戶端的位置平滑的過度到伺服器同步的位置。
前面提到了膠囊體與Mesh的移動是分開處理的,其目的就是提高代理模擬的流暢度。其實在官方文檔上有簡單的例子,
比如這種情況,一個 replicated 的狀態顯示當前的角色在時間為 t=0 的時刻以速度 (100, 0, 0) 移動,那麼當時間更新到 t=1 的時候,這個模擬的代理將會在 X 方向移動 100 個單位,然後如果這時候服務端的角色在發送了那個 (100, 0, 0) 的 replcated 信息後立刻不動了,那麼這個 replcated 信息則會使到服務端角色的位置和客戶端的模擬位置處於不同的點上。
為了避免這種「突變」情況,UE採用了Mesh網格的平滑操作。膠囊體的移動正常進行,但是其對應的Mesh網格不隨膠囊體移動,而要通過SmoothClientPosition處理,在SmoothNetUpdateTime時間內完成移動,這樣玩家在視覺上就不會覺得代理角色的位置突變。通過FScopedPreventAttachedComponentMove類可以限制某個組件暫時不跟隨父類組件移動。
對於Smooth平滑,UE定義了下面幾種情況,默認我們採用Exponential(指數增長,越遠移動越快):
/** Smoothing approach used by network interpolation for Characters. */ UENUM(BlueprintType) enum class ENetworkSmoothingMode : uint8 { /** No smoothing, only change position as network position updates are received. */ Disabled UMETA(DisplayName="Disabled"), /** Linear interpolation from source to target. */ Linear UMETA(DisplayName="Linear"), /** Exponential. Faster as you are further from target. */ Exponential UMETA(DisplayName="Exponential"), /** Special linear interpolation designed specifically for replays. Not intended as a selectable mode in-editor. */ Replay UMETA(Hidden, DisplayName="Replay"), };
4.4 關於物理託管後的移動
一般情況下我們是通過移動組件來控制角色的移動,不過如果給玩家角色的膠囊體(一般Mesh也是)勾選了SimulatePhysics,那麼角色就會進入物理託管而不受移動組件影響,組件的同步自然也是無效了,常見的應用就是玩家結合布娃娃系統,角色死亡後表現比較自然的摔倒效果。相關代碼如下:
// // UCharacterMovementComponent::TickComponent// We dont update if simulating physics (eg ragdolls).if (bIsSimulatingPhysics){ // Update camera to ensure client gets updates even when physics move him far away from point where simulation started if (CharacterOwner->Role == ROLE_AutonomousProxy && IsNetMode(NM_Client)) { APlayerController* PC = Cast<APlayerController>(CharacterOwner->GetController()); APlayerCameraManager* PlayerCameraManager = (PC ? PC->PlayerCameraManager : NULL); if (PlayerCameraManager != NULL && PlayerCameraManager->bUseClientSideCameraUpdates) { PlayerCameraManager->bShouldSendClientSideCameraUpdate = true; } } return;}
對於開啟物理的Character,Simulate的客戶端也是採取移動數據靠伺服器同步的機制,只不過移動的數據不是伺服器PerformMovement算出來的,而是從根組件的物理對象BodyInstance獲取的,代碼如下,
void AActor::GatherCurrentMovement(){ AttachmentReplication.AttachParent = nullptr; UPrimitiveComponent* RootPrimComp = Cast<UPrimitiveComponent>(GetRootComponent()); if (RootPrimComp && RootPrimComp->IsSimulatingPhysics()) { FRigidBodyState RBState; RootPrimComp->GetRigidBodyState(RBState); ReplicatedMovement.FillFrom(RBState, this); ReplicatedMovement.bRepPhysics = true; }}
五.特殊移動模式的實現思路
這一章節不是詳細的實現教程,只是給大家提供常見遊戲玩法的一些設計思路,如果有時間的話也會考慮做一些實現案例。如果大家有什麼特別的需求,歡迎提出來,可以和大家一起商討合理的解決方案。
5.1 二段跳,多段跳的實現
其實4.14以後的版本裡面已經內置了多段跳的功能,找到Character屬性JumpMaxCount,就可以自由設置了。當然這個實現的效果有點簡陋,只要玩家處於Falling狀態就可以進行下一次跳躍。實際上常見的多段跳都是在上升的階段才可以執行的,那我們可以在代碼里加一個條件判斷當前的速度方向是不是Z軸正方向,還可以對每段跳躍的速度做不同的修改。具體如何修改,前面3.2.1小結已經很詳細的描述了跳躍的處理流程,大家理解了就能比較容易的實現了。
5.2 噴氣式背包的實現
噴氣式背包表現上來說就是玩家可以藉助背包實現一個超高的跳躍,然後可以緩慢的下落,甚至是飛起來,這幾個狀態是受玩家操作影響的。如果玩家不操作背包,那肯定就是自然下落了。
首先我們分析一下,現有的移動狀態里有沒有適合的。比如說Fly,如果玩家進入飛行狀態,那麼角色就不會受到重力的影響,假如我在使用噴氣背包時進入Flying狀態,在不使用的時候切換到Falling狀態,這兩種情況好像可以達到效果。不過,如果玩家處於下落中,然後緩慢下落或者幾乎不下落的時候,玩家應該處於Flying還是Falling?這時候突然切換狀態是不是會很僵硬?
所以,最好整個過程是一個狀態,處理上也會更方便一些。那我們試試Falling如何?前面的講解里描述了Falling的整個過程,其實就是根據重力不斷的去計算Z方向的速度並修改玩家位置(NewFallVelocity函數)。重寫給出一個介面MyNewFallVelocity來覆蓋NewFallVelocity的計算,用一個開關控制是否使用我們的介面。這樣,現在我們只需要根據上層邏輯來計算出一個合理的速度即可。可以根據玩家的輸入操作(類似按鍵時間燃料值單位燃料能量)去計算噴氣背包的推動力,然後將這個推動力與重力相加,再應用到MyNewFallVelocity的計算中,基本上就可以達到效果了。
當然,真正做起來其實還會複雜很多。如果是網路遊戲,你要考慮到移動的同步,在客戶端角色是Simulate的情況下,你需要在SimulateTick裡面也處理NewFallVelocity的計算。再者,可能還要考慮玩家在水裡應該怎麼處理。
5.3 爬牆的實現
爬牆這個玩法在遊戲里可以說是相當常見了。刺客信條,虐殺原形,各類武俠輕功甚至很多2D遊戲裡面也有類似的玩法。
在UE裡面,由於爬牆也是一個脫離重力的表現,而且離開牆面玩家就應該進入下落狀態,所以我們可以考慮藉助Flying來實現。基本思路就是:- 創建一個新的移動模式 爬牆模式
- 在角色執行地面移動(MoveAlongFloor)的時候,一旦遇到前面的障礙,就判斷當前是否能進入爬牆狀態
- 檢測條件可以有,障礙的大小,傾斜度甚至是Actor類型等等。
- 如果滿足條件,角色就進入爬牆狀態,然後根據自己的規則計算加速度與速度,其他邏輯仿照Flying處理
- 修改角色動畫,讓玩家看起來角色是在爬牆(這一部分涉及動畫系統,內容比較多)
這樣基本上可以實現我們想要的效果。不過有一個小問題就是,玩家的膠囊體方向實際還是豎直方向的,因此碰撞與動畫表現可能有一點點差異。如果想表現的更好,也可以對整個角色進行旋轉。
5.4 爬梯子的實現
梯子是豎直方向的,所以玩家只能在Z軸方向產生速度與移動,那麼我們直接使用Walking狀態來模擬是否可以呢?很可惜,如果不加修改的話,Walking裡面默認只有水平方向的移動,只有遇到斜面的時候才會根據斜面角度產生Z軸方向的速度。那我這裡給出一個建議,還是使用Flying。(Flying好像很萬能)
玩家在開始爬一個梯子的時候,首先要把角色的Attach到梯子上面,同時播放響應的動畫來配合。一旦玩家爬上了梯子,就應該進入了特殊的 爬梯子狀態。這個狀態仔細想想,其實和前面的爬牆基本上相似,不同的就是爬梯子的速度,而且玩家可以隨時停止。
隨時停止怎麼做?兩個思路:
- 參考Walking移動的計算,計算速度CalcVelocity的時候使用自定義的摩擦係數Friction以及剎車速度(這兩個值都設置大一些)
- 當玩家輸入結束後,也就是Accceleration=0的時候,直接設置速度為0,不執行CalcVelocity
另外,要想讓爬梯子表現的進一步好一些。看起來是一格一格的爬,就需要特殊的控制。玩家每次按下按鈕的時候,角色必須完整的執行一定位移的移動(一定位移大小就是每個梯子格的長度)。這裡可以考慮使用根骨骼位移RootMotion,畢竟動畫驅動下比較容易控制位移,不過根骨骼位移在網路條件差的情況下表現很糟。
還有一個可以進一步優化的操作,就是使玩家的手一直貼著梯子。這個需要用IK去處理,UE商城裡面有一個案例可以參考一下。推薦閱讀: