7.3 顺序约束与解锁
独立游戏关卡设计与环境叙事 讲「这一关要先拿到钥匙才开得了门」,讲的是空间与环境叙事的节奏经验——什么时候给钥匙、门后面是什么。本节把这件事写成规格:带条件的边就是顺序约束,而「钥匙会不会被锁在门后」是一个可以判定的问题。
顺序约束:带条件的边在说什么
7.1 的邻接表里,5 条带条件的边各带一个 when。把它们单独抄出来,就是一张约束表:
| 边 | 条件 | 读作 |
|---|---|---|
tavern → counting_house | met_hui | 见过船帮把头,才进得了商会账房 |
chapel → watch_post | met_shen | 见过教会执事,才进得了守望者哨所 |
lighthouse_base → tide_cave | knows_tide_rule | 知道潮汐规律,才进得了潮汐洞窟 |
tide_cave → harbor_office | ledger_page | 手上有账页,才进得了港务所 |
market_row → drowned_bell | knows_bell_legend | 听过沉钟传说,才进得了沉钟塔 |
这 5 条约束有一个共同形状:条件引用的都是一个状态(flag 或 item),而这个状态必须在进入目标场景之前被产生。于是「解锁」的正确性问题就变成:每条约束引用的状态,能不能在进门之前拿到?
两种坏情况
顺序约束会以两种方式坏掉,两种都可以判定:
| 坏情况 | 症状 | 判定法 |
|---|---|---|
| 环 | A 等 B 的状态、B 等 A 的状态,两个门互相锁死 | DFS 三色法找后向边 |
| 钥匙锁在门后 | 进 Y 需要 X 的状态,而 X 只能在 Y 或其下游产生 | 生产者场景是否在入口前可达 |
第一种是结构病:有环的图根本没有「解锁顺序」这个概念,因为不存在一个拓扑序。第二种是设计病:图没有环,但某个门的钥匙被放在了门后面——玩家按设计路径走,永远凑不齐进门的条件。
S 层:环检测与解锁可达性
下面这段是 S 层脚本 sim/na_07_3_order_unlock.py。它从 dialogue 里建一张生产者映射(effects.set 写 flag、effects.grant 写 item,按对话节点的 scene 归到场景),然后对 5 条带条件边逐条判定:条件引用的状态,是否至少有一个生产者能在「进入目标场景之前」被走到(生产者从起点可达,且从生产者能走到目标场景)。
#!/usr/bin/env python3
# file: sim/na_07_3_order_unlock.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_07_3_order_unlock.py
"""卷一 7.3 顺序约束与解锁(S 层)。
带条件的边表达的是**顺序约束**:`lighthouse_base -> tide_cave` 的 when 是
`knows_tide_rule`,读作「先拿到潮汐规则,才进得了洞窟」。本节把 5 条带条件边折成
一张**约束表**,并做两项判定:
1. **环检测**:场景图是不是 DAG。有环则「解锁顺序」不可定义——A 要等 B 的状态、
B 又要等 A 的状态,两个门互相锁死。DFS 三色法找后向边;Kahn 算法顺带给出一个
拓扑序(无环时它与幕序一致)。
2. **不可达的解锁条件**:某条边的条件引用了 flag / item,而它只在**目标场景或
其后**才可能被产生——「要进去得先有钥匙,钥匙却在门后面」。判定法:该引用的
生产者场景里,是否至少有一个能在「进入目标场景之前」被走到(生产者从起点可达,
且从生产者能走到目标场景)。
生产者映射来自 `dialogue`:`effects.set` 写 flag、`effects.grant` 写 item;把每个对话
节点按 scene 归到场景,就得到「哪个场景会产出哪个 flag / item」。
产出 out/narr07_constraints.csv;有环或有不可达解锁条件即非零退出。
"""
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")
def cond_label(c) -> str:
if c is None:
return ""
k = c[0]
if k in ("flag", "item"):
return f"{k}:{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 cond_refs(c, acc) -> None:
"""收集条件里引用的 (kind, id):flag / item 是场景级锁,aff 不是。"""
if c is None:
return
if c[0] in ("flag", "item"):
acc.append((c[0], c[1]))
elif c[0] in ("and", "or"):
cond_refs(c[1], acc)
cond_refs(c[2], acc)
elif c[0] == "not":
cond_refs(c[1], acc)
def succ_map(graph):
succ = {n["id"]: [] for n in graph["nodes"]}
for e in graph["edges"]:
succ[e["from"]].append(e["to"])
return succ
def reach(succ, src):
seen, stack = {src}, [src]
while stack:
for v in succ[stack.pop()]:
if v not in seen:
seen.add(v)
stack.append(v)
return seen
def find_cycle(succ):
"""DFS 三色法:返回一条后向边构成的环,无环返回空列表。"""
color = {k: 0 for k in succ}
stack = []
def dfs(u):
color[u] = 1
stack.append(u)
for v in succ[u]:
if color[v] == 1:
return stack[stack.index(v):] + [v]
if color[v] == 0:
got = dfs(v)
if got:
return got
color[u] = 2
stack.pop()
return []
for u in succ:
if color[u] == 0:
got = dfs(u)
if got:
return got
return []
def topo_order(graph):
"""Kahn 算法:入度归零者按字典序入队,返回一个拓扑序。"""
succ = succ_map(graph)
indeg = {k: 0 for k in succ}
for u in succ:
for v in succ[u]:
indeg[v] += 1
order, ready = [], sorted(k for k, d in indeg.items() if d == 0)
while ready:
u = ready.pop(0)
order.append(u)
for v in succ[u]:
indeg[v] -= 1
if indeg[v] == 0:
ready.append(v)
ready.sort()
return order
def producers(spec):
"""(kind, id) -> 会产出它的场景集合。set 写 flag,grant 写 item。"""
scene_of = {n["id"]: n["scene"] for n in spec["dialogue"]}
prod = {}
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
eff = c.get("effects") or {}
for f in (eff.get("set") or {}):
prod.setdefault(("flag", f), set()).add(scene_of[n["id"]])
for it in (eff.get("grant") or {}):
prod.setdefault(("item", it), set()).add(scene_of[n["id"]])
return {k: sorted(v) for k, v in prod.items()}
def main():
with open(SPEC, encoding="utf-8") as fh:
spec = json.load(fh)
graph = spec["scene_graph"]
succ = succ_map(graph)
start = next(n["scene"] for n in spec["dialogue"] if n["id"] == spec["meta"]["start"])
cycle = find_cycle(succ)
order = topo_order(graph)
print(f"环检测:{'有环 ' + str(cycle) if cycle else '无环(DAG)'} 节点={len(graph['nodes'])}")
print(f"拓扑序(Kahn):{' -> '.join(order)}")
prod = producers(spec)
from_start = reach(succ, start)
rows, bad = [], 0
for e in graph["edges"]:
if not e.get("when"):
continue
refs = []
cond_refs(e["when"], refs)
for kind, rid in refs:
ps = prod.get((kind, rid), [])
ok_scenes = [p for p in ps if p in from_start and p != e["to"] and e["to"] in reach(succ, p)]
ok = bool(ok_scenes)
bad += 0 if ok else 1
rows.append({"edge": f"{e['from']}->{e['to']}", "cond": cond_label(e["when"]),
"ref_kind": kind, "ref_id": rid, "producer_scenes": "|".join(ps),
"unlock_before_entry": "|".join(ok_scenes), "ok": ok})
print(f"带条件边={sum(1 for e in graph['edges'] if e.get('when'))} 约束条目={len(rows)} 不可达解锁条件={bad}")
for r in rows:
print(f" [{'OK' if r['ok'] else 'FAIL'}] {r['edge']:<32} 需 {r['ref_kind']}:{r['ref_id']:<16} "
f"生产于 {r['producer_scenes']:<28} 入口前可满足于 {r['unlock_before_entry'] or '(无)'}")
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "narr07_constraints.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["edge", "cond", "ref_kind", "ref_id",
"producer_scenes", "unlock_before_entry", "ok"])
w.writeheader()
for r in rows:
w.writerow(r)
print(f"wrote out/narr07_constraints.csv rows={len(rows)}")
if cycle:
print(f"FAIL 场景图有环:{cycle}")
return 1
if bad:
print(f"FAIL 有 {bad} 个解锁条件在进入目标场景前无法满足")
return 1
print("OK na_07_3_order_unlock:场景图无环,5 条顺序约束的解锁条件都在入口前可满足")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_07_3_order_unlock.py:
环检测:无环(DAG) 节点=14
拓扑序(Kahn):pier -> chapel -> tavern -> counting_house -> watch_post -> lighthouse_base -> market_row -> drowned_bell -> tide_cave -> harbor_office -> lighthouse_top -> breakwater -> fog_gate -> dawn_quay
带条件边=5 约束条目=5 不可达解锁条件=0
[OK] tavern->counting_house 需 flag:met_hui 生产于 tavern 入口前可满足于 tavern
[OK] chapel->watch_post 需 flag:met_shen 生产于 chapel 入口前可满足于 chapel
[OK] lighthouse_base->tide_cave 需 flag:knows_tide_rule 生产于 harbor_office|lighthouse_base|pier 入口前可满足于 lighthouse_base|pier
[OK] tide_cave->harbor_office 需 item:ledger_page 生产于 counting_house 入口前可满足于 counting_house
[OK] market_row->drowned_bell 需 flag:knows_bell_legend 生产于 chapel|market_row|pier 入口前可满足于 chapel|market_row|pier
wrote out/narr07_constraints.csv rows=5
OK na_07_3_order_unlock:场景图无环,5 条顺序约束的解锁条件都在入口前可满足
同一趟写出的 out/narr07_constraints.csv:
edge,cond,ref_kind,ref_id,producer_scenes,unlock_before_entry,ok
tavern->counting_house,flag:met_hui==1,flag,met_hui,tavern,tavern,True
chapel->watch_post,flag:met_shen==1,flag,met_shen,chapel,chapel,True
lighthouse_base->tide_cave,flag:knows_tide_rule==1,flag,knows_tide_rule,harbor_office|lighthouse_base|pier,lighthouse_base|pier,True
tide_cave->harbor_office,item:ledger_page==1,item,ledger_page,counting_house,counting_house,True
market_row->drowned_bell,flag:knows_bell_legend==1,flag,knows_bell_legend,chapel|market_row|pier,chapel|market_row|pier,True
判读:无环、无锁死的门
两项判定都通过了,但通过的方式值得逐条看:
| 约束 | 生产者 | 入口前可满足于 | 读法 |
|---|---|---|---|
tavern → counting_house | tavern | tavern | 钥匙就在门边:把头本人给 met_hui |
chapel → watch_post | chapel | chapel | 同上,执事给 met_shen |
lighthouse_base → tide_cave | pier / lighthouse_base / harbor_office | pier / lighthouse_base | 三条路能拿到潮汐规律,最早在码头 |
tide_cave → harbor_office | counting_house | counting_house | 账页在第一夜的账房拿,先于第二夜的洞窟 |
market_row → drowned_bell | pier / chapel / market_row | 三处都可 | 沉钟传说三个场景都能听到 |
三点值得记:
- 第一、二条是「门边给钥匙」:
met_hui在tavern场景内产生,而tavern正是这条边的起点。这是最常见的解锁形态——先在同一个场景里和 NPC 打交道拿到状态,再从同一场景走出去。 - 第三条有多个生产者:
knows_tide_rule能在pier(问少年)、lighthouse_base(用潮汐表推算)、harbor_office(问十七年前)三处拿到。判定结果lighthouse_base|pier说明前两处都在入口前可达,第三条路(harbor_office)在洞窟下游,对这条边没用——但它对别的路径有用。同一个状态有多个生产者,是解锁设计里的冗余,不是缺陷。 - 第四条是跨幕解锁:
ledger_page在第一夜的counting_house拿到,tide_cave → harbor_office这条边在第二夜——钥匙早于门整整一幕。这类「长距离解锁」最容易出问题(钥匙和门隔得远,容易忘),脚本把它标成 OK 的依据是「生产者在入口前可达」,而不是「生产者离门近」。
至于环:DFS 三色法一条后向边都没找到,Kahn 算法也顺利吐出了全部 14 个节点的拓扑序。这条拓扑序与幕序一致(第一夜 → 第二夜 → 第三夜),说明场景图是一张按幕分层的 DAG。
自检表
| 检查项 | 合格标准 |
|---|---|
| 环检测有实现 | 用 DFS 三色法找后向边,并用 Kahn 算法交叉验证拓扑序 |
| 解锁判定有依据 | 判定用「生产者从起点可达且能走到目标场景」,不靠场景名相似 |
| 生产者从规格来 | 从 dialogue 的 effects 反推,不手写映射表 |
| 多生产者不误判 | 只要有一个生产者入口前可达即算满足 |
| 口径诚实 | 声明判定是「存在一条解锁路径」,不是「每一局都解锁」 |
场景层的三节到此为止:7.1 把场景写成图,7.2 判可达,7.3 判顺序。这三件事合起来回答了「这张场景图自不自洽」。但场景图只说了「走到哪里」,没说「在那里分了几条岔、又在哪里汇回来」——那是分支拓扑的事,第 8 章接着写。
阅读导航:上一节:7.2 场景的可达性 · 下一节:8.1 分支拓扑:分叉、汇聚、并行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。