fix: 物理录制器区分「被服务端推回」与「输入没进物理」

原来 frozenTicks 只看「有输入 + tick 级无位移 + 物理没报告碰撞」,
但服务端推回发生在 tick 之间,tick 级位移同样是 0 —— 实测大厅里就是这个形态:
collidedHorizontally = false、frozenTicks = 194,而物理其实每 tick 都在动。

现在再按水平速度是否非零细分:
- pushedBackTicks:速度非零 → 物理确实动了,位移被服务端位置包抹掉;
- frozenTicks:速度也是 0 → 这才是「输入根本没进物理」。

类注释里的判定表也同步改成三分法(被挡住 / 被推回 / 输入没生效)。

测试 469 → 470。
这个提交包含在:
lyizai
2026-10-01 16:28:14 +08:00
父节点 468c719e37
当前提交 5513ef9f5f
共修改 5 个文件,包含 57 行新增和 7 行删除
+18 -4
查看文件
@@ -14,9 +14,10 @@
* | --- | --- |
* | `control.forward = true` 但速度恒为 0、位置不动、`collidedHorizontally = false` | **输入没进物理**(applyHeading 没被调用 / control 快照是空的) |
* | 速度非 0 但位置不动、`collidedHorizontally = true` | **被方块挡住**(地形问题,或脚下/身旁方块读错) |
* | 速度与位置都在变,但服务端把位置拉回 | **服务端回弹**(此时才轮到反作弊那条线) |
* | 速度非 0、位置不动、`collidedHorizontally = false` | **被服务端推回**(推回发生在 tick 之间,所以 tick 级位移为 0) |
*
* 所以 `isCollidedHorizontally` 是这里最关键的一个字段。
* 所以 `isCollidedHorizontally` 与「速度是否非零」是这里最关键的两个字段:
* 前者把「被挡住」与其余情况分开,后者把「输入没生效」与「被推回」分开。
*/
/** 默认保留多少个 tick(20 TPS 下约 12 秒) */
@@ -143,11 +144,16 @@ class PhysicsRecorder {
const all = this.ticks;
const hasInput = (t) => t.control.forward || t.control.back || t.control.left || t.control.right;
const hasMoved = (t) => Math.abs(t.dx) > MOVE_EPSILON || Math.abs(t.dz) > MOVE_EPSILON;
const hasHorizVelocity = (t) => Math.hypot(t.vx, t.vz) > MOVE_EPSILON;
const inputTicks = all.filter(hasInput);
const inputMoved = inputTicks.filter(hasMoved);
const blocked = inputTicks.filter((t) => !hasMoved(t) && t.collidedHorizontally);
const frozen = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally);
// 有输入、无位移、物理也没报告碰撞时,还要看速度:
// 速度非零说明物理确实动了,只是位移被服务端的位置包抹掉了(推回发生在 tick 之间);
// 速度也是 0 才是「输入根本没进物理」。
const pushedBack = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally && hasHorizVelocity(t));
const frozen = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally && !hasHorizVelocity(t));
let ticks = all;
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : all.length;
@@ -169,7 +175,15 @@ class PhysicsRecorder {
inputMovedTicks: inputMoved.length,
/** 有输入、没位移、且物理报告水平碰撞:被方块挡住 */
blockedTicks: blocked.length,
/** 有输入、没位移、且物理没报告碰撞:输入根本没生效 */
/**
* 有输入、没位移、物理没报告碰撞、但**水平速度非零**:
* 物理确实在动,位移却被服务端的位置包抹掉了(实测大厅里就是这个形态)。
*/
pushedBackTicks: pushedBack.length,
/**
* 有输入、没位移、物理没报告碰撞、**水平速度也是 0**:
* 这才是「输入根本没进物理」(applyHeading 没被调用 / control 快照为空)。
*/
frozenTicks: frozen.length,
movedTicks: all.filter(hasMoved).length,
maxHorizDelta: Number(maxHoriz.toFixed(ROUND_DIGITS)),