《游戏设计》卷一《玩法机制与可执行规格》 给出的是机制层的可执行规格与守恒不变量——它的真源是数值与规则,量的是伤害、资源、回合这套动力学;本章接住它的真源做叙事层的曲线度量:把同一条路径经过的叙事节拍读成一条张力序列,再与设计目标逐项比。两者共享「整数化 + 确定性 + proxy 口径」的工程纪律,但输入、对象、工具完全不同——那里的一条曲线由仿真跑出来,这里的一条曲线由作者标注沿路径读出。本节先立「曲线怎么从路径长出来」这一步。
6.1 情感曲线的构造
先说一句口径:本节构造出来的「情感曲线」是一个代理指标(proxy)。它是作者标注的节拍张力沿一条路径的序列,不是玩家实际感受到的情绪强度——玩家情绪无法在命令行里复现,本套不做、也不假装能做。这条曲线只用于同一份规格内部的相对比较:比两条路径谁更陡、比规格演化前后曲线怎么变。任何「这段更感人」的绝对结论都不在本节的射程内(6.3 会把这条边界写成可机器检查的声明)。
四步构造:从路径到曲线
曲线由路径唯一决定,中间没有第二个自由参数。构造分四步,每一步都只做整数映射:
| 步 | 输入 | 输出 | 规则 |
|---|---|---|---|
| 1 | 路径(节点序列) | 场景序列 | 每个节点查 dialogue[].scene,得到场景槽序列 |
| 2 | 场景序列 | 去重场景集 | 去连续重复,保留先后顺序 |
| 3 | 去重场景集 | 节拍序列 | 取 beats 中 scene 命中该集合的节拍,按节拍真源顺序 |
| 4 | 节拍序列 | 张力序列 | 每个节拍取 beats[].tension(整数 1–5) |
关键在第 3 步的口径:曲线取的是节拍真源顺序(beats 数组的下标序),不是玩家走动的先后序。这样同一条路径无论怎么绕,只要命中同一批场景,算出的曲线就是同一条——它量的是「这条路覆盖了哪一段结构」,而不是「玩家先去了哪」。「先去了哪」的先后序是第 1 章路径编码的对象,本节不重复。
张力的唯一真源
beats 段有 16 个节拍,每个节拍带一个整数 tension(1–5),全书常量里它们之和 = 52。这个整数是作者标注——是策划写下的「这一幕有多紧」,不是任何机器算出来的量。本节的曲线、6.2 的目标曲线、第 5 章的张力与释放,全部读这同一个字段,没有第二份真源。
S 层:把一条路径构造成曲线
脚本走一条确定性路径(每步选第一个条件成立的选项,同 nm_00_toc_metric.py),把它的 26 个节点构造成场景序列、节拍序列与张力曲线,落 out/nm_06_1_curve.json:
#!/usr/bin/env python3
# file: sim/nm_06_1_emotion_curve_construction.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_1_emotion_curve_construction.py
"""卷三 6.1 情感曲线的构造(S 层)。
一条路径 → 经过的节点 → 场景序列 → 节拍序列 → 张力序列(情感曲线)。
张力的唯一真源是 beats[].tension(整数 1–5),本脚本只做「路径 → 曲线」这一步
构造,不做任何文本处理,全程整数。
与 nm_00_toc_metric.py 分工:那个给一页摘要(曲线 + 覆盖率 + 熵三件套);本脚本
只把「曲线怎么从路径长出来」单独量清楚——场景序列、节拍序列、张力序列,以及曲线
的形状统计(和 / 长度 / 千分位均值 / 极值)。
与《游戏设计》卷二 gd-play-09-2 的分工:那条曲线由机制动力学(HP / 资源 / 回合
节奏)仿真算出;本条曲线由作者标注的叙事节拍沿玩家路径的序列读出。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")
def cond_ok(c, s):
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) # not
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 collapse(seq):
"""去掉连续重复,保留「第一次踏入某场景」的先后顺序。"""
out = []
for x in seq:
if not out or out[-1] != x:
out.append(x)
return out
def main() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
s = walk(spec)
path = s["path"]
raw_scenes = [node(spec, nid)["scene"] for nid in path]
scene_seq = collapse(raw_scenes)
visited = set(scene_seq)
beats = spec["beats"]
beat_seq = [b for b in beats if b["scene"] in visited] # 节拍真源顺序
curve = [b["tension"] for b in beat_seq] # 情感曲线 = 张力序列
n = len(curve)
total = sum(curve)
mean_permille = total * 1000 // n if n else 0
print(f"真源 {os.path.basename(spec['meta']['code'])} {spec['meta']['title']}")
print(f"起点 {spec['meta']['start']} 节点数 {len(path)} 终局 {s['node']}")
print(f"节点→场景 {len(path)} 个节点落到 {len(raw_scenes)} 个场景槽(含连续重复)")
print(f"覆盖场景 {len(visited)} / {len(spec['scene_graph']['nodes'])}(去重后)")
print(f"场景序列 {len(scene_seq)} 槽(去连续重复):{' → '.join(scene_seq)}")
print(f"节拍序列 {' '.join(b['id'] for b in beat_seq)}")
print(f"张力序列 {' '.join(str(v) for v in curve)}")
print(f"曲线长度 {n} (覆盖节拍 {n} / {len(beats)})")
print(f"张力之和 {total}")
print(f"均值千分位 {mean_permille} 毫 ({total} × 1000 // {n})")
print(f"极值 min={min(curve)} max={max(curve)} (真源 tension ∈ 1–5)")
# 整数不变量:tension ∈ [1,5],曲线长度 = 命中节拍数
assert all(1 <= v <= 5 for v in curve), "tension 越界"
assert n == sum(1 for b in beats if b["scene"] in visited)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_06_1_curve.json"), "w", encoding="utf-8") as fh:
json.dump({"nodes": len(path), "ending": node(spec, s["node"]).get("ending", ""),
"scenes": scene_seq, "scene_unique": len(visited),
"beats": [b["id"] for b in beat_seq],
"curve": curve, "sum": total, "mean_permille": mean_permille,
"min": min(curve), "max": max(curve)},
fh, ensure_ascii=False, indent=1)
fh.write("\n")
print("wrote out/nm_06_1_curve.json")
print("OK nm_06_1_emotion_curve_construction:路径 → 场景 → 节拍 → 张力序列已构造")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_06_1_emotion_curve_construction.py:
真源 mistport 雾港夜航
起点 d_pier_1 节点数 26 终局 e_lit
节点→场景 26 个节点落到 26 个场景槽(含连续重复)
覆盖场景 9 / 14(去重后)
场景序列 11 槽(去连续重复):pier → tavern → pier → counting_house → watch_post → lighthouse_base → tide_cave → harbor_office → pier → lighthouse_top → dawn_quay
节拍序列 b01 b02 b04 b05 b06 b07 b09 b11 b12 b15 b16
张力序列 1 2 3 2 3 4 4 3 4 4 2
曲线长度 11 (覆盖节拍 11 / 16)
张力之和 32
均值千分位 2909 毫 (32 × 1000 // 11)
极值 min=1 max=4 (真源 tension ∈ 1–5)
wrote out/nm_06_1_curve.json
OK nm_06_1_emotion_curve_construction:路径 → 场景 → 节拍 → 张力序列已构造
判读:曲线是序列,不是集合
这条路径走了 26 个节点,落到 11 个场景槽(去连续重复后),去重后覆盖 9 / 14 个场景。它命中 11 / 16 个节拍,所以曲线长度是 11——曲线是「命中的节拍序列」,长度等于命中节拍数,这是构造规则直接给出的不变量,脚本用 assert 钉死。
| 观察 | 数字 | 读法 |
|---|---|---|
| 节点数 | 26 | 一条确定性路径的长度 |
| 场景槽 | 11 | 去连续重复后的序列长度(回头会重复出现) |
| 覆盖场景 | 9 / 14 | 去重后的真实场景数 |
| 曲线长度 | 11 | = 命中节拍数,构造规则的不变量 |
| 张力之和 | 32 | 11 个节拍的 tension 相加(全 16 个是 52) |
| 均值(千分位) | 2909 毫 | 32 × 1000 // 11,整数地板除,无浮点 |
注意「均值千分位」这个写法:11 个整数求平均本来是 2.909…,本套不写小数,一律按千分位整数地板除记成 2909 毫。这不是显示格式的偏好,而是跨语言对照的前提——Python 与 TypeScript 对浮点的舍入、打印、精度处理并不一致,只有整数才谈得上「逐位一致」。6.2 的 MAE 沿用同一条纪律。
这里还要提醒一个反直觉的点:曲线的长度不是固定的。同一份规格,走不同的路径会命中不同的节拍集合,曲线长度随之变化——本节这条路是 11 点,换一条路可能是 9 点或 14 点。所以两条曲线要放在一起比时,必须先确认它们是不是同一口径:路径口径的 MAE 只在同一条路径上自比才有意义,跨路径比较要回到全量口径(6.2 会把两个口径都算出来)。这也解释了为什么本卷目录篇那条 11 点曲线和本节是同一条——它们跑的是同一策略、同一条确定性路径。
自检表
| 检查项 | 合格标准 |
|---|---|
| 真源唯一 | 张力只读 beats[].tension,无第二份 |
| 构造确定 | 同一路径算出的曲线唯一,无随机项 |
| 整数不变量 | tension ∈ [1,5];曲线长度 = 命中节拍数(脚本 assert) |
| 全程无浮点 | 均值走千分位整数地板除 |
| proxy 口径 | 曲线是作者标注序列,不是玩家情绪 |
曲线构造出来了,接下来要回答:它跟设计目标差多少。下一节把这条曲线与 metrics.target_tension_curve 逐项比,按千分位整数地板除算 MAE,并解释为什么当前这个 0 是自洽的证据而不是指标失效。
阅读导航:上一节:5.3 与机制张力的分工 · 下一节:6.2 目标曲线与 MAE 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。