7.2 场景的可达性

把带条件的边当作「可能通」后,场景图上的 BFS 给出结构可达性:14 个场景全部可达,但 5 个场景(counting_house、watch_post、tide_cave、harbor_office、drowned_bell)的入口边全部带条件,可达性完全取决于状态。本节给出入度、出度、最少跳数与最早出现幕四列。

7.2 场景的可达性

独立游戏关卡设计与环境叙事 谈「玩家会不会走到那个地方」,靠的是动线、地标、视觉引导——那是空间与环境叙事的设计经验,它给的是「大概会走进去」。本节换一个问法:把场景图当成一张有向图,从起始场景出发,哪些场景真的可达?答案不靠直觉,靠一次 BFS。

可达性要问两个问题

「可达」这个词在这个案例里要拆成两个不同的问题:

问题口径回答它的工具
图连不连通带条件的边当作「可能通」场景图 BFS(本节)
某一局走不走得到条件在具体状态下是否成立状态空间穷举(卷二第 5 章)

本节只回答第一个。它的价值是便宜的:14 个节点、16 条边,一次 BFS 就完了,不需要展开状态。它的局限也是明确的:结构可达不等于一局可达——一个场景可能在图上通,却因为它的入口条件在每一局里都凑不齐而永远进不去。

口径:把带条件的边当作「可能通」

BFS 要能走,就得先定「什么算能走」。本节的口径是:一条带条件的边,只要它的条件在某个可达状态下能满足,就当作可通行。于是 5 条带条件的边在 BFS 里全都被当成普通边。

这个口径有一个直接后果:BFS 走出来的可达集,是「可能被走到」的上界。真实的可达集是它的子集。所以如果连这个上界里都有场景进不去,那才是真问题;反过来,上界里全通,不代表每一局都通。

S 层:场景图上的 BFS

下面这段是 S 层脚本 sim/na_07_2_reachability.py。它从起始场景出发做 BFS,每个场景统计四列:入度 / 出度(按边计,不看条件)、最少跳数(hops,边数)、最早出现幕(earliest_act,所有路径上「途经场景的最大幕号」的最小值)。earliest_act 用松弛法算——earliest[v] = max(act(v), min over preds earliest[pred])——不依赖「图恰好按幕分层」这个先验,万一哪天加一条跨幕回绕的边,它照样算得出。

#!/usr/bin/env python3
# file: sim/na_07_2_reachability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_07_2_reachability.py
"""卷一 7.2 场景的可达性(S 层)。

关键问题:**14 个场景里,哪些真的可达?**

本节在**场景图层面**做 BFS,把带条件的边当作「可能通」——条件只要在某个状态
下能满足,就视为这条边可走。于是 BFS 给出的是一张**结构可达性**表,它回答的是
「这张图连不连通」,不是「某一局一定走得到」。

每个场景统计四列:

  in / out          入度、出度(按边计,不看条件)
  hops              从起始场景出发的最少跳数(边数)
  earliest_act      最早出现幕:所有路径上「途经场景的最大幕号」的最小值
  entry_cond_all    入口边是否**全部**带条件(是则它的可达性完全取决于状态)

`earliest_act` 用松弛法算(`earliest[v] = max(act(v), min over preds earliest[pred])`),
不依赖「图恰好按幕分层」这个先验——如果哪天有人加一条跨幕回绕的边,它照样算得出。

产出 out/narr07_reachability.csv;不可达场景数 > 0 即非零退出。
"""
from __future__ import annotations

import csv
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")
SPEC = os.path.join(ROOT, "config/story_spec.json")
CSV_PATH = os.path.join(OUT, "narr07_reachability.csv")


def load_spec() -> dict:
    with open(SPEC, encoding="utf-8") as fh:
        return json.load(fh)


def start_scene(spec: dict) -> str:
    """起始场景 = meta.start 指向的对话节点所属的 scene。"""
    sid = spec["meta"]["start"]
    node = next(n for n in spec["dialogue"] if n["id"] == sid)
    return node["scene"]


