9.2 方差与体验

同一份机制在两种发牌结构下,体验能差多少?本节取「起手 5 张的总伤害」为体验代理量,对 200 组显式种子分别用洗牌袋(真实发牌)与可放回(假想)各算一次:洗牌袋极差 42、方差代理量 2505 千分比;可放回极差 64、方差代理量 3878。可放回把哑火局与爆开局的占比都抬高了一档。

9.2 方差与体验

《独立游戏平衡与经济设计》 里,方差是调参时的一个手感描述:「这版抽卡太极端了,压一压」。本节把这句话变成可量的东西——同一份机制,换一种发牌结构,体验代理量的离散程度变多少。9.1 量的是单次抽样的分布形状,本节量的是一整局里抽样叠起来的结果。

体验代理量:起手 5 张的总伤害

「体验」不可直接度量,但可以选一个代理量:开局压制力。取《灰烬牌库》的 12 张牌库,起手 5 张的总伤害就是代理量:

牌伤害张数12 张里的贡献
strike6530
heavy14342
jab326
guard020
合计—1278

起手伤害高 = 开局能立刻压制;起手伤害低 = 前几回合只能防守,体感「卡手」。这个代理量只用于相对比较(哪种结构更极端),不能绝对化——「多少伤害最好玩」不是它能回答的。

两种发牌结构

同一份牌库,两种抽法:

  • 洗牌袋(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——这一点很重要:两种结构的期望起手伤害相同,因为单张的期望伤害一样。差别全在离散程度上:

指标洗牌袋可放回差异
最小起手伤害126可放回更低
最大起手伤害5470可放回更高
极差4264可放回宽 52%
方差代理量(千分比)25053878可放回高 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(哑火)413可放回
18–297982接近
30–418365洗牌袋
42–532928接近
54–70(爆开)512可放回

两头的尾巴都是可放回更厚:低尾 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 可播种随机与确定性回放 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