站内的独立游戏的叙事设计与分支对话 谈的是玩家选择体验——什么样的选项算「有意义」,配的是清单与示意图,代码不可跑。本节把「一次选择离它被玩家察觉有多远」变成一条可判定的整数距离:一个选项写下的状态差量,要隔几个节点才被下游的条件读到。间隔越长,玩家越难把后来的变化和自己刚才那一下联系起来——这是本章三节的入口,4.2 判「有没有后果」,本节判「后果离得多远」。
4.1 后果可见性
先把口径讲死:本节算出来的「后果可见性」是一个代理指标(proxy)。它量的是状态差量到第一个读点的结构距离——一个纯整数、可复现的结构量,不是玩家实际有没有感觉到。玩家是否留意到某个 flag 变了、是否把结局的差异归因到自己三夜前的那次选择,都需真实业务环境、命令行不可复现,本套不做、也不假装能做。这个距离只用于同一份规格内部的相对比较:比两个选项谁的后果更「即时」、比规格演化前后可见性怎么变。
定义:差量、读点、距离
三样东西,全部写死在代码里:
| 记号 | 含义 | 来源 |
|---|---|---|
write(c) | 选项 c 的效果写到的可观测量集合 | set 的 flag、add 的好感度变量、grant / revoke 的物品 |
read(n) | 节点 n 处会被求值的可观测量集合 | n 的每个选项的 when、n 的 line_when、n 若为结局节点则含该结局的 when |
d(c) | 可见性距离:从 c.goto(记为第 0 步)沿 goto 边结构 BFS,第一个满足 read(n) ∩ write(c) ≠ ∅ 的节点所在步数 | 结构量,与状态无关 |
d(c) = 0 表示「落点自己就依赖这个差量」——玩家一下脚,下一屏的条件就变了;d(c) = 5 表示要走过 5 个节点,差量才第一次被某条 when 读到。若整个下游都读不到 write(c),记 d(c) = ∞:这条差量写进了状态,却永远没人看。
为什么距离可以只按结构算、不必按状态算?因为 write(c) 是选项自带的——effects 写在规格里,与当前状态无关;read(n) 也是节点自带的。两者相交与否只取决于「哪个节点读了哪些变量」,是一条纯图上的可达性问题,不需要展开状态空间。代价是它忽略 when 的可满足性:某条读点也许在状态上不可达,距离会被低估。这一点在判读里显式标出。
S 层:115 个选项逐个算距离
脚本 sim/nm_04_1_consequence_visibility.py 读 config/story_spec.json,对 115 个选项各算一次 write / d / 分类,落 out/nm_04_1_visibility.csv 与 .json,并用纯 xml.etree 出直方图 out/nm_04_1_visibility.svg:
#!/usr/bin/env python3
# file: sim/nm_04_1_consequence_visibility.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_04_1_consequence_visibility.py
"""卷三 4.1 后果可见性(S 层):一个选项产生的状态差量,要隔几步才被下游「读到」。
可判定口径(写死在代码里,不按结果调参):
1) write(c) = 选项 c 的效果写到的可观测量集合:set 写的 flag、add 写的好感度变量、
grant / revoke 写的物品。三者都是**整数**量,不涉及文本。
2) read(n) = 节点 n 处会被求值的可观测量集合:n 的每个选项的 when、n 的 line_when、
以及 n 若是结局节点则含该结局的 when。
3) 可见性距离 d(c) = 从 c.goto 出发(c.goto 记为第 0 步),沿 goto 边做**结构 BFS**
(忽略 when 的可满足性),第一个满足 read(n) ∩ write(c) != 空 的节点所在的步数。
若整个下游都读不到 write(c),则 d(c) = ∞(「写入即遗忘」)。
这是一个 **proxy**:它度量的是「差量离它的第一个读点有多远」这个**结构量**,只用于
不同选项之间的**相对比较**,不预测玩家的主观感受,也不声称「间隔短 = 体验好」。
产出 out/nm_04_1_visibility.csv(115 行)、out/nm_04_1_visibility.json、
纯 xml.etree 直方图 out/nm_04_1_visibility.svg。选项数不符即非零退出。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
from collections import Counter, deque
HERE = os.path.dirname(os.path.abspath(__file__))
SVG = "{http://www.w3.org/2000/svg}"
def read_vars(spec):
"""节点 id -> 该节点处会被求值的可观测量集合(flag / 好感度 / 物品)。"""
by = {n["id"]: n for n in spec["dialogue"]}
out: dict[str, set] = {}
def walk(c, acc):
if not c:
return
k = c[0]
if k in ("and", "or"):
walk(c[1], acc)
walk(c[2], acc)
elif k == "not":
walk(c[1], acc)
elif k == "flag":
acc.add(("flag", c[1]))
elif k == "aff":
acc.add(("aff", c[1]))
elif k == "item":
acc.add(("item", c[1]))
end_when = {e["name"]: e.get("when") for e in spec["endings"]}
for n in spec["dialogue"]:
acc: set = set()
walk(n.get("line_when"), acc)
for c in (n.get("choices") or []):
walk(c.get("when"), acc)
if n.get("ending") and end_when.get(n["ending"]):
walk(end_when[n["ending"]], acc)
out[n["id"]] = acc
return by, out
def write_vars(c):
eff = c.get("effects") or {}
acc = set()
for k in (eff.get("set") or {}):
acc.add(("flag", k))
for k in (eff.get("add") or {}):
acc.add(("aff", k))
for k in list((eff.get("grant") or {})) + list((eff.get("revoke") or {})):
acc.add(("item", k))
return acc
def visibility_distance(by, reads, c):
"""从 c.goto 出发结构 BFS,返回第一个读点的步数;读不到返回 None。"""
w = write_vars(c)
if not w:
return None, None
start = c["goto"]
seen = {start}
q = deque([(start, 0)])
while q:
nid, d = q.popleft()
if reads.get(nid, set()) & w:
return d, nid
for ch in (by[nid].get("choices") or []):
if ch["goto"] not in seen:
seen.add(ch["goto"])
q.append((ch["goto"], d + 1))
return None, None
def bucket(d):
if d is None:
return "never"
if d == 0:
return "d0"
if d <= 2:
return "d1_2"
return "d3plus"
def main() -> int:
with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
spec = json.load(fh)
by, reads = read_vars(spec)
rows = []
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
w = write_vars(c)
d, at = visibility_distance(by, reads, c)
rows.append({
"node": n["id"], "choice": c["id"], "goto": c["goto"],
"writes": len(w),
"writes_flags": sum(1 for t, _ in w if t == "flag"),
"writes_aff": sum(1 for t, _ in w if t == "aff"),
"writes_items": sum(1 for t, _ in w if t == "item"),
"visible_at": at or "",
"distance": "" if d is None else d,
"bucket": bucket(d) if w else "no_delta",
})
n = len(rows)
no_delta = [r for r in rows if r["bucket"] == "no_delta"]
with_delta = [r for r in rows if r["bucket"] != "no_delta"]
bk = Counter(r["bucket"] for r in with_delta)
dist = [r["distance"] for r in with_delta if r["distance"] != ""]
mean_milli = (sum(dist) * 1000 // len(dist)) if dist else 0
mx = max(dist) if dist else 0
out_dir = os.path.join(HERE, "../out")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "nm_04_1_visibility.csv"), "w",
encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
summary = {
"choices": n,
"no_delta": len(no_delta),
"with_delta": len(with_delta),
"immediate_d0": bk["d0"],
"lag_d1_2": bk["d1_2"],
"lag_d3plus": bk["d3plus"],
"never_read": bk["never"],
"mean_distance_milli": mean_milli,
"max_distance": mx,
"writes_flags_total": sum(r["writes_flags"] for r in rows),
"writes_aff_total": sum(r["writes_aff"] for r in rows),
"writes_items_total": sum(r["writes_items"] for r in rows),
}
with open(os.path.join(out_dir, "nm_04_1_visibility.json"), "w", encoding="utf-8") as fh:
json.dump(summary, fh, ensure_ascii=False, indent=1)
# 纯 xml.etree 直方图:可见性距离 0..max 与 never
order = [str(i) for i in range(mx + 1)] + ["never"]
counts = [sum(1 for r in with_delta if r["bucket"] != "never" and r["distance"] == i)
for i in range(mx + 1)]
counts.append(bk["never"])
W, H, PAD, BW = 520, 260, 40, 44
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
"width": str(W), "height": str(H),
"viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W),
"height": str(H), "fill": "#ffffff"})
cxm = max(counts) if counts else 1
for i, cnt in enumerate(counts):
h = 0 if cxm == 0 else (H - 2 * PAD) * cnt // cxm
x = PAD + i * BW
ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - PAD - h),
"width": str(BW - 8), "height": str(h),
"fill": "#3b6ea5"})
ET.SubElement(svg, "text", {"x": str(x + (BW - 8) // 2), "y": str(H - PAD + 16),
"font-size": "12", "text-anchor": "middle"})
svg[-1].text = order[i]
ET.SubElement(svg, "text", {"x": str(x + (BW - 8) // 2), "y": str(H - PAD - h - 4),
"font-size": "11", "text-anchor": "middle"})
svg[-1].text = str(cnt)
ET.SubElement(svg, "text", {"x": str(PAD), "y": "22", "font-size": "13"})
svg[-1].text = "visibility distance histogram (choices with delta)"
svg_path = os.path.join(out_dir, "nm_04_1_visibility.svg")
ET.ElementTree(svg).write(svg_path, encoding="utf-8", xml_declaration=True)
back = ET.fromstring(open(svg_path, encoding="utf-8").read())
bars = sum(1 for e in back.iter() if e.tag.rsplit("}", 1)[-1] == "rect") - 1
assert bars == len(order), f"SVG 回读 rect 数 {bars} != {len(order)}"
print(f"选项 {n} 条:无差量(空效果){len(no_delta)} 条 / 有差量 {len(with_delta)} 条")
print(f"有差量选项的可见性距离:立即(d=0) {bk['d0']} · 延迟 1-2 步 {bk['d1_2']} · "
f"延迟 >=3 步 {bk['d3plus']} · 永不被读到 {bk['never']}")
print(f"平均可见性距离 {mean_milli} 毫(即 {mean_milli / 1000:.2f} 步) 最大 {mx} 步")
print(f"写出的可观测量:flag {summary['writes_flags_total']} 次 / "
f"好感度 {summary['writes_aff_total']} 次 / 物品 {summary['writes_items_total']} 次")
fin = [r for r in with_delta if r["distance"] != ""]
if fin:
far = max(fin, key=lambda r: int(r["distance"]))
print(f"最远的一条:{far['node']} {far['choice']} -> {far['goto']} "
f"d={far['distance']} 读点 {far['visible_at']}")
print(f"写而不读(d=∞)的选项 {bk['never']} 条,前 5 条:"
f"{[r['choice'] for r in with_delta if r['distance'] == ''][:5]}")
print(f"wrote out/nm_04_1_visibility.csv rows={len(rows)} / .json / .svg (bars={bars})")
if len(rows) != spec["meta"]["scale"]["choices"]:
print(f"FAIL 选项数 {len(rows)} != scale {spec['meta']['scale']['choices']}")
return 1
print("OK nm_04_1_consequence_visibility:115 个选项的后果可见性距离已量出")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_04_1_consequence_visibility.py:
选项 115 条:无差量(空效果)16 条 / 有差量 99 条
有差量选项的可见性距离:立即(d=0) 34 · 延迟 1-2 步 14 · 延迟 >=3 步 23 · 永不被读到 28
平均可见性距离 2422 毫(即 2.42 步) 最大 12 步
写出的可观测量:flag 56 次 / 好感度 66 次 / 物品 16 次
最远的一条:d_ch_3 c_ch_3a -> d_hub_a2 d=12 读点 d_dq_2
写而不读(d=∞)的选项 28 条,前 5 条:['c_pier_2a', 'c_tavern_1b', 'c_ch_1a', 'c_wp_1a', 'c_wp_1c']
wrote out/nm_04_1_visibility.csv rows=115 / .json / .svg (bars=14)
OK nm_04_1_consequence_visibility:115 个选项的后果可见性距离已量出
判读:三档差量与一档「写而不读」
115 个选项里,16 个根本没有差量(effects 为空,既不 set flag、不 add 好感度、也不 grant / revoke 物品)——它们连「写」这一步都没有,谈不上可见性,单列一档。剩下 99 个有差量的选项分三档:
| 档 | 数量 | 读法 |
|---|---|---|
| 立即(d=0) | 34 | 落点自己的条件就读了这个差量——后果当场可见 |
| 延迟 1–2 步 | 14 | 隔一两个节点被读到 |
| 延迟 ≥3 步 | 23 | 要走过一段路才被读到 |
| 永不被读到(∞) | 28 | 写进了状态,但下游没有任何条件读它 |
有差量选项的平均可见性距离 2422 毫(2.42 步),最远 12 步(d_ch_3/c_ch_3a,一路走到 d_dq_2 才被读到)。写出的可观测量分布是 flag 56 次 / 好感度 66 次 / 物品 16 次——注意好感度写得最多,但它的读点集中在少数阈值门上(第 8 章专门量阈值触达)。
最该盯的是「永不被读到」那 28 条。 它们和 4.2 的假选择是同一族问题的两个面:假选择是「两条边走了同一个结果」,写而不读是「一条边写了结果但没人读」。两者的共同后果都是玩家的操作没有落在任何后续条件上。但口径要诚实:「永不被读到」不等于「这条选项没用」——它可能只是给玩家一句即时反馈、或改了一个只在结局 when 里才被读的量(本节口径里结局 when 是算作读点的)。判据给的是一份可复核的清单,不是判决。
还要提醒一个反直觉的点:距离短不等于设计好。 34 个「立即可见」的选项里,很多是「选完立刻设一个 flag,落点马上用它分岔」——这是清晰,不是深度;而延迟 12 步那条也许恰恰是「伏笔」。本节的数只回答「多远」,不回答「多好」。
自检表
| 检查项 | 合格标准 |
|---|---|
| 真源唯一 | 只读 config/story_spec.json,无第二份 |
| 口径可判定 | write / read 只依赖规格,与状态无关;距离 = 结构 BFS 步数 |
| 整数不变量 | 距离是整数;无差量选项单列,不混进距离分布 |
| 精确非抽样 | 覆盖全部 115 个选项,不抽样 |
| proxy 口径 | 结构距离只做相对比较,不声称玩家能感觉到 |
可见性算完,还剩一问:那 28 条「写而不读」和 16 条「无差量」,在状态推进的层面会不会更严重——两条不同的选项推进出完全相同的状态?下一节给这件事一个可判定、可计数的检测。
阅读导航:上一节:3.3 结局可达性的回归 · 下一节:4.2 假选择的检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。