《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的位置。
圖2-1一個預設Character的元件構成
當然,我們也並不是一定要設定膠囊體為UpdateComponent,對於DefaultPawn(觀察者)會把他的SphereComponent作為UpdateComponent,對於交通工具物件AWheeledVehicle會預設把他的Mesh網格元件作為UpdateComponent。你可以自己定義你的UpdateComponent,但是你的自定義元件必須要繼承USceneComponent(換句話說就是元件得有世界座標資訊),這樣他才能正常的實現其移動的邏輯。
2.2移動元件繼承樹
移動元件類並不是只有一個,他通過一個繼承樹,逐漸擴充套件了移動元件的能力。從最簡單的提供移動功能,到可以正確模擬不同移動狀態的移動效果。如圖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)
圖2-3移動框架相關類圖(點選圖片檢視高清大圖)
在一個普通的三維空間裡,最簡單的移動就是直接修改角色的座標。所以,我們的角色只要有一個包含座標資訊的元件,就可以通過基本的移動元件完成移動。但是隨著遊戲世界的複雜程度加深,我們在遊戲裡面新增了可行走的地面,可以探索的海洋。我們發現移動就變得複雜起來,玩家的腳下有地面才能行走,那就需要不停的檢測地面碰撞資訊(FFindFloorResult,FBasedMovementInfo);玩家想進入水中游泳,那就需要檢測到水的體積(GetPhysicsVolume(),Overlap事件,同樣需要物理);水中的速度與效果與陸地上差別很大,那就把兩個狀態分開寫(PhysSwimming,PhysWalking);移動的時候動畫動作得匹配上啊,那就在更新位置的時候,更新動畫(TickCharacterPose);移動的時候碰到障礙物怎麼辦,被其他玩家推怎麼處理(MoveAlongFloor裡有相關處理);遊戲內容太少,想增加一些可以自己尋路的NPC,又需要設定導航網格(涉及到FNavAgentProperties);一個玩家太無聊,那就讓大家一起聯機玩(模擬移動同步FRepMovement,客戶端移動修正ClientUpdatePositionAfterServerUpdate)。
這麼一看,做一個優秀移動元件還真不簡單。但是不管怎麼樣,UE基本上都幫你實現了。通過上面的描述,你現在也大體上了解了移動元件在各個方面的處理,不過遇到具體的問題也許還是無從下手,所以我們們繼續往下分析。
三.各個移動狀態的細節處理
這一節我們把焦點集中在UCharacterMovementComponent元件上,來詳細的分析一下他是如何處理各種移動狀態下的玩家角色的。首先肯定是從Tick開始,每幀都要進行狀態的檢測與處理,狀態通過一個移動模式MovementMode來區分,在合適的時候修改為正確的移動模式。移動模式預設有6種,基本常用的模式有行走、游泳、下落、飛行四種,有一種給AI代理提供的行走模式,最後還有一個自定義移動模式。
圖3-1單機模式下的移動處理流程(點選圖片檢視高清大圖)
3.1 Walking
行走模式可以說是所有移動模式的基礎,也是各個移動模式裡面最為複雜的一個。為了模擬出出真實世界的移動效果,玩家的腳下必須要有一個可以支撐不會掉落的物理物件,就好像地面一樣。在移動元件裡面,這個地面通過成員變數FFindFloorResult CurrentFloor來記錄。在遊戲一開始的時候,移動元件就會根據配置設定預設的MovementMode,如果是Walking,就會通過FindFloor操作來找到當前的地面,CurrentFloor的初始化堆疊如下圖3-2(Character Restart()的會覆蓋Pawn的Restart()):
圖3-2
下面先分析一下FindFloor的流程,FindFloor本質上就是通過膠囊體的Sweep檢測來找到腳下的地面,所以地面必須要有物理資料,而且通道型別要設定與玩家的Pawn有Block響應。這裡還有一些小的細節,比如我們在尋找地面的時候,只考慮腳下位置附近的,而忽略掉腰部附近的物體;Sweep用的是膠囊體而不是射線檢測,方便處理斜面移動,計算可站立半徑等(參考圖3-3,HitResult裡面的Normal與ImpactNormal在膠囊體Sweep檢測時不一定相同)。另外,目前Character的移動是基於膠囊體實現的,所以一個不帶膠囊體元件的Actor是無法正常使用UCharacterMovementComponent的。
圖3-3(點選圖片檢視高清大圖)
找到地面玩家就可以站立住麼?不一定。這又涉及到一個新的概念PerchRadiusThreshold,我稱他為可棲息範圍半徑,也就是可站立半徑。預設這個值為0,移動元件會忽略這個可站立半徑的相關計算,一旦這個值大於0.15,就會做進一步的判斷看看當前的地面空間是否足夠讓玩家站立在上面。
前面的準備工作完成了,現在正式進入Walking的位移計算,這一段程式碼都是在PhysWalking裡面計算的。為了表現的更為平滑流暢,UE4把一個Tick的移動分成了N段處理(每段的時間不能超過MaxSimulationTimeStep)。在處理每段時,首先把當前的位置資訊,地面資訊記錄下來。在TickComponent的時候根據玩家的按鍵時長,計算出當前的加速度。隨後在CalcVelocity()根據加速度計算速度,同時還會考慮地面摩擦,是否在水中等情況。
- // apply input to acceleration
- Acceleration = 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後,會在下面的程式碼裡面處理碰撞障礙的情況。
1.如果Hit.Normal在Z方向上有值而且還可以行走,那說明這是一個可以移動上去的斜面,隨後讓玩家沿著斜面移動
2.判斷當前的碰撞體是否可以踩上去,如果可以的話就試著踩上去,如果過程中發現沒有踩上去,也會呼叫SlideAlongSurface()沿著碰撞滑動。
- // UCharacterMovementComponent::PhysWalking
- else 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
- {
- // Don't 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,
圖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-5夾在縫隙導致不停的切換狀態
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-6(點選圖片檢視高清大圖)
3.3 Swiming
各個狀態的差異本質有三個點:
1.速度的不同
2.受重力影響的程度
3.慣性大小
游泳狀態表現上來看是一個有移動慣性(鬆手後不會立刻停止),受重力影響小(在水中會慢慢下落或者不動),移動速度比平時慢(表現水有阻力)的狀態。而玩家是否在水中的預設檢測邏輯也比較簡單,就是判斷當前的updateComponent所在的Volume是否是WaterVolume。(在編輯器裡面拉一個PhysicsVolume,修改屬性WaterVolume即可)
CharacterMovement元件裡面有浮力大小配置Buoyancy,根據玩家潛入水中的程度(ImmersionDepth返回0-1)可計算最終的浮力。隨後,開始要計算速度了,這時候我們需要獲取Volume裡面的摩擦力Friction,然後傳入CalcVelocity裡面,這體現出玩家在水中移動變慢的效果。隨後在Z方向通過計算浮力大小該計算該方向的速度,隨著玩家潛水的程度,你會發現玩家在Z方向的速度越來越小,一旦全身都浸入了水中,在Z軸方向的重力速度就會被完全忽略。
- // UCharacterMovementComponent::PhysSwimming
- const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction * Depth;
- CalcVelocity(deltaTime, Friction, true, BrakingDecelerationSwimming);
- Velocity.Z += GetGravityZ() * deltaTime * (1.f - NetBuoyancy);
- // UCharacterMovementComponent::CalcVelocity Apply fluid friction
- if (bFluid)
- {
- Velocity = Velocity * (1.f - FMath::Min(Friction * DeltaTime, 1.f));
- }
圖3-7角色在水體積中飄浮
速度計算後,玩家就可以移動了。這裡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);
- //Don't 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 Relative
- RestorePreAdditiveRootMotionVelocity();
- if( !HasAnimRootMotion() && !CurrentRootMotion.HasOverrideVelocity() )
- {
- if( bCheatFlying && Acceleration.IsZero() )
- {
- Velocity = FVector::ZeroVector;
- }
- const float Friction = 0.5f * GetPhysicsVolume()->FluidFriction;
- CalcVelocity(deltaTime, Friction, true, BrakingDecelerationFlying);
- }
- //RootMotion Relative
- ApplyRootMotionToVelocity(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,他的本地沒有控制的角色,對移動的處理就有差異了。分為兩種情況:
1.該角色在客戶端是模擬(Simulate)角色,移動完全由伺服器同步過去,如各類AI角色。這類移動一般是伺服器上行為樹主動觸發的
2.該角色在客戶端是擁有自治(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 won't 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-1(點選圖片檢視高清大圖)
整個流程如下圖所示:
圖4-2 Autonomous角色移動流程圖(點選圖片檢視高清大圖)
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 isn't 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移動預測及儲存的資料結構示意圖(點選圖片檢視高清大圖)
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:reReplication裡會將伺服器當前的移動資料賦值給ReplicatedServerLastTransformUpdateTimeStamp然後進行同步)。
瞭解了這些同步的資料後,我們開始分析其移動流程。流程如下圖所示(RootMotion的情況我在上一章節已經描述,這裡不再贅述)。其實其基本思路與普通的移動處理相似,只不過是呼叫SimulateTick去根據當前的速度等條件模擬客戶端移動,但是有一點非常重要的差異就是Simulate的角色的膠囊體移動與Mesh移動是分開進行的。這麼做的原因是什麼呢?我們稍後再解釋。
圖4-4 Simulate角色移動流程圖(點選圖片檢視高清大圖)
客戶端的模擬我們大致瞭解了流程,那麼接收伺服器資料並修正是在哪裡處理的呢?答案是AActor::OnRep_ReplicatedMovement。客戶端在接收到伺服器同步的ReplicatedMovement時,會產生回撥函式觸發SmoothCorrection的執行,從當前客戶端的位置平滑的過度到伺服器同步的位置,同時會通過APawn::PostNetReceiveVelocity修改當前的移動速度,隨後的客戶端在Simulate時就可以用這個速度進行模擬。(參考ACharacter的PostNetReceiveLocationAndRotation)
前面提到了膠囊體與Mesh的移動是分開處理的,其目的就是提高代理模擬的流暢度。其實在官方文件上有簡單的例子,
比如這種情況,一個replicated的狀態顯示當前的角色在時間為t=0的時刻以速度(100,0,0)移動,那麼當時間更新到t=1的時候,這個模擬的代理將會在X方向移動100個單位,然後如果這時候服務端的角色在傳送了那個(100,0,0)的replcated資訊後立刻不動了,那麼這個replcated資訊則會使到服務端角色的位置和客戶端的模擬位置處於不同的點上。
為了避免這種“突變”情況,UE採用了Mesh網格的平滑操作。膠囊體的移動正常進行,但是其對應的Mesh網格不隨膠囊體移動,而要通過SmoothClientPosition處理,在SmoothNetUpdateTime時間內完成移動,這樣玩家在視覺上就不會覺得代理角色的位置突變。通過FScopedPreventAttachedComponentMove類可以限制某個元件暫時不跟隨父類元件移動。(可參考:UCharacterMovementComponent的SmoothClientPosition_UpdateVisuals)
對於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 don't 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來實現。基本思路就是:
1.建立一個新的移動模式爬牆模式
2.在角色執行地面移動(MoveAlongFloor)的時候,一旦遇到前面的障礙,就判斷當前是否能進入爬牆狀態
3.檢測條件可以有,障礙的大小,傾斜度甚至是Actor型別等等。
4.如果滿足條件,角色就進入爬牆狀態,然後根據自己的規則計算加速度與速度,其他邏輯仿照Flying處理
5.修改角色動畫,讓玩家看起來角色是在爬牆(這一部分涉及動畫系統,內容比較多)
這樣基本上可以實現我們想要的效果。不過有一個小問題就是,玩家的膠囊體方向實際還是豎直方向的,因此碰撞與動畫表現可能有一點點差異。如果想表現的更好,也可以對整個角色進行旋轉。
5.4爬梯子的實現
梯子是豎直方向的,所以玩家只能在Z軸方向產生速度與移動,那麼我們直接使用Walking狀態來模擬是否可以呢?很可惜,如果不加修改的話,Walking裡面預設只有水平方向的移動,只有遇到斜面的時候才會根據斜面角度產生Z軸方向的速度。那我這裡給出一個建議,還是使用Flying。(Flying好像很萬能)
玩家在開始爬一個梯子的時候,首先要把角色的Attach到梯子上面,同時播放響應的動畫來配合。一旦玩家爬上了梯子,就應該進入了特殊的爬梯子狀態。這個狀態仔細想想,其實和前面的爬牆基本上相似,不同的就是爬梯子的速度,而且玩家可以隨時停止。
隨時停止怎麼做?兩個思路:
1.參考Walking移動的計算,計算速度CalcVelocity的時候使用自定義的摩擦係數Friction以及剎車速度(這兩個值都設定大一些)
2.當玩家輸入結束後,也就是Accceleration=0的時候,直接設定速度為0,不執行CalcVelocity
另外,要想讓爬梯子表現的進一步好一些。看起來是一格一格的爬,就需要特殊的控制。玩家每次按下按鈕的時候,角色必須完整的執行一定位移的移動(一定位移大小就是每個梯子格的長度)。這裡可以考慮使用根骨骼位移RootMotion,畢竟動畫驅動下比較容易控制位移,不過根骨骼位移在網路條件差的情況下表現很糟。
還有一個可以進一步優化的操作,就是使玩家的手一直貼著梯子。這個需要用IK去處理,UE商城裡面有一個案例可以參考一下。
作者:Jerish
專欄地址:https://zhuanlan.zhihu.com/p/34257208
相關文章
- 《Exploring in UE4》遊戲角色的移動原理(上)遊戲
- 《Exploring in UE4》遊戲角色的移動原理(下)遊戲
- 《Exploring in UE4》網路同步原理深入(下):原理分析
- UE4 C++(11):移動元件和碰撞C++元件
- Android官方架構元件Lifecycle:生命週期元件詳解&原理分析Android架構元件
- TreeViewTemplate移動元件詳細介紹View元件
- JDK動態代理實現原理詳解(原始碼分析)JDK原始碼
- 主成分分析(PCA)原理詳解PCA
- SAP 移動型別詳解型別
- 如何在 UE4 移動端中實現 HZB?
- CAS原理分析及ABA問題詳解
- 主流移動端元件庫的對比和分析元件
- 移動端鍵盤遮擋頁面原理分析
- vue內建元件——transition簡單原理圖文詳解Vue元件
- Swipe 移動端滑動外掛使用詳解
- 移動端事件touchstart、touchmove、touchend詳解事件
- 移動端開發viewport用法詳解View
- 移動前端第一彈:viewport詳解前端View
- 詳解Facebook的移動廣告投放技巧
- iSlider移動端原生滑動元件原始碼解讀IDE元件原始碼
- Vue 動態元件 & 非同步元件原理Vue元件非同步
- Java緩衝流概述詳解(原理畫圖分析)Java
- 移動端事件穿透的原理與解決方案事件穿透
- GoPlay 原理詳解Go
- GCD 原理詳解GC
- 移動端動態更新的原理和模式,你瞭解多少?模式
- 【移動端相容問題研究】javascript事件機制詳解(涉及移動相容)JavaScript事件
- 動態移動控制元件 (轉)控制元件
- 移動端使用rem原理REM
- Unity 之 UGUI Scroll Rect滾動矩形元件詳解UnityUGUI元件
- 移動IM開發指南2:心跳指令詳解
- 基於Apache元件,分析物件池原理Apache元件物件
- Kafka核心元件詳解Kafka元件
- ReactNative元件詳解React元件
- UE4點選原始碼分析原始碼
- Webpack Tapable原理詳解Web
- Java CAS 原理詳解Java
- CTMediator 原理詳解(一)