9.2 方差与体验
《独立游戏平衡与经济设计》 里,方差是调参时的一个手感描述:「这版抽卡太极端了,压一压」。本节把这句话变成可量的东西——同一份机制,换一种发牌结构,体验代理量的离散程度变多少。9.1 量的是单次抽样的分布形状,本节量的是一整局里抽样叠起来的结果。
体验代理量:起手 5 张的总伤害
「体验」不可直接度量,但可以选一个代理量:开局压制力。取《灰烬牌库》的 12 张牌库,起手 5 张的总伤害就是代理量:
| 牌 | 伤害 | 张数 | 12 张里的贡献 |
|---|---|---|---|
| strike | 6 | 5 | 30 |
| heavy | 14 | 3 | 42 |
| jab | 3 | 2 | 6 |
| guard | 0 | 2 | 0 |
| 合计 | — | 12 | 78 |
起手伤害高 = 开局能立刻压制;起手伤害低 = 前几回合只能防守,体感「卡手」。这个代理量只用于相对比较(哪种结构更极端),不能绝对化——「多少伤害最好玩」不是它能回答的。
两种发牌结构
同一份牌库,两种抽法:
- 洗牌袋(bag):真实发牌。
ashlib.shuffle_deck打乱 12 张,取前 5 张——不放回,不可能抽到同一张两次。 - 可放回(replace):假想发牌。每次
rng_next % 12独立抽一张——可放回,可能重复抽到同一张。
对 200 组显式种子(1…200)各算一次起手伤害,统计离散程度。
#!/usr/bin/env python3
# file: sim/gd_mech_09_variance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_09_variance.py
"""9.2 方差与体验(S 层)。
9.1 比的是「单次抽样的分布形状」,本节比的是**同一条体验代理量在两套发牌结构下
的离散程度**。体验代理量取**起手 5 张的总伤害**(开局压制力):
- bag 真实发牌:Fisher-Yates 打乱 12 张牌库,取前 5 张(不放回)
- replace 假想发牌:每次 rng_next % 12 独立抽一张(可放回,允许重复)
对 200 组显式种子各算一次起手伤害,用**整数与千分比**度量离散程度:
- range 极差 = max - min(整数)
- avg 均值(整数地板除)
- varPm 方差代理量 = Σ(x-avg)² ×1000 // (S·avg)(χ² 型千分比)
- lowPm 起手伤害 < 20 的「哑火局」占比(千分比)
- hiPm 起手伤害 >= 45 的「爆开局」占比(千分比)
体验代理量只用于**相对比较**,禁绝对化:它回答的是「可放回比不放回宽多少」,
不是「多少伤害最好玩」。
"""
from __future__ import annotations
import os
import sys
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
DECK = [6, 6, 6, 6, 6, 14, 14, 14, 3, 3, 0, 0] # strike×5 heavy×3 jab×2 guard×2
HAND = 5
SEEDS = list(range(1, 201))
LOW = 20
HIGH = 45
HEADER = ["structure", "minD", "maxD", "range", "avg", "varPm", "lowPm", "hiPm"]
def bag_hand(seed: int) -> int:
"""真实发牌:洗牌袋取前 5 张,总伤害。"""
cards = [{"d": d} for d in DECK]
shuffled = ashlib.shuffle_deck(cards, seed)
return sum(c["d"] for c in shuffled[:HAND])
def replace_hand(seed: int) -> int:
"""可放回发牌:每次独立抽一张,允许重复。"""
state = seed % ashlib.RNG_MOD or 1
total = 0
for _ in range(HAND):
state = ashlib.rng_next(state)
total += DECK[state % len(DECK)]
return total
def stats(vals: list) -> dict:
s = len(vals)
avg = sum(vals) // s
var_pm = sum((x - avg) ** 2 for x in vals) * 1000 // (s * avg)
low = sum(1 for x in vals if x < LOW)
hi = sum(1 for x in vals if x >= HIGH)
return {
"min": min(vals), "max": max(vals), "range": max(vals) - min(vals),
"avg": avg, "varPm": var_pm,
"lowPm": low * 1000 // s, "hiPm": hi * 1000 // s,
}
def main() -> int:
bag = [bag_hand(s) for s in SEEDS]
rep = [replace_hand(s) for s in SEEDS]
rows: list = []
for name, vals in (("bag", bag), ("replace", rep)):
st = stats(vals)
rows.append([name, st["min"], st["max"], st["range"], st["avg"],
st["varPm"], st["lowPm"], st["hiPm"]])
out = os.path.join(ROOT, "out", "mech09_variance.csv")
n = ashlib.write_csv(out, HEADER, rows)
print(f"seeds={len(SEEDS)} hand={HAND} lowThresh={LOW} highThresh={HIGH} deckSum={sum(DECK)}")
print(" ".join(HEADER))
for r in rows:
print(" ".join(str(x) for x in r))
print(f"bagRange={stats(bag)['range']} replaceRange={stats(rep)['range']}")
print(f"wrote out/mech09_variance.csv rows={n}")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_09_variance.py:
seeds=200 hand=5 lowThresh=20 highThresh=45 deckSum=78
structure minD maxD range avg varPm lowPm hiPm
bag 12 54 42 32 2505 70 80
replace 6 70 64 32 3878 110 135
bagRange=42 replaceRange=64
wrote out/mech09_variance.csv rows=2
判读:可放回把方差推宽了一档
两行的均值 avg 都是 32——这一点很重要:两种结构的期望起手伤害相同,因为单张的期望伤害一样。差别全在离散程度上:
| 指标 | 洗牌袋 | 可放回 | 差异 |
|---|---|---|---|
| 最小起手伤害 | 12 | 6 | 可放回更低 |
| 最大起手伤害 | 54 | 70 | 可放回更高 |
| 极差 | 42 | 64 | 可放回宽 52% |
| 方差代理量(千分比) | 2505 | 3878 | 可放回高 55% |
| 哑火局占比(<20) | 70‰ | 110‰ | 可放回多 57% |
| 爆开局占比(≥45) | 80‰ | 135‰ | 可放回多 69% |
均值不动、方差变宽是「换结构不换数值」的典型结果。可放回的 minD = 6 意味着存在「起手 5 张全是 jab(伤害 3)」这类局——两张 jab 加三张 guard 的组合被重复抽中;maxD = 70 则是「多张 heavy 重复出现」的结果。洗牌袋不可能出现这两种极端,因为牌库只有 3 张 heavy、2 张 jab,抽完就没了。
哑火局与爆开局同时变多,这正是高方差体验的特征:极端好事与极端坏事都更频繁。这不是「更好」或「更坏」——它取决于你想要什么体验。追求稳定的构筑游戏偏好洗牌袋;追求刺激的抽卡游戏偏好可放回。
方差该往哪边调
方差不是越低越好,也不是越高越好,它要和游戏的承诺对齐:
| 想要的体验 | 方差方向 | 结构手段 |
|---|---|---|
| 稳定可控、比构筑 | 低 | 洗牌袋(不放回)、保底计数 |
| 心跳刺激、比运气 | 高 | 可放回均匀、加权稀有 |
| 有惊喜但不失控 | 中 | 洗牌袋 + 袋内加权 |
| 消灭「连续倒霉」 | 低(截尾) | 袋 + 保底:连续 N 次未出稀有则强制出 |
最后一行是截尾方差的思路:不改均值,只砍掉分布的尾巴。它和纯洗牌袋的区别是——洗牌袋保的是「频次均匀」,保底保的是「间隔上限」。9.1 的 maxRun 量的正是间隔,两者可以配合使用。
这里必须重复一句:varPm、lowPm、hiPm 都是代理指标,只做相对比较。把 varPm 从 3878 压到 2505 不会自动让游戏更好玩,它只是让「极端局」更少。好玩与否要靠真实的玩家测试——那是 X 档的事,命令行不可复现。
为什么均值不变、方差会变
均值不变可以手算。12 张牌的总伤害是 78,每张牌的期望伤害是 78 / 12 = 6.5。无论放回还是不放回,单张的边际期望都是 6.5,所以起手 5 张的期望都是 5 × 6.5 = 32.5,整数地板除后两行都是 32。放回与不放回的差别只在相关性:不放回时抽到一张 heavy 会让下一张抽到 heavy 的概率下降(3/11 < 3/12),于是结果被「摊平」;放回时每张独立,重复无法被抑制,结果被「撑开」。
一句话:结构改变的是变量之间的相关,不是每个变量的期望。这也是为什么「换结构不换数值」是可行的调参手段——它不动均值,只动方差。
分布长什么样:整数分桶
把 200 组种子按起手伤害分 5 个区间,两行的形状差异一目了然:
| 伤害区间 | 洗牌袋 | 可放回 | 谁更多 |
|---|---|---|---|
| 6–17(哑火) | 4 | 13 | 可放回 |
| 18–29 | 79 | 82 | 接近 |
| 30–41 | 83 | 65 | 洗牌袋 |
| 42–53 | 29 | 28 | 接近 |
| 54–70(爆开) | 5 | 12 | 可放回 |
两头的尾巴都是可放回更厚:低尾 4 → 13,高尾 5 → 12。中间那段(18–29 与 42–53)反而差不多——方差变大挤压的是中段,不是整体平移。洗牌袋取到过的最小值是 12、最大是 54;可放回取到 6 到 70,跨度从 21 个不同取值扩到 33 个。换句话说,洗牌袋把结果收进一个窄带,可放回允许它跑到带外——而带外的局正是玩家会记住的那些。
低尾那 13 组里,可放回抽出了 6(一张 strike 加四张 guard,伤害 0)这类洗牌袋物理上不可能的组合——洗牌袋只有 2 张 guard,凑不出「四张 guard」。高尾的 70 同理,需要 heavy 重复出现。洗牌袋的取值上界 54 = 14×3 + 6×2(三张 heavy 加两张 strike),这是牌库结构给出的硬上界。
反过来说,洗牌袋的窄带是被牌库的构成锁死的:想让起手伤害的分布变宽或变窄,改牌库(多一张 heavy、少一张 strike)比改发牌结构更直接。结构决定「允不允许重复」,牌库构成决定「重复与不重复各自的取值范围」——两者是正交的两个旋钮。
varPm 是怎么算出来的
方差代理量故意写成整数地板除,避免任何浮点。以本节的实测为例:
bag avg = 32 Σ(x-avg)² = 16033 16033 × 1000 // (200 × 32) = 2505
replace avg = 32 Σ(x-avg)² = 24825 24825 × 1000 // (200 × 32) = 3878
分母 S·avg = 200 × 32 = 6400 把「样本数」和「量纲」一起归一,乘 1000 得到千分比。可放回的离差平方和是洗牌袋的 24825 / 16033 ≈ 1.55 倍——这正是 3878 与 2505 的比值。换成浮点的标准差会得到「约 8.9 vs 约 11.1」,但整数版更稳:它在任何语言、任何机器上都逐位一致,可以写进断言。这就是本套为什么用 χ² 型代理量而不是标准差。
方差与惩罚节奏
方差直接决定惩罚落点。8.2 的「挫败预算」量的是玩家能承受多少负面事件,而方差决定这些负面事件是均匀撒开还是扎堆出现。可放回的 lowPm = 110‰ 意味着每 100 局里有 11 局开局哑火;如果这 11 局是连续发生的(玩家连玩几把都卡手),挫败感远大于它们均匀分布的情形。
所以调方差时不能只看总量,要看聚类。9.1 的 maxRun(最长连号)量的就是聚类程度:洗牌袋的 2 对可放回的更高值,说明洗牌袋把负面事件打散得更均匀。把 varPm 与 maxRun 一起读,才能判断「极端局是稀疏的还是成串的」——成串的极端局才是真正伤体验的那种。
代理量本身也要选对
「起手伤害」只是众多代理量之一。换一个代理量,结论可能变。若取「起手能否打出至少一张费用 ≤ 1 的牌」,同样 200 组种子下:洗牌袋「无牌可打」的占比是 0‰,可放回是 5‰(200 组里只有 1 组抽到 5 张全是费用 2 的 heavy)。差距从伤害代理量的 70‰ vs 110‰ 缩到 0‰ vs 5‰——因为费用 ≤ 1 的牌有 9 张、费用 2 的只有 3 张,两种结构都几乎保证每手有可打的牌。这说明代理量的选择本身是一次设计判断:它决定了你会看到哪种方差。
| 检查项 | 合格标准 |
|---|---|
| 代理量显式声明 | 明说「只做相对比较,禁绝对化」 |
| 结构对照公平 | 两种结构同一份牌库、同一组种子、同一均值口径 |
| 度量是整数 | range / avg / varPm / lowPm / hiPm 全为整数或千分比 |
| 阈值显式 | 哑火(<20)与爆开局(≥45)的阈值写在规格里 |
| 均值先对齐 | 先确认两种结构均值相同,再谈方差差异 |
| 分桶看形状 | 只看极差不够,要看分布中段与两尾各占多少 |
| 不越界下结论 | 不写「方差低更好」,只写结构差异 |
结构选对了,方差也量出来了,还剩最后一个问题:这套随机可不可复现。同一个种子,在 Python 与 TypeScript 两处跑,会不会给出同一个结果?这听起来像工程细节,其实是设计能力的前提——只有可复现,上面这些方差数字才能被复跑、被断言、被写进验收。下一节用 digest 逐字证明它。
阅读导航:上一节:9.1 随机性的结构:均匀、加权、洗牌袋 · 下一节:9.3 可播种随机与确定性回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。