站内那篇独立游戏的叙事设计与分支对话 (约 1076 行)是散文式的叙事经验:分支怎么写、好感度怎么调、选择怎么做,配的是清单与示意图,代码不可跑。本章把同一批问题变成可算的数——「一次通关」不再是读者脑补的一段经历,而是一条可以被编码、被存储、被逐位比对的路径。这是本卷的第一个基本对象,后面所有度量(覆盖率、结局分布、张力曲线)都挂在它这根轴上。
卷一把故事还原成结构,卷二把结构还原成可执行的引擎;这一卷要做的是把执行出来的体验还原成可度量的数。度量的第一步不是选指标,而是先想清楚一件事:「玩家走了一趟」这件事,到底由哪些数据构成。
一次通关由三段数据构成
「玩家通关了一次」听起来像一段经历,但在引擎里它只是三串数据。把这三串分开,是本卷全部度量能成立的前提。
| 表示 | 是什么 | 长度 | 谁产生 |
|---|---|---|---|
选择序列 choices | 玩家按下的一串按钮(choice_id) | 25 步 | 玩家 |
节点序列 nodes | 引擎走出来的一串位置(node_id) | 26 个 | 引擎 |
状态轨迹 states | 每一步之后的完整状态快照 | 26 个 | 引擎 |
三者的关系是刚性的:节点数 = 选择数 + 1。每按一次按钮,就从前一个节点跳到后一个节点,起点那个节点没有对应的按钮,所以节点总是比选择多一个。这不是约定,是可断言的整数不变量——脚本会在末尾直接打印它是否成立。
为什么三种表示缺一不可
三种表示不是同一件事的三种写法,它们各自回答一个不同的问题,也各自喂给一组不同的指标。
- 选择序列回答「玩家做了什么」。它是唯一的外部输入——只要给它一串
choice_id,引擎的推进就被完全决定。分支覆盖率(走过的对话边 / 全部对话边)算的就是它。 - 节点序列回答「故事走到了哪」。它比选择序列短一截(少一个起点之外的重复),是场景覆盖、节拍覆盖、结局判定直接吃的表示。
- 状态轨迹回答「一路上变成了什么样」。好感度曲线、flag 置位序列、物品持有变化,全部只存在于它里面——节点序列看不见这些。
换句话说,只看节点序列会丢掉状态,只看选择序列会丢掉位置。要让「一次通关」可度量,三者都得留,而且要在同一趟推进里对齐。
选择序列与节点序列
选择序列是玩家视角:c_pier_1a、c_tavern_2a…… 一串选项 id。节点序列是引擎视角:d_pier_1、d_pier_2…… 一串对话节点 id。
两者不是一一对应的。整份规格里 48 个对话节点、115 条选项,但去重后的唯一跳转只有 56 条——多条不同的选项会汇到同一个后继节点(这是卷二 4.1 实测过的 115 → 56 汇聚)。所以「按了哪些按钮」和「走过了哪些位置」是两种不同的表示:前者记录玩家做了什么,后者记录故事走到了哪。本卷两个都要,因为不同的指标吃不同的表示——覆盖率算的是边(选择),场景与节拍覆盖算的是节点。
状态轨迹:每一步之后发生了什么
状态是一个纯数据快照,只有五个字段。它不含对象引用、不含随机数生成器,因此可以被完整序列化、逐字段比对、算摘要。
| 字段 | 含义 | 初值 |
|---|---|---|
node | 当前停在哪个对话节点 | spec.meta.start(d_pier_1) |
aff | 四个好感度变量(整数 0–100) | 各角色 affinity.init |
flags | 18 个布尔 flag | 全 0 |
items | 9 件线索物品的持有量 | 全 0 |
path | 走过的节点序列 | [起点] |
状态轨迹就是把这条路径上每一个中间状态都留下来,而不是只留终局。它比「节点序列」更重:节点序列只记位置,状态轨迹还记着「走到这个位置时,好感度是多少、哪些 flag 亮了、手里有几件线索」。后面第 5 章的张力曲线、第 8 章的好感度曲线,吃的都是状态轨迹。
S 层:把一条路径摊开
sim/nm_01_1_trajectory.py 用「总是选第一个可用选项」的确定性策略走完一条路径,逐步骤打印状态轨迹:每步给出进入前的节点、所选选项、四个好感度、置位 flag 数、持有物品数,以及该步之后的 FNV-1a 状态摘要。推进与摘要复用卷一 sim/na_02_digest.py(只读 import,不改动它)。
#!/usr/bin/env python3
# file: sim/nm_01_1_trajectory.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_01_1_trajectory.py
"""卷三 1.1 一次通关的轨迹(S 层):把一次通关拆成三种表示。
选择序列 choices = [choice_id, ...] 玩家按下的一串按钮
节点序列 nodes = [node_id, ...] 引擎走出来的一串位置(比 choices 多 1)
状态轨迹 states = [state, ...] 每一步之后的完整状态快照
用「总是选第一个可用选项」的确定性策略走完一条路径(与 ne_00 / nm_00 同策略),
逐步打印状态轨迹与步后摘要,落 out/nm_01_1_trajectory.json。
推进与摘要复用卷一 sim/na_02_digest.py(只读 import,不改动它)。
全部整数,不出现浮点;digest 只对状态做,不对文本做。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from na_02_digest import cond_ok, init_state, node_by_id, apply_choice, digest # noqa: E402
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
JSON_PATH = os.path.join(OUT, "nm_01_1_trajectory.json")
AFF_LO, AFF_HI = 0, 100
VARS = ["yan", "hui", "shen", "mu"]
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def walk(spec: dict) -> list[dict]:
"""确定性策略:每步选第一个条件成立的选项。返回逐步骤记录。"""
s = init_state(spec)
rows = [{"step": 0, "choice": "(起点)", "node": s["node"], "state": s}]
for i in range(64):
cur = node_by_id(spec, s["node"])
if cur.get("ending"):
break
opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
if not opts:
break
s = apply_choice(spec, s, opts[0]["id"])
rows.append({"step": i + 1, "choice": opts[0]["id"], "node": s["node"], "state": s})
return rows
def main() -> int:
spec = load_spec()
rows = walk(spec)
final = rows[-1]["state"]
choices = [r["choice"] for r in rows[1:]]
nodes = [r["node"] for r in rows]
scenes = {node_by_id(spec, nid)["scene"] for nid in nodes}
beats = [b for b in spec["beats"] if b["scene"] in scenes]
print(f"spec={spec['meta']['title']} 起点 {spec['meta']['start']} 策略=首选项")
print(f"选择序列({len(choices)} 步):{' '.join(choices)}")
print(f"节点序列({len(nodes)} 个):{' '.join(nodes)}")
print("轨迹 进入节点 事件(选项) yan hui shen mu flag item 步后摘要")
for r in rows:
st = r["state"]
aff = " ".join(f"{st['aff'][v]:>3}" for v in VARS)
nfl = sum(1 for v in st["flags"].values() if v)
nit = sum(1 for v in st["items"].values() if v)
print(f"{r['step']:>4} {r['node']:<14} {r['choice']:<18} {aff} {nfl:>4} {nit:>4} {digest(st):>10}")
print(f"三条长度 选择 {len(choices)} 步 / 节点 {len(nodes)} 个 / 状态 {len(rows)} 个")
print(f"节点 = 选择 + 1:{len(nodes) == len(choices) + 1}")
print(f"终局 节点 {final['node']} 结局 {node_by_id(spec, final['node']).get('ending')}")
print(f"覆盖场景 {len(scenes)} / {len(spec['scene_graph']['nodes'])}")
print(f"覆盖节拍 {len(beats)} / {len(spec['beats'])}")
bad = sum(1 for r in rows for v in r["state"]["aff"].values() if not (AFF_LO <= v <= AFF_HI))
print(f"好感度越界 {bad}")
if bad or len(nodes) != len(choices) + 1:
print("FAIL 轨迹不变量")
return 1
os.makedirs(OUT, exist_ok=True)
with open(JSON_PATH, "w", encoding="utf-8") as fh:
json.dump({
"start": spec["meta"]["start"], "choices": choices, "nodes": nodes,
"steps": [{"step": r["step"], "choice": r["choice"], "node": r["node"],
"digest": digest(r["state"])} for r in rows],
"final_node": final["node"],
"final_ending": node_by_id(spec, final["node"]).get("ending"),
"final_digest": digest(final),
"scenes_covered": len(scenes), "scenes_total": len(spec["scene_graph"]["nodes"]),
"beats_covered": len(beats), "beats_total": len(spec["beats"]),
}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"wrote out/nm_01_1_trajectory.json final_digest={digest(final)}")
print(f"OK nm_01_1_trajectory:一次通关的三种表示已展开,终局摘要 {digest(final)}")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_01_1_trajectory.py:
spec=雾港夜航 起点 d_pier_1 策略=首选项
选择序列(25 步):c_pier_1a c_pier_2a c_tavern_1a c_tavern_2a c_tavern_3a c_hub_a1a c_ch_1a c_ch_2a c_ch_3a c_hub_a2a c_lb_1a c_lb_2a c_lb_3a c_tc_1a c_tc_2a c_tc_3a c_ho_1a c_ho_2a c_ho_3a c_hub_b2a c_lt_1b c_lt_2a c_lt_3a c_dq_1c c_dq_2a
节点序列(26 个):d_pier_1 d_pier_2 d_tavern_1 d_tavern_2 d_tavern_3 d_hub_a1 d_ch_1 d_ch_2 d_ch_3 d_hub_a2 d_lb_1 d_lb_2 d_lb_3 d_tc_1 d_tc_2 d_tc_3 d_ho_1 d_ho_2 d_ho_3 d_hub_b2 d_lt_1 d_lt_2 d_lt_3 d_dq_1 d_dq_2 e_lit
轨迹 进入节点 事件(选项) yan hui shen mu flag item 步后摘要
0 d_pier_1 (起点) 20 50 50 50 0 0 4127714464
1 d_pier_2 c_pier_1a 20 50 50 50 2 0 3085224634
2 d_tavern_1 c_pier_2a 20 50 50 50 3 0 3641975660
3 d_tavern_2 c_tavern_1a 20 55 50 50 4 0 2314516345
4 d_tavern_3 c_tavern_2a 20 60 50 50 4 0 649771119
5 d_hub_a1 c_tavern_3a 20 60 50 50 4 1 3389763485
6 d_ch_1 c_hub_a1a 20 60 50 50 4 1 3040035912
7 d_ch_2 c_ch_1a 20 60 50 50 5 1 3406712909
8 d_ch_3 c_ch_2a 20 60 50 55 5 1 760222539
9 d_hub_a2 c_ch_3a 20 60 50 55 5 2 537685670
10 d_lb_1 c_hub_a2a 20 60 50 55 6 2 168581929
11 d_lb_2 c_lb_1a 20 60 50 55 6 2 253695422
12 d_lb_3 c_lb_2a 25 60 50 55 6 2 907750077
13 d_tc_1 c_lb_3a 25 60 50 55 6 2 3255935952
14 d_tc_2 c_tc_1a 25 60 50 55 7 2 3751148729
15 d_tc_3 c_tc_2a 35 60 50 55 7 2 2928125429
16 d_ho_1 c_tc_3a 35 60 50 55 8 3 1130405027
17 d_ho_2 c_ho_1a 35 60 50 60 8 3 4150811831
18 d_ho_3 c_ho_2a 35 60 50 65 8 3 2318798367
19 d_hub_b2 c_ho_3a 35 60 50 65 8 4 3800715304
20 d_lt_1 c_hub_b2a 35 60 50 65 8 4 241919427
21 d_lt_2 c_lt_1b 45 60 50 65 8 4 1087722381
22 d_lt_3 c_lt_2a 55 60 50 65 8 4 2427991916
23 d_dq_1 c_lt_3a 65 60 50 65 8 4 3535535470
24 d_dq_2 c_dq_1c 65 60 50 65 8 4 748181400
25 e_lit c_dq_2a 65 60 50 65 8 4 4162796975
三条长度 选择 25 步 / 节点 26 个 / 状态 26 个
节点 = 选择 + 1:True
终局 节点 e_lit 结局 灯守
覆盖场景 9 / 14
覆盖节拍 11 / 16
好感度越界 0
wrote out/nm_01_1_trajectory.json final_digest=4162796975
OK nm_01_1_trajectory:一次通关的三种表示已展开,终局摘要 4162796975
判读:三段数据各说明了什么
选择 25 步、节点 26 个、状态 26 个,节点数 = 选择数 + 1 成立。 这条不变量是三条表示之间唯一的硬约束:只要它不成立,就说明「路径」和「按钮」在某一步对不上了,后面任何基于它们的度量都不必看。脚本把它当成退出码的一部分。
终局摘要 4162796975 与卷二 1.1 逐位相同。 这不是巧合——同一个策略、同一份规格、同一个 FNV-1a 算法,终局状态就该是同一个 32 位整数。这条路径本身也就是目录篇里那条「覆盖 9 / 14 场景、11 / 16 节拍」的路径:它只走过了 9 个场景、11 个节拍,只到达 1 个结局。单条路径的形状,从这一节开始就是可数的。
好感度是离散跳跃,不是均匀累加。 起点 yan=20,直到第 12 步(d_lb_2)才升到 25,第 21 步跳到 45、第 23 步到 65;而 hui 在第 3、4 步就连升两次到 60,此后不再动。哪一步踩到带 add 的选项,哪一步才变——这解释了为什么第 8 章要按「曲线形状」而不是「平均值」来分类。
每一步的摘要都不同。 26 行里没有两行摘要相等,说明摘要对状态变化是敏感的。这是下一节「路径的编码」能成立的前提:如果摘要对变化不敏感,它就没法用来判两条路径是否相同。
这里的每一个数都是 proxy 口径下的结构量——覆盖率、节拍覆盖、摘要,只用来做相对比较,不声称「这条路径体验更好」。本卷通篇如此。
自检表
| 检查项 | 合格标准 | 本节实测 |
|---|---|---|
| 三种表示齐全 | 选择序列 / 节点序列 / 状态轨迹各有定义 | 三张表 |
| 长度不变量 | 节点数 = 选择数 + 1 | 26 = 25 + 1 |
| 状态是纯数据 | 五字段可序列化、可摘要 | json.dump 成功 |
| 摘要只覆盖状态 | 由节点、好感度、flag、物品、路径算出 | 不含文本 |
| 整数不变量 | 好感度全程 ∈ [0,100] | 越界 0 |
| 说法诚实 | 覆盖数只对这条路径声明,不外推 | 9 / 14、11 / 16 |
三种表示立起来之后,下一个问题是:怎么把一条路径压成一个可存储、可比较的编码——规范串怎么排、能不能压成整数元组、摘要到底对什么做。下一节把这三种编码写出来,并证明它们在同一条路径上跨语言逐位一致。
阅读导航:上一节:目录 · 下一节:1.2 路径的表示与编码 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。