《Exploring in UE4》游戏角色的移动原理(下)

作者:Jerish 游戏开发那些事 2019-09-12 2k
上一篇主要从单机角色的移动原理进行分析。今天这篇文章会详细的分析多个玩家的移动同步是如何处理的。文章的内容可能比较深,需要读者有一定游戏开发经验,而且结合引擎源码才能更好的理解,建议收藏找时间慢慢阅读。

知乎原文链接:https://zhuanlan.zhihu.com/p/34257208

下篇

四.移动同步解决方案

前面关于移动逻辑的细节处理都是在PerformMovement里面实现的,我们可以把函数PerformMovement当成一个完整的移动处理流程。这个流程无论是在客户端还是在服务器都必须要执行,或者作为一个单机游戏,这一个接口基本上可以满足我们的正常移动了。不过,在网络游戏中,为了让所有的玩家体验一个几乎相同的世界,需要保证一个具有绝对权威的服务器,这个服务器可以修正客户端的不正常移动行为,保证各个客户端的一致性。相关同步的操作都是基于UCharacterMovement组件实现的,所以我们的角色必须要使用这个移动组件。


移动组件的同步全都是基于RPC不可靠传输的,你会在UCharacterMovement头文件里面看到多个以Server或者Client开头的RPC函数。

关于移动组件的同步思路,建议选阅读一下官方文档的内容,回头看可能更为清晰一点。现在我们把整个移动细节作为一个接口封装起来,宏观的研究移动组件的同步细节。

另外,如果还没有完全搞清Authority,AutonomousProxy以及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发给服务器进行模拟的。


从下面的代码可以了解到这两种情况的处理(注意注释):

  1. // UCharacterMovementComponent:: TickComponent
  2. // simulate的角色在服务器执行IsLocallyControlled也会返回true
  3. // Allow root motion to move characters that have no controller.
  4. if( CharacterOwner->IsLocallyControlled() || (!CharacterOwner->Controller && bRunPhysicsWithNoController) || (!CharacterOwner->Controller && CharacterOwner->IsPlayingRootMotion()) )
  5. {
  6.    {
  7.        SCOPE_CYCLE_COUNTER(STAT_CharUpdateAcceleration);

  8.        // We need to check the jump state before adjusting input acceleration, to minimize latency
  9.        // and to make sure acceleration respects our potentially new falling state.
  10.        CharacterOwner->CheckJumpInput(DeltaTime);

  11.        // apply input to acceleration
  12.        Acceleration = ScaleInputAcceleration(ConstrainInputAcceleration(InputVector));
  13.        AnalogInputModifier = ComputeAnalogInputModifier();
  14.    }

  15.    if (CharacterOwner->Role == ROLE_Authority)
  16.    {
  17.        // 单机或者DedicateServer控制simulate角色移动
  18.        PerformMovement(DeltaTime);
  19.    }
  20.    else if (bIsClient)
  21.    {
  22.        ReplicateMoveToServer(DeltaTime, Acceleration);
  23.    }
  24. }
  25. else if (CharacterOwner->GetRemoteRole() == ROLE_AutonomousProxy)
  26. {
  27.    //DedicateServer控制自治客户端角色移动
  28.    // Server ticking for remote client.
  29.    // Between net updates from the client we need to update position if based on another object,
  30.    // otherwise the object will move on intermediate frames and we won't follow it.
  31.    MaybeUpdateBasedMovement(DeltaTime);
  32.    MaybeSaveBaseLocation();

  33.    // Smooth on listen server for local view of remote clients. We may receive updates at a rate different than our own tick rate.
  34.    if (CharacterMovementCVars::NetEnableListenServerSmoothing && !bNetworkSmoothingComplete && IsNetMode(NM_ListenServer))
  35.    {
  36.        SmoothClientPosition(DeltaTime);
  37.    }
  38. }
复制代码

