如何判断一次攻击能否开启 Combo
上一篇文章介绍了 Minecraft 1.8.9 的玩家同步:自己的位置由本地客户端立即更新,其他玩家的位置则来自服务器的位置包,并经过三 tick 插值。
有了这些基础,就可以严格定义一个问题:A 当前的这次攻击,能不能保证在生效过程中不被 B 打到,从而开启 Combo?
本文只讨论这一次攻击本身。它不预测后续如何追击,也不设计任何自动操作策略。
文中提到的类名都来自仓库 src/minecraft/net/minecraft/ 下的 1.8.9 源码:PlayerControllerMP 创建 C02PacketUseEntity 攻击包,NetHandlerPlayServer 在服务器处理攻击,EntityPlayer 执行伤害和击退,NetHandlerPlayClient 把 S12PacketEntityVelocity 速度包写入客户端实体的 motion(速度分量),EntityOtherPlayerMP 负责远端玩家的插值。代码片段均为保留这些行为的简化写法。
一、什么叫“这次攻击能 Combo”
一次攻击能够开启 Combo,需要同时满足两件事:
- A 的攻击确实在服务器生效,并使 B 进入被击退状态;
- 从窗口起点到击退开始改变 B 的位置,B 没有任何一次能够发出攻击包。
第二条需要按照最不利情况判断:假设 B 在每一个可能命中的 tick 都会攻击。只要 B 在其中一个 tick 能够发出攻击包,就不能保证这次攻击能够开启 Combo。
如果 B 恰好没有按下攻击键,只能说明这一次实战没有受到反击,不能说明攻击本身具备稳定的 Combo 条件。
二、B 的攻击判定使用哪两个位置
B 客户端执行攻击判定时,比较的不是服务器中 A 和 B 的位置,而是 B 客户端里的两份位置:
- B 自己的本地玩家位置;
- B 客户端中 A 的远端实体显示位置。
B 自己的位置由 B 客户端立即更新。A 的显示位置则来自服务器位置包,并经过 EntityOtherPlayerMP 的插值。
因此,某个 tick 的攻击距离应写成:
其中:
B_local(t)是 B 在该 tick 执行准星射线检测(raycast)时的本地位置;A_display_on_B(t)是同一时刻 B 客户端中 A 的插值显示位置。
这两个位置必须来自同一个 tick、同一个准星射线检测时刻。服务器位置、插值目标位置,以及其他 tick 的显示位置,都不能直接代替它们。
这里的 raycast 可以理解为:客户端从玩家视线方向发出一条射线,检查它首先碰到的方块或实体,并计算碰撞点距离。玩家按下左键时,客户端用这次检测判断准星是否指向可攻击的实体;检测使用的是客户端当前保存的实体位置。
在只研究一条水平轴时,距离可以简化为:
完整游戏中还需要使用实际的三维包围盒和视线检测。
三、一次攻击的危险窗口
设 A 在客户端 tick 发送攻击包。四条网络链路分别记作:
| 符号 | 链路 | 作用 |
|---|---|---|
| A 客户端到服务器 | 决定 A 的攻击何时由服务器处理 | |
| 服务器到 A 客户端 | 决定 A 何时收到服务器发来的结果 | |
| B 客户端到服务器 | 决定 B 的反击何时由服务器处理 | |
| 服务器到 B 客户端 | 决定 B 何时收到 A 造成的击退速度 |
四条延迟不能合并成一个固定的 ping。对当前这次 A 攻击而言,危险窗口的结束主要由 和 决定; 用来判断 B 的攻击包何时在服务器生效; 只影响 A 何时看到服务器返回的结果。
再把服务器排队和 tick 调度产生的额外等待记为 ,B 客户端处理包之前的额外等待记为 。服务器处理 A 攻击的时刻为:
服务器处理攻击后向 B 发送 S12PacketEntityVelocity 速度包。B 收到该包的时刻为:
代入后得到:
需要检查的是一条连续窗口:从需要回看的 B 过去位置开始,一直到 B 收到 A 造成的击退速度包:
是最早仍可能产生相关攻击包的 B 客户端检测时刻。它由 B 到服务器的实际延迟、服务器排队和已经记录的包决定,不是固定的 tick 数。窗口的后半段自然包含 A 出手后的检测,前半段则覆盖 B 在 A 出手前已经处于攻击位置、并可能发出仍在路上的攻击包的时刻。窗口的 tick 数为:
实际判定应读取这条时间轴上的位置、攻击包发送和速度包到达时刻。
下文的 tick 编号表示同一条事件时间轴上的离散时刻,便于把两台客户端和服务器的事件放在一起比较;它不是三台机器各自独立的本地计数器。
为什么窗口要从过去的位置开始
A 的本地攻击建立在当前看到的 B 上。B 的位置更新先经过服务器到达 A,再由 A 客户端更新插值目标;A 在随后的准星射线检测中命中这个远端实体,才发送当前这次攻击。因此,B 的位置包到达 A 只是在准备这次攻击所使用的观察状态,当前攻击真正的起点仍是 。
窗口不能只从 开始。B 在更早的 tick 可能已经处于攻击距离内并发出攻击包,这个包在 A 出手时可能仍在网络链路中。即使 A 的画面还没有显示受击,也不能把这段过去的 B 位置从判定中删掉。
B 发出的攻击包是发给服务器的,A 客户端不会直接收到它。A 的客户端在出手前没有显示受伤,只能说明反馈尚未显示,不能证明此前没有一笔 B 攻击已经在服务器结算,也不能证明网络中不存在在途攻击。
为什么收到速度包的 tick 仍然危险
B 客户端一个 tick 中的相关顺序是:
1 | |
B 收到速度包时,改变的是 motion,位置不会立即跳走。B 仍然会使用当前的位置执行一次 raycast;新的速度到移动阶段才第一次改变 B 的位置。
所以危险窗口必须包含 。窗口结束点不是“B 收到速度包”,而是“B 使用新速度完成第一次移动”。换成 tick 边界描述,就是最后一次危险准星射线检测发生在 ,击退第一次改变位置后的状态出现在 。
为什么击退第一次移动后可以结束窗口
在正常的正面 PvP 场景中,击退给 B 的水平速度改变量指向远离 A 的方向。记 B 向 A 奔跑时能够产生的最大向内速度为 ,击退带来的向外速度改变量为 。
当:
击退生效后的第一段运动仍然指向远离 A。B 即使继续按住前进,也无法用正常奔跑抵消这次速度突变。原版击退造成的速度改变量远大于玩家平跑时能够保持的最高向内速度,这正是击退能够迅速拉开位置的原因。
要证明 B 眼中的攻击距离也在扩大,还要比较 B 的向外位移和 A 在 B 视角中的靠近位移。记 B 第一次击退移动的向外位移为 ,同一 tick 内 A 的远端显示位置向 B 靠近的位移为 。当:
就有:
也就是说,B 在击退前最后一次 raycast 已经没有命中,击退第一次改变位置后,B 与其画面中 A 的距离只会更远,不会凭空产生一次新的近距离攻击机会。
因此,判定“这次攻击能否开启 Combo”只需要覆盖击退第一次改变 B 位置之前的攻击机会。后续追击是否还能继续命中,属于维持 Combo 的另一个问题。
这个结论依赖击退方向确实远离 A,并且第一次击退位移满足上面的相对距离条件。场景中若存在墙体反弹、拉回、传送、额外速度修改,或者 A 的显示位置靠近得更快,窗口就不能在这里直接结束,而应继续检查后续 tick。
位置包和速度包不能混淆
B 的普通位置包到达 A,只决定 A 客户端里 B 的远端实体目标和插值位置,也就是 A 的本地准星射线检测能否命中 B。它不决定危险窗口何时结束。
危险窗口的结束由 B 收到的击退速度包决定。位置包更新远端显示,速度包则直接改变 B 自己客户端的 motion;只有后者会在随后的移动阶段把 B 推离 A。
四、逐 tick 检查 B 是否能够发出攻击
在完整窗口中的每一个 B 客户端 tick,都取准星射线检测时刻的两份真实状态:
1 | |
然后按照 B 客户端正常的准星射线检测和攻击规则检查:
- A 是否位于 B 的攻击方向和视线内;
- 准星射线是否命中 A 的包围盒;
- 命中距离是否在 B 的本地攻击范围内。
如果只讨论一维水平距离,本地判定可以简化为:
其中 是 B 客户端的本地攻击距离。
窗口中的每一个 tick 如果都满足:
那么 B 在整个窗口中都无法发出攻击包,这已经足以证明 A 不会被反击。这里使用严格大于,因为距离刚好等于攻击范围时,准星射线仍可能命中目标。
反过来, 就表示 B 在该 tick 具备发出攻击包的客户端条件。为了判断这次攻击是否能保证 Combo,只要窗口中出现一次这样的机会,就必须判定为存在反击风险。
窗口中的每个 tick 都使用同一套位置配对。若某个 的准星射线能够命中 A,就说明 B 可以在这一拍发出攻击包;在最不利情况分析中,应按 B 会发出这个包处理。窗口前面的 tick 找出已经发生或正在路上的反击,后面的 tick 检查 A 出手后、击退速度到达前的新反击。
位置包在某个 tick 开头到达 B 时,只会更新 A 的插值目标和剩余插值次数,不会立即改变 A 的显示位置。该 tick 的准星射线检测仍使用当前 A_display_on_B;插值在随后更新远端实体时推进,其结果到下一个 tick 才参与攻击判定。
五、窗口中的攻击包如何到达
窗口中的任意 tick,B 都可能发出攻击包。这个包会先从 B 到服务器,再由服务器产生对 A 的结果反馈;它不是直接发给 A,但发送时刻决定了它可能在什么时候影响 A。
击退不会取消已经发出的攻击包。即使 B 在当前画面中已经超出攻击范围,窗口前面发出的攻击仍可能在 A 出手之后才到达服务器或让 A 收到反馈。
对窗口中的每个包,都可以记录四个时刻:B 发包的时刻 、它到达服务器的时刻 、服务器产生结果的时刻,以及 A 收到结果反馈的时刻。这里的判定只关心 B 是否已经具备发包条件,不把服务器后续是否再次检查攻击数据包作为窗口缩短或风险消失的依据。
可以把窗口内可能发出的攻击包写成一个集合:
判定时对 只检查其发送时刻是否落在窗口内;只要存在一个窗口 tick 能发出攻击包,这次 A 攻击就不能保证 Combo。
A 客户端不会直接收到 B 发出的 C02PacketUseEntity;这个包只从 B 发往服务器。A 后续收到的受击、速度或其他状态包只是反馈。因此,“A 当前还没有在画面中受伤”不能证明窗口中没有已经发出的 B 攻击包。
在途攻击的具体时间线
假设 B 在 tick 97 的本地准星检测中命中 A,于是发出攻击包。这个包不是直接发给 A,而是沿着 B 到服务器的链路传输。设它在 tick 99 到达服务器,服务器随后产生的受击反馈要到 tick 101 才到达 A。
如果 A 在 tick 100 发出当前攻击,那么时间顺序是:
1 | |
tick 97 已经属于完整窗口 。只要 B 在这一拍具备发出攻击包的条件,这次 A 攻击就不能被判定为“保证不被 B 打到”;A 在 tick 100 是否已经看到反馈,不改变这个结论。这个判断不依赖把攻击包再交给服务器做一次距离或视线筛选。
六、A 的攻击本身也必须有效
A 客户端发出当前攻击包,并且这次攻击确实让 B 收到击退速度包,才有必要继续判断窗口。本文把这两个条件作为当前攻击已经成立的前提。
因此,Combo 判定还需要确认:
- A 的本地 raycast 命中了 B,因此攻击包确实被发送;
- B 实际进入了这次攻击造成的击退状态。
如果攻击包没有发送,或者没有产生预期的击退,那么后面的“B 是否能够反击”已经没有意义,这次攻击不能开启 Combo。
七、完整判定条件
完整窗口就是 。一次攻击能够保证开启 Combo,需要下面的条件全部成立:
1 | |
下面的一维距离条件是第 3 条的直接充分条件:
完整伪代码如下:
1 | |
如果所有 tick 的 B 本地准星射线检测都落空,窗口内就没有 B 的攻击包机会;如果某个 tick 能发出攻击包,这次 A 攻击就不能保证 Combo。
判定所需的数据来自双方客户端和网络时序:每个 tick 的准星射线检测、位置状态、攻击包发送,以及速度包和攻击结果反馈的到达时刻。
八、具体判断例子
下面用一组假设数据完整走一遍判断流程。数字只用于说明计算方法。
1. 先确定时间
假设 A 在客户端 tick 100 发出攻击包。A 到服务器需要 2 tick,服务器在收到包后立即处理;服务器发给 B 的速度包需要 3 tick。
于是:
在这个例子中,根据 B 到服务器的实际延迟和包队列,需要回看 tick 97 到 tick 99;因此完整窗口是从 tick 97 一直连续到 tick 105:
在 tick 102,服务器处理 A 的攻击并向 B 发送击退速度。B 要到 tick 105 才收到速度包;但 tick 105 仍会先用旧位置进行一次准星射线检测,随后才在移动阶段应用击退速度。
假设 B 的本地攻击距离为 ,每一行都使用同一个 tick 中 B 的本地位置和 B 眼中 A 的插值显示位置:
| B tick | B 眼中的距离 | 本地准星检测 | 攻击包时间线 |
|---|---|---|---|
| 97 | 2.90 | 可以命中 | 发出,随后到达服务器 |
| 98 | 3.12 | 未命中 | 无 |
| 99 | 3.18 | 未命中 | 无 |
| 100 | 3.35 | 未命中 | 无 |
| 101 | 3.22 | 未命中 | 无 |
| 102 | 3.18 | 未命中 | 无 |
| 103 | 3.26 | 未命中 | 无 |
| 104 | 3.41 | 未命中 | 无 |
| 105 | 3.80 | 未命中 | 无 |
tick 97 已经在完整窗口中。B 在这一拍可以发出攻击包;即使这个包要经过延迟,A 到 tick 101 才看到反馈,当前 A 攻击仍不能保证 Combo。后面的 tick 98 到 105 全部落空,也不能抵消 tick 97 已经存在的反击机会。
如果把 tick 97 的距离也变成大于 ,使窗口中每一个 tick 都满足:
那么 B 在整条窗口内都无法发出攻击包;此时 A 的攻击在已确认生效并让 B 收到击退的前提下,才可以判定为能够开启 Combo。
2. 得出结论
这个例子体现了完整判定顺序:先根据实际延迟确定从过去位置到 B 收到击退的整条窗口,再逐 tick 检查 B 的本地位置和 B 眼中的 A;窗口中任何一拍能发出攻击包,都足以让这次 A 攻击失去“保证 Combo”的条件。