人物移动组件

本页面的内容:

使用 CharacterMovementComponent 的人物会自动内建客户端-服务器网络连接。下面介绍了如何在网络游戏中通过 CharacterMovementComponent 进行玩家动作预测、复制和修正:

每经过一个时钟单位,TickComponent() 函数都会调用一次。该函数能够确定当前帧的加速和旋转变化,然后调用 PerformMovement()(用于本地控制的人物)或 ReplicateMoveToServer()(用于网络客户端)。

ReplicateMoveToServer() 可以保存动作(到 PendingMove 列表),调用 PerformMovement(),然后通过调用复制的函数 ServerMove() 并传送动作参数、客户端的最终位置和一个时间戳,将动作复制到服务器。

ServerMove() 将在服务器上执行。它会将动作参数解码并产生相应的动作。然后,该函数会查看最终位置。如果在上次响应之后经过了太长时间,或者位置错误大到了一定程度,服务器将调用复制的 ClientAdjustPosition() 函数。

ClientAdjustPosition() 将在客户端上执行。客户端将根据服务器的位置数据来设置其位置,然后将 bUpdatePosition 标志设为 true。

当客户端上再次调用 TickComponent() 时,如果 bUpdatePosition 为 true,客户端将首先调用 ClientUpdatePosition(),然后再调用 PerformMovement()。ClientUpdatePosition() 可以重放待处理动作列表中的所有动作(必须发生在服务器所调整动作的时间戳之后)。

人物移动和模拟代理

到目前为止介绍的 CharacterMovementComponent 网络连接方法,还只是详细讲解了与主控服务器相连的单独客户端。那么,那些在服务器上移动的 AI,或是在其他计算机上运行游戏的玩家该怎么办呢?我们的客户端机器会将它们全部视作模拟代理,并对它们采用稍微不同的代码路径。

对于那些不由人类控制的人物,其动作往往会通过正常的 PerformMovement() 代码在服务器(此时充当了主控者)上进行更新。Actor 的状态,如方位、旋转、速率和其他一些选定的人物特有状态(如跳跃)都会通过正常的复制机制复制到其他机器,因此,它们不必在每一帧都经由网络传送。为了在远程客户端上针对这些人物提供更流畅的视觉呈现,该客户端机器将在每一帧为模拟代理执行一次模拟更新,直到新的数据(由服务器主控)到来。本地客户端查看其他远程人类玩家时也是如此;远程玩家将其更新发送给服务器,后者为该玩家执行一次完整的动作更新,然后定期复制数据给所有其他玩家。

这个更新的作用是根据复制的状态来模拟预期的动作结果,以便在下一次更新前“填补空缺”。所以,客户端并没有在新的位置放置由服务器发送的代理,然后将它们保留到下次更新到来(可能是几个后续帧),而是通过应用速率和移动规则,在每一帧模拟出一次更新。在另一次更新到来时,客户端将重置本地模拟并开始新一次模拟。

模拟代理的大部分动作更新都是在 UCharacterMovementComponent::SimulateMovement() 乃至 MoveSmooth() 中执行。MoveSmooth() 通常以更简化的方式进行各种动作模式(行走、飞行等)的完整更新,其运行开销更小,意图相对简单。

提升模拟代理的流畅度

当人物只是简单的向前移动时,模拟更新很可能与下一个更新复本高度匹配,因为直线移动是非常容易预测的。即便是在世界中跑向静止墙体的情况,其偏转和后续更新也能非常精确的模拟出来。

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

为了避免在收到服务器更新时模拟代理出现视觉“突变”的情况,我们使用 UCharacterMovementComponent::SmoothClientPosition() 函数增加了人物网格(对于物体的复杂视觉呈现,并非碰撞呈现中所用的简单形状)的定位流畅度。默认情况下,这时会采用一个简单的流畅提升函数在特定时间(由客户端网络数据的 “SmoothNetUpdateTime” 设定)内抵达目标位置。

调试 CharacterMovementComponent 的网络连接

您可以借助一些实用工具,对人物的网络连接进行调试和分析。通常来讲,您首先要做的就是在出现不规则行为的客户端的控制台中输入 “p.NetShowCorrections 1” (仅适用于非发售版本)。在服务器上启用该特性,也可能会对您有所帮助。这样可以通过登录到输出控制台,以及在“正确”位置(绿色)和“不正确”位置(红色)绘制碰撞形状,看到客户端何时收到网络连接(或服务器发出网络连接)。在客户端上,“正确”位置是服务器为进行修正而下发的位置,而“不正确”位置是被判定为超出服务器错误容限的本地位置。服务器的情况与此类似 - “正确”的服务器位置绘制为绿色,而收到的“不正确”客户端位置则为红色。“p.NetCorrectionLifetime” 控制了世界中可视化调试效果存留的秒数(例如,“p.NetCorrectionLifetime 5” 表明存留时间是 5 秒钟)。

另一个诊断问题的有效方法就是开启相应功能,对 CharacterMovement 连网动作函数发送的数据进行记录。控制台命令 “log LogNetPlayerMovement Verbose” 可以记录每个收发的人物动作数据,包括方位、旋转和加速数据。这或许有助于解释出现错误修正的原因,例如,由于没有采用向服务器复制方位变化时所用的方法,导致方位数据仅仅在客户端上更新。

高级讨论话题:向人物动作组件添加新的动作能力