这两种情况详细的流程我们在下面两个小结分析。

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(表示将要执行的移动)并取消本次给服务器消息的发送。

  1. const bool bCanDelayMove = (CharacterMovementCVars::NetEnableMoveCombining != 0) && CanDelaySendingMove(NewMove);

  2. if (bCanDelayMove && ClientData->PendingMove.IsValid() == false)
  3. {
  4.    // Decide whether to hold off on move
  5.    // send moves more frequently in small games where server isn't likely to be saturated
  6.    float NetMoveDelta;
  7.    UPlayer* Player = (PC ? PC->Player : nullptr);
  8.    AGameStateBase const* const GameState = GetWorld()->GetGameState();

  9.    if (Player && (Player->CurrentNetSpeed > 10000) && (GameState != nullptr) && (GameState->PlayerArray.Num() <= 10))
  10.    {
  11.        NetMoveDelta = 0.011f;
  12.    }
  13.    else if (Player && CharacterOwner->GetWorldSettings()->GameNetworkManagerClass)
  14.    {
  15.        //这里会根据网络管理的配置以及客户端网络速度来决定是否延迟发送
  16.        NetMoveDelta = FMath::Max(0.0222f,2 * GetDefault<AGameNetworkManager>(CharacterOwner->GetWorldSettings()->GameNetworkManagerClass)->MoveRepSize/Player->CurrentNetSpeed);
  17.    }
  18.    else
  19.    {
  20.        NetMoveDelta = 0.011f;
  21.    }

  22.    if ((GetWorld()->TimeSeconds - ClientData->ClientUpdateTime) * CharacterOwner->GetWorldSettings()->GetEffectiveTimeDilation() < NetMoveDelta)
  23.    {
  24.        // Delay sending this move.
  25.        ClientData->PendingMove = NewMove;
  26.        return;
  27.    }
  28. }
复制代码


当客户端进去下次Tick的时候,就会判断当前的新的移动是否能与上次保存的PendingMove合并。如果可以,就可以减少一次消息的发送。如果不能合并,那么在本次移动结束后给服务器发送一个两次移动(ServerMoveDual),就是单纯的执行两次ServerMove。

服务器在受到两次移动的时候对第一次移动不进行任何校验,只对第二个移动进行正常的校验,判断是否是第一次的标准就是ClientPosition是不是FVector(1.f,2.f,3.f)。通过下面的代码就可以了解了

  1. void UCharacterMovementComponent::ServerMoveDual_Implementation(
  2.    float TimeStamp0,
  3.    FVector_NetQuantize10 InAccel0,
  4.    uint8 PendingFlags,
  5.    uint32 View0,
  6.    float TimeStamp,
  7.    FVector_NetQuantize10 InAccel,
  8.    FVector_NetQuantize100 ClientLoc,
  9.    uint8 NewFlags,
  10.    uint8 ClientRoll,
  11.    uint32 View,
  12.    UPrimitiveComponent* ClientMovementBase,
  13.    FName ClientBaseBone,
  14.    uint8 ClientMovementMode)
  15. {
  16.    ServerMove_Implementation(TimeStamp0, InAccel0, FVector(1.f,2.f,3.f), PendingFlags, ClientRoll, View0, ClientMovementBase, ClientBaseBone, ClientMovementMode);
  17.    ServerMove_Implementation(TimeStamp, InAccel, ClientLoc, NewFlags, ClientRoll, View, ClientMovementBase, ClientBaseBone, ClientMovementMode);
  18. }
复制代码


其实,UE的思想就是,将所有的移动的关键信息都数据化,这样移动就可以自由的存储和回放。为了节省带宽,提高效率,我们也就可以想出各种办法来减少发送不必要的消息,对于一个没有移动过的玩家,理论上我们甚至都可以不去同步他的移动信息。

图4-3移动预测及保存的数据结构示意图

