7.1 场景图:节点、边与入口条件
独立游戏关卡设计与环境叙事 讲场景,讲的是「这个空间怎么把故事讲出来」——雾、潮声、刻着名字的石壁,那是一套空间与环境的叙事经验,散文式,读得懂但判不了。本章把场景写成一张图:节点、带条件的边、入口条件。一旦成为图,「可达」「顺序」「解锁」就从形容词变成了可判定的对象——7.2 判可达,7.3 判顺序。
三个组件:节点、边、入口条件
scene_graph 段只有两个字段,但拆开来是三个组件:nodes 里的节点、edges 里的边、以及挂在节点上的 enter_when 与挂在边上的 when。
| 组件 | 字段 | 数量(实测) | 语义 |
|---|---|---|---|
| 节点 | nodes[].id | 14 | 一个可进入的场景 |
| 边 | edges[].from/to | 16 | 场景之间的连接 |
| 节点入口条件 | nodes[].enter_when | 5 | 进这个场景前需要的状态 |
| 边条件 | edges[].when | 5 | 沿这条边走过去需要的状态 |
后两行要分清: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_graph | 14 | 16 |
| 对话层 | dialogue | 48 | 115(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 入度为 0 | 7.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 场景的可达性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。