14.1 叙事失调的形式化
独立游戏的叙事设计与分支对话 谈的是「叙事与玩法要协调」——那是经验判断:读得懂,判不了。本节把它形式化成一个失配计数:设定声明了什么、规格允许了什么、两者的差集有多大。先说清一句:这是代理指标,只做相对比较,不声称哪份规格「更协调」。
从形容词到差集
「叙事失调」(ludonarrative dissonance)在评论里常指「故事说一套、玩法做一套」。要让机器判它,得先把它拆成两份可枚举的集合:
- 声明集:设定写下的东西——
world.rules(禁则)、characters[].secret(秘密)、world.factions[].stance(派系立场)。 - 许可集:规格真正允许玩家做的事——
dialogue[].choices[].effects与它们的when前置。
失调就是两者的差集:声明了一条约束,许可集里却存在一个动作绕过它。差集是集合,不是程度——所以能数。
三类失配
| 类 | 声明 | 许可里绕过它的动作 | 判定 |
|---|---|---|---|
| R 规则失配 | rules 的禁则 | 一个无门选项直接违反禁则 | 数无门违规选项 |
| S 立场失配 | 派系 stance = -1 | 同一条路径上同时讨好敌对双方 | 数敌对角色对 |
| K 秘密失配 | characters[].secret | 见证旗标全部无门,秘密对所有人敞开 | 数无门秘密 |
三类的判定都靠一张读法表:哪些选项算「违反」、哪个 flag 是秘密的「见证」。读法表是本节自写的建模步骤,写在脚本里可被审阅——换一种读法,结论会变。
#!/usr/bin/env python3
# file: sim/na_14_1_dissonance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_14_1_dissonance.py
"""14.1 叙事失调的形式化(S 层):把「失调」拆成三类可判定的失配。
① 规则失配 R:设定声明禁则,规格却存在一个**无门**选项去违反它。
② 立场失配 S:两派系 stance = -1,规格却允许玩家在同一条路径上同时讨好双方。
③ 秘密失配 K:角色声明 secret,见证旗标却**全部无门**,秘密对所有人敞开。
三类都靠一张**读法表**(哪些选项算「违反」、哪个 flag 是秘密的见证)——读法表是本节
自写的建模步骤,显式列出以便审阅;换一种读法结论会变。本节只做**相对比较**:残差是
失配计数,不是「协调度」评分,不声称哪份规格「更协调」。
"""
from __future__ import annotations
import json
import os
import sys
# 灯塔区(基座 + 顶层)的对话节点前缀:rule_curfew 的禁区
LIGHTHOUSE_NODES = ("d_lb_", "d_lt_")
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def choices(spec: dict) -> list:
"""展平成 (节点, 选项) 序列,供三类检测器共用。"""
return [(n, c) for n in spec["dialogue"] for c in (n.get("choices") or [])]
def eff(c: dict) -> dict:
return c.get("effects") or {}
def adds(c: dict) -> list:
return sorted((eff(c).get("add") or {}).keys())
def sets(c: dict, flag: str) -> bool:
return (eff(c).get("set") or {}).get(flag) == 1
def grants(c: dict, item: str) -> bool:
return (eff(c).get("grant") or {}).get(item) == 1
def gated(c: dict) -> bool:
"""默认门判定:有 when 前置即算门。扰动层会传入替换口径。"""
return c.get("when") is not None
def rule_dissonance(spec: dict, is_gated=gated) -> list:
"""读法表:每条规则 -> 一条「违反它」的选项谓词(只认**无门**的选项)。"""
def curfew(n, c):
# 只认**进入**灯塔区(从区外跨进来),区内移动不算「进入」
return (c["goto"].startswith(LIGHTHOUSE_NODES)
and not n["id"].startswith(LIGHTHOUSE_NODES) and not is_gated(c))
def bell(n, c):
return sets(c, "bell_rung") and not is_gated(c)
def guild(n, c):
return grants(c, "rope_knot") and not is_gated(c)
def church(n, c):
return n.get("speaker") == "shen" and sets(c, "saw_lighthouse_light") and not is_gated(c)
table = [("rule_curfew", "潮涨后不得进入灯塔区", curfew),
("rule_bell", "沉钟非大难不得鸣", bell),
("rule_guild", "船渡需人作保", guild),
("rule_church", "教会不承认灯塔之光", church)]
out = []
for rid, reading, pred in table:
hits = [f"{n['id']}/{c['id']}" for n, c in choices(spec) if pred(n, c)]
out.append({"rule": rid, "reading": reading, "count": len(hits), "hits": hits})
return out
def stance_dissonance(spec: dict) -> list:
"""路径级双向示好:敌对派系(stance = -1)两侧角色**都**有 add 提供者,玩家就能在
同一条路径上把双方好感都加上去——声明的敌意没有在可行动作上留下任何代价。"""
fac = {c["id"]: c["faction"] for c in spec["characters"]}
stance = {f["id"]: f["stance"] for f in spec["world"]["factions"]}
adders: dict[str, list] = {}
for n, c in choices(spec):
for t in adds(c):
adders.setdefault(t, []).append(f"{n['id']}/{c['id']}")
pairs = []
ids = sorted(fac)
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
a, b = ids[i], ids[j]
fa, fb = fac[a], fac[b]
if (stance[fa].get(fb, 0) == -1 or stance[fb].get(fa, 0) == -1) and a in adders and b in adders:
pairs.append({"a": a, "b": b, "fa": fa, "fb": fb,
"providers_a": len(adders[a]), "providers_b": len(adders[b])})
return pairs
def secret_dissonance(spec: dict, is_gated=gated) -> list:
"""读法表:每个角色的 secret -> 一个见证旗标。见证旗标若**全部**由无门选项置位,
这条秘密对所有人无条件敞开,记一次失配。"""
witness = {"yan": "found_yan_journal", "hui": "hui_trusts", "shen": "shen_trusts",
"mu": "mu_trusts", "lin": "met_lin", "hai": "promised_hai"}
out = []
for ch in spec["characters"]:
cid, flag = ch["id"], witness[ch["id"]]
prov = [(f"{n['id']}/{c['id']}", c) for n, c in choices(spec) if sets(c, flag)]
g = [pid for pid, c in prov if is_gated(c)]
out.append({"char": cid, "witness": flag, "providers": len(prov),
"gated": len(g), "ungated": len(prov) - len(g),
"mismatch": len(prov) > 0 and len(g) == 0})
return out
def main() -> int:
spec = load_spec()
R, S, K = rule_dissonance(spec), stance_dissonance(spec), secret_dissonance(spec)
r_total = sum(x["count"] for x in R)
s_total, k_total = len(S), sum(1 for x in K if x["mismatch"])
print(f"spec={spec['meta']['title']} 角色 {len(spec['characters'])} 选项 {len(choices(spec))}")
print("三类失配(proxy:失配计数,只做相对比较,不声称「更协调」)")
print("\n① 规则失配 R(读法表:无门选项违反声明禁则)")
for x in R:
print(f" {x['rule']:<14}{x['reading']:<24} 违反 {x['count']} {x['hits']}")
print(f" 小计 R = {r_total}")
print("\n② 立场失配 S(敌对派系两侧都有 add 提供者:路径级双向示好)")
for x in S:
print(f" {x['fa']}/{x['a']} ↔ {x['fb']}/{x['b']} 提供者 {x['providers_a']}/{x['providers_b']}")
print(f" 小计 S = {s_total}")
print("\n③ 秘密失配 K(见证旗标全部无门:秘密无条件敞开)")
for x in K:
print(f" {x['char']:<5} witness={x['witness']:<20} 提供者 {x['providers']} 带门 {x['gated']}"
f" 失配 {'是' if x['mismatch'] else '否'}")
print(f" 小计 K = {k_total}")
total = r_total + s_total + k_total
print(f"\n叙事失调残差 = R + S + K = {r_total} + {s_total} + {k_total} = {total}")
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/na14_dissonance.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"R": R, "S": S, "K": K,
"totals": {"R": r_total, "S": s_total, "K": k_total, "total": total}},
fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("wrote out/na14_dissonance.json")
print(f"OK na_14_1_dissonance:三类失配已计数,残差 {total}(只做相对比较)")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/na_14_1_dissonance.py:
spec=雾港夜航 角色 6 选项 115
三类失配(proxy:失配计数,只做相对比较,不声称「更协调」)
① 规则失配 R(读法表:无门选项违反声明禁则)
rule_curfew 潮涨后不得进入灯塔区 违反 2 ['d_hub_a2/c_hub_a2a', 'd_hub_b2/c_hub_b2a']
rule_bell 沉钟非大难不得鸣 违反 0 []
rule_guild 船渡需人作保 违反 1 ['d_tavern_3/c_tavern_3a']
rule_church 教会不承认灯塔之光 违反 0 []
小计 R = 3
② 立场失配 S(敌对派系两侧都有 add 提供者:路径级双向示好)
guild/hui ↔ church/shen 提供者 13/18
church/shen ↔ watch/yan 提供者 18/23
小计 S = 2
③ 秘密失配 K(见证旗标全部无门:秘密无条件敞开)
yan witness=found_yan_journal 提供者 5 带门 3 失配 否
hui witness=hui_trusts 提供者 3 带门 3 失配 否
shen witness=shen_trusts 提供者 5 带门 5 失配 否
mu witness=mu_trusts 提供者 3 带门 3 失配 否
lin witness=met_lin 提供者 3 带门 0 失配 是
hai witness=promised_hai 提供者 3 带门 1 失配 否
小计 K = 1
叙事失调残差 = R + S + K = 3 + 2 + 1 = 6
wrote out/na14_dissonance.json
OK na_14_1_dissonance:三类失配已计数,残差 6(只做相对比较)
R = 3 全部落在两处:d_hub_a2/c_hub_a2a 与 d_hub_b2/c_hub_b2a 是进入灯塔区的无门选项,d_tavern_3/c_tavern_3a 是无门地给出作保信物;rule_bell 与 rule_church 的反证源为 0(鸣钟与教会侧都有门)。S = 2 的两对都含沈:hui↔shen、shen↔yan——船帮与教会、教会与守望者工会声明互为 -1,规格却允许玩家在同一条路径上把两边好感都加上去。K = 1 是林:他的见证旗标 met_lin 由三个无门选项置位,秘密对所有人敞开。
三类的口径边界
- R 只认「进入」:从区外跨进灯塔区才算违反禁足,区内移动不算——否则
d_lb_1→d_lb_2这种同区跳转会污染计数。 - S 是路径级、不是选项级:没有任何单个选项同时给两个敌对角色加分,失配出在路径上,所以判据是「两侧都有提供者」,而不是「同一选项改两个变量」。
- K 的见证旗标是读法:换一个 flag 当见证,K 会变。这里选的 6 个见证旗标逐个写在脚本里。
自检表
| 检查项 | 合格标准 | 本案例 |
|---|---|---|
| 三类各有实例 | R / S / K 都能数出整数 | 3 / 2 / 1 |
| 读法可审 | 读法表写在脚本里,不是心照 | 4 条规则读法 + 6 个见证旗标 |
| 代理已声明 | 正文显式标注「只做相对比较」 | 首段与脚本输出各一处 |
| 不越权裁定 | 只报失配计数,不判「好不好」 | 残差 6,无评级 |
残差 6 是「有多少处对不上」,不是「有多糟」。它能不能被参数撼动?14.2 先把每条声明逐条对齐,14.3 再对参数做扰动,看残差是阶跃还是连续。
阅读导航:上一节:13.3 一致性校验 · 下一节:14.2 设定与行为的对齐检验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。