def analyse(graph: dict, start: str) -> dict:
    nodes = {n["id"]: n for n in graph["nodes"]}
    succ: dict[str, list[str]] = {k: [] for k in nodes}
    preds: dict[str, list[str]] = {k: [] for k in nodes}
    entry_cond: dict[str, list[bool]] = {k: [] for k in nodes}
    for e in graph["edges"]:
        succ[e["from"]].append(e["to"])
        preds[e["to"]].append(e["from"])
        entry_cond[e["to"]].append(bool(e.get("when")))

    # BFS:最短跳数 + 可达集
    hops: dict[str, int] = {start: 0}
    queue = [start]
    while queue:
        u = queue.pop(0)
        for v in succ[u]:
            if v not in hops:
                hops[v] = hops[u] + 1
                queue.append(v)

    # 松弛:earliest_act[v] = max(act(v), min over preds earliest_act[pred])
    INF = 1 << 30
    earliest: dict[str, int] = {k: INF for k in nodes}
    earliest[start] = nodes[start]["act"]
    changed = True
    while changed:
        changed = False
        for u in nodes:
            if earliest[u] == INF:
                continue
            for v in succ[u]:
                cand = max(nodes[v]["act"], earliest[u])
                if cand < earliest[v]:
                    earliest[v] = cand
                    changed = True

    rows = []
    for nid, n in nodes.items():
        reachable = nid in hops
        ec = entry_cond[nid]
        rows.append({
            "scene": nid,
            "act": n["act"],
            "in_degree": len(preds[nid]),
            "out_degree": len(succ[nid]),
            "hops": hops.get(nid, -1),
            "earliest_act": earliest[nid] if reachable else -1,
            "entry_edges": len(ec),
            "entry_cond_all": bool(ec) and all(ec),
            "reachable": reachable,
        })
    return {"rows": rows, "hops": hops, "earliest": earliest, "preds": preds}


def main() -> int:
    spec = load_spec()
    graph = spec["scene_graph"]
    start = start_scene(spec)
    res = analyse(graph, start)
    rows = res["rows"]

    reachable = [r for r in rows if r["reachable"]]
    unreachable = [r for r in rows if not r["reachable"]]
    cond_only = [r for r in rows if r["entry_cond_all"]]

    print(f"起始场景={start}  节点={len(rows)}  可达={len(reachable)}  不可达={len(unreachable)}")
    print(f"{'scene':<16}{'act':>4}{'in':>4}{'out':>4}{'hops':>6}{'earliest':>10}{'entry':>7}  entry_cond_all")
    for r in sorted(rows, key=lambda r: (r["act"], r["hops"] if r["hops"] >= 0 else 99, r["scene"])):
        print(f"{r['scene']:<16}{r['act']:>4}{r['in_degree']:>4}{r['out_degree']:>4}"
              f"{r['hops']:>6}{r['earliest_act']:>10}{r['entry_edges']:>7}  {r['entry_cond_all']}")
    print(f"入口边全部带条件的场景(可达性完全取决于状态):{[r['scene'] for r in cond_only]}")
    if unreachable:
        print(f"不可达场景:{[r['scene'] for r in unreachable]}")

    # 结构不变量:可达 + 不可达 = 全部;起始场景入度 0
    assert len(reachable) + len(unreachable) == len(rows), "可达 + 不可达应等于全部节点"
    assert res["preds"][start] == [], f"起始场景 {start} 不应有入边"

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["scene", "act", "in_degree", "out_degree", "hops",
                                           "earliest_act", "entry_edges", "entry_cond_all", "reachable"])
        w.writeheader()
        for r in sorted(rows, key=lambda r: (r["act"], r["hops"] if r["hops"] >= 0 else 99, r["scene"])):
            w.writerow(r)
    print(f"wrote out/narr07_reachability.csv rows={len(rows)}")
    if unreachable:
        print(f"FAIL 有 {len(unreachable)} 个场景不可达")
        return 1
    print("OK na_07_2_reachability:14 个场景在「条件视为可满足」的口径下全部可达")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_07_2_reachability.py:

