7.1 场景图:节点、边与入口条件

场景图把 14 个场景写成一张图:节点带 enter_when,16 条边里 11 条无条件、5 条带条件。本节做结构校验(每条边两端都在 nodes 里)与邻接表,并用纯 xml.etree 出一张按幕分列的场景图 SVG;其中 5 个场景只有带条件的入口边,可达性完全取决于状态。

7.1 场景图:节点、边与入口条件

独立游戏关卡设计与环境叙事 讲场景,讲的是「这个空间怎么把故事讲出来」——雾、潮声、刻着名字的石壁,那是一套空间与环境的叙事经验,散文式,读得懂但判不了。本章把场景写成一张图:节点、带条件的边、入口条件。一旦成为图,「可达」「顺序」「解锁」就从形容词变成了可判定的对象——7.2 判可达,7.3 判顺序。

三个组件:节点、边、入口条件

scene_graph 段只有两个字段,但拆开来是三个组件:nodes 里的节点、edges 里的边、以及挂在节点上的 enter_when 与挂在边上的 when。

组件字段数量(实测)语义
节点nodes[].id14一个可进入的场景
边edges[].from/to16场景之间的连接
节点入口条件nodes[].enter_when5进这个场景前需要的状态
边条件edges[].when5沿这条边走过去需要的状态

后两行要分清:enter_when 是门(没有这个状态就进不去),when 是路(没有这个状态就走不过去)。两者在本案例里数量都是 5,但集合不同——enter_when 非空的节点是 lighthouse_base / tide_cave / harbor_office / drowned_bell / lighthouse_top,带 when 的边是另外 5 条。它们恰好错开一格,正说明「门」与「路」是两套独立的约束。

场景节点不是对话节点

最容易混淆的一点:scene_graph.nodes 与 dialogue 是两层。对话节点是 48 个(含 5 个结局节点),场景节点只有 14 个;每个对话节点通过 scene 字段挂到某个场景上,多个对话节点共享一个场景。

层段节点数边/跳转数
场景层scene_graph1416
对话层dialogue48115(choices[].goto)

所以「场景可达」与「对话可达」不是一回事:场景层说「玩家能不能走到灯塔基座这个地方」,对话层说「玩家能不能走到某句台词」。本章只处理场景层;对话层的遍历校验在第 11 章。把两层分开的好处是,一张 14 节点 16 边的图可以在纸上画完、可以用眼睛核,而 48 节点 115 边的对话图只能靠脚本。

S 层:结构校验与邻接表

下面这段是 S 层脚本 sim/na_07_1_scene_graph.py。它做三件事:结构校验(每条边的两端都必须在 nodes 里,节点 id 不重复,无自环)、邻接表(把 edges 折成「每个场景通向哪些场景」,带条件边标注 when)、场景图 SVG(纯 xml.etree,节点按幕分列,同列的边走右侧弧线、跨列的走直线,实线 = 无条件边、虚线 = 带条件边)。回读时按 tag.endswith("rect") 取元素——带 xmlns 的 SVG 用 root.iter("rect") 匹配不到任何东西。

#!/usr/bin/env python3
# file: sim/na_07_1_scene_graph.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_07_1_scene_graph.py
"""卷一 7.1 场景图:节点、边与入口条件(S 层)。

场景图是三元组:**节点**(14 个场景)/ **边**(16 条连接)/ **入口条件**(节点上的
enter_when 与边上的 when)。本节做三件事:

  1. 结构校验:每条边的两端都必须在 nodes 里(断链检测),节点 id 不得重复;
  2. 邻接表:把 edges 折成「每个场景通向哪些场景」,带条件边标注 when;
  3. 场景图 SVG:纯 xml.etree 生成,节点按幕分列,实线 = 无条件边、虚线 = 带条件边。

节点上的 enter_when 与边上的 when 是两套条件:前者说「进这个场景要不要先有某个
状态」,后者说「能不能沿这条边走过去」。本节只做结构校验与可视化,可通行性判定
留给 7.2,解锁顺序留给 7.3。

产出 out/narr07_scene_graph.csv、out/narr07_scene_graph.svg。
"""
from __future__ import annotations

