Minecraft 1.8.9 的服务器与客户端玩家同步

Minecraft 里的“一个玩家”其实同时存在于几份状态中:自己的客户端有一份,服务器有一份,其他玩家的客户端也各自保存一份。

这些状态描述的是同一个人,却不一定处于同一个时刻。本文直接沿着 1.8.9 源码的执行顺序,解释玩家位置如何从客户端到服务器,再从服务器到其他客户端。

下面的源码都来自仓库中的 src/minecraft/net/minecraft/。文中的代码片段是对应原版逻辑的简化表示。

一、客户端先更新自己的玩家

EntityPlayerSP 是客户端里的本地玩家实体,负责读取输入、处理移动和更新自己在客户端世界中的位置。

一个游戏 tick 是 1/20 秒。客户端每个 tick 都会推进一次本地玩家状态。和本文同步有关的顺序可以概括为:

  1. 处理左键攻击;
  2. 根据输入处理移动和跳跃;
  3. 更新是否在地面等状态;
  4. 在 tick 末尾,把需要同步的状态发给服务器。

因此,按下 W 后,自己的画面会立即移动,不需要等待服务器回复。这是客户端预测:客户端先把操作表现出来,服务器随后检查结果是否合理。

这里的“客户端先更新”不等于“客户端拥有最终权威”。客户端只负责即时体验,服务器仍然负责最终的规则判断。

二、客户端发送哪些消息

客户端不会把所有状态塞进同一个包。位置、攻击和疾跑分别有不同的消息和发送时机。

位置状态:C03PacketPlayer

C03PacketPlayer 是玩家状态更新包。它有几个变体,可以携带:

  • X、Y、Z 位置,类型是 double;
  • Yaw、Pitch 视角;
  • 是否在地面。

在 EntityPlayerSP.onUpdateWalkingPlayer 中,客户端会比较当前位置和上一次已经报告的位置。如果移动距离达到阈值,就发送带坐标的形式;如果只改变了视角,就发送带视角的形式;如果位置和视角都没有变化,也可以只发送 onGround。

可以把这段选择逻辑简化成:

1
2
3
4
位置变了 + 视角变了 -> 发送位置和视角
位置变了 -> 发送位置
视角变了 -> 发送视角
都没变 -> 发送 onGround

客户端每 tick 发送一次玩家状态更新,但只有需要时才在包中携带完整 double 坐标。

位置包没有速度字段。客户端不会告诉服务器“我的 XZ 速度是多少”或“我的 Y 速度是多少”。

攻击状态:C02PacketUseEntity

PlayerControllerMP 负责处理客户端的攻击动作。玩家按下左键并完成本地目标判断后,它会直接发送 C02PacketUseEntity。

攻击包不会等到 tick 末尾再和位置包一起发送。按照客户端 tick 的实际顺序,它属于移动之前处理的即时动作。

疾跑状态:C0BPacketEntityAction

疾跑由 C0BPacketEntityAction 同步。EntityPlayerSP 在 tick 末尾比较当前疾跑状态和上一份已经同步的状态:

1
2
当前状态 != 已同步状态 -> 发送 START_SPRINTING 或 STOP_SPRINTING
状态没有变化 -> 不发送

服务器收到后,会直接切换服务器玩家的疾跑状态。因此,疾跑不是每 tick 重复上报,而是在状态发生变化时发送一次。

三、服务器怎样处理客户端消息

NetHandlerPlayServer 是服务器端的连接处理器,负责接收一个客户端发来的网络包。包进入服务器线程后,基本按照到达顺序分别处理。

攻击包

服务器收到 C02PacketUseEntity 后,会先找到目标,再检查距离和视线等条件。通过检查后才执行攻击。

客户端发出攻击包,只表示客户端认为目标可以攻击;服务器仍会进行自己的验证。

疾跑包

服务器收到 C0BPacketEntityAction 后,直接修改服务器玩家实体的疾跑状态。之后服务器处理攻击和移动时,读取的是这份服务器状态,而不是客户端本地刚刚改变但还没同步到服务器的状态。

位置包

位置包会进入单独的验证流程。NetHandlerPlayServer.processPlayer 的主要步骤是:

  1. 检查坐标是否为有效数字;
  2. 计算客户端报告位置和服务器当前状态之间的位移;
  3. 检查是否移动过快;
  4. 检查移动后是否出现碰撞或位置错误;
  5. 验证失败时,通过 setPlayerLocation 将玩家回到旧位置;
  6. 验证通过后,将服务器玩家位置设置为客户端报告的位置。

其中第 6 步可以简化成:

1
2
3
服务器先计算和检查
检查失败 -> 回弹
检查通过 -> 接受客户端报告的坐标

服务器不是无条件相信客户端,也不是完全按照自己的移动结果忽略客户端,而是把客户端坐标作为待验证的移动结果。

四、服务器不会接收客户端速度

C03PacketPlayer 只有位置、视角和 onGround,没有速度字段。服务器因此不会从网络包中得到客户端本地的完整运动状态。