4.3 Simulated角色

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

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

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


  1. void ACharacter::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const
  2.    {
  3.        Super::GetLifetimeReplicatedProps( OutLifetimeProps );
  4.        DOREPLIFETIME_CONDITION( ACharacter, RepRootMotion,COND_SimulatedOnlyNoReplay);
  5.        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedBasedMovement,   COND_SimulatedOnly );
  6.        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedServerLastTransformUpdateTimeStamp, COND_SimulatedOnlyNoReplay);

  7.        DOREPLIFETIME_CONDITION( ACharacter, ReplicatedMovementMode,    COND_SimulatedOnly );
  8.        DOREPLIFETIME_CONDITION( ACharacter, bIsCrouched,           COND_SimulatedOnly );

  9.        // 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
  10.        DOREPLIFETIME_CHANGE_CONDITION(AActor,ReplicatedMovement,COND_SimulatedOrPhysicsNoReplay);
  11.    }
复制代码

ReplicatedMovement记录了当前Character的位置旋转,速度等重要的移动数据,这个成员(包括其他属性)在Simulate或者开启物理模拟的客户端才执行(可以先忽略NoReplay,这个和回放功能有关)。同时,我们可以看到Character大部分的同步属性都是与移动同步有关,而且基本都是SimulatedOnly,这表示这些属性只在模拟客户端才会进行同步。除了ReplicatedMovement属性以外,Replicated MovementMode同步了当前的移动模式,ReplicatedBasedMovement同步了角色所站在的Component的相关数据,Replicated-ServerLastTransform-Update TimeStamp同步了最新的服务器移动更新帧,也就相当于最后一次服务器更新移动的时间(在ACharacter:reReplication里会将服务器当前的移动数据赋值给Replicated-ServerLastTransform-UpdateTimeStamp然后进行同步)。

了解了这些同步的数据后,我们开始分析其移动流程。流程如下图所示(RootMotion的情况我在上一章节已经描述,这里不再赘述)。其实其基本思路与普通的移动处理相似,只不过是调用SimulateTick去根据当前的速度等条件模拟客户端移动,但是有一点非常重要的差异就是Simulate的角色的胶囊体移动与Mesh移动是分开进行的。这么做的原因是什么呢?我们稍后再解释。

图4-4 Simulate角色移动流程图

客户端的模拟我们大致了解了流程,那么接收服务器数据并修正是在哪里处理的呢?答案是AActor::OnRep_ReplicatedMovement。

Simulateticked客户端是完全走属性同步的,客户端在接收到服务器同步的ReplicatedMovement时,会产生回调函数触发SmoothCorrection的执行,从当前客户端的位置平滑的过度到服务器同步的位置,同时会通过APawn::PostNetReceiveVelocity修改当前的移动速度,随后的客户端在Simulate时就可以用这个速度进行模拟。SmoothCorrection是插值的核心函数之一。(参考ACharacter的PostNetReceiveLocation AndRotation)

那么这里描述一下:SmoothCorrection到底做了什么?

  • Replay会直接返回,Disabled Mode会直接设置胶囊体位置
  • GetPredictionData_Client_Character客户端获取预测数据结构体,第一次会主动创建。这里面会保存一段时间内的移动数据以及时间戳,还有各种位置和旋转的偏移用于平滑
  • 记录一个旧坐标到新坐标的偏移NewToOldVector,同时算出一个二维的距离。如果偏移距离太大,可能不去设置偏移。如果小于MaxSmoothNetUpdateDist,就设置MeshTranslationOffset为当前的值加上NewToOldVector
  • 如果是线性插值,先将当前的OriginalMeshTranslationOffset设置为前面刚计算的MeshTranslationOffset;设置OriginalMeshRotationOffset以及MeshRotationOffset都为OldRotation,设置MeshRotationTarget为NewRotation,设置胶囊体到新的坐标(不修改其rotation,也不修改Mesh的位置)
  • 如果是指数插值,记录相对新的Rotation的MeshRotationOffset,直接同时设置胶囊体的location以及Rotation
  • 如果当前的客户端smooth时间戳大于服务器的,就把当前的时间戳lerp到服务器。
  • 获取服务器同步的ReplicatedServerLastTransformUpdateTimeStamp,然后赋值给SmoothingServerTimeStamp
  • 计算服务器两次传递数据的时间差,根据默认的配置得到一个MaxDelta
  • 设置SmoothingClientTimeStamp,范围在SmoothingServerTimeStamp-MaxDelta与SmoothingServerTimeStamp之间
  • 记录客户端与服务器的时间差,LastCorrectionDelta,其实可以认为服务器领先客户端的时间。同时还有一个被服务器的纠正时间LastCorrectionTime,也就是当前客户端的时间


