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 倍。这里的高方差代理量是设计意图,不是缺陷。
| 结构 | repeatPm | maxRun | varPm | 体验含义 |
|---|---|---|---|---|
| 均匀 | 178 | 4 | 10020 | 短期可能出现连号,体感「真随机」 |
| 加权 | 240 | 6 | 186420 | 偏斜放大了重复与连号,稀有项更难出 |
| 洗牌袋 | 35 | 2 | 0 | 不重复、不连号,体感「公平」 |
注意 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 方差与体验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。