Minecraft 1.8.9 的疾跑状态同步与重置
在 Minecraft 1.8.9 中,疾跑不是客户端和服务器共用的一枚开关。客户端有自己的疾跑状态,服务器有自己的疾跑状态,客户端还保存着一个“我最后一次告诉服务器的疾跑状态”。服务器改变状态后,又会通过实体元数据把结果发回客户端。
这些状态大多数时候会保持一致,但攻击会主动清除疾跑。要理解按住疾跑攻击、手动重置疾跑以及硬直期间重新同步,必须先把一个客户端 tick 和一个服务器 tick 按源码顺序拆开。
本文使用的源码位于仓库的 src/minecraft/net/minecraft/,版本为 Minecraft 1.8.9。代码片段是保留原版行为的简化写法。仓库中的 Sprint、ServerDebug 等属于扩展,会单独说明。
一、疾跑状态到底有几份
可以把相关状态先压缩成下面这张对照表:
| 状态 | 保存位置 | 作用与关键行为 |
|---|---|---|
| 本地疾跑状态 | 客户端 EntityPlayerSP 的 isSprinting() |
控制本地移动速度,也影响攻击瞬间是否按疾跑处理 |
serverSprintState |
客户端 EntityPlayerSP |
记录上一次通过 C0BPacketEntityAction 告诉服务器的状态,不是服务器实时确认值 |
| 服务器疾跑状态 | 服务器 EntityPlayerMP 的 isSprinting() |
参与服务器侧攻击、跳跃和实体属性计算 |
| 实体疾跑标志 | 客户端和服务器的 DataWatcher |
通过 S1CPacketEntityMetadata 把服务器状态反馈给观察者和玩家自己的客户端 |
服务器如果在攻击代码中自行调用 setSprinting(false),客户端的 serverSprintState 不会自动改变。元数据是服务器对实体状态的反馈,不是 C0BPacketEntityAction 的确认,也不会修改 serverSprintState。
客户端用下面的比较决定是否发送疾跑包:
1 | |
二、客户端一个 tick 的严格顺序
客户端主循环在 Minecraft.runGameLoop 中运行,每次 tick 调用 Minecraft.runTick。相关顺序是:
1 | |
网络线程收到的包会通过 PacketThreadUtil 安排到客户端主线程,因此已经到达的元数据或速度包可以在本 tick 的鼠标攻击之前生效。Minecraft.runTick 中的鼠标处理位于 World.updateEntities 之前,所以本地攻击先发生,本地移动后发生。
三、客户端如何处理疾跑
1. setSprinting 的调用链
客户端调用 EntityPlayerSP.setSprinting 时,会进入父类逻辑,再维护客户端的疾跑计时器:
1 | |
三个类的分工是:
Entity.setSprinting:修改实体标志位;EntityLivingBase.setSprinting:添加或移除疾跑速度修饰符;EntityPlayerSP.setSprinting:额外维护sprintingTicksLeft。
2. onLivingUpdate 中的顺序
EntityPlayerSP.onLivingUpdate 每个本地 tick 执行。和疾跑有关的顺序是:
- 减少
sprintingTicksLeft,归零时关闭疾跑; - 减少双击 W 使用的
sprintToggleTimer; - 更新移动输入;
- 根据输入尝试开启疾跑;
- 如果前进不足、发生水平碰撞或饥饿,则关闭疾跑;
- 进入父类的跳跃和移动结算。
按住疾跑键的核心条件可以简化为:
1 | |
自动停止可以简化为:
1 | |
双击 W 还使用 sprintToggleTimer 保存 7 tick 的再次按键窗口。
3. 攻击先于移动,并在本地重置疾跑
Minecraft.runTick 处理左键时调用 PlayerControllerMP.attackEntity。它依次:
1 | |
本地的 EntityPlayer.attackTargetEntityWithCurrentItem 先读取攻击瞬间的疾跑:
1 | |
攻击结算成功后,如果击退等级大于 0:
1 | |
因此本地疾跑攻击会记录为疾跑攻击、降低水平 motion,并关闭本地疾跑;这一步发生在本地移动之前。
客户端这里是预测执行。远端玩家在客户端中是 EntityOtherPlayerMP,本地攻击可能已经完成,而服务器随后仍可能因为距离、视线或受伤无敌时间拒绝这次攻击。
4. tick 末尾发送 C0B 和 C03
本地移动完成后,EntityPlayerSP.onUpdateWalkingPlayer 比较本地疾跑和 serverSprintState:
1 | |
之后才发送 C03PacketPlayer。一次客户端 tick 的典型包顺序是:
1 | |
C03PacketPlayer 没有客户端速度字段,服务器不会从它得到客户端的 motionX、motionY、motionZ。
四、服务器一个 tick 的严格顺序
服务器主循环在 MinecraftServer.updateTimeLightAndEntities 中运行:
1 | |
同一条连接保持客户端发包顺序。延迟会改变包到达的是哪一个服务器 tick,但不会把同一条连接中的包重新排序。
1. 服务器收到 C02
NetHandlerPlayServer.processUseEntity 会找到目标,检查目标存在、视线和距离,再在普通模式下调用 EntityPlayerMP.attackTargetEntityWithCurrentItem。
EntityPlayerMP 的普通攻击会转交给父类 EntityPlayer,所以真正计算疾跑、击退等级和攻击后重置的代码仍在 EntityPlayer.attackTargetEntityWithCurrentItem。
服务器攻击顺序是:
1 | |
服务器读取的是 C02 到达服务器这一刻的服务器疾跑状态。如果客户端的 C0B 尚未到达,服务器不会因为客户端本地正在疾跑就自动开启服务器疾跑。
2. 服务器收到 C0B
NetHandlerPlayServer.processEntityAction 直接执行:
1 | |
服务器不会等待确认,也不会把收到的 C0B 原样回传。它只是客户端向服务器声明一次状态变化。
3. 服务器收到 C03
NetHandlerPlayServer.processPlayer 处理客户端位置报告。普通步行时,它会读取位置和 onGround,调用 playerEntity.onUpdateEntity(),计算报告位移,检查移动过快、碰撞和移动错误,必要时根据 onGround 变化调用 jump(),最后接受报告位置或回弹。
位置包没有速度字段。服务器的 motionX/Y/Z 是服务器自己的内部状态,客户端速度不会被复制到服务器。
五、服务器如何把疾跑状态发回客户端
1. setSprinting 修改实体元数据
Entity.setSprinting 最终调用:
1 | |
第 3 位是实体疾跑标志。服务器收到 START_SPRINTING、STOP_SPRINTING,或者服务器攻击代码主动重置疾跑时,都会经过这条路径。
2. EntityTrackerEntry 发送元数据
服务器每 tick 更新 EntityTrackerEntry。当 DataWatcher 有变化时,sendMetadataToAllAssociatedPlayers 创建 S1CPacketEntityMetadata。
这个包发给正在观察该实体的玩家。对于玩家实体,func_151261_b 还会把包发给被追踪玩家自己的连接:
1 | |
客户端的 NetHandlerPlayClient.handleEntityMetadata 收到包后更新本地实体的 DataWatcher,但不更新 serverSprintState。
六、现象一:按住疾跑攻击为什么会不同步
假设攻击前:
1 | |
第一步:客户端发出 C02 并本地结算
PlayerControllerMP.attackEntity 先发送 C02PacketUseEntity,随后本地攻击代码读取到疾跑:
1 | |
第二步:服务器收到 C02
服务器读取到自己的 isSprinting() == true,于是这次攻击有疾跑额外击退。攻击成功后,服务器执行 setSprinting(false)。
此时服务器已经是:
1 | |
第三步:客户端移动前又开启疾跑
如果玩家仍按住疾跑键,客户端随后进入 onLivingUpdate,再次满足开启条件并执行 setSprinting(true)。
到 tick 末尾:
1 | |
两者相等,所以客户端不发送 STOP_SPRINTING。服务器已经是 false,客户端却恢复为 true,于是产生不同步。
第四步:为什么要手动刷新
serverSprintState 只是客户端的发送记录,不是服务器确认值。服务器内部的攻击重置不会让客户端自动补发停止包。
要刷新服务器,必须让客户端真正产生一次新的状态变化:
1 | |
七、现象二:硬直期间重新同步后,下一次造成伤害的攻击仍有额外击退
服务器取消疾跑的代码位于攻击成功分支的后面。也就是说,只有 attackEntityFrom 真正接受了伤害,攻击方法才会继续执行击退,并在其中调用:
1 | |
受伤硬直或受伤无敌时间本身不会关闭服务器疾跑。硬直期间收到的一次攻击如果被 attackEntityFrom 拒绝,就不会进入击退分支,也不会执行服务器侧的 setSprinting(false)。
因此,假设玩家在硬直期间已经通过状态包把服务器疾跑重新打开:
1 | |
只要之后没有一笔真正造成伤害的攻击,服务器就会继续保持 isSprinting() == true。硬直期间的无效攻击不会把它关掉。
下一次攻击到达服务器时,服务器会先读取当前的 isSprinting() == true,把疾跑额外击退计入击退等级。只有这一次攻击最终造成伤害,攻击代码才会在击退结算后关闭服务器疾跑:
1 | |
如果这一次攻击仍处于硬直保护而没有造成伤害,服务器疾跑仍然不会被关闭;它会保持到下一次造成伤害的攻击,或者直到收到新的 STOP_SPRINTING 包。
八、现象三:服务器回传的取消元数据可能影响本地攻击减速
服务器因攻击执行 setSprinting(false) 后,DataWatcher 发生变化。EntityTrackerEntry 随后可能把关闭疾跑的 S1CPacketEntityMetadata 发回攻击者自己的客户端。
如果这个包在下一次客户端 tick 的攻击处理之前到达,顺序可能是:
1 | |
本地攻击减速位于:
1 | |
如果元数据让攻击开始时的 isSprinting() 变成 false,且没有其他击退等级,本地攻击就可能跳过这段减速。随后 onLivingUpdate 又会根据疾跑键重新开启疾跑,所以元数据不会直接取消本 tick 的疾跑移动,也不会直接修改位置。
三个读取时刻要分开:
1 | |
九、完整时间线
一次按住疾跑的攻击可以压缩成:
1 | |
疾跑攻击时的本地重置、客户端发送的 C0BPacketEntityAction、服务器攻击逻辑中的重置,以及服务器回传的 S1CPacketEntityMetadata,都是不同的步骤。它们修改和读取的不是同一个状态变量;按住疾跑时出现的不同步,正是这些步骤处于不同时间点的结果。