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
2
3
本地 isSprinting() != serverSprintState
-> 发送 START_SPRINTING 或 STOP_SPRINTING
-> 更新 serverSprintState

二、客户端一个 tick 的严格顺序

客户端主循环在 Minecraft.runGameLoop 中运行,每次 tick 调用 Minecraft.runTick。相关顺序是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. 处理已经到达客户端的服务器包
例如 S1CPacketEntityMetadata、S12PacketEntityVelocity

2. 处理本 tick 的鼠标和键盘输入
左键攻击在这里发生

3. PlayerControllerMP.attackEntity
发送 C02PacketUseEntity
立即执行一次本地攻击

4. World.updateEntities 更新本地实体
EntityPlayerSP.onLivingUpdate
维护疾跑
处理跳跃和移动

5. EntityPlayerSP.onUpdateWalkingPlayer
比较疾跑状态并发送 C0B
发送 C03 位置状态包

网络线程收到的包会通过 PacketThreadUtil 安排到客户端主线程,因此已经到达的元数据或速度包可以在本 tick 的鼠标攻击之前生效。Minecraft.runTick 中的鼠标处理位于 World.updateEntities 之前,所以本地攻击先发生,本地移动后发生。

三、客户端如何处理疾跑

1. setSprinting 的调用链

客户端调用 EntityPlayerSP.setSprinting 时,会进入父类逻辑,再维护客户端的疾跑计时器:

1
2
3
4
5
6
7
8
9
10
11
12
13
EntityPlayerSP.setSprinting(true)
-> EntityLivingBase.setSprinting(true)
-> Entity.setSprinting(true)
-> DataWatcher 疾跑标志 = true
-> 添加 movementSpeed 疾跑修饰符
-> sprintingTicksLeft = 600

EntityPlayerSP.setSprinting(false)
-> EntityLivingBase.setSprinting(false)
-> Entity.setSprinting(false)
-> DataWatcher 疾跑标志 = false
-> 移除 movementSpeed 疾跑修饰符
-> sprintingTicksLeft = 0

三个类的分工是:

  • Entity.setSprinting:修改实体标志位;
  • EntityLivingBase.setSprinting:添加或移除疾跑速度修饰符;
  • EntityPlayerSP.setSprinting:额外维护 sprintingTicksLeft。

2. onLivingUpdate 中的顺序

EntityPlayerSP.onLivingUpdate 每个本地 tick 执行。和疾跑有关的顺序是:

  1. 减少 sprintingTicksLeft,归零时关闭疾跑;
  2. 减少双击 W 使用的 sprintToggleTimer;
  3. 更新移动输入;
  4. 根据输入尝试开启疾跑;
  5. 如果前进不足、发生水平碰撞或饥饿,则关闭疾跑;
  6. 进入父类的跳跃和移动结算。

按住疾跑键的核心条件可以简化为:

1
2
3
4
5
没有疾跑
且前进输入 >= 0.8
且疾跑键按住
且没有使用物品、失明和饥饿
-> setSprinting(true)

自动停止可以简化为:

1
2
3
4
5
正在疾跑
且前进输入 < 0.8
或发生水平碰撞
或食物不足
-> setSprinting(false)

双击 W 还使用 sprintToggleTimer 保存 7 tick 的再次按键窗口。

3. 攻击先于移动,并在本地重置疾跑

Minecraft.runTick 处理左键时调用 PlayerControllerMP.attackEntity。它依次:

1
2
发送 C02PacketUseEntity
调用本地玩家的 attackTargetEntityWithCurrentItem

本地的 EntityPlayer.attackTargetEntityWithCurrentItem 先读取攻击瞬间的疾跑:

1
2
3
4
5
boolean sprintingAtAttack = this.isSprinting();

if (sprintingAtAttack) {
++knockbackLevel;
}

攻击结算成功后,如果击退等级大于 0:

1
2
3
4
targetEntity.addVelocity(...);
this.motionX *= 0.6D;
this.motionZ *= 0.6D;
this.setSprinting(false);

因此本地疾跑攻击会记录为疾跑攻击、降低水平 motion,并关闭本地疾跑;这一步发生在本地移动之前。

客户端这里是预测执行。远端玩家在客户端中是 EntityOtherPlayerMP,本地攻击可能已经完成,而服务器随后仍可能因为距离、视线或受伤无敌时间拒绝这次攻击。

4. tick 末尾发送 C0B 和 C03

本地移动完成后,EntityPlayerSP.onUpdateWalkingPlayer 比较本地疾跑和 serverSprintState:

1
2
3
4
5
6
7
8
9
10
11
boolean flag = this.isSprinting();

if (flag != this.serverSprintState) {
if (flag) {
send(START_SPRINTING);
} else {
send(STOP_SPRINTING);
}

this.serverSprintState = flag;
}

之后才发送 C03PacketPlayer。一次客户端 tick 的典型包顺序是:

1
2
3
C02PacketUseEntity       如果本 tick 攻击
C0BPacketEntityAction 如果疾跑状态发生变化
C03PacketPlayer 位置、视角和 onGround

