6.2 节拍表:把结构写成 beat 序列
独立游戏的叙事设计与分支对话
里讲节拍,用的是「这一段要压住」「这里要放个转折」这样的句子——那是散文式的结构经验,它把节拍当成一种感觉。本节把节拍表当成一张有顺序约束的表:每一行是一拍,列是固定字段,行与行之间的先后关系可以被脚本检查。上一节把 act 映射成了三种分段,但没问「这张表本身合不合法」;这一节补上。
一行一拍:节拍表的字段
beats 段是一个有序数组,每一拍固定五个字段。顺序由数组下标承载,不写进字段——这是刻意的:如果把「第几拍」也写成字段,就会出现「下标说第 5、字段说第 4」这种双真源冲突。
| 字段 | 类型 | 含义 | 本节怎么用 |
|---|---|---|---|
id | 字符串 | 拍标识(b01..b16) | 唯一性检查 + 分段表的起止锚点 |
act | 整数 1–3 | 所属幕(夜) | 单调性检查 + 三种模型的分段输入 |
scene | 字符串 | 该拍发生的场景节点 | 存在性检查 + 与节点幕的一致性检查 |
tension | 整数 1–5 | 该拍的张力档位 | 不变量检查 + 曲线与峰值的输入 |
summary | 字符串 | 一句话摘要(给人读) | 不参与任何断言 |
最后一行值得强调:summary 是唯一不参与断言的字段。它给策划读,不给脚本判。凡是要被脚本判的东西,都必须落成整数或枚举,否则判据就变成了「这句话读起来对不对」。
顺序约束:六条可判定的规则
把节拍表当序列读,就能问出六个可判定的问题。前四条是硬约束(违反即数据损坏),后两条是覆盖性约束(违反即结构有洞):
| 编号 | 约束 | 判据 | 违反意味着 |
|---|---|---|---|
| F1 | 字段完整 | 每拍都有五个字段,id 不重复 | 节拍无法被下游消费 |
| F2 | act 单调不减 | act[i] >= act[i-1] | 时间线回退:第三夜之后又回第一夜 |
| F3 | scene 存在 | 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 → 2 | F2 + F4 | 改一个字段,两条约束同时报,约束之间并不互斥 |
② b01 的 scene 改成不存在的节点 | F3 + C1 | 节点消失,覆盖随之掉一格,出现连带效应 |
③ b09 的 scene 改成第一夜的 pier | F4 + C1 | 只查 F2 会漏掉这条:act 序列依旧递增 |
| ④ 直接删掉 b14 | C1 | 顺序与归属都没坏,只有覆盖报出来 |
注入③是最值钱的一条:它证明「顺序约束」与「归属约束」必须同时存在。如果只查 act 单调,注入③会静默通过——16 拍的 act 依然递增,只是有一拍跑错了夜。
自检表
| 检查项 | 合格标准 |
|---|---|
| 顺序承载 | 「第几拍」由数组下标承载,不额外写字段 |
| 双真源排除 | 除了 summary,每个字段都参与至少一条断言 |
| 顺序双查 | F2 管拍间顺序,F4 管拍与场景的归属,两条都要 |
| 覆盖显式 | 覆盖检查报的是 被覆盖节点数/总节点数 的整数比 |
| 退出码 | 任一约束不成立即非零退出,不静默通过 |
节拍表校验完,我们知道了 16 拍在顺序上、归属上、覆盖上都立得住。但「立得住」不等于「节奏对」——各幕各占多少拍、张力峰值落在第几拍,是比例层面的问题,需要单独算。下一节处理它。
阅读导航:上一节:6.1 三幕、五幕与起承转合 · 下一节:6.3 结构与节奏的比例检验 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。