服务器中的玩家实体是 EntityPlayerMP。它也有自己的 motionX、motionY、motionZ,但这三个值属于服务器内部状态,和客户端本地的三个速度没有一一对应关系。

普通移动时,服务器主要收到的是连续的位置报告:上一拍在哪里,这一拍报告在哪里。客户端如何通过输入、摩擦和本地物理得到这段位移,并不会以速度的形式上传。

服务器对跳跃的粗略推断

处理位置包时,服务器会检查一个非常有限的条件:

  • 服务器上一状态认为玩家在地面;
  • 客户端这次报告自己不在地面;
  • 客户端报告的 Y 坐标比之前更高。

源码中的判断可以简化为:

1
2
3
4
如果 server_on_ground
且 client_on_ground == false
且 client_y 上升
那么调用 jump()

jump() 会把服务器玩家的初始 Y 速度设为 0.42。如果服务器认为玩家正在疾跑,jump() 还会根据朝向追加 XZ 水平速度。

这不是客户端速度的同步,而是服务器根据客户端上报的结果做出的猜测。服务器并不知道玩家离开地面的真正原因,所以击退离地也可能满足这段条件。击退逻辑同样会在服务器侧直接修改玩家的 XYZ 速度。

除了这些服务器自己的逻辑,客户端的 XYZ 速度不会被复制到服务器;两边的速度始终是两套彼此独立的状态。

五、服务器多久向其他客户端发送一次位置

EntityTracker 负责把服务器实体登记为“需要被其他玩家观察的实体”。玩家实体在登记时使用的默认更新频率是 2 tick,也就是每两拍检查一次。

真正决定是否发包的是 EntityTrackerEntry。它的判断可以概括为:

1
2
3
4
5
每次 tracker 更新:
如果还没到 2 tick -> 通常不检查位置
到了 2 tick,或实体正在 airborne -> 检查位置
量化后位移足够大 -> 发送位置包
位移太小 -> 这次不发送位置

玩家位置同步的实际规则是:

  • 玩家通常每 2 tick 检查一次同步;
  • 量化后移动太小时,检查也可能不发位置包;
  • 长时间没有位置更新时,会有周期性的强制更新;
  • 玩家被击退、进入空中状态时,可以绕过普通的 2 tick 间隔及时更新。

六、位置为什么是 1/32 格

服务器内部的玩家位置仍然是 double。但 EntityTrackerEntry 准备发送位置时,会先把每个坐标转换到 1/32 格的整数网格:

1
网络坐标 = floor(服务器坐标 × 32)

客户端收到后再除以 32:

1
客户端目标位置 = 网络坐标 ÷ 32

例如,服务器内部位置是 2.90 格,网络坐标会被取整到 92,客户端还原后得到 2.875 格。

这个过程有两个作用:

  1. 相对移动包可以用较小的整数表示位移;
  2. 网络同步不需要传输完整 double。

代价是网络位置和服务器原始位置之间最多存在约 1/32 格的量化差异。

七、客户端收到位置后如何插值

在普通玩家移动同步中,NetHandlerPlayClient 负责接收 S15PacketEntityRelMove。它完成两件事:

  1. 把网络整数坐标还原成 1/32 格的位置;
  2. 把这个位置交给远端玩家实体。

远端玩家实体由 EntityOtherPlayerMP 表示。它不会收到消息后立刻把画面中的玩家移动到新坐标,而是保存三个状态:

  • 当前显示位置;
  • 网络消息给出的目标位置;
  • 剩余插值次数。

收到普通移动消息时,客户端会把插值次数设为 3。EntityOtherPlayerMP.onLivingUpdate 随后每拍执行一次类似下面的逻辑:

1
2
3
如果剩余次数 > 0:
当前显示位置 += (目标位置 - 当前显示位置) / 剩余次数
剩余次数 -= 1

假设当前显示位置是 2.0,目标位置是 3.5,剩余 3 拍,那么三次更新会是:

1
2
3
第 1 拍:2.0 -> 2.5   (2.0 + (3.5 - 2.0) / 3)
第 2 拍:2.5 -> 3.0 (2.5 + (3.5 - 2.5) / 2)
第 3 拍:3.0 -> 3.5 (3.0 + (3.5 - 3.0) / 1)

实际源码对 X、Y、Z 三个坐标分别执行相同的计算,同时还会对视角做类似的平滑处理。插值期间,远端实体的 posX、posY、posZ 是中间值,只有插值结束后才完全到达网络消息指定的目标。

所以,服务器同步给客户端的坐标经历了三次变化:

  1. 服务器内部的 double 位置;
  2. 网络传输前量化到 1/32 格的位置;
  3. 客户端收到后正在逐步靠近的插值位置。

这三者不是同一个坐标。服务器发来的位置是插值目标,客户端画面中的远端玩家位置则是插值过程中的当前值。

八、完整算例:A 前进,B 收到并插值

