5.1 弧光的阶段与转折

弧光在规格里就是两个数组:三个 stages 阶段名,加三个 beats 转折节拍号。本节把《雾港夜航》六个角色的弧光写成阶段表,并用 S 层脚本校验三阶段、三节拍号、严格递增、落在 1..16 的范围内;顺带暴露 b12 与 b16 被两个角色同时声明为转折点的结构拥挤。

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 弧光与行为的对齐 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长