5.2 张力与释放的节拍计算
《游戏设计》卷二《策略涌现与体验度量》
的情绪曲线是机制仿真的产物——它把一局战斗里 HP、资源、回合节奏的变化按时间采样,画成一条会自然起伏的线,作者不直接决定每个采样点的值。本节的张力曲线则来自叙事节拍结构:作者在 beats[].tension 里给 16 个节拍各标一个整数,玩家走哪条路径、经过哪些场景,就取出那些场景上的节拍,拼成这条路径自己的张力曲线。上一节量的是全量 16 拍;这一节量的是一条路径在节拍上的投影——起点不同、对象不同,工具也不同。
从「一条序列」到「一条曲线」
上一节把 16 个节拍摊成一条序列,那是规格的全貌。但一个玩家不会经历全部 16 拍:他走一条路径,路径经过一组场景,只有这些场景上的节拍才会被他实际感受到。所以要把全量序列投影到路径上,得到这条路径的张力曲线。
投影规则很简单:走一条确定性路径(每步取第一个条件成立的选项,与目录篇 nm_00_toc_metric.py 同策略),收集路径经过的所有场景,再按规格里 beats 的原始顺序把「所属场景在集合里」的节拍挑出来,它们的 tension 依序拼成曲线。这条曲线天然带释放结构——张力不会一直涨,它涨一段、回一段、再涨,中间夹着平台;把这些段落切出来,就能读出「哪里在加压、哪里在松、哪里到顶」。
S 层:沿路径取节拍、切段、定位
下面的脚本 sim/nm_05_2_tension_release.py 做四件事:走路径、投影节拍、切段(上升 / 平台 / 回落)、定位峰值与谷值,最后把曲线与 metrics.target_tension_curve 逐点比对,算千分位 MAE。切段用的是连续差分的符号:相邻两点差为正归上升段、为零归平台段、为负归回落段;峰值取「左严格高、右不低于」的局部极大,谷值取「左严格低、右不高于」的局部极小。
#!/usr/bin/env python3
# file: sim/nm_05_2_tension_release.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_05_2_tension_release.py
"""卷三 5.2 张力与释放的节拍计算(S 层)。
把节拍张力沿**玩家路径**串成一条序列:走一条确定性路径(每步取第一个可用选项,
同 nm_00_toc_metric.py),路径经过哪些场景,就把这些场景上的节拍按规格顺序取出来,
得到这条路径的张力曲线。再在曲线上做三件事:
1) 切段:把曲线分解成「上升段 / 平台段 / 回落段」(连续差分的符号)
2) 定位:局部极大(峰值)与局部极小(谷值),并给出全局峰值
3) 比对:与 metrics.target_tension_curve 的 MAE(千分位,定点整数)
关键实测:真源里 beats[].tension 与 target_tension_curve **逐项相同**,所以全曲线 MAE
恒为 0——这不是指标失效,而是**当前规格自洽**。脚本另做一次单点扰动探针,证明 MAE 会
立刻抬起来(完整回归在第 14 章)。产出 out/nm_05_2_tension_release.json。全部定点整数。
"""
from __future__ import annotations
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")
AFF_LO, AFF_HI = 0, 100
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def cond_ok(c, s) -> bool:
if c is None:
return True
k = c[0]
if k == "flag":
return s["flags"].get(c[1], 0) == c[2]
if k == "item":
return s["items"].get(c[1], 0) == c[2]
if k == "aff":
v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
if k == "and":
return cond_ok(c[1], s) and cond_ok(c[2], s)
if k == "or":
return cond_ok(c[1], s) or cond_ok(c[2], s)
return not cond_ok(c[1], s)
def node(spec, nid):
return next(n for n in spec["dialogue"] if n["id"] == nid)
def init_state(spec):
aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
for v in sorted(spec["state"]["vars"])}
return {"node": spec["meta"]["start"], "aff": aff,
"flags": {f: 0 for f in sorted(spec["state"]["flags"])},
"items": {i: 0 for i in sorted(spec["state"]["items"])},
"path": [spec["meta"]["start"]]}
def step(spec, s, c):
eff = c.get("effects") or {}
aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
for k, v in (eff.get("set") or {}).items():
flags[k] = v
for k, v in (eff.get("add") or {}).items():
aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
for k, v in (eff.get("grant") or {}).items():
items[k] = v
for k in (eff.get("revoke") or {}):
items[k] = 0
return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
"path": s["path"] + [c["goto"]]}
def walk(spec):
"""确定性策略:每步选第一个条件成立的选项。同 nm_00_toc_metric.py。"""
s = init_state(spec)
for _ in range(64):
cur = node(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 = step(spec, s, opts[0])
return s
def segments(curve):
"""把曲线切成连续同号的段:+1 上升 / 0 平台 / -1 回落。返回 (kind, lo, hi) 三元组。"""
if len(curve) < 2:
return []
segs, start, kind = [], 0, None
for i in range(1, len(curve)):
d = curve[i] - curve[i - 1]
k = 1 if d > 0 else (-1 if d < 0 else 0)
if kind is None:
kind, start = k, 0
elif k != kind:
segs.append((kind, start, i - 1))
kind, start = k, i - 1
segs.append((kind, start, len(curve) - 1))
return segs
def peaks_troughs(curve):
"""局部极大 / 极小:左严格高、右不低于(峰值);左严格低、右不高于(谷值)。"""
peaks, troughs = [], []
for i in range(1, len(curve) - 1):
if curve[i] > curve[i - 1] and curve[i] >= curve[i + 1]:
peaks.append(i)
if curve[i] < curve[i - 1] and curve[i] <= curve[i + 1]:
troughs.append(i)
return peaks, troughs
def mae_milli(curve, target):
return sum(abs(a - b) for a, b in zip(curve, target)) * 1000 // len(curve)
def main() -> int:
spec = load_spec()
beats = spec["beats"]
m = spec["metrics"]
target = m["target_tension_curve"]
s = walk(spec)
path = s["path"]
visited = {node(spec, nid)["scene"] for nid in path}
idx = [i for i, b in enumerate(beats) if b["scene"] in visited]
curve = [beats[i]["tension"] for i in idx]
tgt = [target[i] for i in idx]
beat_ids = [beats[i]["id"] for i in idx]
print(f"路径节点 {len(path)}(终局 {s['node']})")
print(f"覆盖场景 {len(visited)} / {len(spec['scene_graph']['nodes'])}")
print(f"覆盖节拍 {len(idx)} / {len(beats)} {' '.join(beat_ids)}")
print(f"张力曲线 {' '.join(str(x) for x in curve)}")
print(f"目标曲线 {' '.join(str(x) for x in tgt)}")
segs = segments(curve)
names = {1: "上升", 0: "平台", -1: "回落"}
print("分段:")
for kind, lo, hi in segs:
print(f" {names[kind]} {curve[lo]}→{curve[hi]} 长度 {hi - lo + 1}")
pk, tr = peaks_troughs(curve)
print(f"峰值位置 {' '.join(f'{beat_ids[i]}({curve[i]})' for i in pk)}")
print(f"谷值位置 {' '.join(f'{beat_ids[i]}({curve[i]})' for i in tr)}")
print(f"全局峰值 路径最高 {max(curve)}(节拍标注上限 {max(b['tension'] for b in beats)})")
rises = sum(curve[i] - curve[i - 1] for i in range(1, len(curve)) if curve[i] > curve[i - 1])
falls = sum(curve[i - 1] - curve[i] for i in range(1, len(curve)) if curve[i] < curve[i - 1])
print(f"上升总量 {rises} 回落总量 {falls} 净值 {rises - falls}")
path_mae = mae_milli(curve, tgt)
full_mae = mae_milli([b["tension"] for b in beats], target)
print(f"路径 MAE {path_mae} 毫({len(idx)} 拍,阈值 {m['tension_mae_max'] * 1000} 毫)")
print(f"全曲线 MAE {full_mae} 毫(16 拍,阈值 {m['tension_mae_max'] * 1000} 毫)")
# 灵敏度探针:把第一个峰值节拍 b10 从 5 降到 4,看全曲线 MAE 抬多少
probe = [b["tension"] for b in beats]
probe[9] = 4
print(f"扰动探针 b10 5→4 后全曲线 MAE {mae_milli(probe, target)} 毫(b10 张力已改,目标未同步)")
assert max(curve) <= 5 and min(curve) >= 1, "张力越界"
assert path_mae == 0 and full_mae == 0, "当前规格应自洽"
os.makedirs(OUT, exist_ok=True)
report = {
"path_nodes": len(path),
"final_node": s["node"],
"visited_scenes": sorted(visited),
"beats_on_path": beat_ids,
"tension_curve": curve,
"target_curve": tgt,
"segments": [{"kind": names[k], "lo": lo, "hi": hi, "span": [curve[lo], curve[hi]]}
for k, lo, hi in segs],
"peaks": [beat_ids[i] for i in pk],
"troughs": [beat_ids[i] for i in tr],
"global_peak": max(curve),
"rise_total": rises,
"fall_total": falls,
"path_mae_milli": path_mae,
"full_curve_mae_milli": full_mae,
"probe_b10_mae_milli": mae_milli(probe, target),
"invariant_residual": 0,
}
with open(os.path.join(OUT, "nm_05_2_tension_release.json"), "w", encoding="utf-8") as fh:
json.dump(report, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/nm_05_2_tension_release.json")
print(f"OK nm_05_2_tension_release:路径 {len(idx)} 拍,MAE {path_mae} 毫,规格自洽")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_05_2_tension_release.py:
路径节点 26(终局 e_lit)
覆盖场景 9 / 14
覆盖节拍 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
分段:
上升 1→3 长度 3
回落 3→2 长度 2
上升 2→4 长度 3
平台 4→4 长度 2
回落 4→3 长度 2
上升 3→4 长度 2
平台 4→4 长度 2
回落 4→2 长度 2
峰值位置 b04(3) b07(4) b12(4)
谷值位置 b05(2) b11(3)
全局峰值 路径最高 4(节拍标注上限 5)
上升总量 5 回落总量 4 净值 1
路径 MAE 0 毫(11 拍,阈值 1000 毫)
全曲线 MAE 0 毫(16 拍,阈值 1000 毫)
扰动探针 b10 5→4 后全曲线 MAE 62 毫(b10 张力已改,目标未同步)
wrote out/nm_05_2_tension_release.json
OK nm_05_2_tension_release:路径 11 拍,MAE 0 毫,规格自洽
判读:一条只有 4 的路径曲线
第一,投影后只剩 11 拍。 全量 16 拍,这条路径经过 9 个场景,命中其中 11 拍(lighthouse_base 与 dawn_quay 各带 2 拍,所以 9 个场景映射到 11 拍)。剩下 5 拍——chapel、market_row、drowned_bell、breakwater、fog_gate——这条路径一个都没走到。
第二,切段读出「加压—松—再压」的节奏。 曲线是 1 2 3 2 3 4 4 3 4 4 2,切成 8 段:三段上升(1→3、2→4、3→4)、两段平台(4→4、4→4)、三段回落(3→2、4→3、4→2)。上升总量 5、回落总量 4、净值 1——整条路径是净加压的,但每一次加压之后都有一次回落,不是单调爬升。
第三,峰值只到 4。 局部峰值定位在 b04(3)、b07(4)、b12(4);局部谷值在 b05(2)、b11(3)。全局峰值是 4,而节拍标注的上限是 5——因为三个张力为 5 的节拍(b10 / b13 / b14)所在的场景 drowned_bell、breakwater、fog_gate,这条路径一个都没经过。这正是第 2 章「单条路径覆盖不足」在张力上的直接后果:确定性首选项策略永远撞不到最高潮。
为什么 MAE 是 0:规格自洽,不是指标失效
脚本给出路径 MAE 0 毫、全曲线 MAE 0 毫。这个 0 很容易被误读成「指标没接上」或「算错了」,所以必须讲清楚它的来源:真源里 beats[].tension 与 metrics.target_tension_curve 逐项相同——16 个标注值就是那条目标曲线本身,两点相减每一项都是 0,MAE 自然恒为 0。
这不是指标失效,是当前规格自洽:作者把「目标曲线」直接写成了节拍标注的镜像,说明此刻标注与目标没有分歧。MAE 真正起作用是在规格演化时——只要改了某个节拍的 tension 而没同步 target_tension_curve,它立刻抬起来。脚本里的扰动探针演示了这一点:把 b10 的张力从 5 降到 4(目标曲线不动),全曲线 MAE 从 0 变成 62 毫(1 × 1000 // 16 = 62)。完整的演化回归在第 14 章,这里只用它证明「MAE 是一条活的度量,不是死数」。
D 层:释放计算规则
把上面用到的曲线构造、切段规则、极值判据与 MAE 公式固化成一份规则表,供后续章节复用:
{
"section": "5.2",
"tier": "D",
"curve": {
"build": "沿确定性路径取出经过场景的节拍,按规格顺序拼接 tension",
"length": "等于路径覆盖到的节拍数(本路径 11 / 16)"
},
"segments": {
"rise": "相邻差分 > 0 的极大连续段",
"flat": "相邻差分 = 0 的极大连续段",
"fall": "相邻差分 < 0 的极大连续段"
},
"extremes": {
"peak": "curve[i] > curve[i-1] 且 curve[i] >= curve[i+1]",
"trough": "curve[i] < curve[i-1] 且 curve[i] <= curve[i+1]"
},
"mae": {
"formula": "sum(abs(curve[i] - target[i])) * 1000 // n",
"unit": "milli",
"threshold_milli": 1000,
"note": "本规格 beats[].tension 与 target_tension_curve 逐项相同,故 MAE 恒 0"
},
"proxy": "张力曲线与 MAE 均为 proxy,只做相对比较,不声称绝对情绪强度"
}
自检表
| 检查项 | 合格标准 |
|---|---|
| 曲线可复算 | 投影规则固定,脚本输出 11 拍曲线 |
| 切段完整 | 上升 / 平台 / 回落三类覆盖整条曲线,无遗漏 |
| 峰值可定位 | 峰值与谷值由脚本列出,非手写 |
| MAE 归因诚实 | 明写 0 来自标注与目标逐项相同,非指标失效 |
| 扰动可复现 | b10 5→4 后 MAE = 62 毫,由脚本实跑 |
| 整数不变量 | tension ∈ [1,5],残差 0 |
张力曲线量出来了,但它和机制侧的张力曲线长得像、来源却完全不同:一条是叙事节拍沿路径的投影,一条是机制仿真的采样。两者都是 proxy,但输入、对象、工具没有一处重叠——把这条边界写死,才不会把两套度量混为一谈。这是下一节的事。
阅读导航:上一节:5.1 张力从哪里来:叙事节拍 · 下一节:5.3 与机制张力的分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。