所以这里我们就明白了前面提到的胶囊体与Mesh的移动分开处理,其目的就是提高代理模拟的流畅度。而且在官方文档上有简单的例子,

比如这种情况,一个replicated的状态显示当前的角色在时间为t=0的时刻以速度(100,0,0)移动,那么当时间更新到t=1的时候,这个模拟的代理将会在X方向移动100个单位,然后如果这时候服务端的角色在发送了那个(100,0,0)的replcated信息后立刻不动了,那么这个replcated信息则会使到服务端角色的位置和客户端的模拟位置处于不同的点上。

为了避免这种“突变”情况,UE采用了Mesh网格的平滑操作。胶囊体的移动正常进行,但是其对应的Mesh网格不随胶囊体移动,而要通过simulateTick的SmoothClientPosition处理。在SmoothNetUpdateTime时间内,移动组件会通过ClientData->MeshTranslationOffset去差值平滑Mesh相对胶囊体的偏移而并不会修改胶囊体的坐标,这样在通常情况下玩家在视觉上就不会觉得代理角色的位置突变。通过FScopedPreventAttached ComponentMove类可以限制某个组件暂时不跟随父类组件移动。

另外,有一点需要注意,目前模拟客户端的移动更新有几种模式,线性、指数以及replay回放,他们的差值方式不同,对角色的移动处理也有所不同。比如,线性模式就会做外插值(也就是在没有服务器数据的时候也会进行扩展插值),replay属于回放系统的处理方式,则会读取所有回放数据进行线性插值,但是没有外差值。

这里我们以常见的线性插值来做分析:

  • SmoothClientPosition是simulated客户端Tick执行的,里面包括两个重要的函数一个是用于插值计算的SmoothClientPosition_Interpolate函数,另一个是应用插值更新Mesh的SmoothClientPosition_UpdateVisuals函数
  • 在SmoothClientPosition_Interpolate里面,会用到前面的ClientData里面记录了服务器领先时间LastCorrectionDelta,客户端上次处理时间戳SmoothingClientTimeStamp,收到的服务器时间戳SmoothingServer TimeStamp
  • SmoothClientPosition_Interpolate执行Tick来更新SmoothingClientTime Stamp(+=DeltaSeconds),由于支持外插值,所以客户端运行0.15比例的外插。当客户端的时间戳大于服务器的话,就会最大外插0.15比例的offset
  • 假如我们还是客户端落后于服务器,那么就会按照DeltaSeconds/LastCorrectionDelta的比例计算插值
  • 如果插值比例接近1或者大于1,就要判断一下当前的速度是否为0,为0的话就直接设置offset为0,反之就继续插值。如果比例远小于1,就正常按照比例插值。
  • 由于前面一直是把数据记录在ClientData里面而没有处理,所以在SmoothClientPosition_Interpolate结束后需要调用SmoothClientPositio n_UpdateVisuals真正的应用这些数据。
  • 根据当前的MeshRotationOffset以及MeshTranslationOffset反向计算一个Mesh的新的相对位置,用于正确的处理位置偏移以及rotation的偏移
  • 如果Rotation几乎没有变化,那么直接设置相对位置即可。如果Rotation变化比较大,那么就需要先设置前面计算的相对位置,再去设置新的rotation(这时候Mesh的Rotation会随着胶囊体一起旋转)


