14.1 叙事失调的形式化

「叙事失调」通常是主观说法:故事说一套、玩法做一套。本节把它形式化为可判定的失配——设定声明的规则、立场与秘密,与规格实际允许玩家做的事之间的差集,给出 R / S / K 三类判定口径,并在《雾港夜航》上实跑得到残差 6。这是代理指标,只做相对比较,不声称「更协调」。

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 设定与行为的对齐检验 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长