留存设计清单 那一篇给的是清单:该做什么、多久做一次。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 场) | 82 | 96 | 41 | 1170 | 500 |
| 惩罚期(第 8–15 场) | 95 | 0 | 96 | 0 | 1010 |
两阶段完全反转:奖励期每单位动作换到 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=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 |
表里两个方向不对称,这是本节最值钱的一条结论:
- 横着看(加惩罚)几乎没用:
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 随机性的结构:均匀、加权、洗牌袋 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。