C03PacketPlayer 没有客户端速度字段,服务器不会从它得到客户端的 motionX、motionY、motionZ。

四、服务器一个 tick 的严格顺序

服务器主循环在 MinecraftServer.updateTimeLightAndEntities 中运行:

1
2
3
4
5
6
7
1. 处理 futureTaskQueue 中已经到达的网络任务
按实际到达顺序处理 C02、C0B、C03

2. 更新 WorldServer 中的实体

3. 更新 EntityTrackerEntry
广播位置、速度和实体元数据

同一条连接保持客户端发包顺序。延迟会改变包到达的是哪一个服务器 tick,但不会把同一条连接中的包重新排序。

1. 服务器收到 C02

NetHandlerPlayServer.processUseEntity 会找到目标,检查目标存在、视线和距离,再在普通模式下调用 EntityPlayerMP.attackTargetEntityWithCurrentItem。

EntityPlayerMP 的普通攻击会转交给父类 EntityPlayer,所以真正计算疾跑、击退等级和攻击后重置的代码仍在 EntityPlayer.attackTargetEntityWithCurrentItem。

服务器攻击顺序是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
读取服务器 isSprinting()
-> 如果为 true,knockbackLevel 加 1

保存目标原来的 motion
调用 targetEntity.attackEntityFrom

如果伤害结算成功且 knockbackLevel > 0
-> 修改目标服务器侧速度
-> 攻击者 motionX/Z *= 0.6
-> 攻击者 setSprinting(false)

如果目标是 EntityPlayerMP 且速度发生变化
-> 发送 S12PacketEntityVelocity
-> 恢复目标被保存的旧 motion

服务器读取的是 C02 到达服务器这一刻的服务器疾跑状态。如果客户端的 C0B 尚未到达,服务器不会因为客户端本地正在疾跑就自动开启服务器疾跑。

2. 服务器收到 C0B

NetHandlerPlayServer.processEntityAction 直接执行:

1
2
3
4
5
6
7
case START_SPRINTING:
this.playerEntity.setSprinting(true);
break;

case STOP_SPRINTING:
this.playerEntity.setSprinting(false);
break;

服务器不会等待确认,也不会把收到的 C0B 原样回传。它只是客户端向服务器声明一次状态变化。

3. 服务器收到 C03

NetHandlerPlayServer.processPlayer 处理客户端位置报告。普通步行时,它会读取位置和 onGround,调用 playerEntity.onUpdateEntity(),计算报告位移,检查移动过快、碰撞和移动错误,必要时根据 onGround 变化调用 jump(),最后接受报告位置或回弹。

位置包没有速度字段。服务器的 motionX/Y/Z 是服务器自己的内部状态,客户端速度不会被复制到服务器。

五、服务器如何把疾跑状态发回客户端

1. setSprinting 修改实体元数据

Entity.setSprinting 最终调用:

1
2
3
public void setSprinting(boolean sprinting) {
this.setFlag(3, sprinting);
}

第 3 位是实体疾跑标志。服务器收到 START_SPRINTING、STOP_SPRINTING,或者服务器攻击代码主动重置疾跑时,都会经过这条路径。

2. EntityTrackerEntry 发送元数据

服务器每 tick 更新 EntityTrackerEntry。当 DataWatcher 有变化时,sendMetadataToAllAssociatedPlayers 创建 S1CPacketEntityMetadata。

这个包发给正在观察该实体的玩家。对于玩家实体,func_151261_b 还会把包发给被追踪玩家自己的连接:

1
2
3
4
服务器玩家 setSprinting(false)
-> DataWatcher 疾跑标志改变
-> EntityTrackerEntry 发现变化
-> S1CPacketEntityMetadata 发给观察者和玩家自己

客户端的 NetHandlerPlayClient.handleEntityMetadata 收到包后更新本地实体的 DataWatcher,但不更新 serverSprintState。

六、现象一:按住疾跑攻击为什么会不同步

假设攻击前:

1
2
3
客户端本地 isSprinting() = true
serverSprintState = true
服务器 isSprinting() = true

第一步:客户端发出 C02 并本地结算

PlayerControllerMP.attackEntity 先发送 C02PacketUseEntity,随后本地攻击代码读取到疾跑:

1
2
3
记录为疾跑攻击
降低本地 motionX/Z
setSprinting(false)

第二步:服务器收到 C02

服务器读取到自己的 isSprinting() == true,于是这次攻击有疾跑额外击退。攻击成功后,服务器执行 setSprinting(false)。

此时服务器已经是:

1
服务器 isSprinting() = false

第三步:客户端移动前又开启疾跑

如果玩家仍按住疾跑键,客户端随后进入 onLivingUpdate,再次满足开启条件并执行 setSprinting(true)。

到 tick 末尾:

1
2
本地 isSprinting() = true
serverSprintState = true

两者相等,所以客户端不发送 STOP_SPRINTING。服务器已经是 false,客户端却恢复为 true,于是产生不同步。

第四步:为什么要手动刷新