import csv
import json
import os
import sys
import xml.etree.ElementTree as ET

SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)

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")

ACT_FILL = {1: "#e0f2fe", 2: "#fef3c7", 3: "#fee2e2"}
ACT_STROKE = {1: "#38bdf8", 2: "#f59e0b", 3: "#f87171"}
BW, BH, GAP = 150, 30, 68


def el(tag, **attrs):
    return ET.Element(f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in attrs.items()})


def text(parent, x, y, s, size=12, anchor="start", fill="#334155"):
    t = el("text", x=x, y=y, **{"font-size": size, "font-family": "ui-sans-serif, system-ui",
                                "text-anchor": anchor, "fill": fill})
    t.text = s
    parent.append(t)


def cond_label(c) -> str:
    """把条件折成短标签,仅用于邻接表与 CSV(求值不在这里)。"""
    if c is None:
        return ""
    k = c[0]
    if k == "flag":
        return f"flag:{c[1]}=={c[2]}"
    if k == "item":
        return f"item:{c[1]}=={c[2]}"
    if k == "aff":
        return f"aff:{c[1]}{c[2]}{c[3]}"
    if k in ("and", "or"):
        return f"({cond_label(c[1])} {'&' if k == 'and' else '|'} {cond_label(c[2])})"
    if k == "not":
        return f"!{cond_label(c[1])}"
    return k


def layout(graph):
    """按幕分列、幕内按声明顺序排行;返回 (acts, cols, col_x, pos)。"""
    acts = {}
    for n in graph["nodes"]:
        acts.setdefault(n["act"], []).append(n)
    cols = sorted(acts)
    col_x = {a: 90 + i * 280 for i, a in enumerate(cols)}
    pos = {}
    for a in cols:
        for j, n in enumerate(acts[a]):
            pos[n["id"]] = (col_x[a], 70 + j * GAP)
    return acts, cols, col_x, pos


