概率与游戏:伤害检定

作者:gutenberg
2017-01-01
27 71 10

引言

原文来自 Amit Petel 的博客,由游戏古登堡计划翻译整理为中文版,探讨了随机数生成的问题,初步研究了如何生成期望的概率分布,对无论是电子游戏设计还是桌面游戏设计都颇具参考价值。

像《龙与地下城》一类的纸上角色扮演游戏往往通过掷骰子来检定基础伤害(以骰子决定伤害的基础值),这在使用骰子的游戏中很常见。很多计算机上的 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 面骰子的结果:

1+random(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)

现在让我们来投掷 面骰子 (d) 并对结果进行求和:

damage = 0
for each 0  i < :
    damage += 1+random()
6 (均为1)至 12 (均为 2),掷出 9 的可能性比 6 和 12 大。
d

结果分布从 ( )到 ( ). 且投掷出的可能性比投掷出或者的可能性大。

如果我们增加骰子数,减少骰子的面数,会发生什么?试试上面这些最大值为 12 的情况。

最主要的影响就是概率分布从了,第二个影响则是峰值的右移。首先我们来探究下偏移的作用。

常数偏移

《龙与地下城》的一些武器具有额外伤害,我们可以用 2d6+1 来表示具有 +1 的额外伤害。在一些游戏中,铠甲或盾牌能抵挡伤害,我们可以用 2d6-3 来表示防御了 3 点伤害;假设在这个例子中最小伤害是 0。

来试试正向或者反向调整伤害数值:

2d6+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 的随机数,调整 骰子来得到分布结果。

rollDice( , )

尝试调整骰子的数量 —

— 来查看它对分布的影响。在给定范围 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 文件。不含参数的分布给了你很大的可塑性,而使用表存储数据避免将分布写死在代码中,可以让你在不重新编译的情况下进行快速迭代。

仅仅使用简单的代码,也还有很多方法去产生有趣的分布。但是,首先要定下来的应当是你希望分布具有怎样的性质,随后才是写下合适的代码。

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. eastecho 2017-01-01

    干货!

  2. doodle 2017-01-01

    有点厉害的页面,几乎所有例子中的参数都可以手动调整并立刻看到变化0.0

    感谢翻译、校对和程序猿gg的努力!!

  3. Probe 2017-01-01

    这篇文章厉害了

  4. 至尊小夜猫 2017-01-02

    咱曾把某卡牌手游解压缩,然后看到了SSR的概率,然后卸载了。。。。。

  5. AnoToki 2017-01-03

    这个厉害 不用自己一个个试了

  6. 富春山 2017-01-03

    问一下用的什么软件生成的分布函数,或者能提供下代码么=。=

    • craft 2017-01-03

      @富春山:本页里的 js 代码没有混淆过哦。主要在 main.js 里。

  7. rayriver 2017-01-05

    这个真的厉害

  8. rumia 2017-08-27

    确实是干货

您需要登录或者注册后才能发表评论

登录/注册