《游戏设计》卷一《玩法机制与可执行规格》 那套把每个指标的口径写死在规格里,让「这个数怎么算、能断言什么」可被机器检查;本章接住它的真源做叙事层的曲线度量,并把同一条纪律用在最危险的地方——代理指标。张力曲线 MAE 是本节的主角:它是 proxy,不是玩家情绪。这一节把「它是 proxy、不能声称什么」从一句文档里的话,变成一份可 lint 的产物。
6.3 代理指标的口径声明
本卷通篇在度量叙事体验,但体验类指标有一个不可回避的事实:命令行量不出玩家情绪。真人测试需要真实业务环境,命令行不可复现,本套不做。所以本卷的每一个指标都必须显式声明自己是 proxy(代理指标),只做相对比较,禁绝对化。张力曲线 MAE 尤其容易越界——它叫「情感曲线」的 MAE,一不小心就会被读成「玩家有多被打动」。本节把这条边界钉死。
声明要写清的六件事
一份合格的 proxy 声明,必须回答下面六个问题,缺一个就是可被误读的空档:
| 字段 | 回答的问题 |
|---|---|
metric / kind | 是哪个指标,是不是 proxy |
unit / rounding | 单位与取整口径(毫 / 千分位整数地板除) |
inputs | 读的是哪两段真源(作者标注 / 设计目标) |
measures | 度量的是什么(标注与目标的偏离) |
does_not_measure | 不度量什么(玩家情绪、台词感染力、机制张力) |
forbidden_claims | 禁声称什么(更感人 / 提升 N 倍 / 满意度 X%) |
其中 does_not_measure 与 forbidden_claims 是这份声明真正的价值所在:它们不是补充说明,是硬边界。 一个只写「度量情感曲线误差」的声明,会让人以为它能证明体验好坏;加上「不度量玩家实际感受到的情绪强度」,这个误会才被堵住。
三个 proxy 与它们的真值
本卷有三类 proxy,每一个都有一个「真值」是想量而命令行量不到的。把真值摆出来,proxy 的边界就一目了然:
| proxy | 想量的真值(命令行量不到) | 实际度量的是什么 |
|---|---|---|
| 张力曲线 MAE | 玩家实际感受的情绪起伏 | 作者标注与设计目标的偏离 |
| 分支覆盖率 | 玩家实际探索到的丰富度 | 走过的对话边 / 全部对话边 |
| 结局分布熵 | 玩家实际体验到的多样性 | 5 个结局在采样分布上的均匀度 |
三者的形状不同,纪律却完全一样:只做相对比较。覆盖率比的是「这条策略比那条策略覆盖得多」,熵比的是「这个分布比那个分布更均匀」,MAE 比的是「改之前比改之后偏离得更少」——没有一条能单独证明「体验好」。
这也回答了「为什么非得声明」:proxy 本身没有对错,只有口径。同一个 MAE 值 0,读成「标注与目标此刻一致」是对的,读成「玩家很满意」就是错的——差别不在数字,在口径。声明就是把这个口径固化下来,让读法不能随人改。而第 15 章会把三类 proxy 收成一张总表,专门讲「指标不能声称什么」;本节先把 MAE 这一类做成可检查的样本。
声明怎么被消费
这份声明不是写给人看的文档,它有明确的消费方:
- lint 脚本读它,检查字段齐不齐、
kind是不是proxy——不齐即 FAIL; - 报告产物(
out/nm_06_3_proxy.json)把声明与实测值绑在一起,值变了声明还在; - 规格演化时(第 14 章)它给出「这个数可以怎么用」的边界——只允许相对对照,不允许绝对断言。
换句话说,声明把「度量」和「解读」分开了:脚本负责给出数,声明负责给出这个数允许被怎样使用。前者可复现,后者可 lint,两者都不依赖写作者的自觉。
这也是本卷与《游戏设计》卷二在口径上的接续:那套给机制指标定容差,本卷给叙事 proxy 定用途——容差管「差多少算一致」,用途管「这个数能被拿来说什么」。两者一起,才让「整数化 + 确定性 + proxy 口径」这条纪律在三卷之间不落空。
D 层:可 lint 的声明文件
声明本身是一份 JSON,落 config/nm_06_3_proxy_declaration.json:
{
"metric": "tension_curve_mae",
"kind": "proxy",
"unit": "permille",
"rounding": "floor_integer",
"inputs": {
"actual": "beats[].tension",
"target": "metrics.target_tension_curve"
},
"measures": "作者标注的节拍张力序列与设计目标曲线的平均绝对偏离",
"does_not_measure": [
"玩家实际感受到的情绪强度",
"台词的感染力",
"机制层的张力动力学"
],
"allowed_use": [
"同一规格内两条曲线的相对偏离",
"规格演化前后的回归对照"
],
"forbidden_claims": [
"更感人",
"提升 N 倍",
"玩家满意度 X%",
"情绪强度绝对达标"
],
"threshold_milli": 1000
}
这份声明是数据,不是散文——它可以被脚本读取、被 lint、被拒绝。allowed_use 里只列了两种用途,都是相对的:同一规格内两条曲线比、演化前后对照。没有任何一条允许「这段体验绝对达标」这类断言。
S 层:lint 声明并把实测值填进报告
脚本读声明、读规格,逐条 lint 声明的字段,再把当前实测的 MAE 填进报告,落 out/nm_06_3_proxy.json:
#!/usr/bin/env python3
# file: sim/nm_06_3_proxy_metric_declaration.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_3_proxy_metric_declaration.py
"""卷三 6.3 代理指标的口径声明(S 层)。
张力曲线 MAE 是 proxy:它度量的是「作者标注 vs 设计目标」的偏离,**不是**玩家
实际感受到的情绪。本脚本读 config/nm_06_3_proxy_declaration.json,逐条 lint 这份
声明的字段是否齐全,再把当前规格下实测的 MAE 填进报告,落 out/nm_06_3_proxy.json。
声明的 lint 是硬断言:缺字段、kind 不是 proxy、单位不是千分位、禁声称清单为空,
任一不满足即 FAIL。这样「这个数是 proxy、不能声称什么」不是文档里的一句话,而是
一份可被机器检查的产物。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
DECL = os.path.join(HERE, "../config/nm_06_3_proxy_declaration.json")
REQUIRED = ["metric", "kind", "unit", "rounding", "inputs", "measures",
"does_not_measure", "allowed_use", "forbidden_claims", "threshold_milli"]
def main() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
with open(DECL, encoding="utf-8") as fh:
decl = json.load(fh)
beats = spec["beats"]
target = spec["metrics"]["target_tension_curve"]
actual = [b["tension"] for b in beats]
mae = sum(abs(a - t) for a, t in zip(actual, target)) * 1000 // len(actual)
missing = [k for k in REQUIRED if k not in decl]
print(f"声明文件 config/{os.path.basename(DECL)}")
print(f"指标 {decl['metric']}")
print(f"口径类型 {decl['kind']} (必须为 proxy)")
print(f"单位 {decl['unit']} (千分位整数地板除)")
print(f"输入真源 {decl['inputs']['actual']} / {decl['inputs']['target']}")
print(f"度量的是 {decl['measures']}")
print(f"不度量 {len(decl['does_not_measure'])} 项:{';'.join(decl['does_not_measure'])}")
print(f"允许的用途 {len(decl['allowed_use'])} 项:{';'.join(decl['allowed_use'])}")
print(f"禁声称 {len(decl['forbidden_claims'])} 项:{';'.join(decl['forbidden_claims'])}")
print(f"当前实测 MAE {mae} 毫 (全 16 节拍,阈值 {decl['threshold_milli']} 毫)")
print(f"lint 字段 {len(REQUIRED) - len(missing)} / {len(REQUIRED)} 齐" +
(f",缺 {missing}" if missing else ""))
assert not missing, f"声明缺字段 {missing}"
assert decl["kind"] == "proxy", "口径类型必须是 proxy"
assert decl["unit"] == "permille", "单位必须是千分位"
assert decl["forbidden_claims"], "禁声称清单不能为空"
assert decl["does_not_measure"], "不度量清单不能为空"
assert mae <= decl["threshold_milli"], "MAE 超阈值"
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_06_3_proxy.json"), "w", encoding="utf-8") as fh:
json.dump({"declaration": decl, "observed_mae_milli": mae,
"lint_ok": not missing, "fields": len(REQUIRED)},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_06_3_proxy.json")
print("OK nm_06_3_proxy_metric_declaration:口径声明字段齐全,MAE 为 proxy 且已声明边界")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_06_3_proxy_metric_declaration.py:
声明文件 config/nm_06_3_proxy_declaration.json
指标 tension_curve_mae
口径类型 proxy (必须为 proxy)
单位 permille (千分位整数地板除)
输入真源 beats[].tension / metrics.target_tension_curve
度量的是 作者标注的节拍张力序列与设计目标曲线的平均绝对偏离
不度量 3 项:玩家实际感受到的情绪强度;台词的感染力;机制层的张力动力学
允许的用途 2 项:同一规格内两条曲线的相对偏离;规格演化前后的回归对照
禁声称 4 项:更感人;提升 N 倍;玩家满意度 X%;情绪强度绝对达标
当前实测 MAE 0 毫 (全 16 节拍,阈值 1000 毫)
lint 字段 10 / 10 齐
wrote out/nm_06_3_proxy.json
OK nm_06_3_proxy_metric_declaration:口径声明字段齐全,MAE 为 proxy 且已声明边界
判读:声明是产物,不是修辞
第一,字段齐全是被 lint 钉住的。 脚本检查 10 个必填字段,10 / 10 齐。缺任何一个、kind 不是 proxy、单位不是千分位、禁声称清单为空,assert 直接让脚本 FAIL——声明不是「写了就算」,是「写全才算」。
第二,边界是双向的。 声明同时写了 measures(度量标注与目标的偏离)与 does_not_measure(不度量玩家情绪、台词感染力、机制张力)。前者告诉读者这个数能说什么,后者告诉读者它不能说什么——只写前者,就给了误读的空间。
第三,禁声称清单是可执行的。 forbidden_claims 里四条——更感人、提升 N 倍、玩家满意度 X%、情绪强度绝对达标——正是本卷口径里点名禁止的表述。把它写成数据,意味着任何引用了这个 MAE 的结论,都可以对着这份清单逐条自查。
顺带说一句:这份声明是章的产物,不是章的附录。它和 out/nm_06_3_proxy.json 一起被落盘,意味着「这个数是 proxy、不能声称什么」有可核对的落地物——换个人来读这一章,拿到的边界与作者写下的边界是同一份,不依赖任何口头约定。
| 检查项 | 实测 | 合格标准 |
|---|---|---|
| 必填字段 | 10 / 10 | 全齐 |
| 口径类型 | proxy | 必须是 proxy |
| 单位 | permille | 千分位整数地板除 |
| 禁声称清单 | 4 项 | 非空 |
| 实测 MAE | 0 毫 | ≤ 阈值 1000 毫 |
三个常见误读
围绕这个 MAE,最常见的三个误读都能被声明直接挡下:
- 误读一:MAE 为 0 说明「这段叙事很成功」。 声明里
measures只写「标注与目标的偏离」——0 只说明标注和目标一致,与玩家感受无关。 - 误读二:MAE 涨了说明「叙事变差了」。 它只说明标注与目标分开了;分开可能是目标过时,也可能是标注更新,方向要人判,指标只负责报警。
- 误读三:可以拿 MAE 跨作品比。 声明里
allowed_use只列了「同一规格内」与「演化前后」——跨规格比没有共同真源,不在允许用途之内。
三条误读的共同点:都把相对量当成了绝对量。声明的作用,就是让这类越界在使用之前就被字段挡住,而不是等结论已经写进汇报再去补救。
最后把这一章收口:6.1 把路径构造成曲线,6.2 把曲线与目标比出 MAE,6.3 把 MAE 的边界写成声明。三步连起来,就是「把体验还原成可度量」这句话的正身——曲线怎么长出来、跟目标差多少、这个差不能声称什么,全都有可跑的产物兜底。本章不评价《雾港夜航》写得好不好,只保证:它跑出来的每一条路径,其张力形状都能被算成一个整数,且这个整数不会冒充玩家情绪。
自检表
| 检查项 | 合格标准 |
|---|---|
| 声明是数据 | 字段可被脚本读取与 lint |
| 边界双向 | 同时声明 measures 与 does_not_measure |
| 禁声称清单 | 非空,且为数据而非注释 |
| 单位一致 | 与 6.2 的 MAE 同口径(毫) |
| 实测值 | 由脚本实跑填入,非手写 |
| 误读覆盖 | 常见越界都能被字段挡下 |
| 消费方明确 | lint / 报告 / 演化三处可读 |
阅读导航:上一节:6.2 目标曲线与 MAE · 下一节:7.1 叙事节奏的度量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。