def build_svg(graph, path):
    acts, cols, col_x, pos = layout(graph)
    W, H = 860, 420
    svg = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
    svg.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
    # 边:同列走右侧弧线(path),跨列走直线(line);虚线 = 带条件
    for e in graph["edges"]:
        sx, sy = pos[e["from"]]
        tx, ty = pos[e["to"]]
        dash = "5 4" if e.get("when") else "none"
        if sx == tx:
            svg.append(el("path", d=f"M {sx + BW} {sy + BH // 2} "
                                   f"Q {sx + BW + 42} {(sy + ty) // 2 + BH // 2} "
                                   f"{tx + BW} {ty + BH // 2}", fill="none", stroke="#94a3b8",
                          **{"stroke-width": 1.4, "stroke-dasharray": dash}))
        else:
            svg.append(el("line", x1=sx + BW, y1=sy + BH // 2, x2=tx, y2=ty + BH // 2,
                          stroke="#94a3b8", **{"stroke-width": 1.4, "stroke-dasharray": dash}))
    for a in cols:
        for n in acts[a]:
            x, y = pos[n["id"]]
            svg.append(el("rect", x=x, y=y, width=BW, height=BH, rx=6,
                          fill=ACT_FILL[a], stroke=ACT_STROKE[a], **{"stroke-width": 1.5}))
            text(svg, x + 10, y + 20, n["id"], 12, "start", "#0f172a")
            if n.get("enter_when"):
                text(svg, x + BW - 8, y + 20, "需", 11, "end", "#b91c1c")
        text(svg, col_x[a] + BW // 2, 46, f"act {a}", 13, "middle", "#0f172a")
    text(svg, 20, 22, "《雾港夜航》场景图(14 节点 / 16 边;实线=无条件边,虚线=带条件边)",
         13, "start", "#0f172a")
    svg.append(el("line", x1=60, y1=H - 22, x2=250, y2=H - 22, stroke="#94a3b8", **{"stroke-width": 1.4}))
    text(svg, 258, H - 18, "无条件边", 11, "start", "#64748b")
    svg.append(el("line", x1=340, y1=H - 22, x2=530, y2=H - 22, stroke="#94a3b8",
                  **{"stroke-width": 1.4, "stroke-dasharray": "5 4"}))
    text(svg, 538, H - 18, "带条件边", 11, "start", "#64748b")
    text(svg, 640, H - 18, "「需」= 节点带 enter_when", 11, "start", "#b91c1c")
    tree = ET.ElementTree(svg)
    ET.indent(tree, space="  ")
    tree.write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


def readback(path):
    root = ET.parse(path).getroot()
    counts = {}
    for e in root.iter():
        if isinstance(e.tag, str):
            for k in ("rect", "line", "text", "path"):
                if e.tag.endswith(k):
                    counts[k] = counts.get(k, 0) + 1
    return counts


def main():
    with open(SPEC, encoding="utf-8") as fh:
        spec = json.load(fh)
    graph = spec["scene_graph"]
    nodes, edges = graph["nodes"], graph["edges"]

    # 1) 结构校验:边两端都在 nodes 里、节点 id 不重复、无自环
    ids = [n["id"] for n in nodes]
    known = set(ids)
    problems = []
    if len(ids) != len(set(ids)):
        problems.append(f"场景节点 id 重复:{sorted({x for x in ids if ids.count(x) > 1})}")
    for e in edges:
        if e["from"] not in known or e["to"] not in known:
            problems.append(f"断链:{e['from']} -> {e['to']}")
        if e["from"] == e["to"]:
            problems.append(f"自环:{e['from']}")
    for p in problems:
        print(f"FAIL {p}")
    if problems:
        return 1

    # 2) 邻接表 + 入度 / 出度
    adj = {n["id"]: [] for n in nodes}
    indeg = {n["id"]: 0 for n in nodes}
    for e in edges:
        adj[e["from"]].append((e["to"], cond_label(e.get("when"))))
        indeg[e["to"]] += 1

    uncond = sum(1 for e in edges if not e.get("when"))
    cond = len(edges) - uncond
    print(f"nodes={len(nodes)}  edges={len(edges)}  无条件边={uncond}  带条件边={cond}")
    print(f"带 enter_when 的节点={sum(1 for n in nodes if n.get('enter_when'))}  "
          f"幕分布={ {a: sum(1 for n in nodes if n['act'] == a) for a in sorted({n['act'] for n in nodes})} }")
    print("邻接表:")
    for n in nodes:
        outs = adj[n["id"]]
        if not outs:
            print(f"  {n['id']:<16} -> (终点)")
        else:
            print(f"  {n['id']:<16} -> " + "  ".join(f"{t}{'[' + c + ']' if c else ''}" for t, c in outs))
    print(f"出度为 0 的节点 {[n['id'] for n in nodes if not adj[n['id']]]}")
    print(f"入度为 0 的节点 {[k for k, v in indeg.items() if v == 0]}")

    os.makedirs(OUT, exist_ok=True)
    csv_path = os.path.join(OUT, "narr07_scene_graph.csv")
    with open(csv_path, "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["from", "to", "conditional", "when"])
        for e in edges:
            w.writerow([e["from"], e["to"], "1" if e.get("when") else "0", cond_label(e.get("when"))])

    svg_path = os.path.join(OUT, "narr07_scene_graph.svg")
    size = build_svg(graph, svg_path)
    counts = readback(svg_path)
    print(f"wrote out/narr07_scene_graph.svg {size} bytes  {counts}")
    assert counts.get("rect", 0) == len(nodes) + 1, "矩形数应为节点数 + 背景"
    assert counts.get("path", 0) + counts.get("line", 0) == len(edges) + 2, "边元素数应为边数 + 2 条图例线"
    print(f"wrote out/narr07_scene_graph.csv rows={len(edges)}")
    print("OK na_07_1_scene_graph:每条边两端都在 nodes 里,邻接表与 SVG 回读通过")
    return 0


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

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

nodes=14  edges=16  无条件边=11  带条件边=5
带 enter_when 的节点=5  幕分布={1: 5, 2: 5, 3: 4}
邻接表:
  pier             -> tavern  chapel
  tavern           -> counting_house[flag:met_hui==1]
  chapel           -> watch_post[flag:met_shen==1]
  counting_house   -> lighthouse_base
  watch_post       -> lighthouse_base
  lighthouse_base  -> tide_cave[flag:knows_tide_rule==1]  market_row
  tide_cave        -> harbor_office[item:ledger_page==1]
  market_row       -> drowned_bell[flag:knows_bell_legend==1]
  harbor_office    -> lighthouse_top
  drowned_bell     -> lighthouse_top
  lighthouse_top   -> breakwater  fog_gate
  breakwater       -> dawn_quay
  fog_gate         -> dawn_quay
  dawn_quay        -> (终点)
出度为 0 的节点 ['dawn_quay']
入度为 0 的节点 ['pier']
wrote out/narr07_scene_graph.svg 7144 bytes  {'rect': 15, 'path': 12, 'line': 6, 'text': 26}
wrote out/narr07_scene_graph.csv rows=16
OK na_07_1_scene_graph:每条边两端都在 nodes 里,邻接表与 SVG 回读通过

同一趟写出的 out/narr07_scene_graph.csv(每边一行,conditional=1 即带条件):

from,to,conditional,when
pier,tavern,0,
pier,chapel,0,
tavern,counting_house,1,flag:met_hui==1
chapel,watch_post,1,flag:met_shen==1
counting_house,lighthouse_base,0,
watch_post,lighthouse_base,0,
lighthouse_base,tide_cave,1,flag:knows_tide_rule==1
lighthouse_base,market_row,0,
tide_cave,harbor_office,1,item:ledger_page==1
market_row,drowned_bell,1,flag:knows_bell_legend==1
harbor_office,lighthouse_top,0,
drowned_bell,lighthouse_top,0,
lighthouse_top,breakwater,0,
lighthouse_top,fog_gate,0,
breakwater,dawn_quay,0,
fog_gate,dawn_quay,0,

SVG 回读得到 rect=15(14 个节点框 + 1 个背景)、path=12(同列边)、line=6(4 条跨列边 + 2 条图例线),与断言一致。

从邻接表能读出什么

邻接表里有三条结构性事实,后面的两节都要用:

事实邻接表里的样子后续用途
唯一入口只有 pier 入度为 07.2 的 BFS 从它出发
唯一汇点只有 dawn_quay 出度为 0三幕都汇到黎明码头
5 条带条件的边全部标注 [flag:…] 或 [item:…]7.3 的顺序约束

第二行值得单独说:dawn_quay 是唯一出度为 0 的场景,且它被 breakwater 与 fog_gate 两条边汇入。也就是说,无论玩家在第三夜先走防波堤还是先走雾门,最后都会落到黎明码头——这正是「汇流点」在场景层的体现,第 8 章会把这类结构展开成分支拓扑。

第三行的 5 条带条件边不是装饰:它们把「自由走」变成了「有前提地走」。lighthouse_base → tide_cave 要 knows_tide_rule,tide_cave → harbor_office 要 ledger_page——这些条件从哪来、能不能在进门之前拿到,是 7.3 要判的事。

自检表

检查项合格标准
断链校验每条边的 from / to 都在 nodes 的 id 集合里
无自环没有任何 from == to 的边
门与路分开enter_when(节点)与 when(边)分别统计,不混为一谈
两层分开明确声明场景节点 14 ≠ 对话节点 48
回读口径用 tag.endswith("rect") 取元素,不用 root.iter("rect")
可视化可核SVG 的矩形数 = 节点数 + 背景,边元素数 = 边数 + 图例线

邻接表把「谁能到谁」写清楚了,但它还没回答「谁真的到得了」。下一节在场景图上跑一次 BFS,把 14 个场景的可达性算成一张表。

阅读导航:上一节:6.3 结构与节奏的比例检验 · 下一节:7.2 场景的可达性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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