卷 IV · 数CH 15深度 15/24

随机:玩家其实讨厌真随机

你写下 if randf() < 0.3,觉得这就是 30% 暴击率,公平、正确、简单。然后有个玩家在论坛发帖,说你的暴击率是假的,他连续二十次没暴击。你去查代码,代码没问题。你去算概率,概率也没问题。而他说的也是真的 —— 这一章讲的就是这件事怎么可能同时成立。

真随机的长尾洗牌袋递增概率输入随机 vs 输出随机保底

先看那三个数字

下面这个 demo 会用三种不同的方式实现「30% 概率」,每种真的跑一万次,然后统计最长的连续不出记录。

默认设置(30%,10000 次)的结果:

真随机  命中 29.6% 最长连续不出 20 次
洗牌袋  命中 30.0% 最长连续不出 12 次
递增概率 命中 29.9% 最长连续不出  7 次
◆ 三者的期望完全一样,体验天差地别

三种做法的长期命中率都是 30%(29.6% / 30.0% / 29.9%,误差在采样范围内)。从数学上说,它们是等价的。

但玩家的记忆不是这样工作的。他不记得「我的平均暴击率是 29.6%」,他记得「我有一次连着二十下没暴击,差点被打死」

那次二十连不出,在真随机里是必然会发生的 —— 只要样本量够大。而你的玩家群体加起来,样本量一定够大。

算一下那个概率

30% 命中率,连续 20 次不中的概率是 0.7²⁰ ≈ 0.08%

听起来很小。但是:

  • 一个玩家一局游戏可能攻击 500 次
  • 500 次里出现一段 20 连不中的概率,大约是 30%
  • 你有 1000 个玩家 → 大约 300 个人会遇到
  • 他们中会有人去论坛发帖

「小概率事件在大样本下必然发生」是随机数设计的第一课。你不是在为一次抽样设计,你是在为几百万次抽样设计。

做法一:洗牌袋(Shuffle Bag)

思路来自纸牌:把结果装进一个袋子,摸完再重新装。

30% 暴击 → 袋子里放 10 个球,3 个「暴击」7 个「不暴击」,洗匀,一个个摸。摸完 10 个再重新装。

class_name ShuffleBag
extends RefCounted

var _items: Array = []
var _template: Array = []

func _init(template: Array) -> void:
	_template = template.duplicate()
	_refill()

func _refill() -> void:
	_items = _template.duplicate()
	_items.shuffle()

func draw():
	if _items.is_empty():
		_refill()
	return _items.pop_back()
# 用法:30% 暴击
var crit_bag := ShuffleBag.new([true, true, true, false, false,
                                false, false, false, false, false])

func attack() -> void:
	var is_crit: bool = crit_bag.draw()
	deal_damage(20 * (2.0 if is_crit else 1.0))
优点缺点
  • 命中率精确等于设定值
  • 不可能出现超长连败(上限可算)
  • 实现简单,容易理解和调试
  • 玩家可以数牌:如果前 7 次都没暴击,他知道剩下 3 次必出
  • 袋子越小,可预测性越强
  • 不适合暴露给玩家的高价值随机(会被算计)
◆ 洗牌袋最适合的地方:卡牌抽取和道具掉落

「数牌」这个缺点在某些场景下恰恰是优点

《杀戮尖塔》的抽牌就是洗牌袋(它本来就是牌堆)—— 玩家能推算「我这一轮还剩几张攻击牌」,而这个推算能力本身就是策略深度

所以判据是:你希望玩家能推算这个随机吗?希望 → 洗牌袋。不希望 → 看下一种。

做法二:递增概率(PRD)

Dota 2 使用的方案,也叫 Pseudo-Random Distribution。

思路:连续没出,概率就往上顶;一旦出了,重置回底。

class_name PRD
extends RefCounted

var _c: float           # 每次失败增加的概率步长
var _fails: int = 0

func _init(target_chance: float) -> void:
	_c = _solve_c(target_chance)

func roll() -> bool:
	var chance := minf(1.0, _c * (_fails + 1))
	if randf() < chance:
		_fails = 0
		return true
	_fails += 1
	return false

关键在 _c 怎么算。它不等于目标概率 —— 因为「概率递增」这个机制本身会把实际命中率抬高。要让长期期望等于 30%,C 大约是 0.155。

