游戏程序 平台类型: 程序设计: 编程语言: 引擎/SDK:
文/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的位置。 图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后,会在下面的代码里面处理碰撞障碍的情况。 如果Hit.Normal在Z方向上有值而且还可以行走,那说明这是一个可以移动上去的斜面,随后让玩家沿着斜面移动 判断当前的碰撞体是否可以踩上去,如果可以的话就试着踩上去,如果过程中发现没有踩上去,也会调用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函数。 关于移动组件的同步思路,建议选阅读一下官方文档的内容,回头看可能更为清晰一点。现在我们把整个移动细节作为一个接口封装起来,宏观的研究移动组件的同步细节。 另外,如果还没有完全搞清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 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的执行,从当前客户端的位置平滑的过度到服务器同步的位置。 前面提到了胶囊体与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 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来实现。基本思路就是: 创建一个新的移动模式 爬墙模式 在角色执行地面移动(MoveAlongFloor)的时候,一旦遇到前面的障碍,就判断当前是否能进入爬墙状态 检测条件可以有,障碍的大小,倾斜度甚至是Actor类型等等。 如果满足条件,角色就进入爬墙状态,然后根据自己的规则计算加速度与速度,其他逻辑仿照Flying处理 修改角色动画,让玩家看起来角色是在爬墙(这一部分涉及动画系统,内容比较多) 这样基本上可以实现我们想要的效果。不过有一个小问题就是,玩家的胶囊体方向实际还是竖直方向的,因此碰撞与动画表现可能有一点点差异。如果想表现的更好,也可以对整个角色进行旋转。 5.4 爬梯子的实现 梯子是竖直方向的,所以玩家只能在Z轴方向产生速度与移动,那么我们直接使用Walking状态来模拟是否可以呢?很可惜,如果不加修改的话,Walking里面默认只有水平方向的移动,只有遇到斜面的时候才会根据斜面角度产生Z轴方向的速度。那我这里给出一个建议,还是使用Flying。(Flying好像很万能) 玩家在开始爬一个梯子的时候,首先要把角色的Attach到梯子上面,同时播放响应的动画来配合。一旦玩家爬上了梯子,就应该进入了特殊的 爬梯子状态。这个状态仔细想想,其实和前面的爬墙基本上相似,不同的就是爬梯子的速度,而且玩家可以随时停止。 随时停止怎么做?两个思路: 1、参考Walking移动的计算,计算速度CalcVelocity的时候使用自定义的摩擦系数Friction以及刹车速度(这两个值都设置大一些) 2、当玩家输入结束后,也就是Accceleration=0的时候,直接设置速度为0,不执行CalcVelocity 另外,要想让爬梯子表现的进一步好一些。看起来是一格一格的爬,就需要特殊的控制。玩家每次按下按钮的时候,角色必须完整的执行一定位移的移动(一定位移大小就是每个梯子格的长度)。这里可以考虑使用根骨骼位移RootMotion,毕竟动画驱动下比较容易控制位移,不过根骨骼位移在网络条件差的情况下表现很糟。 还有一个可以进一步优化的操作,就是使玩家的手一直贴着梯子。这个需要用IK去处理,UE商城里面有一个案例可以参考一下。
关注我们官方微信公众号 下载我们官方APP-游戏行 关注手游动态微信公众号