游戏抽卡的伪随机数到底有多随机

2025-11-22小游戏

闲来无事做小游戏的时候遇到过一个问题。一个简单的暴击系统,逻辑是这样的:玩家攻击时,生成一个 0 到 1 的随机数,如果小于暴击率(比如 30%)就触发暴击。

测试的时候发现一个很奇怪的现象:玩家有时候连续三次暴击,有时候连续十次都不暴击。虽然从概率上讲 30% 暴击率连续十次不暴击的概率是 2.8%,理论上有可能发生,但玩家体验非常差。当时的策划直接来找我说:"这个暴击有问题吧?"

我确实没法反驳,因为从玩家体验来说,他们想要的是"每十次攻击大概暴击三次",而不是"有一定概率暴击"。

这个问题引发了我对随机数生成器(RNG)在游戏中应用的深入研究。

计算机不会产生真正的随机数

先说一个很多人都知道但容易忽略的事实:计算机里没有真正的随机。

你调用的 Math.random()rand() 或者 Random.Next(),背后都是伪随机数生成器(PRNG)。它们都用数学公式从一个初始值(种子)推算出看似随机的数列。

最常见的 PRNG 叫做线性同余生成器(LCG):

S[n+1] = (a × S[n] + c) % m

S 是状态,a 是乘数,c 是增量,m 是模数。这四个参数选得好,生成的数列在统计上看起来就很随机。选得不好,周期很短或者有明显规律。

JavaScript 的 Math.random() 早期实现就是 LCG。V8 在 2015 年左右换成了 xorshift128+,因为 LCG 的统计质量确实不够好——它在高维度下会表现出明显的相关性。

但不管是用 LCG 还是 xorshift,同一个种子永远产生同一个序列。这既是弱点也是优点。

弱点:如果你用时间做种子,有人在同一毫秒调用了随机函数,他们得到的序列是一样的。这在 2012 年韩国 GameGuard 系统的随机数破解事件里被利用过。

优点:调试时可以固定种子,复现 bug。我后来在开发中养成了一个习惯:所有跟随机相关的功能都在日志里输出种子值。这样玩家报 bug 的时候,我可以拿种子值重现完全一样的场景。

均匀分布不是你想要的那种随机

大多数 PRNG 生成的是均匀分布的随机数——每个值出现的概率相等。这对很多场景是合适的,但游戏里往往不是这么用的。

最简单的例子:如果你要生成一个 RPG 里敌人的掉落位置,用均匀分布会导致敌人完全随机地分布在场景里,可能全部扎堆在左下角。玩家会觉得"这个场景没做完"。

而正态分布更适合这种情况:大部分怪物集中在场景中部,边缘逐渐减少。看起来才像"自然分布"。

同理,武器伤害浮动用正态分布比均匀分布更合理。用均匀分布,打出的伤害从最小值到最大值随机出现,玩家会觉得伤害不稳定。用正态分布,大部分伤害集中在平均值附近,偶尔打出偏高或偏低的伤害,体验更"真实"。

但正态分布在游戏里很少直接实现,因为它的计算比均匀分布复杂(要用 Box-Muller 变换从均匀分布生成正态分布)。大多数引擎会用两个均匀随机数来模拟。

抽卡系统里的伪随机

回到开头的问题:30% 的暴击率,为什么玩家感觉不到 30%?

因为概率根本不能这么理解。

真正的 30% 概率是"每次攻击独立判定,每次有 30% 暴击"。从数学上,这确实没问题。但从玩家的角度,他们的大脑不是这么工作的。玩家记住的是"连续 5 次没暴击"的糟糕体验,而记不住"连续 10 次里暴击了 4 次"的实际分布。

游戏行业解决这个问题有一个常见的做法:伪随机分布(PRD,Pseudo-Random Distribution)

PRD 不是改变随机数的生成方式,而是改变概率的累加方式。举例来说,暴击率从第一次的 8.5% 开始,每次不暴击就增加 8.5%,一旦暴击就重置回 8.5%。这样算下来,平均暴击率大约是 30%,但连续不暴击的概率被大幅降低了。

