6.3 结构与节奏的比例检验
独立游戏的叙事设计与分支对话 谈节奏,谈的是「这一段要拖一拖」「收尾别太快」——那是散文式的结构经验,它把节奏说成一种手感。本节把「节奏」拆成两个能被整数回答的问题:各幕各占多少拍(比例),张力峰值落在第几拍(位置)。这两个量算出来之后,「三段差不多长」和「后面更紧张」都不再是印象,而是可以断言或反驳的数值。
第一个读数:拍数比例
16 拍分到三幕是 5 / 6 / 5。把它折成千分比(分母 16,整数地板除):5*1000//16 = 312、6*1000//16 = 375、5*1000//16 = 312。
| 幕 | 拍数 | 千分比 | 读法 |
|---|---|---|---|
| 第一夜 | 5 | 312‰ | 与第三夜等长 |
| 第二夜 | 6 | 375‰ | 最厚的一幕 |
| 第三夜 | 5 | 312‰ | 与第一夜等长 |
| 合计 | 16 | 999‰ | 地板除的舍入残差 1‰ |
最后一行要专门说:三个千分比加起来是 999‰ 而不是 1000‰。这不是 bug,是整数地板除的必然残差——三份都向下取整,各丢了一部分。跨语言对照时这条尤其重要:只要两侧都用 //,残差就落在同一个位置;如果一侧用了浮点再取整,就会在某个幕上多出 1‰,两边对不上。整数纪律的价值在这里第一次显形。
第二个读数:张力均值与峰值位置
比例只说「占多少拍」,不说「每拍多紧」。把每幕的 tension 取均值(千分位整数)与峰值,得到第二组读数:
| 幕 | 拍数 | 张力合计 | 均值(‰) | 峰值 | 峰值位置(1 起拍号) |
|---|---|---|---|---|---|
| 第一夜 | 5 | 10 | 2000 | 3 | 4 |
| 第二夜 | 6 | 22 | 3666 | 5 | 10 |
| 第三夜 | 5 | 20 | 4000 | 5 | 13, 14 |
三行读下来有两点值得记:
- 均值是单调升的(2000 → 3666 → 4000‰),尽管第二夜比第一、三夜都多一拍。也就是说「多一拍」并没有把均值拉低——第二夜的 22 点张力撑住了 6 拍的分母。
- 峰值位置在三幕里各有归属:第一夜的峰值在第 4 拍(
b04账房先生不肯翻开那一页),第二夜在第 10 拍(b10沉钟塔),第三夜在第 13、14 拍(b13防波堤、b14雾门)。全片峰值 5 一共出现三次,位置是 10、13、14。
可判定的不变量 I1
有了峰值位置,就能写一条可断言的结构不变量:
I1:第三幕的张力峰值不低于前两幕各自的峰值。
这条不变量的含义是「结尾没有塌」。它不要求第三幕最高,只要求它不低于——因为一部作品完全可以让第二幕就是全片最高点,只要结尾不掉下来。用整数写就是:
stats[2].peak >= max(stats[0].peak, stats[1].peak)
本案例里左侧是 5,右侧是 max(3, 5) = 5,成立。如果哪天有人把 b13、b14 的 tension 从 5 调到 4,左侧变成 4、右侧仍是 5,I1 立刻不成立——这就是它作为回归哨兵的用法。
S 层:比例、峰值与各幕张力分布 SVG
下面这段是 S 层脚本 sim/na_06_3_rhythm_proportion.py。它算上表里的全部整数,断言 I1,并用纯 xml.etree 生成各幕张力分布 SVG:16 根柱(每拍一根,柱高 = tension)、按幕着色、画幕边界竖线与各幕均值横线。回读时按 tag.endswith("rect") 取元素——带 xmlns 的 SVG 用 root.iter("rect") 匹配不到任何东西。
#!/usr/bin/env python3
# file: sim/na_06_3_rhythm_proportion.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_06_3_rhythm_proportion.py
"""卷一 6.3 结构与节奏的比例检验(S 层):各幕拍数比例 + 张力均值与峰值位置。
三幕的 5 / 6 / 5 是**比例**,不是「三段差不多长」的印象;张力均值与峰值位置
是**位置**,不是「后面更紧张」的印象。本节把两者都算成整数,并断言一条可判定的
结构不变量:
I1 第三幕张力峰值不低于前两幕各自的峰值
本机无 matplotlib,各幕张力分布走纯 xml.etree 生成 SVG:16 根柱(每拍一根,
柱高 = tension),按幕着色,并画幕边界竖线与各幕均值横线。回读时按
`tag.endswith("rect")` 取元素(带 xmlns 的 SVG 用 root.iter("rect") 匹配不到)。
产出 out/narr06_rhythm.csv、out/narr06_rhythm.svg;I1 不成立即非零退出。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
SVG_NS = "http://www.w3.org/2000/svg"
ET.register_namespace("", SVG_NS)
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_rhythm.csv")
SVG_PATH = os.path.join(OUT, "narr06_rhythm.svg")
TENSION_LO, TENSION_HI = 1, 5
ACT_FILL = {1: "#93c5fd", 2: "#fbbf24", 3: "#f87171"}
def el(tag: str, **attrs) -> ET.Element:
return ET.Element(f"{{{SVG_NS}}}{tag}", {k: str(v) for k, v in attrs.items()})
def text(parent: ET.Element, x: int, y: int, s: str, size: int = 12,
anchor: str = "start", fill: str = "#334155") -> None:
t = el("text", x=x, y=y, **{"font-size": size, "font-family": "ui-sans-serif, system-ui",
"text-anchor": anchor, "fill": fill})
t.text = s
parent.append(t)
def load_spec() -> dict:
with open(SPEC, encoding="utf-8") as fh:
return json.load(fh)
def act_stats(beats: list) -> list:
"""每幕:拍数、张力合计、均值(千分位整数)、峰值、峰值所在 1 起拍号。"""
acts: dict[int, list] = {}
for i, b in enumerate(beats):
acts.setdefault(b["act"], []).append((i + 1, b))
out = []
for a in sorted(acts):
items = acts[a]
ts = [b["tension"] for _, b in items]
peak = max(ts)
out.append({
"act": a,
"beats": len(items),
"sum": sum(ts),
"mean_milli": sum(ts) * 1000 // len(items),
"peak": peak,
"peak_positions": [p for p, b in items if b["tension"] == peak],
"share_permille": len(items) * 1000 // len(beats),
})
return out
def build_svg(beats: list, stats: list, path: str) -> int:
W, H = 760, 360
L, R, T, B = 56, 24, 40, 66
pw, ph = W - L - R, H - T - B
n = len(beats)
slot = pw // n
bw = slot - 8
def py(v: int) -> int:
return T + ph - (ph * (v - TENSION_LO)) // (TENSION_HI - TENSION_LO)
svg = el("svg", width=W, height=H, viewBox=f"0 0 {W} {H}")
svg.append(el("rect", x=0, y=0, width=W, height=H, fill="#ffffff"))
for v in range(TENSION_LO, TENSION_HI + 1):
y = py(v)
svg.append(el("line", x1=L, y1=y, x2=L + pw, y2=y, stroke="#e2e8f0", **{"stroke-width": 1}))
text(svg, L - 8, y + 4, str(v), 11, "end", "#94a3b8")
# 16 根柱,柱高 = 该拍 tension,按幕着色
for i, b in enumerate(beats):
x = L + i * slot + 4
y = py(b["tension"])
svg.append(el("rect", x=x, y=y, width=bw, height=T + ph - y, rx=3,
fill=ACT_FILL[b["act"]], stroke="#475569", **{"stroke-width": 0.6}))
text(svg, x + bw // 2, T + ph + 16, b["id"], 9, "middle", "#94a3b8")
# 幕边界竖线 + 各幕均值横线
acc = 0
for s in stats:
if acc:
x = L + acc * slot
svg.append(el("line", x1=x, y1=T, x2=x, y2=T + ph, stroke="#0f172a",
**{"stroke-width": 1.2, "stroke-dasharray": "4 3"}))
x1 = L + acc * slot + 4
x2 = x1 + s["beats"] * slot - 8
ym = T + ph - (ph * (s["mean_milli"] - TENSION_LO * 1000)) // ((TENSION_HI - TENSION_LO) * 1000)
svg.append(el("line", x1=x1, y1=ym, x2=x2, y2=ym, stroke="#0f172a", **{"stroke-width": 1.6}))
text(svg, (x1 + x2) // 2, ym - 6, f"幕{s['act']} 均值 {s['mean_milli']}‰", 10, "middle", "#0f172a")
acc += s["beats"]
svg.append(el("line", x1=L, y1=T + ph, x2=L + pw, y2=T + ph, stroke="#94a3b8", **{"stroke-width": 1.2}))
svg.append(el("line", x1=L, y1=T, x2=L, y2=T + ph, stroke="#94a3b8", **{"stroke-width": 1.2}))
text(svg, L, 22, "《雾港夜航》各幕张力分布(16 拍,柱高=beats[].tension,虚线=幕边界)", 12, "start", "#0f172a")
tree = ET.ElementTree(svg)
ET.indent(tree, space=" ")
tree.write(path, encoding="utf-8", xml_declaration=True)
return os.path.getsize(path)
def readback(path: str) -> dict:
root = ET.parse(path).getroot()
counts: dict[str, int] = {}
for e in root.iter():
if not isinstance(e.tag, str):
continue
for k in ("rect", "line", "text"):
if e.tag.endswith(k):
counts[k] = counts.get(k, 0) + 1
return counts
def main() -> int:
spec = load_spec()
beats = spec["beats"]
stats = act_stats(beats)
print(f"beats={len(beats)} acts={len(stats)}")
for s in stats:
print(f" 幕{s['act']} 拍={s['beats']} 占比={s['share_permille']}‰ "
f"张力合计={s['sum']} 均值={s['mean_milli']}‰ 峰值={s['peak']} "
f"峰值位置={s['peak_positions']}")
shares = [s["share_permille"] for s in stats]
print(f"拍数比例(千分位,分母 {len(beats)}){shares} 合计 {sum(shares)}‰(整数地板除,合并不恒为 1000‰)")
peak_vals = [s["peak"] for s in stats]
global_peak = max(b["tension"] for b in beats)
print(f"各幕峰值 {peak_vals} 全片峰值 {global_peak} "
f"全片峰值位置 {[i + 1 for i, b in enumerate(beats) if b['tension'] == global_peak]}")
# I1 第三幕峰值不低于前两幕
i1 = stats[-1]["peak"] >= stats[0]["peak"] and stats[-1]["peak"] >= stats[1]["peak"]
print(f"I1 第三幕峰值 {stats[-1]['peak']} >= max(前两幕 {max(peak_vals[:2])}) : {i1}")
if not i1:
print("FAIL I1 第三幕张力峰值低于前两幕之一")
return 1
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["act", "beats", "share_permille", "tension_sum", "mean_milli", "peak", "peak_positions"])
w.writeheader()
for s in stats:
w.writerow({"act": s["act"], "beats": s["beats"], "share_permille": s["share_permille"],
"tension_sum": s["sum"], "mean_milli": s["mean_milli"], "peak": s["peak"],
"peak_positions": "|".join(str(p) for p in s["peak_positions"])})
size = build_svg(beats, stats, SVG_PATH)
counts = readback(SVG_PATH)
print(f"wrote out/narr06_rhythm.svg {size} bytes {counts}")
assert counts.get("rect", 0) == len(beats) + 1, "矩形数应为拍数 + 背景"
assert counts.get("text", 0) >= len(beats), "文本元素数应不少于拍数"
print(f"wrote out/narr06_rhythm.csv rows={len(stats)}")
print("OK na_06_3_rhythm_proportion:比例与峰值位置均为整数,I1 成立,SVG 回读通过")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_06_3_rhythm_proportion.py:
beats=16 acts=3
幕1 拍=5 占比=312‰ 张力合计=10 均值=2000‰ 峰值=3 峰值位置=[4]
幕2 拍=6 占比=375‰ 张力合计=22 均值=3666‰ 峰值=5 峰值位置=[10]
幕3 拍=5 占比=312‰ 张力合计=20 均值=4000‰ 峰值=5 峰值位置=[13, 14]
拍数比例(千分位,分母 16)[312, 375, 312] 合计 999‰(整数地板除,合并不恒为 1000‰)
各幕峰值 [3, 5, 5] 全片峰值 5 全片峰值位置 [10, 13, 14]
I1 第三幕峰值 5 >= max(前两幕 5) : True
wrote out/narr06_rhythm.svg 6158 bytes {'rect': 17, 'line': 12, 'text': 25}
wrote out/narr06_rhythm.csv rows=3
OK na_06_3_rhythm_proportion:比例与峰值位置均为整数,I1 成立,SVG 回读通过
同一趟写出的 out/narr06_rhythm.csv(每幕一行):
act,beats,share_permille,tension_sum,mean_milli,peak,peak_positions
1,5,312,10,2000,3,4
2,6,375,22,3666,5,10
3,5,312,20,4000,5,13|14
SVG 回读得到 rect=17(16 根柱 + 1 个背景)、line=12、text=25,与断言一致。
判读:比例与位置要分开看
两组读数放在一起,能读出三种不同的信息:
| 观察 | 来自哪组读数 | 说明 |
|---|---|---|
| 三幕长短相近但不相等 | 比例 | 312 / 375 / 312,中段厚 63‰ |
| 紧张度逐幕抬升 | 均值 | 2000 → 3666 → 4000‰ |
| 结尾没有塌 | 峰值 | I1 成立,第三幕峰值 5 |
这三条互不蕴含。第二幕拍最多(比例最大),均值却不是最高(4000‰ 在第三幕);第三幕拍数最少,均值反而最高——因为它的 20 点张力摊在 5 拍上,而不是 6 拍。只看拍数会以为第二幕最重,只看均值会以为第三幕最重,两者都对,只是量的不是同一件事。
最后要说清这些读数的局限:tension 是作者标注的整数档位,它是一条代理指标,只用于相对比较——比较幕与幕、版本与版本,而不是断言「这一版比那一版更紧张」。整数档位能做的是让这种比较可复现:同一份规格在任何机器上算出同一组千分比与同一组峰值位置。
自检表
| 检查项 | 合格标准 |
|---|---|
| 比例用整数 | 千分比一律 拍数*1000//总拍数,不出现浮点 |
| 残差显式 | 说明千分比合计为 999‰ 是地板除的舍入结果 |
| 位置可断言 | 峰值位置用 1 起拍号表示,可写进不变量 |
| 不变量成对 | I1 用「不低于」而非「最高」,允许第二幕是全片峰值 |
| 口径诚实 | 显式声明 tension 是代理指标,只做相对比较 |
节拍层面的比例与位置到此为止。但节拍只是故事的一层骨架——真正的玩家路径要在场景之间走,而场景之间的连接有自己的入口条件。下一章把节拍挂回场景图,逐条检查节点、边与进入条件。
阅读导航:上一节:6.2 节拍表:把结构写成 beat 序列 · 下一节:7.1 场景图:节点、边与入口条件 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。