8.3 类型的分布与漂移

类型分布不是常数。本节用整数最佳回应动力学让人口占比按各类型最优得分相对平均分迭代,敌人攻击从 12 扫到 18,同一初始分布漂移 244‰ 到 278‰;再把每条策略当成行为固定的玩家做归类,分类漂移率从 0‰ 涨到 156‰——类型边界本身也在动,重排集中在 14 到 16 这一段。

8.3 类型的分布与漂移

《小游戏开发实战》卷一 5.1 用户画像与场景 那篇的画像是一次性的:做完画像就去指导素材、题材与节奏,画像本身不会变。本节把类型当成人口结构来看——它有占比,会随机制改动与版本迭代整体挪动。要问的是两个可算的漂移:各类型的占比会不会挪(分布漂移),以及类型之间的边界稳不稳(分类漂移)。后者常被忽略:如果只挪了占比,那是「人变了」;如果连边界都在动,那是尺子变了,同一批玩家会被归到不同类里。

分布漂移:整数最佳回应动力学

设类型 t 的占比是 p_t(千分比,和为 1000),它在当前参数下的最优得分是 fit_t。最佳回应动力学的一句话是:得分高于人口平均的类型涨,低于平均的跌。整数版写成三步,全程不碰浮点:

mean    = Σ p_t · fit_t ÷ 1000                              (人口平均得分)
p'_t    = max(floor, p_t · (1000 + κ·(fit_t − mean) ÷ 1000) ÷ 1000)   (按偏离度缩放)
p_t     = 1000 · p'_t ÷ Σ p'                                (归一化回 1000‰)

κ(千分比)是响应强度:κ=500‰ 表示「相对平均高 100‰ 的类型,本代占比乘 1.05」。floor 是占比下限,防止某个类型被压到 0 后再也回不来。这是一个人为构造的动力学——它不假装描述真实玩家的迁移行为,只提供一条可复现的相对比较基准:同一个初始分布、同一个 κ,参数改了之后稳态往哪边挪、挪多少。

分类漂移:类型边界自己会动

分布漂移看的是「占比」,还要看尺子。做法很直接:把 4096 条策略每一条都当成「一个行为固定的玩家」,在参数取值 A 下按最优得分把它归到某一类型、在参数取值 B 下再归一次,归属改变的策略比例就是分类漂移率。它量的是类型边界的位置稳不稳——边界稳,则「同一个玩家在不同版本里仍是同一类人」;边界不稳,则占比的涨跌里混着归类本身的重排,不能当成真实人口变化来读。

旋钮清单、初始分布、动力学参数与软告警阈值都写进 config:

{
  "id": "gd_play_08_drift",
  "note": "卷二 8.3 类型的分布与漂移:整数最佳回应动力学 + 跨参数的分类漂移率(全整数,禁浮点)",
  "typesConfig": "config/gd_play_08_types.json",
  "seeds": [401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416],
  "populationPermille": { "conqueror": 400, "guardian": 250, "optimizer": 200, "explorer": 150 },
  "dynamics": { "generations": 12, "kappaPermille": 500, "floorPermille": 20 },
  "sweep": { "param": "enemyAttack", "values": [12, 14, 16, 18] },
  "driftWarnPermille": 150
}

S 层:四个参数取值上的两种漂移

脚本在 enemyAttack ∈ {12, 14, 16, 18} 四个取值上各跑一遍:先枚举策略空间求四类型的最优得分与每条策略的归类,再对每个取值跑 12 代动力学。四个取值共用同一份初始分布与同一个 κ,所以读数的差异只来自机制参数。漂移超阈值只打印软告警,进程始终 exit 0;产物是 JSON + CSV + 一条纯 xml.etree 的四折线 SVG。