Dota 2 里的剑圣暴击就是用的 PRD。根据 Valve 在 2016 年 GDC 上的分享,这个机制让玩家体验大幅提升——连续四五次不暴击的情况几乎没有发生过。

不过 PRD 也有副作用:暴击发生之后,接下来的一段时间暴击率很低(因为刚重置)。这导致 PRD 不适合用在"必须尽快触发"的场景里。比如治疗暴击——如果因为刚触发过暴击导致接下来治疗量降低,可能直接导致玩家死亡。

另一个常见的"伪随机"是保底机制。原神的 90 抽保底、有概率递增的 180 抽大保底,本质上就是 PRD 的变体。跟 PRD 的区别是:保底是"一定次数后强制触发",而 PRD 是"概率逐步增加,但永远可能不触发"——虽然这个概率随着次数增加趋近于 0。

赌徒谬误,"都连续 5 次不暴击了,下一次肯定暴击"

赌徒谬误是这个话题里绕不开的心理现象。

赌徒谬误说的是:人们错误地认为过去的事件会影响未来的独立随机事件。比如抛硬币连续 5 次正面,有人会觉得第 6 次"该"反面了。

但实际上,每次抛硬币都是独立的 50%。如果硬币是公平的,第 6 次正面的概率还是 50%。

回到 PRD,你能发现 PRD 其实是在利用赌徒谬误来改善体验。它让"连续不暴击后必定暴击"这个错误的直觉变成"真实的"——虽然数学上不是每次独立,但玩家感受到的确实是"越久没暴击越容易暴击"。

这就非常精妙了:你改变了游戏机制,去匹配了玩家的直觉。

用时间做种子的隐患

前面提到一句"用时间做种子"有安全风险。这里展开说说。

很多游戏在启动时用 time(NULL) 或者 Date.now() 做种子。这个种子如果精度只到秒级甚至毫秒级,攻击者可以通过观察游戏行为反推出种子值。

之前好像有个游戏平台被破解的事件。攻击者注册了两个账号,在同一秒内连续调用了随机函数,通过反馈反推出了种子值,然后预测了后续所有的随机结果。

解决方案很简单:用足够多的熵源混合做种子。Linux 上的做法是读 /dev/urandom(或者用 getrandom 系统调用),Windows 上用 CryptGenRandom。游戏引擎通常封装好了这些接口,不需要自己实现。

随机密码生成器在处理随机数时也有同样的问题——如果用 Math.random() 生成密码,理论上是可以被预测的。所以真正的密码生成器应该用加密安全的随机数生成器(CSPRNG),比如 crypto.getRandomValues()

蒙特卡洛方法,用随机数解决确定性问题

说一个跟游戏无关但在开发中有用的应用。

蒙特卡洛方法用大量随机采样来解决确定性的计算问题。比如计算一个不规则形状的面积:随机往包含它的正方形里撒点,落在形状内部的点数除以总点数,乘以正方形面积,就是形状面积的近似值。

这种做法在游戏的某些方面也有用。比如光照计算用的路径追踪就是蒙特卡洛方法。每个像素发射多条光线,随机弹射,统计最终到达光源的比例。采样越多,图像噪点越少,渲染越清晰。

但路径追踪在游戏里用得不多,主要是电影渲染在用。游戏里实时渲染还是靠光栅化和各种光照近似方法——性能差距太大了。不过 NVIDIA 的 RTX 系列显卡已经开始在游戏里做实时的光线追踪了,底层就是蒙特卡洛路径追踪。

写在最后

游戏里的随机数看似简单——不就 Math.random() 吗?但实际涉及的东西比想象中多:种子的选择决定了可复现性,分布的选择决定了玩家体验,PRD 和保底机制则直接关系到游戏的商业设计。

不过个人觉得最值得记住的是 游戏设计里追求的不是"真正的随机",而是"玩家觉得随机"。这两者之间的差距,需要用技术手段和心理学的理解来弥合。