《游戏设计》卷二《策略涌现与体验度量》
那一套从机制参数反推体验曲线;本章把同一套反推思路搬到叙事层——但只对结构参数(节拍张力标注、阈值、采样规模)反推结构曲线,不碰机制动力学。那一套反推的是 HP、资源、回合节奏这类动力学参数;本章反推的是 beats[].tension、MAE 阈值、采样规模这类结构参数,对象是作者标注的节拍序列沿玩家路径的投影。两套都是 proxy 口径,但输入、对象、工具没有一处重叠。
定位不是看曲线,是读残差
第 5 章到第 9 章一直在正向度量:改一个参数,看曲线怎么变。本章把度量倒过来用——曲线已经偏了,要问的是「偏在哪」。倒过来的第一步不是反推参数,而是异常点定位:先把「曲线偏了」这句话拆成「第几个节拍、哪个场景、偏了多少」。
做法只有一步:把观测曲线与目标曲线逐项相减,得到残差向量 r[i] = obs[i] - tgt[i]。残差为零的位置没有异常,残差非零的位置就是候选异常点。定位不靠眼睛看曲线形状,靠读残差向量——因为曲线的形状会骗人(一处尖峰可能是相邻两处反号偏差相消),残差不会。
| 漂移场景 | 改的是哪一拍 | 在路径上吗 | 预期定位结果 |
|---|---|---|---|
| A | b07 4→3 | 在 | 路径与全曲线都能定位,单点唯一 |
| B | b02 2→3 且 b07 4→3 | 都在 | 两个非零点,单点归因失效 |
| C | b10 5→4 | 不在 | 路径口径零残差,只有全曲线能定位 |
三个场景都不改真源,只在内存里造一条「改动后的标注」再和真源的目标曲线比对。这样既演示了定位,又不动 config/story_spec.json 一个字节。
S 层:残差向量与异常点清单
sim/nm_13_1_anomaly_localize.py 走一条确定性路径,先量基线(当前规格自洽、零残差),再造三个漂移场景,对每个场景同时给出路径口径(11 拍投影)与全曲线口径(16 拍)的异常点清单与 MAE。推进复用卷一 sim/na_02_digest.py(只读 import)。
#!/usr/bin/env python3
# file: sim/nm_13_1_anomaly_localize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_13_1_anomaly_localize.py
"""卷三 13.1 曲线的异常点定位(S 层):把「曲线偏了」落到具体的节拍下标与场景。
前几章的度量都是正向的:参数 → 曲线。本章把度量倒过来用,第一步要回答的是——
**一条曲线偏离目标时,偏在哪个节拍上?** 做法是把观测曲线与目标曲线逐项相减得到
残差向量,再对每个非零残差项报出:路径下标、规格下标、节拍 id、所属场景、所属幕、
观测值、目标值、增量 Δ。定位不靠眼睛看曲线,靠残差向量。
三个漂移场景(**都不改真源**,只在内存里造一条「改动后的标注」):
A) b07 4→3 —— 在路径上:路径口径与全曲线口径都能定位
B) b02 2→3 且 b07 4→3 —— 两个异常点:单点归因失效,必须多点定位
C) b10 5→4 —— 不在路径上:路径口径**看不见**,只有全曲线口径能定位
C 是本节最有用的一条:**可观测性决定了可识别性**——玩家路径没经过的节拍,
在路径曲线上永远是零残差,异常只能从全曲线口径里读出来。
推进复用卷一 sim/na_02_digest.py(只读 import,不改动它)。
口径(proxy):张力曲线与 MAE 均为结构量,只做相对比较,不声称绝对情绪强度。
全部定点整数,不出现浮点。产出 out/nm_13_1_anomaly_localize.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from na_02_digest import cond_ok, init_state, node_by_id, apply_choice # noqa: E402
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def walk(spec: dict) -> dict:
"""确定性策略:每步选第一个条件成立的选项。同 nm_00 / nm_05_2。"""
s = init_state(spec)
for _ in range(64):
cur = node_by_id(spec, s["node"])
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
s = apply_choice(spec, s, opts[0]["id"])
return s
def mae_milli(obs: list, tgt: list) -> int:
return sum(abs(a - b) for a, b in zip(obs, tgt)) * 1000 // len(obs)
def localize(obs: list, tgt: list, idx: list, beats: list) -> list:
"""残差向量 → 异常点清单:路径下标 / 规格下标 / 节拍 / 场景 / 幕 / Δ。"""
rows = []
for pos, (o, t) in enumerate(zip(obs, tgt)):
if o == t:
continue
b = beats[idx[pos]]
rows.append({"pos": pos, "spec_idx": idx[pos], "beat": b["id"], "scene": b["scene"],
"act": b["act"], "obs": o, "tgt": t, "delta": o - t})
return rows
def single_point(rows: list) -> str:
"""单点归因:残差只在 1 个位置非零时异常点唯一;否则单点假设不成立。"""
if not rows:
return "无异常"
if len(rows) == 1:
return f"唯一({rows[0]['beat']})"
return f"不成立({len(rows)} 个非零点,需多点定位)"
def fmt(rows: list) -> str:
return " ".join(f"pos{r['pos']}/idx{r['spec_idx']} {r['beat']} {r['scene']} act{r['act']} "
f"obs{r['obs']} tgt{r['tgt']} Δ{r['delta']:+d}" for r in rows)
def main() -> int:
spec = load_spec()
beats = spec["beats"]
m = spec["metrics"]
base = [b["tension"] for b in beats]
tgt_full = list(m["target_tension_curve"])
s = walk(spec)
visited = {node_by_id(spec, nid)["scene"] for nid in s["path"]}
idx = [i for i, b in enumerate(beats) if b["scene"] in visited]
base_path = [base[i] for i in idx]
tgt_path = [tgt_full[i] for i in idx]
print(f"起点 {spec['meta']['start']} 路径节点 {len(s['path'])} 终局 {s['node']}")
print(f"路径节拍 {len(idx)} / {len(beats)} {' '.join(beats[i]['id'] for i in idx)}")
print(f"路径曲线 {' '.join(str(x) for x in base_path)}")
print(f"目标曲线 {' '.join(str(x) for x in tgt_path)}")
print(f"基线定位 {len(localize(base_path, tgt_path, idx, beats))} 个异常点;"
f"路径 MAE {mae_milli(base_path, tgt_path)} 毫,"
f"全曲线 MAE {mae_milli(base, tgt_full)} 毫(当前规格自洽)")
scenarios = [
("A", "b07 4→3(在路径上)", {6: 3}),
("B", "b02 2→3 且 b07 4→3(两个异常点)", {1: 3, 6: 3}),
("C", "b10 5→4(不在路径上)", {9: 4}),
]
report = {"path_nodes": len(s["path"]), "final_node": s["node"],
"beats_on_path": [beats[i]["id"] for i in idx],
"base_path_curve": base_path, "target_path_curve": tgt_path,
"base_path_mae_milli": mae_milli(base_path, tgt_path),
"base_full_mae_milli": mae_milli(base, tgt_full), "scenarios": []}
for tag, desc, drift in scenarios:
obs_full = list(base)
for i, v in drift.items():
obs_full[i] = v
obs_path = [obs_full[i] for i in idx]
rows_full = localize(obs_full, tgt_full, list(range(len(beats))), beats)
rows_path = localize(obs_path, tgt_path, idx, beats)
mae_full, mae_path = mae_milli(obs_full, tgt_full), mae_milli(obs_path, tgt_path)
print("")
print(f"场景 {tag}:{desc}")
print(f" 路径曲线 {' '.join(str(x) for x in obs_path)}")
print(f" 路径异常 {len(rows_path)} 个:{fmt(rows_path) if rows_path else '(无——路径口径看不见)'}")
print(f" 路径 MAE {mae_path} 毫({len(idx)} 拍)")
print(f" 全曲线异常 {len(rows_full)} 个:{fmt(rows_full)}")
print(f" 全曲线 MAE {mae_full} 毫({len(beats)} 拍)")
print(f" 单点归因 路径 {single_point(rows_path)} / 全曲线 {single_point(rows_full)}")
report["scenarios"].append({
"tag": tag, "desc": desc, "drift": {str(k): v for k, v in drift.items()},
"path_curve": obs_path, "path_anomalies": rows_path, "path_mae_milli": mae_path,
"full_anomalies": rows_full, "full_mae_milli": mae_full,
"path_single_point": single_point(rows_path),
"full_single_point": single_point(rows_full)})
assert all(1 <= v <= 5 for v in base), "张力越界"
assert report["base_path_mae_milli"] == 0 and report["base_full_mae_milli"] == 0
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_13_1_anomaly_localize.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("")
print("wrote out/nm_13_1_anomaly_localize.json")
print("OK nm_13_1_anomaly_localize:3 个漂移场景已定位,离路径异常需全曲线口径")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_13_1_anomaly_localize.py:
起点 d_pier_1 路径节点 26 终局 e_lit
路径节拍 11 / 16 b01 b02 b04 b05 b06 b07 b09 b11 b12 b15 b16
路径曲线 1 2 3 2 3 4 4 3 4 4 2
目标曲线 1 2 3 2 3 4 4 3 4 4 2
基线定位 0 个异常点;路径 MAE 0 毫,全曲线 MAE 0 毫(当前规格自洽)
场景 A:b07 4→3(在路径上)
路径曲线 1 2 3 2 3 3 4 3 4 4 2
路径异常 1 个:pos5/idx6 b07 tide_cave act2 obs3 tgt4 Δ-1
路径 MAE 90 毫(11 拍)
全曲线异常 1 个:pos6/idx6 b07 tide_cave act2 obs3 tgt4 Δ-1
全曲线 MAE 62 毫(16 拍)
单点归因 路径 唯一(b07) / 全曲线 唯一(b07)
场景 B:b02 2→3 且 b07 4→3(两个异常点)
路径曲线 1 3 3 2 3 3 4 3 4 4 2
路径异常 2 个:pos1/idx1 b02 tavern act1 obs3 tgt2 Δ+1 pos5/idx6 b07 tide_cave act2 obs3 tgt4 Δ-1
路径 MAE 181 毫(11 拍)
全曲线异常 2 个:pos1/idx1 b02 tavern act1 obs3 tgt2 Δ+1 pos6/idx6 b07 tide_cave act2 obs3 tgt4 Δ-1
全曲线 MAE 125 毫(16 拍)
单点归因 路径 不成立(2 个非零点,需多点定位) / 全曲线 不成立(2 个非零点,需多点定位)
场景 C:b10 5→4(不在路径上)
路径曲线 1 2 3 2 3 4 4 3 4 4 2
路径异常 0 个:(无——路径口径看不见)
路径 MAE 0 毫(11 拍)
全曲线异常 1 个:pos9/idx9 b10 drowned_bell act2 obs4 tgt5 Δ-1
全曲线 MAE 62 毫(16 拍)
单点归因 路径 无异常 / 全曲线 唯一(b10)
wrote out/nm_13_1_anomaly_localize.json
OK nm_13_1_anomaly_localize:3 个漂移场景已定位,离路径异常需全曲线口径
判读:三个场景读出三件事
第一,定位给的是「下标 + 场景 + 增量」,不是一个模糊的「偏了」。 场景 A 里残差向量只有一处非零,脚本报出 pos5/idx6 b07 tide_cave act2 obs3 tgt4 Δ-1——路径上第 5 个位置、规格里第 6 个节拍、id 为 b07、场景 tide_cave、第二幕、观测值 3、目标值 4、增量 −1。这条清单是可复算的,换一条路径、换一个漂移,格式一模一样。
第二,两个异常点会同时失效「单点归因」。 场景 B 有两处非零,脚本如实报「不成立(2 个非零点,需多点定位)」。这不是缺陷——单点归因本来就是一个假设,异常点定位的第一步就是检验这个假设是否成立。如果残差只有一个非零点,异常点唯一;有两个以上,就必须逐点定位,不能挑一个「看起来最像」的。
第三,路径口径会漏掉离路径的异常。 场景 C 把 b10(drowned_bell,张力 5)从 5 降到 4,这是全规格里最重的一拍——但它不在这条路径经过的 9 个场景里,所以路径曲线的残差全零、路径 MAE 仍是 0 毫,而全曲线 MAE 抬到 62 毫。换句话说:如果只看玩家路径,这个异常根本不存在。
可观测性决定了可识别性
场景 C 是本节最该记住的一条。玩家路径只经过 9 / 14 个场景、11 / 16 个节拍,剩下 5 拍——b03 b08 b10 b13 b14——在这条路径的曲线上永远不出现。对这条路径而言,这 5 拍的标注不可观测;不可观测的量,也就不可能从路径曲线上被识别出来。
这直接决定了本章后两节的形态:
- 13.2 的反推必须区分口径:路径口径下,未经过的 5 拍是完全自由的(×5⁵ = 3125),所以同一路径曲线对应的标注解集比全曲线口径大得多。
- 13.3 的稳健性必须承认多解:既然曲线达标推不出唯一标注,反推就只能给解集、不能给唯一设计。
一句话:能定位到什么,取决于你观测了什么。 想看全规格的异常,就必须用全曲线口径——而全曲线口径意味着作者能拿到全部 16 拍的标注,这在「只拿到一份玩家路径日志」的场景里是不成立的。这条限制是度量本身的,不是工具的。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 定位可复算 | 残差向量逐项相减,非零项即异常点 | 三场景各给出清单 |
| 基线诚实 | 当前规格零残差,MAE 0 毫 | 0 个异常点 |
| 单点假设可检验 | 非零点 > 1 时明报「不成立」 | 场景 B 报不成立 |
| 离路径可见性 | 未经过节拍的异常只在全曲线口径出现 | 场景 C 路径 0 个 / 全曲线 1 个 |
| 整数不变量 | 张力全程 ∈ [1,5] | 残差 0 |
| 口径声明 | 曲线与 MAE 均标 proxy | 只做相对比较 |
异常点定位解决了「偏在哪」,但它只回答一处或几处的偏差。真正难的问题是:如果只给你一条目标曲线,能不能反过来求出全部可行的节拍标注? 那是一个搜索空间的问题——节拍张力是 1–5 的整数、共 16 拍,5¹⁶ 显然不可穷举,但加上「MAE 不超阈值」这一条约束之后,解集有多大是可以精确算出来的。这是下一节的事。
阅读导航:上一节:12.3 从测试到决策 · 下一节:13.2 参数到曲线的反推 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。