随机:玩家其实讨厌真随机
你写下 if randf() < 0.3,觉得这就是 30% 暴击率,公平、正确、简单。然后有个玩家在论坛发帖,说你的暴击率是假的,他连续二十次没暴击。你去查代码,代码没问题。你去算概率,概率也没问题。而他说的也是真的 —— 这一章讲的就是这件事怎么可能同时成立。
先看那三个数字
下面这个 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))
| 优点 | 缺点 |
|---|---|
|
|
「数牌」这个缺点在某些场景下恰恰是优点。
《杀戮尖塔》的抽牌就是洗牌袋(它本来就是牌堆)—— 玩家能推算「我这一轮还剩几张攻击牌」,而这个推算能力本身就是策略深度。
所以判据是:你希望玩家能推算这个随机吗?希望 → 洗牌袋。不希望 → 看下一种。
做法二:递增概率(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 # 期望间隔的倒数 = 命中率
- 削平长尾:本页 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()
好处有三个,每个都很实在:
- 能复现 bug。玩家报告「第三关卡住了」,他给你种子,你能跑出一模一样的一局。这个价值怎么强调都不过分。
- 能做「种子分享」。玩家之间可以挑战同一个局面 —— 这是肉鸽类游戏几乎免费的社区功能。
- 能做每日挑战。用日期当种子,所有人当天玩同一局,天然的排行榜。
另外:不同的系统用不同的 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 复现、种子分享、每日挑战。
- 肉鸽 / 卡牌:这一章的主场。核心原则:把随机全部放在输入端(局面、选项、牌序),输出端尽量确定。杀戮尖塔的伤害是完全确定的数字,所有随机都在「你抽到什么、遇到什么」上 —— 这是它耐玩的关键原因之一。
- 塔防:随机应该极少。这类游戏的乐趣是规划,而规划要求可预测。如果一定要随机,放在「下一波敌人的组成」上(输入随机),绝不要放在「塔的伤害浮动」上。
- 平台 / 动作:通常完全不该有随机。玩家在练习肌肉记忆,任何随机都会破坏「同样的操作得到同样的结果」这个学习前提。例外:程序生成的无尽模式 —— 但那时随机的是关卡(输入),不是判定。
- 模拟经营:随机主要用来制造事件(天气、访客、灾害)。原则同上:要能预警。突然的、不可预防的灾害会让玩家觉得规划毫无意义。
- 叙事解谜:几乎不该有随机。这类游戏的每一次体验都应该是被设计的。唯一的例外:用随机来做「氛围性的变化」(环境音、路人行为),它不影响任何决策。