站内的独立游戏的叙事设计与分支对话 (1076 行)是一篇散文式的叙事经验谈:讲分支对话怎么写、讲玩家的选择心理、讲一份靠人工维护的分支清单,代码是 Twine 片段、跑不起来。本节不重复那些经验,而是把叙事形式化成规格字段——让「这个世界有几个派系」「这个角色的秘密是什么」「这个选项在什么条件下才出现」都变成能从文件里读出来、能逐项对账、能穷举判定的事实。分工就在这里:那篇回答「怎么写得好」,本卷回答「怎么写成一个能被程序读的结构」。
这是全书的开篇,先把贯穿三卷的案例讲清楚。《雾港夜航》(工程名 inkwell)是一个互动叙事的最小完整案例:雾港被浓雾与潮汐围困,灯塔守望者「砚」在三夜前失踪;玩家扮演抵达雾港的信使,在三夜之间于 6 个角色、4 个派系之间做出选择,收集线索、推进关系,最终分岔到 5 个结局。它的全部结构写在只读真源 config/story_spec.json 里,S / P / W 三层读的是同一个文件。
1.1 世界、角色、事件、选择
任何一段叙事,只要它要被程序执行,就必然能被拆成四个最小构成。这不是分类学,而是可执行性的下限:四个构成里少一个,故事就推不动。没有世界的选择无从渲染,没有角色的世界无从承载,没有事件的角色无从变化,没有选择的故事则根本不需要被写成规格——它只是一段文本。
| 构成 | 一句话 | 在 config/story_spec.json 里长在哪 |
|---|---|---|
| 世界 | 故事建立在哪些名词之上 | world.factions / world.locations / world.lore / world.rules |
| 角色 | 谁会因为事件而改变 | characters[](目标 / 恐惧 / 秘密 / 好感度 / 弧光) |
| 事件 | 什么在什么时间发生 | meta.acts + beats[] + scene_graph + dialogue[] |
| 选择 | 玩家在哪里改变方向 | dialogue[].choices[](when + effects + goto) |
这四个构成在规格里不是四块平级的 JSON,而是互相引用的一张网:dialogue[].scene 指向 scene_graph.nodes[].id,scene_graph.nodes[].location 指向 world.locations[].id,dialogue[].speaker 指向 characters[].id,choices[].goto 指向另一个 dialogue[].id。本节只把四者各自有多大数清楚;这些引用是否闭合,留给第 7 章(场景图可达性)与第 13 章(结构校验)。
世界:能被引用的名词
世界是叙事里唯一不随时间变化的部分。它的作用不是「氛围」,而是提供一组稳定的标识符,让别处可以引用它们。
| 项 | 数量 | 说明 |
|---|---|---|
| 派系 | 4 | 船帮 guild / 教会 church / 商会 merchant / 守望者工会 watch |
| 派系立场矩阵 | 16 格 | 每个派系对 4 个派系各持一个 −1 / 0 / +1 的立场 |
| 地点 | 14 | 按 act 分属三夜:第一夜 5 个、第二夜 5 个、第三夜 4 个 |
| 带解锁条件的地点 | 7 | 其余 7 个无条件可进 |
设定 lore / 规则 rules | 6 / 4 | lore 每条绑定一个 reveal_flag;rules 是潮禁、鸣钟、船帮、教会四条世界律 |
14 个地点里有 7 个带 unlock_when——这意味着一半的地点不是「走到就能进」,而是要满足某个 flag 或持有某件线索。地点因此不只是布景,它是条件系统的一个作用域:world.locations[].unlock_when 与 scene_graph.nodes[].enter_when 用的是同一套条件文法。
角色:目标、恐惧、秘密、弧光
角色是叙事里唯一会因为事件而改变的部分。规格给每个角色四样东西:他想干什么(goals)、他怕什么(fears)、他瞒着什么(secret)、他会怎么变(arc)。
| 项 | 数量 | 在规格里 |
|---|---|---|
| 角色 | 6 | characters[]:砚 / 徽 / 沈 / 沐 / 林 / 孩 |
| 目标 | 12 | 每角色 2 条 goals |
| 恐惧 | 12 | 每角色 2 条 fears |
| 秘密 | 6 | 每角色 1 条 secret |
| 弧光阶段 | 18 | 每角色 3 段 arc.stages |
| 弧光锚点节拍 | 18 | 每角色 3 个 arc.beats,指向 beats[] |
| 好感度阈值 | 18 | 每角色 3 档 thresholds(30 / 60 / 85) |
这里有一处需要说清楚:6 个角色都有 affinity 声明,但只有 4 个进入 state.vars(yan / hui / shen / mu)。林与孩的好感度声明存在,却没有任何一个选项 add 过它们——声明域比状态域大。这不是错误,但它是第 13 章一致性校验会读到的一类残差,本节先把事实摆出来。
事件:幕、节拍、场景、对话节点
事件是「在什么时间发生了什么」。规格用四层粒度写同一根时间轴,粒度从粗到细各有各的用处。
| 层 | 数量 | 粒度与用处 |
|---|---|---|
幕 acts | 3 | meta.act_names:第一夜 抵港 / 第二夜 潮退 / 第三夜 灯灭 |
节拍 beats[] | 16 | 作者标注的结构单位,每条带一个 1–5 的 tension,之和为 52 |
场景节点 scene_graph.nodes[] | 14 | 可达性的单位:能不能走到这个场景 |
场景边 scene_graph.edges[] | 16 | 11 条无条件 + 5 条带 when |
对话节点 dialogue[] | 48 | 执行的单位:引擎停在这里、渲染台词、给出选项 |
四层的关系是嵌套而非并列:3 幕切分 16 节拍,节拍落到 14 个场景,场景里展开 48 个对话节点。beats[] 的 tension 是作者标注的结构量,不是从别处算出来的——卷三算张力曲线时读的就是这一列整数。
选择:选项与跳转
选择是玩家改变方向的地方。规格把「选什么」与「选了会怎样」分开写:text 是给人看的标签,when 是能否出现的判定,effects 是状态推进,goto 是跳转目标。
| 项 | 数量 | 说明 |
|---|---|---|
| 选项总数 | 115 | 48 个节点上所有 choices[] 之和 |
| 带条件的选项 | 34 | when 非 null,不满足时不出现在界面上 |
| 有选项的节点 | 43 | 玩家在这些节点上必须做决定 |
| 无选项的节点 | 5 | 全部是结局节点 |
| 单节点最宽 | 5 | d_dq_2(黎明码头「看什么」) |
| 跳转目标 | 5 | 5 个结局节点,各被一个或多个选项指向 |
「无选项的节点恰好是 5 个结局节点」是一条结构恒等式,而不是巧合:在规格里,「没有选择」正是结局的定义方式。这条恒等式会在第 8 章变成「死路检测」的判据——一个既无选项又非结局的节点,就是卡死。
S 层:四构成的规模清点
sim/na_01_world_scale.py 只读真源,不做推断:所有计数都是字段长度或直接读取的标量。它同时承担一次骨架存在性校验——任何一段缺失即 KeyError 退出,脚本不可能在骨架有洞时打印出「OK」。
#!/usr/bin/env python3
# file: sim/na_01_world_scale.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_01_world_scale.py
"""《雾港夜航》四个最小构成的规模清点:世界 / 角色 / 事件 / 选择。
只读 config/story_spec.json,不做任何推断:四类计数全部直接来自字段长度。
任何一段缺失即 KeyError 退出(exit != 0),因此本脚本同时是一次「骨架存在性」校验。
"""
from __future__ import annotations
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
TENSION_LO, TENSION_HI = 1, 5
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 count_world(world: dict) -> dict:
"""世界:派系与它们的相互立场、地点、设定条目、世界规则。"""
factions = world["factions"]
return {
"factions": len(factions),
"stance_cells": sum(len(f["stance"]) for f in factions),
"locations": len(world["locations"]),
"locations_locked": sum(1 for loc in world["locations"] if loc.get("unlock_when")),
"lore": len(world["lore"]),
"rules": len(world["rules"]),
}
def count_characters(chars: list[dict]) -> dict:
"""角色:目标 / 恐惧 / 秘密是三个平级列表,弧光另有两个列表。"""
return {
"characters": len(chars),
"goals": sum(len(c["goals"]) for c in chars),
"fears": sum(len(c["fears"]) for c in chars),
"secrets": sum(1 for c in chars if c.get("secret")),
"arc_stages": sum(len(c["arc"]["stages"]) for c in chars),
"arc_beats": sum(len(c["arc"]["beats"]) for c in chars),
"thresholds": sum(len(c["affinity"]["thresholds"]) for c in chars),
}
def count_events(spec: dict) -> dict:
"""事件:幕 / 节拍 / 场景图 / 对话节点。四者是同一条时间轴的不同粒度。"""
graph = spec["scene_graph"]
edges = graph["edges"]
nodes = spec["dialogue"]
return {
"acts": spec["meta"]["acts"],
"beats": len(spec["beats"]),
"scenes": len(graph["nodes"]),
"edges": len(edges),
"edges_unconditional": sum(1 for e in edges if not e.get("when")),
"edges_conditional": sum(1 for e in edges if e.get("when")),
"dialogue_nodes": len(nodes),
"ending_nodes": sum(1 for n in nodes if n.get("ending")),
}
def count_choices(nodes: list[dict]) -> dict:
"""选择:选项总数、条件选项、以及「有选项的节点 / 无选项的节点」。"""
total = 0
conditional = 0
for n in nodes:
for c in (n.get("choices") or []):
total += 1
if c.get("when"):
conditional += 1
with_choices = sum(1 for n in nodes if (n.get("choices") or []))
widest = max(nodes, key=lambda n: len(n.get("choices") or []))
return {
"choices": total,
"choices_conditional": conditional,
"nodes_with_choices": with_choices,
"nodes_without_choices": len(nodes) - with_choices,
"widest_node": f"{widest['id']}({len(widest.get('choices') or [])})",
}
def declared_but_unwritten(spec: dict, nodes: list[dict]) -> list[str]:
"""声明了好感度、但没有任何选项 `add` 过的角色——「声明域 > 状态域」。"""
written: set[str] = set()
for n in nodes:
for c in (n.get("choices") or []):
written |= set(((c.get("effects") or {}).get("add") or {}))
return sorted({c["id"] for c in spec["characters"]} - written)
def invariants(spec: dict, nodes: list[dict]) -> list[str]:
"""整数不变量:残差恒为 0。返回违规清单(空即通过)。"""
bad: list[str] = []
ids = {n["id"] for n in nodes}
for n in nodes:
for c in (n.get("choices") or []):
if c["goto"] not in ids:
bad.append(f"{c['id']} 的 goto 指向不存在的节点 {c['goto']}")
for b in spec["beats"]:
if not (TENSION_LO <= b["tension"] <= TENSION_HI):
bad.append(f"beat {b['id']} 的 tension 越界 {b['tension']}")
for c in spec["characters"]:
a = c["affinity"]
if not (AFF_LO <= a["init"] <= AFF_HI):
bad.append(f"角色 {c['id']} 的 init 越界 {a['init']}")
for f in spec["world"]["factions"]:
for peer, v in f["stance"].items():
if v not in (-1, 0, 1):
bad.append(f"派系 {f['id']} 对 {peer} 的立场越界 {v}")
return bad
def main() -> int:
spec = load_spec()
nodes = spec["dialogue"]
world = count_world(spec["world"])
chars = count_characters(spec["characters"])
events = count_events(spec)
choices = count_choices(nodes)
print("构成 计数项")
for k, v in world.items():
print(f"世界 {k:<24} {v}")
for k, v in chars.items():
print(f"角色 {k:<24} {v}")
for k, v in events.items():
print(f"事件 {k:<24} {v}")
for k, v in choices.items():
print(f"选择 {k:<24} {v}")
# 与 meta.scale 对账:这是四个构成与「声明规模」之间的唯一接口。
scale = spec["meta"]["scale"]
actual = {
"scenes": events["scenes"],
"characters": chars["characters"],
"dialogue_nodes": events["dialogue_nodes"],
"choices": choices["choices"],
"flags": len(spec["state"]["flags"]),
"affinity_vars": len(spec["state"]["vars"]),
"items": len(spec["state"]["items"]),
"factions": world["factions"],
"endings": len(spec["endings"]),
}
print(f"meta.scale 对账:{'一致' if actual == scale else '不一致'} ({actual})")
bad = invariants(spec, nodes)
for b in bad:
print(f"FAIL {b}")
if bad:
return 1
unwritten = declared_but_unwritten(spec, nodes)
print(f"声明了好感度但无选项写入的角色 {len(unwritten)} 个:{unwritten}")
print("OK na_01_world_scale:四个构成清点完成,整数不变量残差 0")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_01_world_scale.py:
构成 计数项
世界 factions 4
世界 stance_cells 16
世界 locations 14
世界 locations_locked 7
世界 lore 6
世界 rules 4
角色 characters 6
角色 goals 12
角色 fears 12
角色 secrets 6
角色 arc_stages 18
角色 arc_beats 18
角色 thresholds 18
事件 acts 3
事件 beats 16
事件 scenes 14
事件 edges 16
事件 edges_unconditional 11
事件 edges_conditional 5
事件 dialogue_nodes 48
事件 ending_nodes 5
选择 choices 115
选择 choices_conditional 34
选择 nodes_with_choices 43
选择 nodes_without_choices 5
选择 widest_node d_dq_2(5)
meta.scale 对账:一致 ({'scenes': 14, 'characters': 6, 'dialogue_nodes': 48, 'choices': 115, 'flags': 18, 'affinity_vars': 4, 'items': 9, 'factions': 4, 'endings': 5})
声明了好感度但无选项写入的角色 2 个:['hai', 'lin']
OK na_01_world_scale:四个构成清点完成,整数不变量残差 0
判读:四张表各自暴露了什么
世界的 14 个地点里有 7 个带解锁条件,比例正好一半。这提示了一件事:地点在规格里不是「地图上的坐标」,而是条件系统的作用域——一个地点的 unlock_when 与一条边的 when 可以指向同一个 flag。第 3 章会把 world 整段拆开讲。
角色的表里有一处不对称:6 个角色都有好感度声明,但状态域只登记了 4 个变量。林与孩的好感度声明存在、无人写入。规格允许这种「声明域大于状态域」的存在,但它必须被显式记录,否则读者会以为它们真的在变。这是第 13 章一致性校验的第一类输入。
事件的四层粒度里,只有 beats[].tension 是人工标注量,其余三层都是结构量(长度、边数、节点数)。这一区分很重要:卷三算张力曲线时读的是标注量,算覆盖率时读的是结构量,两者的可信度口径不同。
选择的两行值得并排看:115 个选项里 34 个带条件(约 30%),而 5 个无选项的节点全部是结局。前者说明分支并不全靠条件筛选(七成选项无条件可见),后者说明「无选项」在规格里不是遗漏而是语义标记。第 8 章会从这里展开「假选择与真后果」。
D 档:四构成的字段归属表
| 构成 | spec 字段 | 是否随时间变化 | 主要消费者 |
|---|---|---|---|
| 世界 | world.factions[].stance | 否 | 第 3 章:派系立场矩阵 |
| 世界 | world.locations[].unlock_when | 否(条件在读时求值) | 第 7 章:场景可达性 |
| 世界 | world.lore[].reveal_flag | 否 | 第 3 章:设定的暴露节奏 |
| 角色 | characters[].goals / fears / secret | 否 | 第 4 章:角色字段与关系 |
| 角色 | characters[].affinity | 是(add 改写) | 第 10 章:好感度与阈值门 |
| 角色 | characters[].arc | 否(阶段由节拍锚定) | 第 5 章:弧光的可达性 |
| 事件 | meta.acts / beats[].tension | 否 | 第 6 章:节拍表与比例检验 |
| 事件 | scene_graph | 否 | 第 7 章:节点、边、入口条件 |
| 事件 | dialogue[] | 否 | 第 11 章:对话树的数据结构 |
| 选择 | choices[].when | 否(求值时读状态) | 第 9 章:条件系统 |
| 选择 | choices[].effects | 是(写状态) | 第 10 章:好感度状态机 |
| 选择 | choices[].goto | 否 | 第 11 章:跳转与遍历校验 |
自检表
| 检查项 | 合格标准 |
|---|---|
| 四构成齐全 | 世界 / 角色 / 事件 / 选择各有一张可核对的表 |
| 计数从规格长出 | 每个数字都是字段长度或直接读取的标量,不手写 |
| 与声明规模对账 | meta.scale 的 9 项与实测逐项一致 |
| 整数不变量 | 好感度 ∈ [0,100]、tension ∈ 1–5、派系立场 ∈ {−1,0,1}、goto 目标存在,残差 0 |
| 说法诚实 | 声明域大于状态域的角色(林 / 孩)显式标出,不假装它们在变 |
四个构成数清楚之后,下一个问题是:这四张表在文件里是怎么排布的?为什么是一份 JSON 而不是四份?下一节把 story_spec.json 的顶层骨架拆开,看它如何用十段字段承载这四个构成,以及一份散文大纲要经过哪些映射才能落到这些字段上。
阅读导航:上一节:目录 · 下一节:1.2 从故事到结构:叙事规格的骨架 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。