最后再通过下面的图理解一下就非常清晰了

图4-5

Smooth平滑方式如下,默认我们采用Exponential:

  1. /** Smoothing approach used by network interpolation for Characters. */
  2.    UENUM(BlueprintType)

  3.     enum class ENetworkSmoothingMode : uint8
  4.     {
  5.       /** No smoothing, only change position as network position updates are received. */
  6.       Disabled     UMETA(DisplayName="Disabled"),

  7.       /** Linear interpolation from source to target. */
  8.       Linear           UMETA(DisplayName="Linear"),

  9.       /** Exponential. Faster as you are further from target. */
  10.       Exponential      UMETA(DisplayName="Exponential"),

  11.       /** Special linear interpolation designed specifically for replays. Not intended as a selectable mode in-editor. */
  12.       Replay           UMETA(Hidden, DisplayName="Replay"),
  13.     };
复制代码


4.4关于物理托管后的移动

一般情况下我们是通过移动组件来控制角色的移动,不过如果给玩家角色的胶囊体(一般Mesh也是)勾选了SimulatePhysics,那么角色就会进入物理托管而不受移动组件影响,组件的同步自然也是无效了,常见的应用就是玩家结合布娃娃系统,角色死亡后表现比较自然的摔倒效果。相关代码如下:

  1. UCharacterMovementComponent::TickComponent
  2. // We don't update if simulating physics (eg ragdolls).
  3. if (bIsSimulatingPhysics)
  4. {
  5.    // Update camera to ensure client gets updates even when physics move him far away from point where simulation started
  6.    if (CharacterOwner->Role == ROLE_AutonomousProxy && IsNetMode(NM_Client))
  7.    {
  8.        APlayerController* PC = Cast<APlayerController>(CharacterOwner->GetController());
  9.        APlayerCameraManager* PlayerCameraManager = (PC ? PC->PlayerCameraManager : NULL);
  10.        if (PlayerCameraManager != NULL && PlayerCameraManager->bUseClientSideCameraUpdates)
  11.        {
  12.            PlayerCameraManager->bShouldSendClientSideCameraUpdate = true;
  13.        }
  14.    }
  15.    return;
  16. }
复制代码


对于开启物理的Character,Simulate的客户端也是采取移动数据靠服务器同步的机制,只不过移动的数据不是服务器PerformMovement算出来的,而是从根组件的物理对象BodyInstance获取的,代码如下,

  1. void AActor::GatherCurrentMovement()
  2. {
  3.    AttachmentReplication.AttachParent = nullptr;

  4.    UPrimitiveComponent* RootPrimComp = Cast<UPrimitiveComponent>(GetRootComponent());
  5.    if (RootPrimComp && RootPrimComp->IsSimulatingPhysics())
  6.    {
  7.        FRigidBodyState RBState;
  8.        RootPrimComp->GetRigidBodyState(RBState);

  9.        ReplicatedMovement.FillFrom(RBState, this);
  10.        ReplicatedMovement.bRepPhysics = true;
  11.    }
  12. }
复制代码

五.特殊移动模式的实现思路

这一章节不是详细的实现教程,只是给大家提供常见游戏玩法的一些设计思路,如果有时间的话也会考虑做一些实现案例。如果大家有什么特别的需求,欢迎提出来,可以和大家一起商讨合理的解决方案。

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商城里面有一个案例可以参考一下。

下篇文章会为大家分析一下Rootmotion在UE4中的实现原理,尽情期待~

End

相关阅读:
《Exploring in UE4》游戏角色的移动原理(上)
《Exploring in UE4》游戏角色的移动原理(下)

作者:Jerish  
来源:游戏开发那些事
原地址:https://mp.weixin.qq.com/s/s7caRnoIMCwOPq_5G8u13Q

相关推荐

最新评论
暂无评论
参与评论

商务合作 查看更多

编辑推荐 查看更多