← 全部文章

我把时间做成系统之后,坑才开始

本文目录
  1. 一、我把”回合数”写成了计数器
  2. 二、31 亿秒:一个默认值类型引发的读档回绕
  3. 三、挂在错误的层:快进 7 天,什么都没发生
  4. 四、走路一小时和等待一小时,差了 36 倍
  5. 五、余数别扔(这条差点改变了游戏平衡)
  6. 六、调试开关不该改变时间语义
  7. 七、怎么证明”真的修好了”
  8. 八、这篇其实在讲什么

之前写引擎构成那篇的时候,我把时间系统说成”最 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 天”对上。反推出来能对上设计意图,才说明这个常量是当初那个设计,而不是后来某次改动留下的幽灵值。


八、这篇其实在讲什么

把上面的东西抽象一层,我觉得是这么几条:

  1. 不要为派生量存状态。 回合数、负重快照、可见性列表——能算就算,存了就得同步,同步就会漏。
  2. “调用一次加一点”几乎总是错的。 只要调用频率取决于玩家行为,这个量就不是时间。
  3. 别假设你以为的那条路径就是全部路径。 走路、等待、睡觉在项目里是三条路,它们只有一个公共点。
  4. 调试开关要窄。 if (god) return; 是一个能覆盖半屏代码的开关。
  5. 验证要用”不变性”,不要用”看起来对”。 逐位相同的三段对比,比跑一次长时间可信得多。

上一次有人问我这个项目最难的地方是什么,我下意识想说是”系统太多”。

现在我觉得不是。难的是那些”看起来不复杂”的地方——时间、数值、余数。 它们不报错、不崩溃,只是安静地差一点点,差到你得先学会怀疑自己,才能开始查。