8.3 节奏与惩罚的度量

奖励给多少、惩罚忍多久,只有放进同一根轴才能比。本节把金币密度、挫败密度、净节奏、兑换率四个千分比算出来:奖励期每动作 1170‰ 金币、惩罚期每动作 1010‰ 挫败,全程净节奏 -232‰。再扫一张 5×4 参数基准表,越界场次从 15 提前到 4,两条软告警标出「预算形同虚设」与「节奏对难度过敏」。

留存设计清单 那一篇给的是清单:该做什么、多久做一次。8.1 把奖励写成谱,8.2 把惩罚写成预算,但两者各自成篇时无法比较——金币是收益、挫败是代价,不同量纲。本节做最后一件事:把两条轴并成一条,用每单位玩家动作的千分比让收益与代价可比,再扫一张参数表,看这些数在调参时会往哪走。

8.3 节奏与惩罚的度量

四个千分比

度量全部是整数地板除,没有浮点,也没有归一化系数:

量定义用途
金币密度‰1000 × 总金币 // 总动作每单位动作的收益
挫败密度‰1000 × 累计挫败 // 总动作每单位动作的代价
净节奏‰金币密度 − 挫败密度收益减去代价,正数才算「赚」
兑换率‰1000 × 总金币 // 累计挫败每 1 分挫败换回多少金币

这些是代理指标:它们能回答「A 方案比 B 方案更划算吗」,回答不了「这个游戏好玩吗」。代理指标的价值在于可复现——同一份规格在任何语言下得到同一组整数。

可跑脚本(S 层)

本节 S 层不重跑仿真,只消费 8.1 与 8.2 落下的两份 JSON。这本身就是一条纪律:度量层不允许自己再造一份战斗规则,否则它测的是自己的实现,不是被测对象。阶段切分点是 last = max(场次 | 该场获胜),即最后一次发奖的场次。

#!/usr/bin/env python3
# file: sim/gd_mech_08_metrics.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_08_metrics.py
"""卷一 8.3 节奏与惩罚的度量(S 层):把收益与代价放到同一根轴上比。

本节**不重跑仿真**,只消费 8.1 / 8.2 的两份 JSON 产物,把「每单位玩家动作换到多少金币」
与「每单位玩家动作攒下多少挫败」算成可比的千分比,再按阶段切开:奖励期 = 最后一次发奖及
之前的场次,惩罚期 = 之后的场次。派生量全部整数地板除:
  金币密度‰ = 1000 × 总金币 / 总动作;挫败密度‰ = 1000 × 累计挫败 / 总动作;
  兑换率‰ = 1000 × 总金币 / 累计挫败(每 1 分挫败换回多少金币 ×1000)。
这些是**代理指标**,只用于相对比较,不构成「好玩」的绝对判据。
产出 out/mech08_metrics.csv + out/mech08_metrics.svg + out/mech08_metrics.json。
"""
from __future__ import annotations

import json
import os
import sys
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")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402

HEADER = ["phase", "encounters", "actions", "gold", "frustration", "goldDensityPermille",
          "frustrationDensityPermille"]


def draw(phases: list[list], gd: int, fd: int, exch: int, path: str) -> int:
    """两阶段对照:每组两根条(金币密度 / 挫败密度),条长按四数最大值归一。"""
    root = ashlib.svg_begin(600, 210)
    ashlib.svg_text(root, 40, 26, "《灰烬牌库》奖励期与惩罚期:单位动作的收益与代价", 14)
    ashlib.svg_text(root, 40, 46, "蓝 = 金币密度‰(收益)  红 = 挫败密度‰(代价)", 11, "#94a3b8")
    top = max(gd, fd, max(p[5] for p in phases), max(p[6] for p in phases)) or 1
    for i, p in enumerate(phases):
        y = 70 + i * 56
        ashlib.svg_text(root, 40, y + 12, p[0], 12)
        ashlib.svg_bar(root, 110, y, 340, 16, p[5], top, fill="#38bdf8", label=f"{p[5]}‰")
        ashlib.svg_bar(root, 110, y + 22, 340, 16, p[6], top, fill="#ef4444", label=f"{p[6]}‰")
    ashlib.svg_text(root, 40, 190, f"全程 金币密度 {gd}‰ / 挫败密度 {fd}‰ / 兑换率 {exch}‰", 12)
    return ashlib.svg_save(root, path)


