9.1 随机性的结构:均匀、加权、洗牌袋

同一句「随机」在实现层是三种结构:均匀(可放回等概率)、加权(整数权重前缀和落点)、洗牌袋(打乱一副袋不放回抽)。本节对三者各抽 600 次,用整数与千分比量出重复率、最长连号与方差代理量——洗牌袋频次恰好每面 100 次、方差代理量为 0,均匀是 10020 千分比,加权偏斜到 186420。

9.1 随机性的结构:均匀、加权、洗牌袋

《独立游戏平衡与经济设计》 讲的是平衡怎么迭代:改数值、跑一轮、看手感、再改。那是一套流程与经验。本节换一个更靠前的角度——随机本身的形状。同样一句「随机抽一张牌」,在实现层可以是三种结构,它们的分布性质差得很远;不改数值、只换结构,玩家的体感就会变。这是方差结构分析,量的是分布,不是平衡。

随机不是一个东西,是三种结构

「随机」在代码里落成三行不同的东西:

结构实现放回?概率典型场景
均匀state = rng_next(state); x = state % n放回等概率掷骰、暴击判定
加权整数权重前缀和,state % total 落进哪个区间取哪项放回不等概率掉落表、稀有度
洗牌袋shuffle_deck 打乱一副袋,不放回抽,抽空重洗不放回袋内等概率发牌、俄罗斯方块块序

三者的关键分野是放回与不放回。放回的结构里,同一个结果可以连着出现;不放回的结构里,一副袋内每个结果恰好出现一次——这正是洗牌袋(bag randomizer)被大量卡牌与消除游戏采用的原因:它把「运气差到连抽十次都一样」这种极端局直接消灭了。

三种结构都能用同一台 PRNG 驱动。本套的 PRNG 是自实现的 Lehmer MINSTD(state = state * 48271 % 2147483647),整数运算、可播种、跨语言同序——所以下面的对照在任何机器上都能复现。

真跑:三种结构各抽 600 次

n = 6(6 种等概率结果)、k = 600(抽 600 次,恰好 100 个洗牌袋,保证袋能走整),加权用 [1,1,2,2,3,5](总和 14,明显偏斜)。三个度量全是整数或千分比:

  • 重复率 repeatPm:相邻两次结果相同的次数 ÷ (k−1),千分比。
  • 最长连号 maxRun:同一结果连续出现的最长长度(极值间隔的整数形态)。
  • 方差代理量 varPm:频次偏离均匀的 χ² 型整数代理,Σ(cᵢ·n − k)² × 1000 ÷ (n·k)。
#!/usr/bin/env python3
# file: sim/gd_mech_09_randomness.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_09_randomness.py
"""9.1 随机性的结构:均匀、加权、洗牌袋(S 层)。

同一种「随机」在实现层有三种截然不同的**结构**,它们的分布性质差得很远:

  ① 均匀   state = rng_next(state); x = state % n          可放回、等概率
  ② 加权   整数权重前缀和 + state % total 落点              可放回、不等概率
  ③ 洗牌袋 Fisher-Yates 打乱一副袋、不放回抽取              不放回、袋内等概率

本脚本对三者各抽 K 次(K 取 n 的整数倍,保证洗牌袋恰好走完整袋),用**整数与
千分比**度量三个性质:

  - 重复率 repeatPm   相邻两次结果相同的次数 / (K-1),千分比
  - 最长连号 maxRun   同一结果连续出现的最长长度(极值间隔的整数形态)
  - 方差代理量 varPm  频次偏离均匀的 χ² 型整数代理,千分比

全部整数运算,无浮点、无 random 模块;同一 seed 在任何机器上给同一张表。
"""
from __future__ import annotations

import os
import sys

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)

SEED = 20261014
N = 6                       # 结果面数:6 种等概率结果
K = 600                     # 抽样次数 = 100 个洗牌袋(K 必须是 N 的整数倍)
WEIGHTS = [1, 1, 2, 2, 3, 5]  # 整数权重,总和 14(明显偏斜)
HEADER = ["structure", "counts", "repeatPm", "maxRun", "varPm"]


