115446fn4y1ry88nr5nu28.jpg
文/Jerish 专栏:https://zhuanlan.zhihu.com/c_164452593

前言

这篇文章对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的位置。

115448ufjd7tdeaz71eltl.jpg 图2-1 一个默认Character的组件构成
当然,我们也并不是一定要设置胶囊体为UpdateComponent,对于DefaultPawn(观察者)会把他的SphereComponent作为UpdateComponent,对于交通工具对象AWheeledVehicle会默认把他的Mesh网格组件作为UpdateComponent。你可以自己定义你的UpdateComponent,但是你的自定义组件必须要继承USceneComponent(换句话说就是组件得有世界坐标信息),这样他才能正常的实现其移动的逻辑。

2.2 移动组件继承树

移动组件类并不是只有一个,他通过一个继承树,逐渐扩展了移动组件的能力。从最简单的提供移动功能,到可以正确模拟不同移动状态的移动效果。如图2-2所示

115448ftmajjzwckwfwq1j.jpg 图2-2 移动组件继承关系类图
移动组件类一共四个。首先是UMovementComponent,作为移动组件的基类实现了基本的移动接口SafeMovementUpdatedComponent(),可以调用UpdateComponent组件的接口函数来更新其位置。

 

  1. bool UMovementComponent::MoveUpdatedComponentImpl( const FVector Delta, const FQuat NewRotation, bool bSweep, FHitResult* OutHit, ETeleportType Teleport)
  2. {
  3. if (UpdatedComponent)
  4. {
  5. const FVector NewDelta = ConstrainDirectionToPlane(Delta);
  6. return UpdatedComponent->MoveComponent(NewDelta, NewRotation, bSweep, OutHit, MoveComponentFlags, Teleport);
  7. }
  8.  
  9. return false;
  10. }
图2-3 移动框架相关类图
在一个普通的三维空间里,最简单的移动就是直接修改角色的坐标。所以,我们的角色只要有一个包含坐标信息的组件,就可以通过基本的移动组件完成移动。但是随着游戏世界的复杂程度加深,我们在游戏里面添加了可行走的地面,可以探索的海洋。我们发现移动就变得复杂起来,玩家的脚下有地面才能行走,那就需要不停的检测地面碰撞信息(FFindFloorResult,FBasedMovementInfo);玩家想进入水中游泳,那就需要检测到水的体积(GetPhysicsVolume(),Overlap事件,同样需要物理);水中的速度与效果与陆地上差别很大,那就把两个状态分开写(PhysSwimming,PhysWalking);移动的时候动画动作得匹配上啊,那就在更新位置的时候,更新动画(TickCharacterPose);移动的时候碰到障碍物怎么办,被其他玩家推怎么处理(MoveAlongFloor里有相关处理);游戏内容太少,想增加一些可以自己寻路的NPC,又需要设置导航网格(涉及到FNavAgentProperties);一个玩家太无聊,那就让大家一起联机玩(模拟移动同步FRepMovement,客户端移动修正ClientUpdatePositionAfterServerUpdate)。

这么一看,做一个优秀移动组件还真不简单。但是不管怎么样,UE基本上都帮你实现了。通过上面的描述,你现在也大体上了解了移动组件在各个方面的处理,不过遇到具体的问题也许还是无从下手,所以咱们继续往下分析。

三.各个移动状态的细节处理

这一节我们把焦点集中在UCharacterMovementComponent组件上,来详细的分析一下他是如何处理各种移动状态下的玩家角色的。首先肯定是从Tick开始,每帧都要进行状态的检测与处理,状态通过一个移动模式MovementMode来区分,在合适的时候修改为正确的移动模式。移动模式默认有6种,基本常用的模式有行走、游泳、下落、飞行四种,有一种给AI代理提供的行走模式,最后还有一个自定义移动模式。

115450qhwc4mha3zwlma3a.jpg 图3-1 单机模式下的移动处理流程
3.1 Walking

行走模式可以说是所有移动模式的基础,也是各个移动模式里面最为复杂的一个。为了模拟出出真实世界的移动效果,玩家的脚下必须要有一个可以支撑不会掉落的物理对象,就好像地面一样。在移动组件里面,这个地面通过成员变量FFindFloorResult CurrentFloor来记录。在游戏一开始的时候,移动组件就会根据配置设置默认的MovementMode,如果是Walking,就会通过FindFloor操作来找到当前的地面,CurrentFloor的初始化堆栈如下图3-2(Character Restart()的会覆盖Pawn的Restart()):

