5.1 弧光的阶段与转折
独立游戏的叙事设计与分支对话
谈人物弧光,谈的是「他慢慢变了」——那是散文式的弧光经验,把变化说成一种能被感觉到的过程。本节把同一件事压成两个数组:三个 stages 阶段名,加三个 beats 转折节拍号。散文里的「慢慢」在这里必须回答一个具体问题:在第几个节拍上变。变早了,机制还没把玩家推到那个关系档;变晚了,观众早就不信。这一节先只管一件事——把弧光写成一张可校验的阶段表。
弧光在规格里是两个数组
story_spec.json 的每个角色都带一个 arc,它只有两个字段。stages 是三个阶段名,beats 是三个节拍号——后者是整数,指向 beats[] 数组里的第几拍。以砚为例:
{"stages": ["失踪的传说", "被怀疑的守夜人", "托付者"], "beats": [1, 8, 12]}
三个阶段名是给人读的标签,真正被脚本读的只有那三个整数。beats: [1, 8, 12] 的意思是「砚的第一次转折落在 b01,第二次落在 b08,第三次落在 b12」。于是「弧光」这个抽象词,在状态空间里落成三个坐标——可以被查询、被比较、被断言。
值得强调为什么恰好是三段:它不是审美偏好,而是和好感度门限绑定的数量。每个角色的 affinity.gates 恰好有三个门限(cold / warm / trust),弧光的三段正好一段对一门。这条对应关系是下一节 5.2 的全部内容,本节先把阶段表的结构立起来。
顺带一提:阶段表里只写「第几拍」,不写「哪一幕」「多少张力」——因为这两样都能从 beats[] 里反查出来。脚本输出里每个转折点后面跟着的 (t3) 这种张力档位,就是顺手从节拍表里取的。同一份数据只写一次,其余全是派生,这是本套从头到尾的纪律。
六个角色的弧光阶段表
把六个角色排在一起,弧光的差异立刻变成可比的坐标:
| 角色 | 阶段① | 阶段② | 阶段③ |
|---|---|---|---|
| 砚 yan | 失踪的传说 · b01 | 被怀疑的守夜人 · b08 | 托付者 · b12 |
| 徽 hui | 交易者 · b02 | 动摇的盟友 · b06 | 共同承担者 · b14 |
| 沈 shen | 教条者 · b03 | 怀疑者 · b09 | 鸣钟者 · b15 |
| 沐 mu | 旁观者 · b04 | 账页的持有者 · b10 | 证人 · b13 |
| 林 lin | 规矩的看守 · b05 | 沉默的同谋 · b11 | 交接者 · b16 |
| 孩 hai | 向导 · b07 | 被抛下的孩子 · b12 | 记得的人 · b16 |
这张表读起来有节奏。阶段①集中在 b01–b07,六个角色各占一拍、几乎不重叠;阶段②散在 b06–b12;阶段③收在 b12–b16。也就是说,弧光的「开场」是均匀铺开的,而「收束」是挤在一起的——第三夜最窄的一段里,要塞进四条弧的终点。
三条结构约束
阶段表要能被脚本吃下去,得先满足三条结构约束。它们都不是审美判断,而是让后续分析能进行的最小前提:
| 约束 | 判据 | 破了会怎样 |
|---|---|---|
| 三阶段三节拍 | len(stages) == len(beats) == 3 | 阶段与门限数量对不上,5.2 无法逐条对齐 |
| 严格递增 | beats[i] > beats[i-1] | 转折可以「倒流」,弧光不再是弧 |
| 落在范围内 | 1 <= b <= len(beats)(本案例 1..16) | 指向不存在的节拍,取 tension / act 会抛异常 |
第二条最容易被忽略。如果用 >= 而不是 >,两个阶段可以落在同一拍上——那意味着「一次转折同时完成两段弧」,通常是作者把两个转折挤在了一处。第三条是硬边界:节拍号是数组下标的人话写法,越界就等于引用了不存在的节拍,脚本里 by_num[x] 会直接抛 KeyError,而不会静默跳过。
三条约束里,前两条管「弧的形状」,第三条管「弧的坐标」。形状错了,脚本报 FAIL 并给出具体是哪个角色;坐标错了,脚本在取值那一刻就抛异常——两者的失败方式不同,但都不该被静默容忍。
S 层:阶段表的结构校验
下面这个脚本把三条约束逐条跑一遍,并顺带统计「哪个节拍被几个角色声明为转折」:
#!/usr/bin/env python3
# file: sim/na_05_1_arc_stages.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_05_1_arc_stages.py
"""弧光阶段表的结构校验:三阶段 / 三转折节拍号 / 严格递增 / 落在 beats 范围内。"""
from __future__ import annotations
import json
import os
import sys
BEAT_LO = 1
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 main() -> int:
spec = load_spec()
n = len(spec["beats"])
by_num = {i + 1: b for i, b in enumerate(spec["beats"])}
problems, rows = [], []
for ch in spec["characters"]:
arc = ch.get("arc") or {}
stages, bs = list(arc.get("stages") or []), list(arc.get("beats") or [])
if len(stages) != 3:
problems.append(f"{ch['id']} 的 stages 不是 3 个:{len(stages)}")
if len(bs) != 3:
problems.append(f"{ch['id']} 的 beats 不是 3 个:{len(bs)}")
for i in range(1, len(bs)):
if not bs[i] > bs[i - 1]:
problems.append(f"{ch['id']} 的转折节拍号非严格递增:{bs}")
for x in bs:
if not (BEAT_LO <= x <= n):
problems.append(f"{ch['id']} 的转折节拍号越界:{x}")
if any(not s.strip() for s in stages) or len(set(stages)) != len(stages):
problems.append(f"{ch['id']} 的阶段名有空值或重复:{stages}")
rows.append({"id": ch["id"], "name": ch["name"], "stages": stages, "beats": bs,
"tension": [by_num[x]["tension"] for x in bs if x in by_num],
"act": [by_num[x]["act"] for x in bs if x in by_num]})
flat = [x for r in rows for x in r["beats"]]
strict = sum(1 for r in rows if all(r["beats"][i] > r["beats"][i - 1] for i in range(1, 3)))
in_range = sum(1 for x in flat if BEAT_LO <= x <= n)
acts: dict[int, int] = {}
for r in rows:
for a in r["act"]:
acts[a] = acts.get(a, 0) + 1
claim: dict[int, list[str]] = {}
for r in rows:
for x in r["beats"]:
claim.setdefault(x, []).append(r["id"])
dup = {k: v for k, v in claim.items() if len(v) > 1}
here = os.path.dirname(os.path.abspath(__file__))
out = os.path.join(here, "../out/arc_stages.json")
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w", encoding="utf-8") as fh:
json.dump({"characters": rows, "beats_total": n, "turning_points_total": len(flat),
"strictly_increasing": strict, "in_range": in_range,
"claimed_per_beat": {str(k): v for k, v in claim.items()},
"claimed_by_multiple": {str(k): v for k, v in dup.items()},
"problems": problems}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print("角色 阶段① → 阶段② → 阶段③(阶段名 → 转折节拍号·张力)")
for r in rows:
cells = [f"{r['stages'][i]} → b{r['beats'][i]:02d}(t{by_num[r['beats'][i]]['tension']})"
for i in range(len(r["stages"]))]
print(f"{r['id']} {r['name']} " + " | ".join(cells))
print(f"转折节拍号:{len(rows)} 角色 × 3 = {len(flat)} 个;严格递增 {strict}/{len(rows)};落在 1..{n} 内 {in_range}/{len(flat)}")
print(f"按幕分布:第一夜 {acts.get(1, 0)} · 第二夜 {acts.get(2, 0)} · 第三夜 {acts.get(3, 0)}")
print(f"被 ≥2 个角色声明的节拍:{dup}")
for p in problems:
print(f"FAIL {p}")
if problems:
return 1
print("OK na_05_1_arc_stages:6 个角色的弧光阶段表结构自洽")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_05_1_arc_stages.py:
角色 阶段① → 阶段② → 阶段③(阶段名 → 转折节拍号·张力)
yan 砚 失踪的传说 → b01(t1) | 被怀疑的守夜人 → b08(t3) | 托付者 → b12(t4)
hui 徽 交易者 → b02(t2) | 动摇的盟友 → b06(t3) | 共同承担者 → b14(t5)
shen 沈 教条者 → b03(t2) | 怀疑者 → b09(t4) | 鸣钟者 → b15(t4)
mu 沐 旁观者 → b04(t3) | 账页的持有者 → b10(t5) | 证人 → b13(t5)
lin 林 规矩的看守 → b05(t2) | 沉默的同谋 → b11(t3) | 交接者 → b16(t2)
hai 孩 向导 → b07(t4) | 被抛下的孩子 → b12(t4) | 记得的人 → b16(t2)
转折节拍号:6 角色 × 3 = 18 个;严格递增 6/6;落在 1..16 内 18/18
按幕分布:第一夜 5 · 第二夜 6 · 第三夜 7
被 ≥2 个角色声明的节拍:{12: ['yan', 'hai'], 16: ['lin', 'hai']}
OK na_05_1_arc_stages:6 个角色的弧光阶段表结构自洽
同一趟写出的 out/arc_stages.json 里,claimed_per_beat 把每个节拍被谁声明列全(这是「节拍拥挤度」的原始数据):
claimed_per_beat: {'1': ['yan'], '8': ['yan'], '12': ['yan', 'hai'], '2': ['hui'], '6': ['hui'], '14': ['hui'], '3': ['shen'], '9': ['shen'], '15': ['shen'], '4': ['mu'], '10': ['mu'], '13': ['mu'], '5': ['lin'], '11': ['lin'], '16': ['lin', 'hai'], '7': ['hai']}
claimed_by_multiple: {'12': ['yan', 'hai'], '16': ['lin', 'hai']}
从输出读到的三件事
第一,结构全过。6 个角色 × 3 = 18 个转折节拍号,严格递增 6/6,落在 1..16 内 18/18。没有角色写出「b12 → b08」这种倒流,也没有越界引用。
第二,按幕分布是 5 / 6 / 7。第一夜 5 个转折、第二夜 6 个、第三夜 7 个。转折在三幕里略向后倾——第三夜最密。这和 6.3 讲的「各幕拍数 5 / 6 / 5」是同一份数据的两个切面:节拍数在第二夜最厚,而弧光的转折却堆在第三夜。两者的差说明一件事——第二夜铺开了更多事件,第三夜则把更多角色推到各自的转折点上。
第三,b12 与 b16 各被两个角色声明。claimed_by_multiple 直接给出这对坐标:b12 是砚的阶段③(托付者)与孩的阶段②(被抛下的孩子),b16 是林的阶段③(交接者)与孩的阶段③(记得的人)。这不是「撞车」,而是同一场景里两条弧同时收束:b12 发生在灯塔顶层(砚在场,孩的线也在此收),b16 是黎明码头的总收束。
一个节拍承载两条弧,是设计选择;但它也意味着——若玩家没走到那一拍,两条弧会一起落空。结构校验保证不了这一点:它只能证明阶段表自洽,证明不了玩家走得到。这正是 5.3 要查的东西,本节先把「自洽」钉死。
16 个节拍全部被声明为转折
把 18 条声明按节拍展开,会看到一个不太显眼但很整齐的事实:16 个节拍一个不落,每个都至少被一个角色当作转折点。18 条声明摊在 16 个节拍上只多出 2 条——正好就是 b12 与 b16 的重复。
| 节拍 | 幕 | tension | 声明角色 |
|---|---|---|---|
| b01 | 一 | 1 | 砚 |
| b02 | 一 | 2 | 徽 |
| b03 | 一 | 2 | 沈 |
| b04 | 一 | 3 | 沐 |
| b05 | 一 | 2 | 林 |
| b06 | 二 | 3 | 徽 |
| b07 | 二 | 4 | 孩 |
| b08 | 二 | 3 | 砚 |
| b09 | 二 | 4 | 沈 |
| b10 | 二 | 5 | 沐 |
| b11 | 二 | 3 | 林 |
| b12 | 三 | 4 | 砚 · 孩 |
| b13 | 三 | 5 | 沐 |
| b14 | 三 | 5 | 徽 |
| b15 | 三 | 4 | 沈 |
| b16 | 三 | 2 | 林 · 孩 |
这张表能读出两件事。一是**「每个节拍都有事发生」是设计出来的,不是碰巧**:如果某个节拍没被任何角色声明,它在弧光层面就是「空拍」——事件有 summary,却不推动任何人的弧。二是 b16 的张力只有 2,却承载了两条弧的终点。收束拍通常是全片张力最低的一段(雾散、账结、灯亮或灯灭),把两条弧压在最低张力的那一拍上,是一种「降调收束」的结构选择。
阶段名的三重作用
阶段名不是装饰,它同时在三个地方被消费:
| 消费方 | 怎么用 | 例子 |
|---|---|---|
| 人 | 读表时一眼看懂这一段的主题 | 「交易者 → 动摇的盟友 → 共同承担者」 |
| 检索 | 按阶段名在内容库里定位台词与场景 | 搜「鸣钟者」找到沈的收束段 |
| 校验 | 检查同一角色的阶段名唯一且非空 | 重复或空名即 FAIL |
真正被脚本读的只有 beats 那三个整数,stages 是给人和给检索用的。这个分工值得记住:把语义塞进整数会失去可读性,把整数塞进阶段名会失去可算性——两者各管一头,缺一不可。
为什么不用「若干阶段」
一个自然的疑问是:为什么不做成「任意数量阶段」的数组?答案是对齐。5.2 要把每一段弧对到一个好感度门限上,而门限恰好有三个(cold / warm / trust)。阶段数一旦可任意,对齐就退化成「长度不等的配对问题」——要么补齐、要么截断,两种都在制造噪音。固定三段不是限制表达力,而是让阶段与门限一一对应这件事有一个确定的形状。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三阶段三节拍 | 每个角色 stages 与 beats 长度都为 3 |
| 严格递增 | 用 > 而非 >=,同一拍不得承载两个阶段 |
| 范围合法 | 节拍号落在 1..len(beats),取 tension / act 不抛异常 |
| 阶段名唯一 | 同一角色的三个阶段名不得重复或为空 |
| 口径诚实 | 阶段名是人读的标签,脚本只依赖那三个整数 |
弧光在规格里是「三阶段 + 三节拍号」。结构校验能保证它自洽,但保证不了它可达。下一节先做一件更便宜的事——把阶段与好感度门限对齐,看看声明的转折节拍是否落在门限可达的位置上。
最后留一个提醒:阶段表是作者声明的,不是从数据里学出来的。脚本能做的是检查它自洽、检查它与门限对齐、检查它可达,但「砚到底该不该在 b08 从『被怀疑』走到『托付』」这个问题,脚本不回答——那是设计判断。可执行规格的价值不是替人做判断,而是让判断的后果可以被算出来。
阅读导航:上一节:4.3 关系的不对称与变化 · 下一节:5.2 弧光与行为的对齐 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。