起始场景=pier  节点=14  可达=14  不可达=0
scene            act  in out  hops  earliest  entry  entry_cond_all
pier               1   0   2     0         1      0  False
chapel             1   1   1     1         1      1  False
tavern             1   1   1     1         1      1  False
counting_house     1   1   1     2         1      1  True
watch_post         1   1   1     2         1      1  True
lighthouse_base    2   2   2     3         2      2  False
market_row         2   1   1     4         2      1  False
tide_cave          2   1   1     4         2      1  True
drowned_bell       2   1   1     5         2      1  True
harbor_office      2   1   1     5         2      1  True
lighthouse_top     3   2   2     6         3      2  False
breakwater         3   1   1     7         3      1  False
fog_gate           3   1   1     7         3      1  False
dawn_quay          3   2   0     8         3      2  False
入口边全部带条件的场景(可达性完全取决于状态):['counting_house', 'watch_post', 'tide_cave', 'harbor_office', 'drowned_bell']
wrote out/narr07_reachability.csv rows=14
OK na_07_2_reachability:14 个场景在「条件视为可满足」的口径下全部可达

同一趟写出的 out/narr07_reachability.csv(每场景一行):

scene,act,in_degree,out_degree,hops,earliest_act,entry_edges,entry_cond_all,reachable
pier,1,0,2,0,1,0,False,True
chapel,1,1,1,1,1,1,False,True
tavern,1,1,1,1,1,1,False,True
counting_house,1,1,1,2,1,1,True,True
watch_post,1,1,1,2,1,1,True,True
lighthouse_base,2,2,2,3,2,2,False,True
market_row,2,1,1,4,2,1,False,True
tide_cave,2,1,1,4,2,1,True,True
drowned_bell,2,1,1,5,2,1,True,True
harbor_office,2,1,1,5,2,1,True,True
lighthouse_top,3,2,2,6,3,2,False,True
breakwater,3,1,1,7,3,1,False,True
fog_gate,3,1,1,7,3,1,False,True
dawn_quay,3,2,0,8,3,2,False,True

判读:14 个场景全部可达,但有 5 个要过门

表里最有信息量的不是「全通」,而是 entry_cond_all 这一列:

观察表里的证据含义
14 / 14 可达不可达=0场景图在结构上连通,没有孤岛
5 个场景入口全带条件entry_cond_all=True 共 5 行它们的可达性完全取决于状态
最早出现幕 = 声明幕earliest_act 与 act 逐行相等图没有跨幕回绕,幕分层是紧的
跳数单调hops 0→8 逐层展开从码头到黎明码头最少 8 跳

第二行是本节的关键发现:counting_house、watch_post、tide_cave、harbor_office、drowned_bell 这 5 个场景,入边全部带条件——它们没有一条「无条件入口」兜底。也就是说,只要对应的 flag / item 在某一局里凑不齐,这个场景在那一局里就进不去。BFS 把它们的可达性算成「可能可达」,但真正的可达与否,要回到状态层去问。

对比之下,lighthouse_base、lighthouse_top、dawn_quay 的入口边里有无条件的(前者是 counting_house / watch_post 两条无条件入边,后两者同理),所以它们在任何一局里都进得去。

第三行是另一种确认:earliest_act 与节点自己声明的 act 逐行相等。这说明所有边都从低幕指向高幕(或同幕),没有「第一夜的场景要等第三夜的状态」这种回绕。这条事实会在 7.3 变成「无环」的判定依据。

最后要写清口径的局限:这里的「可达」是结构可达,是一个上界。它用一次 BFS 换来了便宜,代价是它不看条件——所以它既不能说「某个场景每局都能进」,也不能说「某个场景某局一定进不去」。要判后者,得把条件代进去跑状态空间,那是卷二第 5 章的对象。本节给出的是「有没有孤岛」这个更粗但更便宜的问题的答案,而答案是没有。

自检表

检查项合格标准
起始场景有据由 meta.start 指向的对话节点的 scene 决定,不硬编码
口径写死明确声明「带条件边当作可能通」,并说明结果是上界
上界与实达分开显式区分结构可达与一局可达,后者指向状态层
不变量成对断言「可达 + 不可达 = 全部」与「起始场景入度 0」
跳数可核hops 从 0 起、逐层递增,可在邻接表上手算复核

可达性回答了「能不能到」,但没回答「按什么顺序到」。5 条带条件的边其实在说「先有钥匙、再进门」,而钥匙会不会被锁在门后,是下一节的事。

阅读导航:上一节:7.1 场景图:节点、边与入口条件 · 下一节:7.3 顺序约束与解锁 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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