6.2 节拍表:把结构写成 beat 序列

节拍表把结构写成一行一拍的 beat 序列,于是「顺序」变成可校验的约束:act 必须单调不减、scene 必须存在于 scene_graph、节点所属幕必须与节拍所属幕一致、每个场景节点至少被一拍覆盖。本节用可跑的 S 层脚本逐条校验并打印《雾港夜航》的 16 行节拍表。

6.2 节拍表:把结构写成 beat 序列

独立游戏的叙事设计与分支对话 里讲节拍,用的是「这一段要压住」「这里要放个转折」这样的句子——那是散文式的结构经验,它把节拍当成一种感觉。本节把节拍表当成一张有顺序约束的表:每一行是一拍,列是固定字段,行与行之间的先后关系可以被脚本检查。上一节把 act 映射成了三种分段,但没问「这张表本身合不合法」;这一节补上。

一行一拍:节拍表的字段

beats 段是一个有序数组,每一拍固定五个字段。顺序由数组下标承载,不写进字段——这是刻意的:如果把「第几拍」也写成字段,就会出现「下标说第 5、字段说第 4」这种双真源冲突。

字段类型含义本节怎么用
id字符串拍标识(b01..b16)唯一性检查 + 分段表的起止锚点
act整数 1–3所属幕(夜)单调性检查 + 三种模型的分段输入
scene字符串该拍发生的场景节点存在性检查 + 与节点幕的一致性检查
tension整数 1–5该拍的张力档位不变量检查 + 曲线与峰值的输入
summary字符串一句话摘要(给人读)不参与任何断言

最后一行值得强调:summary 是唯一不参与断言的字段。它给策划读,不给脚本判。凡是要被脚本判的东西,都必须落成整数或枚举,否则判据就变成了「这句话读起来对不对」。

顺序约束:六条可判定的规则

把节拍表当序列读,就能问出六个可判定的问题。前四条是硬约束(违反即数据损坏),后两条是覆盖性约束(违反即结构有洞):

编号约束判据违反意味着
F1字段完整每拍都有五个字段,id 不重复节拍无法被下游消费
F2act 单调不减act[i] >= act[i-1]时间线回退:第三夜之后又回第一夜
F3scene 存在beat.scene 是 scene_graph.nodes 的节点节拍挂在不存在的场景上
F4幕一致节点的 act == 该拍的 act第二夜的节拍挂在第一夜的场景里
C1节点覆盖每个场景节点至少被一拍覆盖该场景在节拍层是孤儿
C2张力不变量tension ∈ [1, 5]张力曲线无法与目标曲线对齐

F2 与 F4 是一对:F2 管拍与拍之间的顺序(不许回退),F4 管拍与场景之间的归属(不许错位)。只查 F2 会漏掉「把第一夜的场景写进第三夜的节拍」——那种表 act 一路递增,却把某个场景放错了夜。两条一起查,顺序才算真的立住。

S 层:校验并打印节拍表

下面这段是 S 层脚本 sim/na_06_2_beat_sheet.py。它读同一份 config/story_spec.json,跑 F1–F4 与 C1–C2 六条检查,把违规逐条打出来(为空即通过),并打印 16 行节拍表与一张 CSV。任何一条不成立即非零退出——这正是「节拍表是一份可校验数据」与「节拍表是一份文档」的差别。

