站内的独立游戏的叙事设计与分支对话 (1076 行)用散文写叙事:先讲一段故事梗概,再讲这段梗概怎么拆成分支,最后给一份人工维护的清单。本节接过它的最后一棒,回答它没有回答的问题:那段散文梗概,究竟要变成哪些字段,才能被程序读? 那篇是散文式叙事经验,本节是形式化的规格字段——同一段故事,前者的产物是给人读的段落,后者的产物是可以逐项对账、可以穷举判定的结构。
上一节把叙事拆成四个最小构成,这一节看它们在文件里怎么排布。答案是一份 config/story_spec.json:一个 meta 头部,加九段内容字段。为什么是一份而不是四份?因为四个构成之间互相引用——选项引用对话节点,对话节点引用场景,场景引用地点,效果引用角色。分成四份文件,引用就变成了跨文件跳转,一致性再也无法在单次解析里校验。
1.2 从故事到结构:叙事规格的骨架
先看散文。下面是《雾港夜航》的一段大纲,写法与任何一份叙事设计文档没有区别:
雾港被浓雾与潮汐围困,三夜一循环:潮涨雾锁,潮退雾薄。灯塔守望者砚在三夜前登塔,此后没人见过他,灯也再没亮过。船帮把头徽三天前拒了他要的一条船。教会执事沈私下修好了沉钟的机括,却不敢鸣。玩家扮演抵达雾港的信使,在三夜之间做选择:点灯、鸣钟、还是什么也不做。雾散之后,会分出五种结局。
这段话不可执行:它没有说「点灯」需要什么前置,也没有说「雾散之后」按什么判定分结局。把它形式化,就是给每句话找到它该去的字段。
散文到字段:逐句映射
| 散文里的那句话 | 落到哪个字段 | 变成了什么 |
|---|---|---|
| 「三夜一循环:潮涨雾锁,潮退雾薄」 | world.lore[lore_tide] + reveal_flag | 一条可被揭示的设定,揭示动作由 flag 记录 |
| 「砚在三夜前登塔,此后没人见过他」 | characters[yan].secret + beats[b01] | 角色的秘密字段 + 一个节拍 |
| 「徽三天前拒了他要的一条船」 | characters[hui].secret + dialogue[d_tavern_2] | 秘密落在角色上,事实落在某句台词里 |
| 「沈修好了机括,却不敢鸣」 | characters[shen].secret + endings[e_truth].when | 秘密 + 一条结局的判定条件 |
| 「玩家在三夜之间做选择」 | dialogue[].choices[] | 每个选项带 when / effects / goto |
| 「点灯、鸣钟、还是什么也不做」 | dialogue[d_dq_1].choices[] | 三个并列选项,各自的 effects 不同 |
| 「雾散之后,会分出五种结局」 | endings[] + 5 个结局节点 | 5 条判定条件 + 5 个无选项节点 |
这张表就是形式化的全部内容:散文里的每一句断言,要么变成一个标识符(yan / d_dq_1),要么变成一个结构量(beats[] 的一条、choices[] 的一项),要么变成一个条件(when)。凡是落不到这三者之一的句子,都是规格之外的散文——留着给人读,程序不看。
| 落不到规格的句子类型 | 为什么 | 该去哪 |
|---|---|---|
| 「这一段读起来很压抑」 | 是对文本的评价,不是结构 | 本套不做(见 1.3 的文本创作划界) |
| 「玩家到这儿会觉得愧疚」 | 是对体验的断言,需要真人验证 | 卷三第 12 章,且标 X |
| 「砚的嗓音应该是沙哑的」 | 是演出参数,不是叙事结构 | 美术与音频管线 |
| 「这三夜应该再紧一点」 | 是相对判断,没有绝对基准 | 卷三的节奏与密度度量 |
骨架:一个头部加九段
| 段 | 类别 | 承载什么 | 散文里对应的说法 |
|---|---|---|---|
meta | 头部 | 标题、工程名、起点节点、幕数与幕名、scale 规模声明 | 「这是一个三夜的故事」 |
world | 内容段 | 派系与立场矩阵、地点与解锁条件、设定条目、世界规则 | 「雾港是个什么样的地方」 |
characters | 内容段 | 目标、恐惧、秘密、好感度、弧光 | 「谁在这里,他想要什么」 |
beats | 内容段 | 节拍与 tension 标注 | 「故事分几段,哪里最紧」 |
scene_graph | 内容段 | 场景节点、边、入口条件 | 「他先到码头,才能去酒馆」 |
dialogue | 内容段 | 对话节点、台词、选项、跳转 | 「他说了什么,你选了什么」 |
conditions | 内容段 | 条件文法声明:算子与六种形式 | 散文里没有对应 |
state | 内容段 | 18 个 flag、4 个好感度变量、9 件线索物品 | 「他记住了什么,跟谁熟了,拿了什么」 |
endings | 内容段 | 结局标识与判定条件 | 「有五种结局」 |
metrics | 内容段 | 目标张力曲线与各项阈值 | 「这一段的紧张感应该是 4」 |
两点值得单独指出。
其一,conditions 段在散文里找不到对应。 它是纯声明(grammar + 4 个算子 + 6 种形式),自己不承载任何内容,却规定了其它段里所有 when 字段的合法形状。这是形式化多出来的东西——散文不需要声明自己的文法,因为它本来就没有文法。
其二,meta 不是内容段,它是头部。 它唯一的实质内容是 scale:9 个声明出来的规模数字。它是整份规格里唯一被自动对账的声明(上一节的脚本正是拿它逐项比对实测值)。头部里放声明、内容段里放事实,这条分界让「规格说自己有多大」与「规格实际有多大」可以被程序分开。
S 层:按实际键序逐段扫描
sim/na_01_skeleton.py 做三件事:按 JSON 里的实际键序遍历顶层(骨架不靠人工记忆)、每段打印一行由字段长度直接得出的摘要、校验九段内容字段齐备且非空——缺段即 FAIL 并 exit 1。
#!/usr/bin/env python3
# file: sim/na_01_skeleton.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_01_skeleton.py
"""叙事规格的骨架扫描:按顶层字段顺序逐段打印规模摘要。
骨架 = `meta` 头部 + 九段内容字段。本脚本只做三件事:
1) 按 JSON 里的**实际键序**遍历顶层,证明骨架不靠人工记忆;
2) 每段给一组**由字段长度直接得出**的计数;
3) 校验九段内容字段全部存在且非空——缺段即 FAIL(exit 1)。
不解析条件语义、不做可达性——那是 na_00 / 后续章节的事。
"""
from __future__ import annotations
import json
import os
import sys
# 九段内容字段。meta 不在其中:它是头部(标题 / 幕数 / 起点 / 规模声明)。
CONTENT_SEGMENTS = [
"world", "characters", "beats", "scene_graph", "dialogue",
"conditions", "state", "endings", "metrics",
]
def load_spec() -> dict:
here = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(here, "../config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def summarize(spec: dict) -> dict[str, str]:
"""每段一行摘要。全部是长度或直接读取的标量,不含任何推导量。"""
meta = spec["meta"]
world = spec["world"]
chars = spec["characters"]
beats = spec["beats"]
graph = spec["scene_graph"]
dlg = spec["dialogue"]
conds = spec["conditions"]
state = spec["state"]
endings = spec["endings"]
met = spec["metrics"]
tension = [b["tension"] for b in beats]
return {
"meta": (
f"HEADER title={meta['title']} acts={meta['acts']} start={meta['start']} "
f"scale={len(meta['scale'])} 项"
),
"world": (
f"factions={len(world['factions'])} locations={len(world['locations'])} "
f"lore={len(world['lore'])} rules={len(world['rules'])}"
),
"characters": (
f"count={len(chars)} goals={sum(len(c['goals']) for c in chars)} "
f"fears={sum(len(c['fears']) for c in chars)} arcs={sum(len(c['arc']['stages']) for c in chars)}"
),
"beats": (
f"count={len(beats)} tension_sum={sum(tension)} "
f"tension_min={min(tension)} tension_max={max(tension)}"
),
"scene_graph": (
f"nodes={len(graph['nodes'])} edges={len(graph['edges'])} "
f"with_cond={sum(1 for e in graph['edges'] if e.get('when'))}"
),
"dialogue": (
f"nodes={len(dlg)} choices={sum(len(n.get('choices') or []) for n in dlg)} "
f"ending_nodes={sum(1 for n in dlg if n.get('ending'))} "
f"cond_lines={sum(1 for n in dlg if n.get('line_when'))}"
),
"conditions": (
f"grammar={conds['grammar']} ops={len(conds['ops'])} forms={len(conds['forms'])}"
),
"state": (
f"flags={len(state['flags'])} vars={len(state['vars'])} items={len(state['items'])}"
),
"endings": (
f"count={len(endings)} guarded={sum(1 for e in endings if e.get('when'))} "
f"unguarded={sum(1 for e in endings if not e.get('when'))}"
),
"metrics": (
f"curve_len={len(met['target_tension_curve'])} "
f"coverage_target={met['coverage_target_permille']} "
f"entropy_min={met['ending_entropy_min_milli_bits']} "
f"aff_mae_max={met['affinity_mae_max']} tension_mae_max={met['tension_mae_max']}"
),
}
def main() -> int:
spec = load_spec()
rows = summarize(spec)
print("段 类别 摘要")
for i, key in enumerate(spec.keys()):
kind = "HEADER" if key == "meta" else "SEG"
print(f"{key:<13} {kind:<7} {rows[key]}")
problems: list[str] = []
# 键序断言:JSON 的顶层键序必须与文件里声明的骨架一致(meta 打头)。
if list(spec.keys())[0] != "meta":
problems.append("meta 不在顶层第一位")
for seg in CONTENT_SEGMENTS:
if seg not in spec:
problems.append(f"缺内容段 {seg}")
elif not spec[seg]:
problems.append(f"内容段 {seg} 为空")
extra = [k for k in spec if k != "meta" and k not in CONTENT_SEGMENTS]
if extra:
problems.append(f"骨架外的顶层键:{extra}")
print(f"骨架:meta 头部 1 段 + 内容段 {len(CONTENT_SEGMENTS)} 段,顶层键共 {len(list(spec.keys()))}")
for p in problems:
print(f"FAIL {p}")
if problems:
return 1
print("OK na_01_skeleton:九段内容字段齐备且非空")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_01_skeleton.py:
段 类别 摘要
meta HEADER HEADER title=雾港夜航 acts=3 start=d_pier_1 scale=9 项
world SEG factions=4 locations=14 lore=6 rules=4
characters SEG count=6 goals=12 fears=12 arcs=18
beats SEG count=16 tension_sum=52 tension_min=1 tension_max=5
scene_graph SEG nodes=14 edges=16 with_cond=5
dialogue SEG nodes=48 choices=115 ending_nodes=5 cond_lines=2
conditions SEG grammar=nested-array ops=4 forms=6
state SEG flags=18 vars=4 items=9
endings SEG count=5 guarded=4 unguarded=1
metrics SEG curve_len=16 coverage_target=850 entropy_min=1500 aff_mae_max=12 tension_mae_max=1
骨架:meta 头部 1 段 + 内容段 9 段,顶层键共 10
OK na_01_skeleton:九段内容字段齐备且非空
判读:骨架告诉了我们什么
十段里只有一段是声明,九段是事实。 meta.scale 的 9 个数字是对账用的;world 到 metrics 的九段是事实本身。这条分界带来一个直接的好处:任何一次「规格改小了但声明没改」都会被一行比对抓住。
骨架里没有一段是散文。 九段内容字段的每一段,要么是标识符表(world.locations、state.flags),要么是结构表(beats[]、scene_graph.nodes[]),要么是条件文法(conditions)。文本只出现在一个地方——dialogue[].line 与 choices[].text——而它们的地位是渲染产物,这正是下一节的主题。
conditions 段的自指性质值得留意。 它只有 6 种形式(flag / aff / item / and / or / not)与 4 个算子(>= / < / == / !=),却规定了五处字段的合法形状。改一处文法,五处同时受影响——这是第 9 章要展开的对象。
| 消费条件文法的字段 | 槽位个数 | 判定的是 |
|---|---|---|
world.locations[].unlock_when | 7 | 这个地点能不能进 |
scene_graph.edges[].when | 5 | 这条场景边能不能走 |
dialogue[].line_when | 2 | 这句台词渲染哪一条 |
choices[].when | 34 | 这个选项出不出现 |
endings[].when | 4 | 这条结局成不成立 |
五处相加是 52 个条件槽位,它们共用同一份文法与同一个求值器。这也是为什么条件文法必须单独成段:它不是某一段的内部细节,而是跨五段的公共契约。
endings 段有 4 条带条件、1 条无条件。 无条件的 e_dark(离港)是兜底结局,它保证任何一条路径都能走到终点。这条「至少一个无条件结局」的约定会在第 8 章变成结局可达性的判据。
自检表
| 检查项 | 合格标准 |
|---|---|
| 九段齐备且非空 | world … metrics 九段都在,且都不是空容器 |
| 头部唯一 | meta 在顶层第一位,且不被算进九段 |
| 无骨架外字段 | 顶层键恰好 10 个,多一个即 FAIL |
| 规模声明可对账 | meta.scale 的 9 项与实测逐项一致 |
| 映射可追 | 散文里的每句断言都能指到具体字段(或显式标为「规格之外」) |
骨架立起来之后,还剩一个位置没交代清楚:dialogue[].line 里的那些中文句子,在规格里到底算什么?下一节给出本套的核心主张——文本是规格的一种渲染产物,并演示同一条规格记录怎么渲染出两种台词。
阅读导航:上一节:1.1 世界、角色、事件、选择 · 下一节:1.3 文本是渲染:规格与文本的分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。