之前写引擎构成那篇的时候,我把时间系统说成”最 CDDA 的设计”。
写的时候我犹豫了一下要不要加一句”也是返工最多的”。最后没写,因为那句会把这个坑挖得太浅——它值得单独一篇。
这篇就是那些返工。全部是实打实的、在我自己的代码里发生过的事。
一、我把”回合数”写成了计数器
「1 move = 1 游戏秒、标准动作 100 moves」这套口径定下来之后,很自然就想要一个 turns() 接口——反正 UI 要显示、测试要断言。
我第一版是这么写的:
void Clock::advance(std::int64_t m) { moves_ += m; ++turns_; } // ← 错在这
看起来没毛病:走一步加一次、等一会儿加一次。
然后测试发现,“原地等 1 小时”和”睡 100 秒”,回合数都只 +1。
为什么?因为 turns_ 数的是调用次数,不是时间。等 1 小时是一次 advance(3600),睡 100 秒是一次 advance(100)——各调用了一次,各 +1。
这就是典型的把派生量当独立状态维护。回合数是时间的函数,它不该有自己的存储:
std::int64_t turns() const { return moves_ / kMovesPerTurn; }
一行的事。但如果一开始没这么写,之后每一个”按回合触发”的机制都会踩在错的节拍上。
教训:「能算出来的东西不要存」——存了就要同步,要同步就有漏掉同步的路径。
二、31 亿秒:一个默认值类型引发的读档回绕
时间系统跑顺之后,我想验证”长时间游玩会不会溢出”。
算一下:一年 31,536,000 moves(365 天 × 86400 秒)。int 上限约 21.4 亿 → 约 68 游戏年就会溢出。
所以我老老实实把 Clock 的成员全换成 std::int64_t。
我以为这事完了。直到某次读档,游戏里的时间突然跳到负数。
定位结果很荒谬:存档里的数字是对的,读的时候被截断成 int32 了。
根因在这一行:
clock_.moves_ = root.value("moves", 0); // 默认值写的是 0(int)
nlohmann::json 的 value() 是模板函数,它按默认值的类型实例化。默认值写 0,得到的就是 int 版本 → 一个 30 亿的数读进来,先回绕成 -1294967196,再赋给 int64_t。
正确写法是 0LL。
更气的是,同一个文件里其他字段我都写了 0LL,唯独 Clock 那一行漏了。
排查这条时还有个技巧值得记一下:不要真的跑 95 游戏年去验证溢出。 直接手工构造一个存档,把 moves 字段写成 3000000000,然后 --load 一下——走读档路径就能立刻暴露回绕,几秒钟的事。
教训:读档截断类的 bug,逐字段检查默认值后缀;验证位宽问题靠构造数据,不靠熬时间。
三、挂在错误的层:快进 7 天,什么都没发生
这个坑的形状特别典型。
项目里有一批”按周期结算”的机制:尸体的腐烂进度、世界环境的渐进变化、地形侵蚀之类的。我写的时候觉得挂哪儿都行,就挂在了行动主循环那一层——每次处理一个玩家动作,顺手 tick 一下。
日常玩的时候完全正常。问题出在测试:
快进 7 天,这些机制的数值恒为 0。
原因是我有两条推进时间的路径:
| 路径 | 走哪儿 |
|---|---|
| 玩家走路、正常行动 | 行动主循环 → 里面调 tick |
等待(wait_seconds)、睡觉(sleep_until_hour) | 一个 while 循环里只调 clock_.advance() 和 tick_vitals() |
快进和睡觉走的是第二条路,压根不经过主循环——我在那台电梯里等了一整天,电梯没动。
修法是把周期机制统一挂到 tick_vitals():它是走路、等待、睡觉三条路唯一的公共结算点。
这里还有个我觉得更通用的教训。当时我不确定”到底挂哪儿才对”,最后的判定方法不是读代码猜,而是挑一条分片长度小于周期阈值的路径去实测:
- 等待的分片是固定的 3600 秒
- 把某个周期的阈值定成 7,344 秒(约 1.7 天)
那么单看任何一片都不够一格。这时候如果跑 7 片(25,200 秒)得到 15、跑 19 片(68,400 秒)得到 45 —— 就证明余数是跨片累积的(逐片取整的话必然恒为 0)。
教训:不确定一段逻辑挂在哪一层,别靠读代码推理。选一条”只有这一层才会经过”的路径,跑一次看数字。
四、走路一小时和等待一小时,差了 36 倍
上一节的问题解决了,但我没意识到它是一类问题的第一个。
真正的病根是:这些周期机制的写法是”每被调用一次就累加一点”。
void tick_implants() { implant_accum_ += 1; if (implant_accum_ < N) return; ... } // ← 错
+1 累加的是调用次数,而调用频率完全取决于玩家在干什么:
- 原地等待 1 小时 = 1 次调用
- 走路 1 小时 = 36 次调用(每步 100 秒)
同样的 3600 秒游戏时间,一个是 1 次,一个是 36 次——速率差 36 倍。
这意味着”装了个植入体、等着它充能”和”一边走路一边充能”,效率能差一个量级。玩家的最优策略变成了”站着不动挂机”,这不可能是设计意图。
正确写法是按真实经过的秒数驱动,并且余数跨片保留:
accum_ += dt_sec;
if (accum_ < kSecPerStep) return;
const auto steps = accum_ / kSecPerStep;
accum_ -= steps * kSecPerStep; // 扣掉已结算的,余数留下
// ... 用 steps 结算
我第一次修的时候只改了几个”明显的”,后来用同一个方法(对比等待 1 小时与走路 1 小时的速率)重新扫了一遍 tick_vitals(),又抓出 6 个:体温与致伤、植入体能源池、两套冷却、变异、僵尸状态。全都是同一个写法。
教训:一个函数里发现了 N 处同一写法的 bug,别只修你看见的那几处——把整个函数按同一个判据过一遍。同一批代码是同一个人的同一种习惯。
五、余数别扔(这条差点改变了游戏平衡)
改完速率之后,代码是这样的:
accum_ += dt_sec;
if (accum_ < kSecPerStep) return;
steps = accum_ / kSecPerStep;
accum_ = 0; // ← 余数扔了
看着没问题。但有一次我想让饥饿预警的档位更细一点,把等待的分片从 3600 秒改成 600 秒。
结果:两个我根本没碰过的数值门禁同时红了。
- 三天后的口渴值:150 → 109
- 死因从”渴死”变成”缺乏症”
根因是结算函数里有个不起眼的写法:池子空的时候,不足 1 点的余数会被丢弃。
if (vital.water <= 0) water_frac_ = 0.0f;
分片从 3600 秒细化到 600 秒,同样的 3 天被拆成 6 倍结算次数 → 余数被丢弃 6 次 → 口渴整体推进变慢。
动一个”分片长度”,等于动了平衡。 而且症状是两个互不相干的数值门禁同时红、方向一致。
所以后来我给自己定了条诊断规则:两个不相关的数值门禁同时红、且方向一致(都是变快或变慢)→ 先查这次有没有动结算频率或时间推进,别去查那两个系统本身。
教训:改结算频率之前,先确认结算函数是纯线性的。有”池空丢弃余数""每片至少 X”这类写法时,细化分片 = 改数值。
六、调试开关不该改变时间语义
我有 --god(无敌)模式,用来做长时段的快进测试。
当时的实现很偷懒——在结算函数中间插一行:
void tick_vitals() {
tick_hunger();
if (debug_god_) return; // ← 后面全跳过了
tick_thirst();
tick_periodic_stuff(); // 包括那批周期机制
...
}
于是 --god 快进 14 天,那些周期机制的数值恒为 0。我一开始以为是我自己的周期机制又坏了,回头查了一遍才发现:是 god 把它们一起跳过了。
--god 应该只豁免死亡判定,而不是”从这个 return 往下所有结算”。改成精确作用:
if (!god_immune && vital.hp <= 0) { die("..."); }
教训:调试开关的语义要尽可能窄。一个 return 的作用域比你想的大得多。
顺带记一下:--god 只豁免饿和渴,不豁免营养缺乏症。所以想跑过 7.3 天,还得同时用参数把营养池灌满。当时的症状很有迷惑性——不同的 --wait-sec 得到完全相同的 moves,说明时间被死亡钳住了,而不是机制没跑。
七、怎么证明”真的修好了”
上面这些坑,一半的排查时间花在”我不确定现在这个数对不对”上。
最后我用的判据叫分片不变性——比”跑一段长时间看数字”强得多:
同一个经过时间,用「一次推进」vs「两段推进(中间存读档续档)」vs「三段推进」跑,终态必须逐位相同。
实测 7200 秒,三个数字分别是:
冷却 28 / 28 / 28
变异 144 / 144 / 144
回合 504
逐位相同,才敢签字。
这条之所以比”单跑一次看结果”强,是因为它同时锁住了两件事:余数没有丢,而且余数没有在存读档时丢。 只跑一次长时段的话,余数丢了你也看不出来——总量总是差不多,只是慢了一点。
另外,周期常量我也不再拍脑袋定。而是实测反推:
实测 --wait-sec 86400 → decay = 55
每步 decay += 5 → 86400 / 11 ≈ 7854
(86400 // 7344) * 5 = 55 ✓
反解出每格 7,344 秒(约 1.7 天),正好和注释里写的”约 1.7 天”对上。反推出来能对上设计意图,才说明这个常量是当初那个设计,而不是后来某次改动留下的幽灵值。
八、这篇其实在讲什么
把上面的东西抽象一层,我觉得是这么几条:
- 不要为派生量存状态。 回合数、负重快照、可见性列表——能算就算,存了就得同步,同步就会漏。
- “调用一次加一点”几乎总是错的。 只要调用频率取决于玩家行为,这个量就不是时间。
- 别假设你以为的那条路径就是全部路径。 走路、等待、睡觉在项目里是三条路,它们只有一个公共点。
- 调试开关要窄。
if (god) return;是一个能覆盖半屏代码的开关。 - 验证要用”不变性”,不要用”看起来对”。 逐位相同的三段对比,比跑一次长时间可信得多。
上一次有人问我这个项目最难的地方是什么,我下意识想说是”系统太多”。
现在我觉得不是。难的是那些”看起来不复杂”的地方——时间、数值、余数。 它们不报错、不崩溃,只是安静地差一点点,差到你得先学会怀疑自己,才能开始查。