serverSprintState 只是客户端的发送记录,不是服务器确认值。服务器内部的攻击重置不会让客户端自动补发停止包。

要刷新服务器,必须让客户端真正产生一次新的状态变化:

1
2
本地关闭疾跑 -> 发送 STOP_SPRINTING
本地重新开启疾跑 -> 发送 START_SPRINTING

七、现象二:硬直期间重新同步后,下一次造成伤害的攻击仍有额外击退

服务器取消疾跑的代码位于攻击成功分支的后面。也就是说,只有 attackEntityFrom 真正接受了伤害,攻击方法才会继续执行击退,并在其中调用:

1
this.setSprinting(false);

受伤硬直或受伤无敌时间本身不会关闭服务器疾跑。硬直期间收到的一次攻击如果被 attackEntityFrom 拒绝,就不会进入击退分支,也不会执行服务器侧的 setSprinting(false)。

因此,假设玩家在硬直期间已经通过状态包把服务器疾跑重新打开:

1
2
STOP_SPRINTING  -> 服务器设为 false
START_SPRINTING -> 服务器设为 true

只要之后没有一笔真正造成伤害的攻击,服务器就会继续保持 isSprinting() == true。硬直期间的无效攻击不会把它关掉。

下一次攻击到达服务器时,服务器会先读取当前的 isSprinting() == true,把疾跑额外击退计入击退等级。只有这一次攻击最终造成伤害,攻击代码才会在击退结算后关闭服务器疾跑:

1
2
3
4
5
读取服务器疾跑 = true
-> 本次攻击获得疾跑额外击退
-> attackEntityFrom 接受伤害
-> 执行击退
-> setSprinting(false)

如果这一次攻击仍处于硬直保护而没有造成伤害,服务器疾跑仍然不会被关闭;它会保持到下一次造成伤害的攻击,或者直到收到新的 STOP_SPRINTING 包。

八、现象三:服务器回传的取消元数据可能影响本地攻击减速

服务器因攻击执行 setSprinting(false) 后,DataWatcher 发生变化。EntityTrackerEntry 随后可能把关闭疾跑的 S1CPacketEntityMetadata 发回攻击者自己的客户端。

如果这个包在下一次客户端 tick 的攻击处理之前到达,顺序可能是:

1
2
3
4
5
6
7
8
9
10
11
12
13
客户端 tick 开始
-> 处理 S1CPacketEntityMetadata
-> 本地 isSprinting() = false

处理左键攻击
-> 本地攻击读取 false
-> 不再把自己识别为疾跑攻击
-> 可能不执行 motionX/Z *= 0.6

进入 EntityPlayerSP.onLivingUpdate
-> 疾跑键仍按住
-> setSprinting(true)
-> 以疾跑状态进行本 tick 移动

本地攻击减速位于:

1
2
3
4
5
if (i > 0) {
this.motionX *= 0.6D;
this.motionZ *= 0.6D;
this.setSprinting(false);
}

如果元数据让攻击开始时的 isSprinting() 变成 false,且没有其他击退等级,本地攻击就可能跳过这段减速。随后 onLivingUpdate 又会根据疾跑键重新开启疾跑,所以元数据不会直接取消本 tick 的疾跑移动,也不会直接修改位置。

三个读取时刻要分开:

1
2
3
4
5
6
7
8
攻击处理时读取的状态
-> 决定本地攻击是否有疾跑减速

服务器处理攻击时读取的状态
-> 决定服务器击退是否有疾跑加成

移动处理时读取的状态
-> 决定本地移动是否使用疾跑速度

九、完整时间线

一次按住疾跑的攻击可以压缩成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
攻击前:
客户端本地疾跑 = true
serverSprintState = true
服务器疾跑 = true

客户端:
发送 C02
本地攻击读取 true
本地 setSprinting(false)

服务器:
收到 C02
读取服务器 true
计算疾跑额外击退
攻击成功后 setSprinting(false)

客户端:
进入 onLivingUpdate
疾跑键仍按住
重新 setSprinting(true)

客户端 tick 末尾:
本地 true == serverSprintState true
不发送 STOP_SPRINTING

结果:
服务器疾跑 = false
客户端本地疾跑 = true
serverSprintState = true

服务器追踪器:
发现 DataWatcher 改变
发送 S1CPacketEntityMetadata

客户端下一 tick:
元数据可能先把本地 isSprinting() 改为 false
之后攻击读取 false
再在移动阶段根据疾跑键恢复 true

疾跑攻击时的本地重置、客户端发送的 C0BPacketEntityAction、服务器攻击逻辑中的重置,以及服务器回传的 S1CPacketEntityMetadata,都是不同的步骤。它们修改和读取的不是同一个状态变量;按住疾跑时出现的不同步,正是这些步骤处于不同时间点的结果。


Minecraft 1.8.9 的疾跑状态同步与重置
https://wtazuresky.github.io/2026/08/28/sprint-state-sync-in-minecraft-1-8-9/
作者
WTAzureSky
发布于
2026年8月28日
许可协议