def uniform_draws(n: int, k: int, seed: int) -> list:
    """均匀:可放回,每次 state % n。"""
    out = []
    state = seed % ashlib.RNG_MOD or 1
    for _ in range(k):
        state = ashlib.rng_next(state)
        out.append(state % n)
    return out


def weighted_draws(weights: list, k: int, seed: int) -> list:
    """加权:整数权重前缀和,state % total 落进哪个前缀区间就取哪一项。"""
    total = sum(weights)
    prefix = []
    acc = 0
    for w in weights:
        acc += w
        prefix.append(acc)
    out = []
    state = seed % ashlib.RNG_MOD or 1
    for _ in range(k):
        state = ashlib.rng_next(state)
        r = state % total
        for i, p in enumerate(prefix):
            if r < p:
                out.append(i)
                break
    return out


def bag_draws(n: int, k: int, seed: int) -> list:
    """洗牌袋:打乱一副 n 张袋不放回抽,抽空后用下一个 state 重洗。"""
    out = []
    state = seed % ashlib.RNG_MOD or 1
    bag = ashlib.shuffle_deck(list(range(n)), state)
    for _ in range(k):
        if not bag:
            state = ashlib.rng_next(state)
            bag = ashlib.shuffle_deck(list(range(n)), state)
        out.append(bag.pop(0))
    return out


def metrics(draws: list, n: int) -> dict:
    """三个整数 / 千分比度量。"""
    k = len(draws)
    counts = [0] * n
    for x in draws:
        counts[x] += 1
    rep = 0
    best = 1
    cur = 1
    for i in range(1, k):
        if draws[i] == draws[i - 1]:
            rep += 1
            cur += 1
            if cur > best:
                best = cur
        else:
            cur = 1
    var_num = sum((c * n - k) ** 2 for c in counts)
    var_pm = var_num * 1000 // (n * k)
    return {
        "counts": counts,
        "repeatPm": rep * 1000 // (k - 1),
        "maxRun": best,
        "varPm": var_pm,
    }