解法是数值反解(二分查找):

func _solve_c(target: float) -> float:
	var lo := 0.0
	var hi := target
	for i in 60:
		var mid := (lo + hi) * 0.5
		if _expected_chance(mid) > target:
			hi = mid
		else:
			lo = mid
	return (lo + hi) * 0.5

# 给定步长 C,算出实际的长期命中率
func _expected_chance(c: float) -> float:
	var p_proc := 0.0
	var p_prev := 0.0        # 累计「已经出过」的概率
	var sum := 0.0           # 期望的「间隔次数」
	var n_max := int(ceil(1.0 / c))
	for n in range(1, n_max + 1):
		p_proc = minf(1.0, c * n) * (1.0 - p_prev)
		p_prev += p_proc
		sum += n * p_proc
	return 1.0 / sum         # 期望间隔的倒数 = 命中率
◆ PRD 的性质
  • 削平长尾:本页 demo 实测,最长连不出从 20 降到 7
  • 也削平了「连续爆发」:因为出过之后概率重置到很低,不会连续暴击五次
  • 不可数牌:玩家感觉不到具体机制,只觉得「挺公平的」
  • 代价:实现复杂(要反解 C),而且方差变小意味着惊喜也变少了

第二条要特别注意:PRD 不只是保护倒霉玩家,它同时削掉了幸运玩家的高光时刻。如果你的游戏的乐趣有一部分来自「哇我连续暴击打死了 Boss」,PRD 会伤害它。

做法三:保底(Pity)

最粗暴也最常见的一种:正常随机,但连续 N 次不出就强制出。

var _fails: int = 0
const HARD_PITY := 10

func roll() -> bool:
	_fails += 1
	if _fails >= HARD_PITY:      # 硬保底
		_fails = 0
		return true
	if randf() < 0.3:
		_fails = 0
		return true
	return false

注意:加了保底之后,实际命中率会高于 30%(因为多了强制命中的情况)。如果你要精确的 30%,得把基础概率调低来补偿。这一点很多人会忘。

保底的最大优点是它可以被公开。抽卡游戏公示「90 抽必出」,玩家能据此规划,焦虑感大幅下降 —— 因为最坏情况被封顶了。

更根本的问题:输入随机 vs 输出随机

上面三种都是在调随机的分布。但还有一个更上层的设计选择,它比选哪种算法重要得多。

◆ 两种随机,位置完全不同
输入随机输出随机
发生在玩家做决策之前玩家做决策之后
例子抽到什么牌、遇到什么地形、商店卖什么攻击是否暴击、是否命中、掉落什么
玩家的感受「这是我要解决的题目「这是命运对我的裁决」
失败时玩家说「我应该那样打的」「运气太差了」

输入随机制造多样性和策略,输出随机制造刺激和挫败。

为什么这个区分如此重要

因为它决定了玩家能不能从失败中学到东西(回到第 6 章那个「能不能复盘」的判据)。

  • 输入随机下失败:牌很烂,但我打得不够好 —— 我下次会打得更好。玩家保留了主动权。
  • 输出随机下失败:我打得对,但没暴击 —— 我什么也学不到。玩家只能怪运气。
◆ 一条实用建议

尽量把随机往前推,推到玩家决策之前。

具体做法举例:

  • 与其「攻击有 30% 概率暴击」(输出随机),不如「你这回合抽到了一张暴击牌」(输入随机)—— 现在玩家要决定什么时候用它
  • 与其「宝箱随机掉落」,不如「宝箱里有三样,你只能拿一样」—— 随机产生的是选项,选择权在玩家
  • 与其「敌人随机行动」,不如「敌人下一步要做什么,显示在它头上」(杀戮尖塔的意图系统)—— 随机产生了局面,应对靠玩家

最后一条是《杀戮尖塔》最重要的设计决定之一。它把「敌人行动随机」这个输出随机,变成了「你知道敌人要干什么,你要怎么应对」的输入随机。同样的随机源,完全不同的体验。

什么时候输出随机是对的