#!/usr/bin/env python3
# file: sim/na_06_2_beat_sheet.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_06_2_beat_sheet.py
"""卷一 6.2 节拍表(S 层):校验 beats 段的字段与顺序约束,并打印 16 行节拍表。

把 beats 当成一张**有序表**读,就能问出四类可判定问题:

  F1 字段完整  每个 beat 必须有 id / act / scene / tension / summary
  F2 act 单调   act 序列必须单调不减(不许在第二夜之后回到第一夜)
  F3 scene 存在 beat.scene 必须是 scene_graph.nodes 里的节点
  F4 幕一致     beat.scene 所属节点的 act 必须等于 beat.act

另外两条覆盖性检查:
  C1 每个场景节点至少被一个 beat 覆盖(否则该节点在节拍层是孤儿)
  C2 tension ∈ [1,5](整数不变量)

产出 out/narr06_beat_sheet.csv(16 行)与控制台节拍表;任一约束不成立即非零退出。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SPEC = os.path.join(ROOT, "config/story_spec.json")
CSV_PATH = os.path.join(OUT, "narr06_beat_sheet.csv")

FIELDS = ("id", "act", "scene", "tension", "summary")
TENSION_LO, TENSION_HI = 1, 5


def load_spec() -> dict:
    with open(SPEC, encoding="utf-8") as fh:
        return json.load(fh)


def check(spec: dict) -> tuple[list[dict], list[str]]:
    """返回 (节拍表行, 违规清单)。违规清单为空即全部通过。"""
    beats = spec["beats"]
    nodes = {n["id"]: n for n in spec["scene_graph"]["nodes"]}
    bad: list[str] = []

    # F1 字段完整 + id 唯一
    seen: set[str] = set()
    for b in beats:
        for f in FIELDS:
            if f not in b:
                bad.append(f"F1 {b.get('id', '?')} 缺字段 {f}")
        if b["id"] in seen:
            bad.append(f"F1 {b['id']} 重复")
        seen.add(b["id"])

    # F2 act 单调不减
    for i in range(1, len(beats)):
        if beats[i]["act"] < beats[i - 1]["act"]:
            bad.append(f"F2 {beats[i]['id']} act={beats[i]['act']} < 前拍 {beats[i - 1]['act']}")

    # F3 scene 存在 + F4 幕一致
    for b in beats:
        if b["scene"] not in nodes:
            bad.append(f"F3 {b['id']} scene={b['scene']} 不在 scene_graph.nodes")
        elif nodes[b["scene"]]["act"] != b["act"]:
            bad.append(f"F4 {b['id']} scene={b['scene']}(act {nodes[b['scene']]['act']}) != beat act {b['act']}")

    # C2 张力不变量
    for b in beats:
        if not (TENSION_LO <= b["tension"] <= TENSION_HI):
            bad.append(f"C2 {b['id']} tension={b['tension']} 越界")

    # C1 场景节点覆盖
    covered = {b["scene"] for b in beats}
    for nid in nodes:
        if nid not in covered:
            bad.append(f"C1 场景节点 {nid} 未被任何 beat 覆盖")

    rows = [{"no": i + 1, "id": b["id"], "act": b["act"], "scene": b["scene"],
             "tension": b["tension"], "summary": b["summary"]} for i, b in enumerate(beats)]
    return rows, bad


def main() -> int:
    spec = load_spec()
    rows, bad = check(spec)
    beats = spec["beats"]
    nodes = spec["scene_graph"]["nodes"]

    print(f"beats={len(beats)}  nodes={len(nodes)}  acts={spec['meta']['acts']}")
    print("节拍表(act 单调不减 / scene 必属 scene_graph)")
    print(f"  {'#':>2}  {'id':<4} {'act':>3}  {'scene':<16} {'ten':>3}  summary")
    for r in rows:
        print(f"  {r['no']:>2}  {r['id']:<4} {r['act']:>3}  {r['scene']:<16} {r['tension']:>3}  {r['summary']}")

    per_act: dict[int, int] = {}
    for b in beats:
        per_act[b["act"]] = per_act.get(b["act"], 0) + 1
    print(f"act 分布 {[per_act[k] for k in sorted(per_act)]}  "
          f"张力合计 {sum(b['tension'] for b in beats)}  "
          f"覆盖节点 {len({b['scene'] for b in beats})}/{len(nodes)}")

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["no", "id", "act", "scene", "tension", "summary"])
        w.writeheader()
        w.writerows(rows)
    print(f"wrote out/narr06_beat_sheet.csv rows={len(rows)}")

    for e in bad:
        print(f"FAIL {e}")
    if bad:
        print(f"== na_06_2_beat_sheet:FAIL {len(bad)}")
        return 1
    print("OK na_06_2_beat_sheet:F1-F4 + C1-C2 全部通过")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_06_2_beat_sheet.py:

beats=16  nodes=14  acts=3
节拍表(act 单调不减 / scene 必属 scene_graph)
   #  id   act  scene            ten  summary
   1  b01    1  pier               1  信使抵港,雾中少年讨一枚铜板
   2  b02    1  tavern             2  船帮把头谈价:渡船要人作保
   3  b03    1  chapel             2  教会执事布道:灯塔是伪灯
   4  b04    1  counting_house     3  账房先生不肯翻开那一页
   5  b05    1  watch_post         2  老守夜人点灯,也点破
   6  b06    2  lighthouse_base    3  基座门锁着,锁上有新痕
   7  b07    2  tide_cave          4  潮退露出的洞窟,十七个名字
   8  b08    2  market_row         3  鱼市街的传闻与一枚绳结
   9  b09    2  harbor_office      4  港务所的旧档与九笔支出
  10  b10    2  drowned_bell       5  沉钟塔内,执事握着一把钥匙
  11  b11    2  lighthouse_base    3  第二夜结束,塔上闪过一瞬光
  12  b12    3  lighthouse_top     4  塔顶的人还活着,灯油只剩一次
  13  b13    3  breakwater         5  防波堤上,把头摊开契约
  14  b14    3  fog_gate           5  雾门前,执事要一个理由
  15  b15    3  dawn_quay          4  黎明码头,所有的账一起结
  16  b16    3  dawn_quay          2  灯亮或灯灭,雾港照旧醒着
act 分布 [5, 6, 5]  张力合计 52  覆盖节点 14/14
wrote out/narr06_beat_sheet.csv rows=16
OK na_06_2_beat_sheet:F1-F4 + C1-C2 全部通过

判读:约束抓住了什么,又放过了什么

六条检查全绿,读出来的信息有三层:

第一层,顺序是干净的。 act 从 b01 到 b16 是 1,1,1,1,1,2,2,2,2,2,2,3,3,3,3,3——单调不减,没有回退。这听起来理所当然,但它是可被破坏的:只要有人把某一拍挪到数组末尾却没改 act,F2 立刻报出来。约束的价值不在于它现在通过,而在于它以后不会静默通过。

第二层,场景与幕是对齐的。 F4 检查了 16 次,每一次都要求「这一拍所属的幕」等于「这一拍的场景所属的幕」。lighthouse_base 出现在 b06 与 b11 两拍上,两拍的 act 都是 2,与节点自身的 act 一致;如果哪天有人把 b06 的 act 改成 3,F4 会抓住。

第三层,覆盖是满的。 14 个场景节点被 16 拍覆盖,比例 14/14——没有孤儿节点。这条检查比前两条更容易被忽略:一个场景可以在 scene_graph 里存在、可以从别的节点可达,却一个节拍都没有。那样的场景是「能进但没事发生」的空场景,F2 与 F4 都查不出来,只有 C1 查得出来。

同时要诚实地说清约束放过了什么:它不检查 summary 写得好不好,不检查两拍之间是否有因果,不检查 tension 的升降是否「合理」。这些要么是主观判断,要么需要额外的规则——tension 的合理性由下一节的比例检验来管,而「写得对不对」不归脚本管。

四条注入:约束真的会拦住东西

一段全绿的检查只说明「没坏」,不说明「检查有效」。要证明约束有效,得故意把它弄坏:下面是四种注入,都是在内存里改一份 story_spec.json 的副本再喂给同一个 check(),以及脚本真实打印出的 FAIL 行。

--- 注入① b13.act 3->2(时间线回退)---
FAIL F2 b13 act=2 < 前拍 3
FAIL F4 b13 scene=breakwater(act 3) != beat act 2
--- 注入② b01.scene -> ghost_pier(不存在的节点)---
FAIL F3 b01 scene=ghost_pier 不在 scene_graph.nodes
FAIL C1 场景节点 pier 未被任何 beat 覆盖
--- 注入③ b09.scene -> pier(幕错位:第二夜挂第一夜场景)---
FAIL F4 b09 scene=pier(act 1) != beat act 2
FAIL C1 场景节点 harbor_office 未被任何 beat 覆盖
--- 注入④ 删掉 b14(fog_gate 失去覆盖)---
FAIL C1 场景节点 fog_gate 未被任何 beat 覆盖

四个注入各说明一件事:

注入撞上的约束读数
① b13 的 act 3 → 2F2 + F4改一个字段,两条约束同时报,约束之间并不互斥
② b01 的 scene 改成不存在的节点F3 + C1节点消失,覆盖随之掉一格,出现连带效应
③ b09 的 scene 改成第一夜的 pierF4 + C1只查 F2 会漏掉这条:act 序列依旧递增
④ 直接删掉 b14C1顺序与归属都没坏,只有覆盖报出来

注入③是最值钱的一条:它证明「顺序约束」与「归属约束」必须同时存在。如果只查 act 单调,注入③会静默通过——16 拍的 act 依然递增,只是有一拍跑错了夜。

自检表

检查项合格标准
顺序承载「第几拍」由数组下标承载,不额外写字段
双真源排除除了 summary,每个字段都参与至少一条断言
顺序双查F2 管拍间顺序,F4 管拍与场景的归属,两条都要
覆盖显式覆盖检查报的是 被覆盖节点数/总节点数 的整数比
退出码任一约束不成立即非零退出,不静默通过

节拍表校验完,我们知道了 16 拍在顺序上、归属上、覆盖上都立得住。但「立得住」不等于「节奏对」——各幕各占多少拍、张力峰值落在第几拍,是比例层面的问题,需要单独算。下一节处理它。

阅读导航:上一节:6.1 三幕、五幕与起承转合 · 下一节:6.3 结构与节奏的比例检验 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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