定点数不是一种能自动保证同步的魔法。它真正做的,是把连续小数压成整数刻度,并把表示、舍入和空间边界都变成可测试、可序列化、可纳入协议的规则。
我追问的链
- 帧同步为什么怕浮点数;同样的代码难道不会算出同样的结果吗?
- 定点数是不是把所有小数都乘以一个倍率,存成整数?
- 倍率为什么选 1024,不选 100、1000 或越大越好?
- 加减乘除之后,那个倍率怎样保持不变?
Math.trunc、Math.floor和Math.round有什么本质区别?- 为什么乘除向 0 截断,负坐标转格子却必须向负无穷取整?
- JavaScript 的
number本身仍是浮点数,凭什么可以拿它保存定点整数? - 角色速度不能整除一格时,怎样保证最终仍落在格心?
- 边界刚好接触算不算碰撞;这个细节为什么也会导致状态分叉?
- 定点数与固定帧、快照、Hash 和表现插值到底怎样咬合?
1. 先纠正一个误解:问题不只是“浮点数不精确”
二进制浮点无法精确表示很多十进制小数:
1 | 0.1 + 0.2 !== 0.3 |
但这还不是帧同步问题的完整答案。同一运行环境、相同运算顺序下,简单浮点运算往往仍能重复得到相同结果。真正危险的是,游戏状态会经历成千上万次迭代,并把很小的差异送进分支条件:
1 | 这一帧是否已经越过格心? |
一旦两个客户端在某个边界上作出不同判断,之后执行的逻辑、随机数调用甚至实体生成顺序都可能不同。微小数值差异会被离散决策放大成整个状态历史的分叉。
定点数的目标不是证明“浮点一定不同”,而是减少隐含数值规则:不用反复累计二进制小数,也不让每个系统自行决定何时取整。
2. 定点数到底是什么
先约定一个固定倍率:
1 | ONE = 1024 |
真实量与存储值的关系是:
1 | 存储值 = real × ONE |
于是常用位置变成:
| 逻辑长度 | 定点值 |
|---|---|
| 0 格 | 0 |
| 1/8 格 | 128 |
| 1/4 格 | 256 |
| 1/2 格 | 512 |
| 1 格 | 1024 |
| 1.5 格 | 1536 |
格子 c 的中心位置是:
1 | center(c) = c × 1024 + 512 |
第 0、1、2 格的中心因此分别是 512、1536、2560。权威状态里只保存这些整数,渲染时才把它换成像素坐标。
3. 为什么这里选择 1024
1024 是 2^10。对格子游戏来说,它有几个朴素好处:
- 1/2、1/4、1/8、1/16 格都能精确表示;
- 128、256、512、1024 这些边界容易辨认和调试;
- 对逐格移动与矩形碰撞已经足够细;
- 数值规模不大,给 JavaScript 安全整数留下更多余量。
倍率不是越大越好。倍率越大,最小刻度越细,但乘法的中间值也增长得越快,更容易越过安全整数范围。
倍率也不必是 2 的幂。选择 1024 是当前游戏规则的一部分,不代表应该用 JavaScript 位移运算代替乘除。一旦快照、回放或联网协议已经使用它,把 1024 改成 1000 就是模拟版本变化,而不是普通的实现重构。
4. 加减容易,乘除必须恢复一次倍率
相同单位的定点数可以直接加减:
1 | 1 格 + 0.5 格 |
两个定点数相乘,会多出一份倍率:
1 | fixedMultiply(a, b) = trunc(a × b / 1024) |
例如:
1 | 1.5 × 1.5 |
定点除法则要先给被除数补一份倍率:
1 | fixedDivide(a, b) = trunc(a × 1024 / b) |
例如:
1 | 1 / 3 |
这里最容易漏掉的不是最终结果,而是中间值。a × b 或 a × 1024 如果已经失去整数精度,后面再除回来也无法恢复。因此定点实现必须检查输入、中间值和最终值,而不是只检查函数返回值。
5. 舍入不是工具细节,而是模拟规则
这个实验中的定点乘除统一使用 Math.trunc,也就是向 0 截断:
1 | trunc( 341.8) = 341 |
选择 trunc、round 或 floor 没有脱离上下文的唯一答案。重要的是:所有客户端、所有相关系统和所有测试必须遵守同一个答案。
但坐标转格子不能沿用 trunc。空间被划分成半开区间:
1 | 第 -1 格:[-1024, 0) |
所以坐标归属应该使用:
1 | cell(x) = floor(x / 1024) |
关键反例是原点左侧的 x = -1:
1 | trunc(-1 / 1024) = 0 // 错:被划进第 0 格 |
因此两种取整各有职责:
trunc定义定点乘除丢弃余数的方式;floor定义一个坐标属于哪个空间区间。
为了代码风格统一而强行只留一种取整函数,反而会破坏规则。
6. 定点空间必须和固定逻辑帧配套
如果位置虽然存成整数,移动时却仍把渲染帧的 deltaTime 乘进权威状态,设备调度差异又回来了:
1 | 错误边界:position += speedPerSecond × renderDeltaTime |
更稳定的权威规则是:
1 | position += speedPerLogicFrame |
例如逻辑频率为 30 Hz,每帧移动 128 个定点单位:
1 | 1024 / 128 = 8 帧走一格 |
无论设备每秒渲染 30、60 还是 144 帧,只要执行的是同一批逻辑帧,权威位置就相同。渲染可以在相邻逻辑状态之间插值,但插值结果只能用于显示,不能写回模拟。
1 | 权威定点状态 → 表现插值 → Cocos 节点坐标 |
这条数据流必须保持单向。
7. 速度不能整除一格,为什么也不会漂移
角色从一个格心走向下一个格心。如果每帧无条件加速度,speedPerFrame = 100 时会这样:
1 | 目标距离:1024 |
一旦越过,position % 1024 === 512 可能再也不能在预期时刻成立,转向和碰撞都会错位。
解决办法不是要求所有速度都能整除 1024,而是让最后一步只走剩余距离:
1 | actualMove = min(speedPerFrame, distanceToNextCenter) |
同样使用速度 100:
1 | 前 10 帧:10 × 100 = 1000 |
这里真正维持的是状态不变量:
- 静止或转向时,角色位于格心;
- 横向移动时 y 保持在格心;
- 纵向移动时 x 保持在格心;
- 两个轴不会同时偏离格心。
快照恢复也应该重新验证这些不变量。字段都是整数,只能证明数据“长得像”状态,不能证明这个状态真的可能由游戏规则产生。
8. 碰撞边界也必须只有一个答案
角色用中心点和半宽表示。判断它与一个格子是否重叠时,可以使用严格不等式:
1 | playerRight > cellLeft |
这样“边界刚好接触”不算重叠。如果改成 >=,角色站在两个格子的分界线上时,可能同时被判定占用两格。
把碰撞盒最大边界换算成格子索引时,还有一个不起眼的 -1:
1 | maxCell = floor((center + halfSize - 1) / 1024) |
假设右边界正好是 2048,它只接触第 2 格的左边缘,并未进入第 2 格。计算 2047 所在的格子,才符合 [min, max) 的半开区间语义。
这类边界规则必须同时出现在移动、碰撞、快照校验与测试中。只修改其中一处,就可能出现“运行时允许、恢复时拒绝”或相反的矛盾。
9. JavaScript number 为什么还能保存定点数
TypeScript 的 number 在运行时是 IEEE-754 双精度浮点数,但它能精确表示安全整数区间内的每一个整数:
1 | [-(2^53 - 1), 2^53 - 1] |
所以可以用 number 保存定点整数,前提是每个权威值始终满足:
- 是有限值,不是
NaN或Infinity; - 没有小数部分;
- 在
Number.MIN_SAFE_INTEGER到Number.MAX_SAFE_INTEGER之间; - 乘除的中间值也没有越界。
JSON 没有独立的整数类型。网络收到 1024 后,运行时仍把它解析成 number,所以快照和配置进入模拟前必须重新校验,不能用 TypeScript 的 as BattleState 假装外部数据已经可信。
也不要用下面这些写法“加速”定点计算:
1 | x | 0 |
JavaScript 位运算会先把值转换成 32 位整数,远早于安全整数上限就可能截断或翻转符号。PRNG 或 Hash 如果采用 32 位位运算,那是它们各自的算法定义,不能推广到坐标。
10. 快照和 Hash 为什么要直接吃原始整数
快照应该序列化权威定点值,而不是 Cocos 节点的像素坐标。回滚或中途加入时,客户端恢复的是可以继续计算的模拟状态,而不是一次显示近似值。
状态 Hash 也应按稳定字段顺序读取同一批原始整数:
1 | 相同初始状态 |
以下内容都属于协议兼容性的一部分:
- 定点倍率
ONE; - 乘除的舍入方式;
- 坐标转格子的取整方式;
- 格心定义;
- 接触边界是否算重叠;
- 每帧速度及合法范围。
修改其中任何一项,都可能让旧回放和旧快照产生不同结果。正确做法是升级模拟版本,并阻止不同版本进入同一房间。
11. 定点数解决不了什么
定点数只统一数值表示与取整边界,它不能替代完整的确定性设计。下面任何一项不同,状态仍然会分叉:
- 一端多执行或少执行一个逻辑帧;
- 系统、实体或集合遍历顺序不同;
- 随机算法、种子、状态或调用次数不同;
- 客户端使用不同配置或模拟版本;
- 把墙钟时间、Cocos 物理结果或节点坐标写回权威状态;
- 表现层为了好看而补出的插值、粒子或音效反过来影响逻辑。
因此正确关系是:
1 | 确定性模拟 |
定点数只是其中一根支柱。
12. 为什么这个炸弹人实验不选其他方案
只保存格子索引
适合棋盘式瞬移,却无法表达角色在两个格心之间的移动,也不方便验证途中碰撞和表现插值。
使用 BigInt
能扩大整数范围,但会增加旧版 Cocos Creator 目标环境、JSON 序列化和工具链的兼容成本。当前地图尺度很小,安全整数加入口与中间值检查已经够用。
引入完整定点物理库
当前规则只有格子移动、矩形边界、炸弹和火焰,没有旋转刚体、冲量、摩擦或复杂多边形碰撞。完整物理库会扩大规则与验证面积,却不能代替固定帧、稳定顺序和确定性随机数。
13. 四个最小实验
实验一:速度能整除一格
令 speedPerFrame = 128,角色应恰好用 8 个逻辑帧从一个格心到达下一个格心。
实验二:速度不能整除一格
令 speedPerFrame = 100,第 11 帧应该只移动剩余的 24。取消最后一步截断后,观察角色越过格心,以及 Hash 从哪一帧开始分叉。
实验三:负坐标归属
1 | toCell(-1) = -1 |
这个实验直接说明空间换算为什么不能使用向 0 截断。
实验四:非法快照
构造 x、y 两个轴都偏离格心,或横向移动时 y 不在格心的状态。快照解码器应该拒绝它,而不是因为字段类型都是 number 就放行。
逻辑闭环 / 锚点
这条兔子洞追到底,地基不是“整数比浮点高级”,而是:
帧同步要求所有参与者对同一状态转换作出唯一解释。定点数把连续空间变成离散刻度;舍入、边界、固定帧和版本协议再把“唯一解释”钉死。
只有表示而没有规则,整数照样会分叉;只有相同代码而没有输入、顺序和版本约束,定点数也救不了同步。
证据边界
本文中的 1024 倍率、向 0 截断、负坐标向下取整、速度 100 的格心截断和快照状态不变量,均来自一个 Cocos Creator 2.4.13 炸弹人帧同步实验的当前实现与单元测试。
这些测试能证明当前 JavaScript 测试环境满足断言,不能单独证明所有目标设备、原生构建和未来代码版本都必然一致。发布前仍应分别进行目标平台构建、相同输入回放、逐帧 Hash 对比与首个分叉字段诊断。
关联
- 和 事件循环 vs 游戏帧循环 共同说明:渲染帧、逻辑帧和 JavaScript 任务调度不是同一层时间。
- 和 分层与封装 是同一个母题:权威模拟与表现层各自维护清晰边界。
- 后续可继续追问固定帧、确定性随机数、状态 Hash、预测与回滚。
来源:Cocos Creator 2.4.13 炸弹人帧同步实验与代码验证,2026-09。