不是说输出随机一律不能用。它有它的位置:

  • 小幅度的输出随机能防止游戏变得机械。伤害在 18–22 之间浮动,比永远 20 有生气 —— 而且幅度小到不会影响策略。
  • 正面的输出随机(意外之喜)比负面的好得多。「偶尔暴击」比「偶尔失手」感觉好,尽管数学上可以等价。这是一条很实用的技巧:把随机设计成奖励,而不是惩罚。
  • 输出随机是「传奇时刻」的来源。那些玩家会讲给朋友听的故事,往往来自小概率事件。完全消除它,游戏会变得平淡。
✕ 最该避免的一种:致命的输出随机

永远不要让一次输出随机直接导致玩家失去大量进度。

「有 5% 概率被一击必杀」—— 这种设计在数学上可能很平衡,但体验上是毁灭性的:玩家玩了 40 分钟,因为一次他无法影响的骰子失去了一切。

如果一定要有致命风险,让它可预警、可规避:敌人抬手三秒、屏幕闪红、有一个明确的躲避窗口。把「运气」变成「反应」。

关于种子:可复现的随机

最后一个实用话题。用固定种子的随机数生成器,而不是全局的 randf()

var rng := RandomNumberGenerator.new()

func start_run(seed_value: int) -> void:
	rng.seed = seed_value
	# 之后所有的随机都用 rng.randf() / rng.randi_range()

好处有三个,每个都很实在:

  1. 能复现 bug。玩家报告「第三关卡住了」,他给你种子,你能跑出一模一样的一局。这个价值怎么强调都不过分。
  2. 能做「种子分享」。玩家之间可以挑战同一个局面 —— 这是肉鸽类游戏几乎免费的社区功能。
  3. 能做每日挑战。用日期当种子,所有人当天玩同一局,天然的排行榜。

另外:不同的系统用不同的 RNG 实例。如果关卡生成和战斗掉落共用一个 RNG,那么玩家在战斗中多打一下,后面的关卡就全变了 —— 这会让种子复现完全失效。

# 各管各的,互不干扰
var rng_level := RandomNumberGenerator.new()   # 关卡生成
var rng_loot  := RandomNumberGenerator.new()   # 掉落
var rng_combat := RandomNumberGenerator.new()  # 战斗中的浮动

func start_run(s: int) -> void:
	rng_level.seed = s
	rng_loot.seed = s + 1
	rng_combat.seed = s + 2

本章小结

  • 小概率事件在大样本下必然发生。30% 暴击,一万次里会有 20 连不出(本页实测)。
  • 三种做法,期望相同但长尾不同:真随机 20 / 洗牌袋 12 / 递增概率 7
  • 洗牌袋:精确、可数牌 —— 适合你希望玩家推算的场合(抽牌)。
  • 递增概率:削平长尾,也削平高光;实现要反解常数 C。
  • 保底:最粗暴,但唯一可以公开的一种,能消除焦虑。注意它会抬高实际概率。
  • 比选算法更重要的:输入随机(决策前)优于输出随机(决策后),因为前者让玩家能复盘。
  • 技巧:把随机设计成奖励而不是惩罚;永远不要让一次骰子毁掉大量进度。
  • 用带种子的 RNG 实例,且不同系统分开 —— 换来 bug 复现、种子分享、每日挑战。
⇄ 换个类型
  • 肉鸽 / 卡牌:这一章的主场。核心原则:把随机全部放在输入端(局面、选项、牌序),输出端尽量确定。杀戮尖塔的伤害是完全确定的数字,所有随机都在「你抽到什么、遇到什么」上 —— 这是它耐玩的关键原因之一。
  • 塔防:随机应该极少。这类游戏的乐趣是规划,而规划要求可预测。如果一定要随机,放在「下一波敌人的组成」上(输入随机),绝不要放在「塔的伤害浮动」上。
  • 平台 / 动作:通常完全不该有随机。玩家在练习肌肉记忆,任何随机都会破坏「同样的操作得到同样的结果」这个学习前提。例外:程序生成的无尽模式 —— 但那时随机的是关卡(输入),不是判定。
  • 模拟经营:随机主要用来制造事件(天气、访客、灾害)。原则同上:要能预警。突然的、不可预防的灾害会让玩家觉得规划毫无意义。
  • 叙事解谜:几乎不该有随机。这类游戏的每一次体验都应该是被设计的。唯一的例外:用随机来做「氛围性的变化」(环境音、路人行为),它不影响任何决策。