#!/usr/bin/env python3
# file: sim/gd_play_08_drift.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_08_drift.py
"""卷二 8.3 类型的分布与漂移(S 层):类型人口结构怎么随机制参数挪动。

两个可算的「漂移」,都只用整数:
  1) **分布漂移**:给定人口分布(各类型占比,千分比),用整数最佳回应动力学迭代——
     每代按「该类型最优得分相对人口平均得分的高低」调整占比,夹下限后归一化回 1000‰;
     跑固定代数后报最终分布与初始分布的**总变差距离**。
  2) **分类漂移**:把每条策略当成「行为固定的玩家」,在参数 A 下按最优得分归类、在参数 B
     下再归一次;归属改变的比例(千分比)就是**类型边界**的稳定度。

参数扫描 enemyAttack 的四个取值各跑一遍,于是漂移本身也有方向与幅度。初始人口分布若来自
真实玩家调研属于 **X 档**(需真实业务环境,命令行不可复现);本脚本用的是**构造分布**。
全部得分与占比都是**代理指标**,只用于相对比较,禁止绝对化。
全程整数、无浮点、无 random。产出 out/play08_drift.json + .csv + .svg。
"""
import copy
import itertools
import json
import os
import sys
import time
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
TYPES_CONFIG = os.path.join(ROOT, "config/gd_play_08_types.json")
CONFIG = os.path.join(ROOT, "config/gd_play_08_drift.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

AXES = ("win", "dealt", "hp", "play", "speed", "eff")
COLORS = {"conqueror": "#38bdf8", "guardian": "#f59e0b",
          "optimizer": "#22c55e", "explorer": "#e879f9"}


def features(spec, seeds, seq, refs):
    p = spec["params"]
    acc = [0, 0, 0, 0]
    wins = 0
    for sd in seeds:
        s = copy.deepcopy(spec)
        s["seed"] = sd
        sn = ashlib.run_spec(s, seq)
        acc = [a + b for a, b in zip(acc, (sn["dealt"], sn["playerHp"], sn["played"], sn["turn"]))]
        wins += 1 if sn["won"] else 0
    n = len(seeds)
    dealt, hp, played, turn = (v // n for v in acc)
    return (1000 * wins // n, 1000 * dealt // p["enemyHp"], 1000 * hp // p["playerHp"],
            1000 * played // refs["playedRef"],
            1000 * (p["turnLimit"] - turn) // p["turnLimit"],
            1000 * dealt // (played * refs["damageRef"]) if played else 0)


def sweep_point(spec, cfg, refs, seeds, param, value):
    """一个参数取值上:每条策略的特征向量 → 各类型得分 → 最优得分与归类。"""
    base = copy.deepcopy(spec)
    base["params"].update({**cfg["slice"], param: value})
    types, patterns = cfg["types"], cfg["patterns"]
    fit = {t["id"]: None for t in types}
    labels = []
    for combo in itertools.product(sorted(patterns), repeat=cfg["horizon"]):
        seq = []
        for letter in combo:
            seq.extend("p%d" % slot for slot in patterns[letter])
            seq.append("e")
        vec = features(base, seeds, seq, refs)
        best_id, best_sc = None, None
        for t in types:
            w = t["weights"]
            sc = sum(w[a] * vec[i] for i, a in enumerate(AXES)) // 1000
            if best_sc is None or sc > best_sc:
                best_id, best_sc = t["id"], sc
            if fit[t["id"]] is None or sc > fit[t["id"]]:
                fit[t["id"]] = sc
        labels.append(best_id)
    return fit, labels


def evolve(pop, fit, ids, dyn):
    """整数最佳回应动力学:按 (fit − 人口均值) 调整占比,夹下限后归一化回 1000‰。"""
    kappa, floor, gens = dyn["kappaPermille"], dyn["floorPermille"], dyn["generations"]
    traj = [dict(pop)]
    for _ in range(gens):
        mean = sum(pop[i] * fit[i] for i in ids) // 1000
        raw = {i: max(floor, pop[i] * (1000 + kappa * (fit[i] - mean) // 1000) // 1000)
               for i in ids}
        total = sum(raw.values())
        nxt = {i: 1000 * raw[i] // total for i in ids}
        nxt[max(ids, key=lambda i: (nxt[i], -ids.index(i)))] += 1000 - sum(nxt.values())
        pop = nxt
        traj.append(dict(pop))
    return traj


def chart(curves, values, path):
    """纯 xml.etree SVG:每个类型一条占比折线,横轴是扫描参数取值。"""
    W, H, x0, y0, y1 = 720, 320, 70, 46, 250
    root = ashlib.svg_begin(W, H)
    ashlib.svg_text(root, 16, 28, "类型占比随 enemyAttack 的漂移(千分比,各类型一条折线)", 15)
    for j, v in enumerate(values):
        ashlib.svg_text(root, x0 + j * (W - 110) // max(1, len(values) - 1), y1 + 22,
                        f"ea={v}", 11, "#94a3b8", "middle")
    for tid, pts in curves.items():
        poly = " ".join(f"{x0 + j * (W - 110) // max(1, len(pts) - 1)},"
                        f"{y1 - p * (y1 - y0) // 1000}" for j, p in enumerate(pts))
        ET.SubElement(root, "polyline", {"points": poly, "fill": "none",
                                         "stroke": COLORS[tid], "stroke-width": "2"})
    return ashlib.svg_save(root, path)


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(TYPES_CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    with open(CONFIG, encoding="utf-8") as f:
        dc = json.load(f)
    refs, seeds = cfg["refs"], dc["seeds"]
    ids = [t["id"] for t in cfg["types"]]
    names = {t["id"]: t["name"] for t in cfg["types"]}
    init = {i: dc["populationPermille"][i] for i in ids}
    param, values = dc["sweep"]["param"], dc["sweep"]["values"]
    points, curves = [], {i: [] for i in ids}
    for value in values:
        fit, labels = sweep_point(spec, cfg, refs, seeds, param, value)
        traj = evolve(dict(init), fit, ids, dc["dynamics"])
        final = traj[-1]
        points.append({"value": value, "fitness": fit, "final": final, "labels": labels,
                       "traj": traj, "drift": sum(abs(final[i] - init[i]) for i in ids) // 2})
        for i in ids:
            curves[i].append(final[i])
    ref_labels = points[0]["labels"]
    for p in points:
        p["labelDrift"] = 1000 * sum(1 for a, b in zip(ref_labels, p["labels"])
                                     if a != b) // len(ref_labels)
    csv = [[param, p["value"], g] + [step[i] for i in ids]
           for p in points for g, step in enumerate(p["traj"])]
    n = ashlib.write_csv(os.path.join(OUT, "play08_drift.csv"),
                         [param, "generation"] + ids, csv)
    size = chart(curves, values, os.path.join(OUT, "play08_drift.svg"))
    warnings = [f"{param}={p['value']} 的分布漂移 {p['drift']}‰ 超过阈值 "
                f"{dc['driftWarnPermille']}‰:机制改动会显著改写类型人口结构"
                for p in points if p["drift"] > dc["driftWarnPermille"]]
    report = {"id": "gd_play_08_drift", "slice": cfg["slice"], "param": param, "values": values,
              "seeds": len(seeds), "initial": init, "dynamics": dc["dynamics"],
              "strategies": len(cfg["patterns"]) ** cfg["horizon"], "points": points,
              "warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play08_drift.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"切片 enemyHp={cfg['slice']['enemyHp']}  初始分布 "
          + " ".join(f"{names[i]}={init[i]}" for i in ids)
          + f"  动力学 kappa={dc['dynamics']['kappaPermille']}‰ "
          f"代数={dc['dynamics']['generations']}")
    print(f"{'ea':>4}  " + "".join(f"{names[i]:>8}" for i in ids)
          + f"{'漂移':>7}{'分类漂移':>9}   最优得分")
    for p in points:
        print(f"{p['value']:>4}  " + "".join(f"{p['final'][i]:>8}" for i in ids)
              + f"{p['drift']:>7}{p['labelDrift']:>9}   "
              + " ".join(f"{names[i]}={p['fitness'][i]}" for i in ids))
    print("末代轨迹(征服者/守护者/效率者/探索者):")
    for p in points:
        print(f"  ea={p['value']:>2}  "
              + " ".join("[" + ",".join(str(p["traj"][g][i]) for i in ids) + "]"
                         for g in range(0, dc["dynamics"]["generations"] + 1,
                                        max(1, dc["dynamics"]["generations"] // 3))))
    print(f"warnings={len(warnings)}")
    for w in warnings:
        print(f"  [WARN] {w}")
    print(f"csv={n} rows  svg={size} bytes  elapsed={report['elapsedMs']} ms")
    print("wrote out/play08_drift.json out/play08_drift.csv out/play08_drift.svg")
    ET.parse(os.path.join(OUT, "play08_drift.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_play_08_drift.py:

切片 enemyHp=55  初始分布 征服者=400 守护者=250 效率者=200 探索者=150  动力学 kappa=500‰ 代数=12
  ea       征服者     守护者     效率者     探索者     漂移     分类漂移   最优得分
  12       625      62     219      94    244        0   征服者=935 守护者=652 效率者=885 探索者=795
  14       632      43     227      98    259        5   征服者=926 守护者=593 效率者=881 探索者=790
  16       633      29     237     101    270      119   征服者=916 守护者=527 效率者=876 探索者=785
  18       633      20     245     102    278      156   征服者=908 守护者=469 效率者=872 探索者=781
末代轨迹(征服者/守护者/效率者/探索者):
  ea=12  [400,250,200,150] [485,166,215,134] [561,104,221,114] [625,62,219,94]
  ea=14  [400,250,200,150] [493,150,220,137] [572,82,228,118] [632,43,227,98]
  ea=16  [400,250,200,150] [498,136,226,140] [576,66,237,121] [633,29,237,101]
  ea=18  [400,250,200,150] [503,122,231,144] [577,52,245,126] [633,20,245,102]
warnings=4
  [WARN] enemyAttack=12 的分布漂移 244‰ 超过阈值 150‰:机制改动会显著改写类型人口结构
  [WARN] enemyAttack=14 的分布漂移 259‰ 超过阈值 150‰:机制改动会显著改写类型人口结构
  [WARN] enemyAttack=16 的分布漂移 270‰ 超过阈值 150‰:机制改动会显著改写类型人口结构
  [WARN] enemyAttack=18 的分布漂移 278‰ 超过阈值 150‰:机制改动会显著改写类型人口结构
csv=52 rows  svg=1287 bytes  elapsed=5750 ms
wrote out/play08_drift.json out/play08_drift.csv out/play08_drift.svg

读表:占比整体重排,边界过一条线才动

先看四类型在四个取值上的最优得分(动力的输入):

enemyAttack征服者守护者效率者探索者
12935652885795
14926593881790
16916527876785
18908469872781

难度一路抬升,四类的绝对得分都在跌,但跌的幅度完全不是一个量级:征服者只掉 27‰,守护者掉了 183‰。守护者的主轴是存活,敌人攻击每加 2 点,它最想要的那条轴就被压得最狠。动力学把这件事放大成了结构:

enemyAttack征服者守护者效率者探索者分布漂移分类漂移
126256221994244‰0‰
146324322798259‰5‰
1663329237101270‰119‰
1863320245102278‰156‰

三件事值得拆开。第一,同一份初始分布(400/250/200/150‰)在四个取值上都漂了 244–278‰,全部超过 150‰ 的阈值,四条软告警一条没漏。守护者从 250‰ 掉到 20‰,正好触到 floor——它已经被挤到模型允许的最小占比;真实世界里这类玩家的退场会比这个数字更彻底,因为模型不允许类型消失。

第二,分布漂移与分类漂移不是一回事。分布漂移在 enemyAttack=12 时就已经有 244‰,可那时的分类漂移是 0‰——占比在大幅重排,但没有任何一条策略换了归属,说明那时是「同一把尺子量出了不同的人口」。到 16、18 两个取值,分类漂移跳到 119‰ 与 156‰,尺子本身开始重排。所以「占比变了」这句话在 enemyAttack ≥ 16 之后就不能直接读成「玩家构成变了」。

第三,重排集中在 14 到 16 这一段:12→14 只挪了 5‰,14→16 一下跳到 119‰。这和本卷第 6 章(相变)那条相界的形状是同一类现象——类型边界不是均匀漂移的,而是过了某个参数值之后整体重排。落到工作流上:如果拿这套分类去做分层运营,先扫一遍分类漂移率,找到它跳变的位置,再决定这份分层能用在哪一段参数区间里。

X 档:真实分布从哪来

本节的初始分布是构造的,它只为演示动力学,不代表任何真实人群。要拿它做产品判断,必须先换成真实数据:

数据来源口径
初始人口分布 400/250/200/150‰本节构造只为演示动力学,不可引用为真实玩家构成
真实类型分布真实玩家调研 + 行为日志X 档:需真实业务环境,命令行不可复现
四类型最优得分与稳态占比本机实跑代理指标,只做相对比较、禁绝对化

换成真实分布之后,上面两列漂移依然成立——因为它们量的是机制对给定的目标函数的影响,与初始占比无关;但「漂移 278‰ 意味着多少真实玩家会流失」这句话,命令行里永远算不出来。

自检表

检查项合格标准
动力学可复现每代只用整数乘加、地板除与归一化,种子与 κ 写进 config
下限入档floor 写进 config;触底要显式指出,不能当成真实稳态
两种漂移分开分布漂移与分类漂移各自报,不混成一个「漂移」
跳变定位报分类漂移在哪个参数区间跳变,不假设它线性增长
构造分布声明明确写初始分布是构造的,不是真实调研结果
X 档标注真实分布处逐条标注「需真实业务环境,命令行不可复现」
代理指标显式得分与占比都是 proxy,只做相对比较、禁绝对化

这一章把「玩家类型」从四个词做成了三样可算的东西:目标函数(8.1)、旋钮的偏向性矩阵(8.2)、会漂移的人口结构(8.3)。下一章离开人群,回到单局体验的时间轴——张力与释放。

阅读导航:上一节:8.2 类型到机制的映射 · 下一节:9.1 张力与释放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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