下面用一个刻意简化的例子,把一条位置同步消息从 A 走到 B 的全过程串起来。为了只观察同步机制,假设 A 每个 tick 固定向 X 轴前进 0.20 格;实际游戏中的速度会受到输入、摩擦、跳跃和碰撞影响,但不会改变下面的同步步骤。

假设条件:

  • A 的初始位置是 0.00;
  • A 到服务器的延迟是 1 tick;
  • 服务器到 B 的延迟是 1 tick;
  • 玩家追踪器每 2 tick 检查一次;
  • B 的远端实体从显示位置 0.00 开始;
  • 每次收到位置更新,B 都执行 3 tick 插值。

1. A 在本地移动并上报

A 的客户端先更新自己的位置,然后在 tick 末尾发送位置状态。这里使用的坐标是 double:

A 客户端 tick tick 结束位置 发出的内容
1 0.20 C03 位置包,携带 x=0.20
2 0.40 C03 位置包,携带 x=0.40
3 0.60 C03 位置包,携带 x=0.60
4 0.80 C03 位置包,携带 x=0.80

这些位置包在 tick 末尾发出。由于 A 到服务器有 1 tick 延迟,服务器分别在自己的 tick 2、3、4、5 收到它们。

2. 服务器接收、验证并保存

服务器收到 A 的位置包后,先检查坐标和位移是否合理。这个例子中的每次位移都是 0.20 格,假设全部通过检查。服务器保存的位置随后变为:

1
2
3
4
服务器 tick 2:0.20
服务器 tick 3:0.40
服务器 tick 4:0.60
服务器 tick 5:0.80

服务器保存的是 double,仍然保留 0.20、0.40 这样的原始数值。位置追踪器的检查节奏与收包节奏是两件事:即使服务器每 tick 收到位置,玩家追踪器仍按自己的 2 tick 周期检查。

3. 第一次 tracker 检查:把 0.40 变成 0.375

假设第一次检查发生在服务器 tick 3。服务器此时准备同步的位置是 0.40,先转换为 1/32 格网格:

1
2
floor(0.40 × 32) = floor(12.8) = 12
12 ÷ 32 = 0.375

服务器不会把 double 0.40 直接发给 B,而是记录网格坐标 12,并发送一个相对移动值 +12。这个消息在服务器到 B 的网络中再等待 1 tick。

4. B 收到第一条位置更新

B 在自己的 tick 4 开始时收到这条消息。NetHandlerPlayClient 将相对值 +12 累加到 B 保存的网络坐标,得到 12,再除以 32,得到新的插值目标:

1
目标位置 = 12 ÷ 32 = 0.375

EntityOtherPlayerMP 不会把显示位置直接改成 0.375,而是把“当前显示位置”保留在 0.00,把“目标位置”设为 0.375,并把剩余插值次数设为 3。

5. B 的三次插值

B 每个 tick 会执行一次“向目标靠近”的计算。如果这三拍期间没有新的位置消息,结果如下:

B tick 计算 插值后显示位置
4 0.000 + (0.375 - 0.000) / 3 0.125000
5 0.125 + (0.375 - 0.125) / 2 0.250000
6 0.250 + (0.375 - 0.250) / 1 0.375000

这三步说明了插值本身的计算方式。实际移动中,新的位置消息可能在三步完成前到达,并重新设置目标位置和剩余次数。

6. 第二条更新在插值完成前到达

服务器 tick 5 又进行一次 2 tick 检查。此时服务器位置是 0.80:

1
floor(0.80 × 32) = floor(25.6) = 25

上一次已发送的网格坐标是 12,所以这次发送的相对移动值是:

1
25 - 12 = 13

B 在自己的 tick 6 开始时收到这条消息。此时第一条消息只完成了前两次插值,显示位置是 0.25。B 把网络坐标从 12 累加到 25:

1
目标位置 = 25 ÷ 32 = 0.78125

如果这条消息到达时 B 的显示位置仍是 0.25,插值计数器会重新设置为 3,后续显示位置变为:

B tick 计算 插值后显示位置
6 0.250 + (0.78125 - 0.250) / 3 0.427083
7 0.427083 + (0.78125 - 0.427083) / 2 0.604167
8 0.604167 + (0.78125 - 0.604167) / 1 0.781250

新的位置消息会重置插值目标和剩余次数,所以 B 不一定先完成上一次插值,再开始下一次。移动持续发生时,目标可能在旧插值结束前不断更新,画面位置就会一直追赶一个不断变化的目标。

这个例子完整展示了四个不同的数值:A 客户端的 0.80、服务器内部的 0.80、网络网格中的 25,以及 B 在插值过程中的 0.427083。它们都在描述 A,却分别属于本地预测、服务器状态、网络坐标和远端显示四个阶段。


Minecraft 1.8.9 的服务器与客户端玩家同步
https://wtazuresky.github.io/2026/08/26/server-client-player-sync/
作者
WTAzureSky
发布于
2026年8月26日
许可协议