def main() -> int:
    with open(os.path.join(OUT, "mech08_rhythm.json"), encoding="utf-8") as f:
        rh = json.load(f)
    with open(os.path.join(OUT, "mech08_budget.json"), encoding="utf-8") as f:
        bu = json.load(f)
    enc, bud = rh["encounters"], bu["encounters"]     # 行数组,列序见 8.1 / 8.2 的 HEADER
    last = max(r[0] for r in enc if r[3])             # 最后一次发奖的场次
    total, gold, frust = rh["actionsTotal"], rh["goldTotal"], bu["frustrationAtCrossing"]
    phases = []
    for name in ("奖励期", "惩罚期"):
        rows = [r for r in enc if (r[0] <= last if name == "奖励期" else r[0] > last)]
        acts, g = sum(r[5] for r in rows), sum(r[7] for r in rows)
        ids = {r[0] for r in rows}
        f = sum(r[3] + r[4] for r in bud if r[0] in ids)
        phases.append([name, len(rows), acts, g, f, 0 if acts == 0 else 1000 * g // acts,
                       0 if acts == 0 else 1000 * f // acts])
    gd, fd, exch = 1000 * gold // total, 1000 * frust // total, 1000 * gold // frust
    dead = 1000 * rh["deadActions"] // total

    print(f"{'阶段':<6}{'场次':>5}{'动作':>6}{'金币':>6}{'挫败':>6}{'金币密度‰':>10}{'挫败密度‰':>10}")
    for p in phases:
        print(f"{p[0]:<6}{p[1]:>5}{p[2]:>6}{p[3]:>6}{p[4]:>6}{p[5]:>10}{p[6]:>10}")
    print(f"全程:动作 {total} 金币 {gold} 挫败 {frust}")
    print(f"金币密度={gd}‰ 挫败密度={fd}‰ 净节奏={gd - fd}‰ 兑换率={exch}‰ 空转占比={dead}‰")
    assert sum(p[2] for p in phases) == total, "两阶段动作数之和 != 全程"
    assert sum(p[4] for p in phases) == frust, "两阶段挫败之和 != 越界时的累计挫败"
    assert phases[0][5] > phases[1][5] and phases[1][6] > phases[0][6], "两阶段的收益/代价未反转"
    n = ashlib.write_csv(os.path.join(OUT, "mech08_metrics.csv"), HEADER, phases)
    size = draw(phases, gd, fd, exch, os.path.join(OUT, "mech08_metrics.svg"))
    report = {"id": "gd_mech_08_metrics", "lastRewardEncounter": last, "actionsTotal": total,
              "goldTotal": gold, "frustrationAtCrossing": frust, "phases": phases,
              "goldDensityPermille": gd, "frustrationDensityPermille": fd,
              "netRhythmPermille": gd - fd, "exchangePermille": exch,
              "deadActionSharePermille": dead, "crossingEncounter": bu["crossingEncounter"]}
    with open(os.path.join(OUT, "mech08_metrics.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    ET.parse(os.path.join(OUT, "mech08_metrics.svg"))
    print(f"wrote out/mech08_metrics.csv({n} 行 + 表头) out/mech08_metrics.svg({size} B) "
          f"out/mech08_metrics.json")
    return 0


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

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

阶段       场次    动作    金币    挫败     金币密度‰     挫败密度‰
奖励期       8    82    96    41      1170       500
惩罚期       8    95     0    96         0      1010
全程:动作 177 金币 96 挫败 137
金币密度=542‰ 挫败密度=774‰ 净节奏=-232‰ 兑换率=700‰ 空转占比=536‰
wrote out/mech08_metrics.csv(2 行 + 表头) out/mech08_metrics.svg(2088 B) out/mech08_metrics.json

判读:收益与代价的相位反转

阶段动作金币挫败金币密度‰挫败密度‰
奖励期(第 0–7 场)8296411170500
惩罚期(第 8–15 场)9509601010

两阶段完全反转:奖励期每单位动作换到 1.17 金币、攒下 0.50 挫败;惩罚期金币归零、每单位动作攒下 1.01 挫败。净节奏 −232‰ 是全章的总结论——把 16 场远征当成一个整体看,玩家每做 1 个动作净亏 0.232 分(金币减挫败)。兑换率 700‰ 说的是另一个角度:整场攒下的 137 分挫败,总共只换回 96 金币。

一处口径必须交代清楚,否则表格会误导:惩罚期的挫败 96 只统计到越界那一场(第 8–9 场)为止,因为 8.2 的脚本越界即停;而惩罚期的动作 95 来自 8.1 跑满的 8 场(第 8–15 场)。也就是说 1010‰ 这个数把「越界前攒下的挫败」摊到了「惩罚期全部动作」上,是一个保守偏低的密度——真实的后 6 场只会让这个数更高。脚本里 assert sum(p[4] for p in phases) == frust 保证的是挫败总量对得上(137),不是两阶段的动作口径一致,这一点在设计度量时必须写明。

B 层:参数基准表

S 层只给一组参数下的四个数。要回答「换个参数会怎样」,就要扫。B 层复用 8.2 的 trace,把难度步长(5 档)与 punishHp(4 档)做成交叉表,每格按三条整数阈值发软告警——永远 exit 0,产物只有 JSON。

#!/usr/bin/env python3
# file: sim/gd_mech_08_metrics_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_mech_08_metrics_bench.py
"""卷一 8.3 度量基准(B 层):越界场次对哪些参数过敏。

金币密度与 `rewardGold` 严格线性(扫它只是验算乘法);越界场次对 `punishHp` 与难度爬升
步长 `difficultyStep` 是**非线性**的——换一个步长,胜场数与动作总数一起变,越界点跟着跳。
故只扫后两者,每格按三条整数阈值发**软告警**(每格至多一条):
  BUDGET_INERT     越界场次为空(预算在整个远征内不触发,形同虚设)
  CROSS_TOO_EARLY  越界场次 ≤ crossTooEarly(玩家还没尝到奖励就被劝退)
  DENSITY_COLLAPSE 最密与最疏步长的密度差 ≥ densityCollapsePermille(节奏对难度爬升过敏)
**永远 exit 0**:B 档软告警不硬失败,产物只有 JSON。复用 8.2 的 trace,不重写战斗规则。
产出 out/mech08_metrics_bench.json。
"""
from __future__ import annotations

import copy
import json
import os
import sys

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


def sweep_step(spec: dict, step: int, cfg: dict) -> list[dict]:
    """一个难度步长下的整段轨迹(与 punishHp 无关,故按步长算一次即可)。"""
    seed, base = spec["seed"], spec["params"]["enemyHp"]
    rows = []
    for k in range(cfg["encounters"]):
        s = copy.deepcopy(spec)
        s["seed"], s["params"]["enemyHp"] = seed, base + k * step
        rows.append(pb.trace(s))
        seed = ashlib.rng_next(seed)
    return rows


def cell(spec: dict, rows: list[dict], step: int, punish: int, cfg: dict) -> dict:
    """给定轨迹与惩罚粒度,算金币密度与越界场次;越界判据 = 累计挫败 >= budget。"""
    frust, hp, crossing, actions, rewards = 0, spec["params"]["playerHp"], None, 0, 0
    for k, r in enumerate(rows):
        actions += r["played"]
        if r["won"]:
            rewards += 1
        else:
            hp -= punish
            frust += punish * cfg["lossWeight"]
        frust += r["rejects"] * cfg["rejectWeight"]
        crossing = k if frust >= cfg["budget"] and crossing is None else crossing
        if frust >= cfg["budget"] or hp <= 0:
            break
    gold = rewards * spec["params"]["rewardGold"]
    return {"step": step, "punishHp": punish, "rewards": rewards, "actions": actions, "gold": gold,
            "densityPermille": 0 if actions == 0 else 1000 * gold // actions,
            "crossing": crossing, "frustration": frust, "runHp": hp}


def warn(c: dict, cfg: dict):
    """三条整数判据,命中第一条即返回;全不命中返回 None。"""
    if c["crossing"] is None:
        return {"code": "BUDGET_INERT", "severity": "soft", "step": c["step"],
                "punishHp": c["punishHp"], "message": f"步长 {c['step']} / punishHp="
                f"{c['punishHp']} 时预算 {cfg['budget']} 在 {cfg['encounters']} 场内不触发"}
    if c["crossing"] <= cfg["crossTooEarly"]:
        return {"code": "CROSS_TOO_EARLY", "severity": "soft", "step": c["step"],
                "punishHp": c["punishHp"], "crossing": c["crossing"],
                "message": f"步长 {c['step']} / punishHp={c['punishHp']} 时越界场次 "
                           f"{c['crossing']}(0 起),玩家还没尝到奖励"}
    return None


def main() -> int:
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    steps, punishes = cfg["difficultyStep"], cfg["punishHp"]
    table, warns = [], []
    for step in steps:
        rows = sweep_step(spec, step, cfg)
        for pun in punishes:
            c = cell(spec, rows, step, pun, cfg)
            table.append(c)
            if warn(c, cfg):
                warns.append(warn(c, cfg))
    dens = [x["densityPermille"] for x in table if x["punishHp"] == punishes[0]]
    if max(dens) - min(dens) >= cfg["densityCollapsePermille"]:
        warns.append({"code": "DENSITY_COLLAPSE", "severity": "soft", "denseStep":
                      steps[dens.index(max(dens))], "sparseStep": steps[dens.index(min(dens))],
                      "spreadPermille": max(dens) - min(dens),
                      "message": f"难度步长从 {steps[dens.index(max(dens))]} 拉到 "
                                 f"{steps[dens.index(min(dens))]},金币密度从 {max(dens)}‰ 掉到 "
                                 f"{min(dens)}‰(差 {max(dens) - min(dens)}‰),节奏对难度爬升过敏"})
    print(f"{'step':>4} " + "".join(f"{'p=' + str(p):>10}" for p in punishes))
    for st in steps:
        cells = [x for x in table if x["step"] == st]
        print(f"{st:>4} " + "".join(
            f"{('—' if x['crossing'] is None else x['crossing']):>10}" for x in cells)
            + f"   奖励 {cells[0]['rewards']} 次 密度 {cells[0]['densityPermille']}‰")
    print(f"cells={len(table)} 软告警={len(warns)}")
    for w in warns:
        print(f"  SOFT {w['code']} {w['message']}")
    report = {"id": "gd_mech_08_metrics_bench", "hardFail": False, "config": cfg,
              "table": table, "warnings": warns, "cells": len(table)}
    with open(os.path.join(OUT, "mech08_metrics_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("wrote out/mech08_metrics_bench.json")
    return 0


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

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

step        p=2       p=4       p=6       p=8
   2         15        13        13        13   奖励 14 次 密度 1037‰
   3         11        11        10        10   奖励 10 次 密度 960‰
   4         10         9         8         8   奖励 8 次 密度 813‰
   6          7         6         6         5   奖励 5 次 密度 681‰
   8          6         5         5         4   奖励 4 次 密度 631‰
cells=20 软告警=2
  SOFT CROSS_TOO_EARLY 步长 8 / punishHp=8 时越界场次 4(0 起),玩家还没尝到奖励
  SOFT DENSITY_COLLAPSE 难度步长从 2 拉到 8,金币密度从 1037‰ 掉到 631‰(差 406‰),节奏对难度爬升过敏
wrote out/mech08_metrics_bench.json

判读:越界点对谁敏感

难度步长p=2p=4p=6p=8奖励次数金币密度‰
215131313141037
31111101010960
4109888813
676655681
865544631

表里两个方向不对称,这是本节最值钱的一条结论:

  • 横着看(加惩罚)几乎没用:punishHp 从 2 涨到 8(4 倍),越界场次只从 15 提前到 13,再往右就平了。原因是挫败分里失败只占 80,被拒指令占 57——加粗惩罚粒度,不如减少零反馈输入。
  • 竖着看(加难度)立竿见影:难度步长从 2 拉到 8(同样 4 倍),越界场次从 15 掉到 6,奖励次数从 14 次掉到 4 次,金币密度从 1037‰ 掉到 631‰(差 406‰,触发 DENSITY_COLLAPSE)。

两条软告警都不是失败,而是给策划看的提问:CROSS_TOO_EARLY 说「步长 8、punishHp 8 时第 4 场(0 起)就越界,玩家还没尝到奖励」,DENSITY_COLLAPSE 说「难度爬升把奖励密度砍掉四成」。B 层永远 exit 0,是因为这类问题不该拦构建,只该进报告——判断「这个节奏能不能接受」是人的事,脚本只负责把 20 个格子和 2 条异常摆出来。

本章小结

第 8 章把第 1 章留下的两个孤儿字段兑现了:rewardGold 在 8.1 变成金币密度,punishHp 在 8.2 变成挫败预算。奖励被写成一条有间隔、有极差、有逆序对的谱,惩罚被写成一条有整数阈值、有越界判据的预算,两者在 8.3 合到「每单位玩家动作」这一根轴上。整章唯一没有浮点的地方是刻意为之——能算的节奏,才谈得上调。

阅读导航:上一节:8.2 惩罚机制与挫败预算 · 下一节:9.1 随机性的结构:均匀、加权、洗牌袋 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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