115451uo82araag1r13waa.jpg 图3-2
下面先分析一下FindFloor的流程,FindFloor本质上就是通过胶囊体的Sweep检测来找到脚下的地面,所以地面必须要有物理数据,而且通道类型要设置与玩家的Pawn有Block响应。这里还有一些小的细节,比如我们在寻找地面的时候,只考虑脚下位置附近的,而忽略掉腰部附近的物体;Sweep用的是胶囊体而不是射线检测,方便处理斜面移动,计算可站立半径等(参考图3-3,HitResult里面的Normal与ImpactNormal在胶囊体Sweep检测时不一定相同)。另外,目前Character的移动是基于胶囊体实现的,所以一个不带胶囊体组件的Actor是无法正常使用UCharacterMovementComponent的。

115451d0xbahab2mmrxbvx.jpg 图3-3
找到地面玩家就可以站立住么?不一定。这又涉及到一个新的概念PerchRadiusThreshold,我称他为可栖息范围半径,也就是可站立半径。默认这个值为0,移动组件会忽略这个可站立半径的相关计算,一旦这个值大于0.15,就会做进一步的判断看看当前的地面空间是否足够让玩家站立在上面。

前面的准备工作完成了,现在正式进入Walking的位移计算,这一段代码都是在PhysWalking里面计算的。为了表现的更为平滑流畅,UE4把一个Tick的移动分成了N段处理(每段的时间不能超过MaxSimulationTimeStep)。在处理每段时,首先把当前的位置信息,地面信息记录下来。在TickComponent的时候根据玩家的按键时长,计算出当前的加速度。随后在CalcVelocity()根据加速度计算速度,同时还会考虑地面摩擦,是否在水中等情况。

 

  1. // apply input to acceleration
    • Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
图3-4
通过获取到的Gravity计算出当前新的FallSpeed(NewFallVelocity里面计算,计算规则很简单,就是单纯的用当前速度-Gravity*deltaTime)。随后再根据当前以及上一帧的速度计算出位移并进行移动,公式如下

 

  1. FVector Adjusted = 0.5f*(OldVelocity + Velocity) * timeTick;
    • SafeMoveUpdatedComponent( Adjusted, PawnRotation, true, Hit);
图3-5 夹在缝隙导致不停的切换状态
3.2.1 Jump

提到Falling,不得不提跳跃这一基本操作。下面大致描述了跳跃响应的基本流程,

1. 绑定触发响应事件

 

  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();
                                  • }
图3-6
3.3 Swiming

各个状态的差异本质有三个点:

1.速度的不同

2.受重力影响的程度

3.惯性大小

游泳状态表现上来看是一个有移动惯性(松手后不会立刻停止),受重力影响小(在水中会慢慢下落或者不动),移动速度比平时慢(表现水有阻力)的状态。而玩家是否在水中的默认检测逻辑也比较简单,就是判断当前的updateComponent所在的Volume是否是WaterVolume。(在编辑器里面拉一个PhysicsVolume,修改属性WaterVolume即可)

CharacterMovement组件里面有浮力大小配置Buoyancy,根据玩家潜入水中的程度(ImmersionDepth返回0-1)可计算最终的浮力。随后,开始要计算速度了,这时候我们需要获取Volume里面的摩擦力Friction,然后传入CalcVelocity里面,这体现出玩家在水中移动变慢的效果。随后在Z方向通过计算浮力大小该计算该方向的速度,随着玩家潜水的程度,你会发现玩家在Z方向的速度越来越小,一旦全身都浸入了水中,在Z轴方向的重力速度就会被完全忽略。

 

 

  1. // 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等情况下就好像有移动惯性一样。

 

  1. // 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;
                      • }
                        • }
图4-1
整个流程如下图所示:

115455y0otejzp2ey7ay5s.jpg 图4-2 Autonomous角色移动流程图
4.2.1 SavedMoves与移动合并