def main() -> int:
    structures = [
        ("uniform", uniform_draws(N, K, SEED)),
        ("weighted", weighted_draws(WEIGHTS, K, SEED)),
        ("bag", bag_draws(N, K, SEED)),
    ]
    rows: list = []
    for name, draws in structures:
        m = metrics(draws, N)
        rows.append([name, "|".join(str(c) for c in m["counts"]),
                     m["repeatPm"], m["maxRun"], m["varPm"]])

    out = os.path.join(ROOT, "out", "mech09_structures.csv")
    n = ashlib.write_csv(out, HEADER, rows)

    print(f"seed={SEED} n={N} k={K} weights={WEIGHTS} weightSum={sum(WEIGHTS)}")
    print(" ".join(HEADER))
    for r in rows:
        print(" ".join(str(x) for x in r))
    print(f"bagExactUniform={all(c == K // N for c in metrics(structures[2][1], N)['counts'])}")
    print(f"wrote out/mech09_structures.csv rows={n}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/gd_mech_09_randomness.py:

seed=20261014 n=6 k=600 weights=[1, 1, 2, 2, 3, 5] weightSum=14
structure counts repeatPm maxRun varPm
uniform 75|108|113|108|104|92 178 4 10020
weighted 53|36|86|87|134|204 240 6 186420
bag 100|100|100|100|100|100 35 2 0
bagExactUniform=True
wrote out/mech09_structures.csv rows=3

判读:三行数字各说了什么

洗牌袋的频次是 100|100|100|100|100|100,方差代理量 0。 这不是「运气好」,是数学必然:600 次抽满 100 个袋,每个袋里 6 个面各出现一次,总和必然每面 100 次。varPm 为 0 意味着频次完全均匀——洗牌袋把「分布均匀」从概率保证升级成了结构性保证。

均匀的 varPm = 10020 是 6 面均匀分布在小样本下的正常波动:期望每面 100 次,实测在 75~113 之间浮动。它和洗牌袋的区别不是「谁更随机」——两者单次抽样的边际分布都是等概率——而是洗牌袋不允许短期内重复。

加权的 varPm = 186420,是均匀的 18 倍多,因为它本来就不均匀:权重 [1,1,2,2,3,5] 让第 6 面的期望频次是第 1、2 面的 5 倍。这里的高方差代理量是设计意图,不是缺陷。

结构repeatPmmaxRunvarPm体验含义
均匀178410020短期可能出现连号,体感「真随机」
加权2406186420偏斜放大了重复与连号,稀有项更难出
洗牌袋3520不重复、不连号,体感「公平」

注意 repeatPm 的排序:加权(240) > 均匀(178) > 洗牌袋(35)。加权比均匀还容易重复,因为偏斜结构本身就把结果往少数几面挤。而洗牌袋的 35‰ 全部来自袋与袋的接缝——同一袋内不可能重复,只有上一袋的末张和下一袋的首张偶尔同面。maxRun 同向印证:洗牌袋最长连号 2,均匀 4,加权 6。

袋的接缝:35‰ 从哪里来

洗牌袋的重复只可能发生在两袋交界处。把 600 次抽样按袋切开,每袋 6 个位置恰好是 6 个不同的面:

bag#1  [3, 2, 4, 1, 0, 5]   袋内无重复
bag#2  [0, 1, 4, 2, 5, 3]   首张 0 != 上袋末张 5
bag#3  [3, 0, 5, 4, 2, 1]   首张 3 == 上袋末张 3  → 记 1 次重复
bag#4  [5, 2, 3, 4, 1, 0]   首张 5 != 上袋末张 1
...    (100 袋,接缝共 99 处,实测同面 21 处)

99 个接缝里实测 21 处恰好同面,600 次抽样换算成 repeatPm 就是 35‰——远低于均匀的 178‰。这个数可以从结构粗估:每个接缝同面的概率是 1/n = 1/6,期望重复次数 ≈ 99/6 ≈ 16,占 599 个相邻对的 27‰ 上下,与实测 35‰ 同量级。洗牌袋的「公平感」不是玄学,是接缝密度的直接后果。

为什么 K 必须是 N 的整数倍

k = 600 是 n = 6 的整数倍,这不是凑数。如果取 k = 599,最后一个洗牌袋只抽了 5 张,第 6 个面就少出现一次,频次立刻变成 100|100|100|100|100|99,varPm 也不再是 0。所以:

  • 要让洗牌袋的均匀性可断言,抽样次数必须是袋大小的整数倍,或者把最后一袋补完再统计。
  • 均匀与加权不受此限:它们可放回,任意 k 都能算,但 varPm 的期望会随 k 增大而下降(样本越多越接近均匀)。
  • 对照三种结构时,k 必须统一:否则比的是样本量差异,不是结构差异。

什么时候选哪种

  • 要「真随机」的赌感 → 均匀。暴击、命中这类判定用可放回均匀,玩家接受连号。
  • 要控制稀有度 → 加权。掉落表、稀有度抽取用整数权重前缀和,权重可调、可审计。
  • 要消灭极端局 → 洗牌袋。发牌、块序、抽卡这类「玩家会记得连续倒霉」的地方用不放回。
  • 要兼顾两者 → 混合。主流做法是「洗牌袋 + 袋内加权」:袋保证每 N 次里稀有项出现固定次数,权重决定它在袋里的占比。

洗牌袋也有代价:它可预测。抽满一副袋后,玩家能算出「下一张一定不是刚出过的」。对竞技性强的游戏这是优点(减少运气),对追求刺激的游戏这是缺点。这不是好坏,是结构选择——而选择的前提是你能把三种结构的分布量出来。

检查项合格标准
三种结构都真跑均匀 / 加权 / 洗牌袋各有频次与度量
度量是整数频次、repeatPm、maxRun、varPm 全为整数或千分比
洗牌袋走整袋K 是 N 的整数倍,袋不被截断
无浮点无 random只用 ashlib.rng_next 与 ashlib.shuffle_deck
结论只做相对比较不写「哪种更好」,只写分布差异

三种结构的单次分布量完了,但玩家体验到的是一局里很多次抽样叠起来的结果。下一节把视角从「单次抽样」抬到「一局的整体离散度」——同一份机制在两种发牌结构下,体验代理量能差多少。

阅读导航:上一节:8.3 节奏与惩罚的度量 · 下一节:9.2 方差与体验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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