为人物新增动作能力时,您有很多种选择。让我们先完成一系列的理论尝试,为一个连网游戏人物添加“瞬间移动”能力。我们的基本思路是,让玩家按下 T 键时,他们将向前瞬间移动 10 米(如果目的地没有阻挡的话)。

方法 1:仅在客户端上执行

在此方法中,我们完全不考虑网络连接问题,只需在按下 T 时向前瞬间移动。

Result:在连网游戏中行不通。本地客户端看上去是向前瞬间移动,但接下来就快速扭转回起始位置。

Analysis:这么做实际上不会对网络连接有任何具体影响,因此注定失败。为什么呢?因为服务器并未打算触发此能力,它只是处理客户端人物的方位、旋转和加速来触发动作,因此从服务器的角度看,人物不可能前进了那么多。

方法 2:仅用于服务器的 RPC

要在连网环境下实现目标,最简单的办法是设置一个可靠的网络 RPC 来触发此能力。这时,我们只调用一个 Server 函数来处理瞬间移动。

Result:在连网游戏中可用,但存在重大问题,例如,在客户端上执行时出现明显延迟,甚至有可能失去某些瞬移功能。

Analysis:可以使用,但很不理想。此时将依次发生以下事件:服务器获得让玩家向前瞬移的函数调用。人物动作更新由客户端发送至服务器(带有未瞬间移动的方位数据)。服务器将此视为一个错误,因为服务器是通过 RPC 完成瞬移。服务器将一个修正发送给客户端,客户端位置随即变化,就像是发生了瞬间移动。(调试技巧:此时可以借助于 “p.NetShowCorrections 1” 命令,因为我们会在客户端上发现该动作正是网络修正的结果。)

这里面存在不少问题:在面对网络延迟的情况下,客户端需要等上一整个来回(也就是从客户端到服务器,再回到客户端),才能真正在本地机器上产生瞬移。这会严重影响到玩家体验。此外,“瞬间移动”能力中附带出现的任何额外功能都可能无法在客户端上触发,因为它们永远不会在本地执行这样的能力。因此,假如您在目标位置播放了声音和粒子效果,这些都将在客户端上丢失。

方法 3:服务器 RPC 与本地触发

如果使用此方法,客户端将会执行瞬间移动,然后调用服务器 RPC 来进行瞬移。

Result:可用于连网游戏,但个别时候可能会产生严重问题。

Analysis:此方法会视图补救之前尝试中出现的问题,包括本地动作的延迟以及因修正(而非能力)产生的动作。我们还获得了完整的瞬间移动功能,例如声音和粒子效果。这个方法的效果不错,但有一些重要的事项需要注意,而且在实际的连网环境中仍有可能出现问题。

最主要的问题是,如果客户端被修正到触发瞬间移动之前,那么在回到当前时间后,它们便不知道要重新触发瞬移,于是我们会发现,瞬间移动动作好像从客户端上消失了。

方法 4:实施 CharacterMovementComponent 能力

利用这个方法,我们可以将瞬移能力的信息添加到 CharacterMovementComponent 代码(的子类),以便其正常接受网络修正。

Result:适用于连网游戏,但实施的过程中需要稍加留意。

Analysis:这种方法需要更多一些的背景知识:正如之前所述,使用 CharacterMovementComponent 的人物会将输入结果列队加入到名为 “动作存档列表(a saved move list)” 或 “待处理动作列表(a saved move list)” 的地方(在 C++ 代码中,这些动作是由 “FSavedMove_Character” 类派生而来)。每一个保存的动作都会记录某一帧动作开始时的状态,如方位、旋转、加速(通常由玩家输入产生)、跳跃状态等。

当动作由客户端发送至服务器时,我们会保留这些动作,并在服务器确认该动作后移除旧的动作(记住,服务器对于我们所处位置的视角要稍微滞后于我们自己的即时视角)。在处理修正方面,我们知道其何时出现,而且能“重放”所有发生在这一时间点之后的动作。这一点确实不错,客户端甚至不会注意到修正的存在,因为它们会从修正的时间点开始,再次尝试将时间向前推进。这同样意味着在修正后触发的能力也会一起重放。您可能想要在自己的能力里测试这个过程,以防重新触发某些已经重新触发过的效果(相关示例请参见 UCharacterMovementComponent::DoJump() 函数的 “bReplayingMoves” 参数)。

现在,我们回去添加“瞬间移动”能力!这时会采取非常类似的做法,因为 CharacterMovementComponent 中已经处理了一次跳跃。我们只需要表明该能力已经被触发,并在服务器上将其正确处理。当然,我们仍然要在本地执行它,以便客户端快速做出响应。客户端和服务器之间的数据收发会在现有的网络连接中自动完成,您只需要对数据进行打包和解包。

首先,我们需要从 UCharacterMovementComponent 类进行派生,然后覆盖 AllocateNewMove() 以创建我们自己的 FSavedMove_Character。这时,我们需要在 FSavedMove_Character 中实施几个方法,其中最重要的是 GetCompressedFlags(),它将会装入少量数据以表明我们已经触发了瞬移能力。然后,我们将覆盖 UCharacterMovementComponent::UpdateFromCompressedFlags(),它的作用是对那些标志进行解包,然后在服务器端触发该能力。

基本上就是这样。在 CharacterMovementComponent 保存的动作中实施自定义标志时,情况可能会更复杂一些,但我们所说的这些将帮助您踏上正确的道路。如果您想对您的动作提供强大的网络连接支持,多做些努力也是值得的。

相关页面