仔细阅读源码的朋友对上面给出的流程可能并不是很满意,因为除了ServerMove你可能还看到了ServerMoveDual以及ServerMoveOld等函数接口。而且除了SavedMoves列表,还有PendingMove,FreeMove这些移动列表。他们都是做什么的?

简单来讲,这属于移动带宽优化的一个方式,将没有意义的移动合并,减少消息的发送量。

当客户端执行完本次移动后,都会把当前的移动数据以一个结构体保存到SavedMove列表,然后会判断当前的这个移动是否可以被延迟发送(CanDelaySendingMove(),默认为true),如果可以就会继续判断当前的客户端网络速度如何。如果当前的速度有一点慢或者上次更新的时间很短,移动组件就会将当前的移动赋值给PendingMove(表示将要执行的移动)并取消本次给服务器消息的发送。

 

  1. 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;
                                                      • }
                                                        • }
图4-3 移动预测及保存的数据结构示意图
4.3 Simulate角色

首先看一下官方文档对Simulate角色移动的描述:

对于那些不由人类控制的人物,其动作往往会通过正常的 PerformMovement() 代码在服务器(此时充当了主控者)上进行更新。Actor 的状态,如方位、旋转、速率和其他一些选定的人物特有状态(如跳跃)都会通过正常的复制机制复制到其他机器,因此,它们不必在每一帧都经由网络传送。为了在远程客户端上针对这些人物提供更流畅的视觉呈现,该客户端机器将在每一帧为模拟代理执行一次模拟更新,直到新的数据(由服务器主控)到来。本地客户端查看其他远程人类玩家时也是如此;远程玩家将其更新发送给服务器,后者为该玩家执行一次完整的动作更新,然后定期复制数据给所有其他玩家。 这个更新的作用是根据复制的状态来模拟预期的动作结果,以便在下一次更新前“填补空缺”。所以,客户端并没有在新的位置放置由服务器发送的代理,然后将它们保留到下次更新到来(可能是几个后续帧),而是通过应用速率和移动规则,在每一帧模拟出一次更新。在另一次更新到来时,客户端将重置本地模拟并开始新一次模拟。

简单来说,Simulate角色的在服务器上的移动就是正常的PerformMovement流程。而在客户端上,该角色的移动分成两个步骤来处理——收到服务器的同步数据时就直接进行设置。在没有收到服务器消息的时候根据上一次服务器传过来的数据(包括速度与旋转等)在本地执行Simulate模拟,等着下一个同步数据到来。Simulate角色采用这样的机制,本质上是为了减小同步带来的开销。下面代码展示了所有Character的同步属性

 

  1. 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);
                      •  
                      •  
                      • }
图4-4 Simulate角色移动流程图
客户端的模拟我们大致了解了流程,那么接收服务器数据并修正是在哪里处理的呢?答案是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(指数增长,越远移动越快):

 

  1. /** 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,那么角色就会进入物理托管而不受移动组件影响,组件的同步自然也是无效了,常见的应用就是玩家结合布娃娃系统,角色死亡后表现比较自然的摔倒效果。相关代码如下:

 

  1. // // 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获取的,代码如下,

 

  1. 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到梯子上面,同时播放响应的动画来配合。一旦玩家爬上了梯子,就应该进入了特殊的 爬梯子状态。这个状态仔细想想,其实和前面的爬墙基本上相似,不同的就是爬梯子的速度,而且玩家可以随时停止。

随时停止怎么做?两个思路:

1、参考Walking移动的计算,计算速度CalcVelocity的时候使用自定义的摩擦系数Friction以及刹车速度(这两个值都设置大一些)

2、当玩家输入结束后,也就是Accceleration=0的时候,直接设置速度为0,不执行CalcVelocity

另外,要想让爬梯子表现的进一步好一些。看起来是一格一格的爬,就需要特殊的控制。玩家每次按下按钮的时候,角色必须完整的执行一定位移的移动(一定位移大小就是每个梯子格的长度)。这里可以考虑使用根骨骼位移RootMotion,毕竟动画驱动下比较容易控制位移,不过根骨骼位移在网络条件差的情况下表现很糟。

还有一个可以进一步优化的操作,就是使玩家的手一直贴着梯子。这个需要用IK去处理,UE商城里面有一个案例可以参考一下。

锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