引言
原文来自 Amit Petel 的博客,由游戏古登堡计划翻译整理为中文版,探讨了随机数生成的问题,初步研究了如何生成期望的概率分布,对无论是电子游戏设计还是桌面游戏设计都颇具参考价值。
- 原文:Probability and Games: Damage Rolls
- 翻译:XuLYC
- 审校:Eldath, craft
像《龙与地下城》一类的纸上角色扮演游戏往往通过掷骰子来检定基础伤害(以骰子决定伤害的基础值),这在使用骰子的游戏中很常见。很多计算机上的 RPG 也使用类似的系统来计算伤害和其它属性(力量,魔法点,敏捷等等)。
一般来说你会写一些调用 random()
的代码,通过调整产生的数值和结果来得到适用于你游戏中的情况。这份教程包含三个部分:
- 基本调整-平均值与方差
- 增加非对称性 - 丢弃骰子或增加暴击
- 完全自由生成随机数,并不局限于使用骰子
基础
在阅读这篇文章的时候,我假设你有一个函数 random(N)
,能随机返回 0 到 N-1 的整数。
在 Python 中,可以使用 random.randrange(N)
;
在 Javascript 中,可以使用 Math.floor(N * Math.random())
;
在 C 中,标准库里有 rand() % N
,但其性能很差,推荐使用其它随机数生成器;
在 C++ 中,可以用 uniform_int_distribution(0, N-1)
获取一个随机数生成器的对象;
在 Java 中,可以用 new Random()
生成随机数生成器的对象,随后用 .nextInt(N)
来调用它。
很多语言的标准库中并不包含好的随机数生成器,需要第三方库的支持,例如 C 和 C++ 中的 PCG。
让我们从 1 个骰子开始。直方图展示了使用 1+random(12)
掷 1 个 12 面骰子的结果:
由于 random(12)
返回从 0 至 11 的数,而我们需要 1 至 12,因此在其上加了 1。图中 x 轴是伤害;y 轴是产生伤害的概率。在 1 个骰子的情况下,产生 2 点或 12 点基础伤害的概率与产生 7 点的概率是一样的。
对于使用多个骰子的情况,使用骰子游戏中的书写习惯将有利于我们的说明:用 NdS 表示掷 1 个 S 面的骰子 N 次。 例如上文中的掷 1 个 12 面骰子可以写为 1d12;3d4 则是掷 1 个 4 面骰子 3 次,代码可以写为 3 + random(4) + random(4) + random(4)
。
damage = 0 for each 0 ≤ i < : damage += 1+random()6 (均为1)至 12 (均为 2),掷出 9 的可能性比 6 和 12 大。
结果分布从 ( )到 ( ). 且投掷出的可能性比投掷出或者的可能性大。
如果我们增加骰子数,减少骰子的面数,会发生什么?试试上面这些最大值为 12 的情况。
最主要的影响就是概率分布从宽变窄了,第二个影响则是峰值的右移。首先我们来探究下偏移的作用。
常数偏移
《龙与地下城》的一些武器具有额外伤害,我们可以用 2d6+1 来表示具有 +1 的额外伤害。在一些游戏中,铠甲或盾牌能抵挡伤害,我们可以用 2d6-3 来表示防御了 3 点伤害;假设在这个例子中最小伤害是 0。
来试试正向或者反向调整伤害数值:
分布方差
当我们从 2d6 逐步变为 6d2 时,分布变窄了同时峰值向右移动了。在前一节中,我们讨论了其中的偏移,现在我们看一下分布的方差。
function rollDice(N, S): # 对 N 个值为 0 至 S 的骰子求和 value = 0 for 0 ≤ i < N: value += random(S+1) return value
让我们定义一个计算 N 次 random(S+1)
并求和,返回值在 0 到 N*S 之间的函数:
function rollDice(N, S): # 对 N 个值为 0 至 S 的骰子求和 value = 0 for 0 ≤ i < N: value += random(S+1) return value生成从 0 到 24 的随机数,调整 个 骰子来得到分布结果。
尝试调整骰子的数量 — — 来查看它对分布的影响。在给定范围 0 至 N*S 的情况下,投掷的次数越多,分布变得越窄(方差更小),也会有更多的结果会位于值域中心附近。
边注:如果你增加骰子的面数 S(可以在下方尝试),并将结果除以 S,那么分布将会近似于正态分布。使用Box-Muller 分布可以简单地得到服从正态分布的随机数。
非对称性
rollDice(N, S)
的分布是对称的,低于或高于均值的可能性是一样的。在你的游戏中需要这种情况吗?如果不需要的话,我们可以用不同的办法来制造非对称性。
丢弃或重掷骰子
现在我们掷 2 次 rollDice(2, 12)
并且选择高的那次:
roll1 = rollDice(2, 12) roll2 = rollDice(2, 12) damage = max(roll1, roll2)
当我们选择 rollDice(2, 12)
和 rollDice(2, 12)
中较高的一个时,我们得到了 0 至 24 的数。另一种获取 0 至 24 的方法则是掷 rollDice(1, 12)
3 次并且对最高的 2 次求和。相较于第一种情况,第二种情况得到的分布更加不对称:
roll1 = rollDice(1, 12) roll2 = rollDice(1, 12) roll3 = rollDice(1, 12) damage = roll1 + roll2 + roll3 # 丢弃最低值 damage = damage - min(roll1, roll2, roll3)
还有一种方法就是重掷点数最低的骰子。这种方法所产生分布的形状和前面方法的很相似,仅仅在细节上有些不一样:
roll1 = rollDice(1, 8) roll2 = rollDice(1, 8) roll3 = rollDice(1, 8) damage = roll1 + roll2 + roll3 # 丢弃点数最低的骰子并重掷 damage = damage - min(roll1, roll2, roll3) + rollDice(1, 8)
上面任意一种方法都可以反着用,以得到低值可能性更大的情况。也就是说这种分布只会偶尔产生高值。这些分布经常用来产生伤害,不常用于生成属性。在代码中把 max()
换成 min()
即可实现。
roll1 = rollDice(2, 12) roll2 = rollDice(2, 12) damage = min(roll1, roll2)
假设你希望高值出现的可能性更大。这种情况对伤害来说比较少见,但是可以用来生成力量,智力等属性。一种方法就是掷多次骰子并且选择结果最好的那次。
现在让我们投掷两次rollDice(, )
并且选择高的那次:
roll1 = rollDice(, ) roll2 = rollDice(, ) damage = max(roll1, roll2)
当我们选择 rollDice(2, 12)
和 rollDice(2, 12)
中较高的一个时,我们得到了从 0 到 24 的数。另一种获取从 0 到 24 的数的方法则是丢掷 rollDice(1,
12)
三次,并且选取其中较高的两次求和。相较于第一种情况,第二种情况得到的分布更加不对称:
roll1 = rollDice(, ) roll2 = rollDice(, ) roll3 = rollDice(, ) damage = roll1 + roll2 + roll3 # 丢弃最低值: damage = damage - min(roll1, roll2, roll3)
还有一种方法就是重掷点数最低的骰子。这种方法所产生分布的形状和前面方法的很相似,仅仅在细节上有些不一样:
roll1 = rollDice(, ) roll2 = rollDice(, ) roll3 = rollDice(, ) damage = roll1 + roll2 + roll3 # 丢弃点数最低的骰子并重掷: damage = damage - min(roll1, roll2, roll3) + rollDice(1, 8)
上面任意一种方法都可以反着用,以得到低值可能性更大的情况。也就是说这种分布只会偶尔产生高值。这些分布经常用来产生伤害,不常用于生成属性。在代码中把 max() 换成 min() 即可实现:
roll1 = rollDice(, ) roll2 = rollDice(, ) damage = min(roll1, roll2)
暴击
damage = rollDice(3, 4) if random(100) < : damage += rollDice(3, 4)
其它增加非对称性的方法包括:本次暴击有一定概率触发下一次暴击;用暴击触发第二次攻击,而直接跳过防御阶段;暴击后导致对手的攻击会失误。但是我不会在这里分析多次攻击下的伤害分布情况。
尝试设计你自己的分布
对于每一种随机数(伤害,属性,等等),可以从你期望得到的游戏体验来刻画它的分布特性:
- 取值范围:最小值和最大值是什么?用缩放和平移来调整分布。
- 方差:需要结果更集中吗?越少的骰子数可以造成越大的波动,而越多骰子数产生的波动越小。
- 非对称性:希望经常出现高值或者低值吗?可以在你的分布中加入
min(), max()
或者暴击来得到非对称性。
可以修改参数在右侧查看产生的分布:
value = + rollDice(, ) # 不取最小值 # 不取最大值 # 无暴击
还有很多其他的方法来产生不同结构的随机数,但是可以看到,我们介绍的这种方法已经有很大的弹性空间了。点击这里可以查看一个伤害计算器。不过有些时候,掷骰子的组合依然不能满足你的需要。
任意形状的分布
下面开始讨论不同的输入和对应分布的输出。我们会演示各种不同的模式直到深化出我们需要的程度。那么是否存在更直接的方法来得到正确的算法呢?答案是肯定的。
让我们从最基础的部分重新来过,从给定输出结果的直方图开始。让我们试试这个简单例子。
相应的代码应该如何写呢?
x = random(+++) if x < : value = 3 else if x < +: value = 4 else if x < ++: value = 5 else: value = 6
在进行下一步前请确保你看懂了上面的代码并且明白它的含义。试试给出不同的 x
并且看看会得到怎样的结果。让我们扩大这段代码的使用范围,使得它可以适用于不同的比例表。第一步先写出对应的比例表格:
damage_table = [ # (权重, 伤害)的数组 (, 3), (, 4), (, 5), (, 6), ];
在第一段代码中,每个 if
条件语句比较了 x
和积累比例的大小。可以把这段手工写死的 if
语句替换成对比例表的循环:
cumulative_weight = 0 for (weight, result) in table: cumulative_weight += weight if x < cumulative_weight: value = result break
最后我们还需要自动生成比例的总和。让我们计算出总和并且以它来抽取随机数 x
:
sum_of_weights = 0 for (weight, value) in table: sum_of_weights += weight x = random(sum_of_weights)
整合以上的代码,我们得到了从比例表查询结果的函数,以及抽取随机数的函数(也许你希望把这些写成一个伤害表类里面的方法):
这段从伤害表生成随机数的方法很简单。对于我来说已经足够快了,但是如果你的分析器说它太慢,可以把查找伤害表时的顺序查找替换成二分查找、插值查找,或者使用这个替代方案。
function lookup_value(table, x): # assume 0 ≤ x < sum_of_weights cumulative_weight = 0 for (weight, value) in table: cumulative_weight += weight if x < cumulative_weight: return value function roll(table): sum_of_weights = 0 for (weight, value) in table: sum_of_weights += weight x = random(sum_of_weights) return lookup_value(damage_table, x)
绘制完全自定义的分布
如果能够实现任意形状的分布无疑会是非常好的一种方案。试着在测试区域中尝试绘制任意的分布,并查看对应的代码:
damage_table = [(78,1), (76,2), (55,3), (55,4), (64,5), (67,6), (55,7), (69,8), (71,9), (71,10), (56,11), (29,12), (25,13), (26,14), (71,15), (63,16), (70,17), (63,18), (38,19), (61,20), (62,21), (52,22), (69,23), (62,24), (61,25), (69,26), (73,27), (63,28), (68,29), (71,30), (75,31), (67,32), (79,33), (68,34), (83,35), (69,36), (69,37), (91,38), (72,39), (72,40), (73,41), (71,42), (71,43), (38,44), (37,45), (27,46), (27,47), (28,48), (38,49), (28,50)]
通过上述方法,就能根据自己想要的游戏体验来选择合适的随机分布,而无需拘泥于一定要使用骰子。
结论
生成随机伤害和随机属性是很容易实现的。作为一名游戏设计者,你应当考虑结果分布的特性是怎样的。如果你希望用掷骰子来决定这些数值:
用投掷的次数来控制方差。次数越少波动性越大,次数越多波动性越小。
使用平移和骰子的面数来控制结果的大小。如果你希望随机数在 X 到 Y 之间,则在 N 次掷骰子的过程中每次应当产生 0 到 (Y-X)/N 的随机数,随后再加上 X 得到结果。正向平移可以应用于额外伤害或者额外属性点;负向平移可以用于防御伤害。
使用非对称性来产生更多的高值或者低值。一般来说属性点更可能出现高值,取不同投掷下的最大值,取三个骰子中最大的两个,重掷点数最低的骰子均可以达到这一目的。一般来说伤害更可能出现低值,使用取小或者加入暴击来达到这一目的。一般来说随机事件的难度系数也是更经常出现低值的。
要好好思考你的分布应当怎样左右游戏进程。可以增加简单的参数来改变分布以达到额外攻击,伤害防御,暴击等等。这些参数可以放在游戏的道具上。用上面的自定义区来看看这些参数会如何影响分布。想想当角色升级时,分布应该怎样变化;这个页面介绍了随着进程应增加均值并且降低方差,可以用 AnyDice 来计算分布(附上 blog)。
不同于纸上角色扮演类的游戏,你构造的分布将不局限于对随机数的求和。你可以采用在小节“设计你自己的分布”中介绍的方法,生成任何你自己期望的分布。你可以设计一个可视化工具,让你画出直方图并且保存相应的数据到比例表,然后再从这个分布中抽取随机数。你也可以在 JSON 或者 XML 中编辑比例表。或者可以在 Excel 里面编辑并输出为 .CSV 文件。不含参数的分布给了你很大的可塑性,而使用表存储数据避免将分布写死在代码中,可以让你在不重新编译的情况下进行快速迭代。
仅仅使用简单的代码,也还有很多方法去产生有趣的分布。但是,首先要定下来的应当是你希望分布具有怎样的性质,随后才是写下合适的代码。
干货!
有点厉害的页面,几乎所有例子中的参数都可以手动调整并立刻看到变化0.0
感谢翻译、校对和程序猿gg的努力!!
这篇文章厉害了
咱曾把某卡牌手游解压缩,然后看到了SSR的概率,然后卸载了。。。。。
@至尊小夜猫:SSR概率有多少→_→
这个厉害 不用自己一个个试了
问一下用的什么软件生成的分布函数,或者能提供下代码么=。=
@富春山:本页里的 js 代码没有混淆过哦。主要在 main.js 里。
这个真的厉害
确实是干货
就是因为赞你才注册的账号