6.3 结构与节奏的比例检验

比例检验把「三段差不多长」「后面更紧张」这类印象换成整数:各幕拍数 5/6/5 折成千分比 312/375/312,张力均值 2000/3666/4000‰,峰值位置落在第 4、10、13/14 拍。本节给出可跑的 S 层脚本与纯 xml.etree 的各幕张力分布 SVG,并断言第三幕峰值不低于前两幕。

6.3 结构与节奏的比例检验

独立游戏的叙事设计与分支对话 谈节奏,谈的是「这一段要拖一拖」「收尾别太快」——那是散文式的结构经验,它把节奏说成一种手感。本节把「节奏」拆成两个能被整数回答的问题:各幕各占多少拍(比例),张力峰值落在第几拍(位置)。这两个量算出来之后,「三段差不多长」和「后面更紧张」都不再是印象,而是可以断言或反驳的数值。

第一个读数:拍数比例

16 拍分到三幕是 5 / 6 / 5。把它折成千分比(分母 16,整数地板除):5*1000//16 = 312、6*1000//16 = 375、5*1000//16 = 312。

幕拍数千分比读法
第一夜5312‰与第三夜等长
第二夜6375‰最厚的一幕
第三夜5312‰与第一夜等长
合计16999‰地板除的舍入残差 1‰

最后一行要专门说:三个千分比加起来是 999‰ 而不是 1000‰。这不是 bug,是整数地板除的必然残差——三份都向下取整,各丢了一部分。跨语言对照时这条尤其重要:只要两侧都用 //,残差就落在同一个位置;如果一侧用了浮点再取整,就会在某个幕上多出 1‰,两边对不上。整数纪律的价值在这里第一次显形。

第二个读数:张力均值与峰值位置

比例只说「占多少拍」,不说「每拍多紧」。把每幕的 tension 取均值(千分位整数)与峰值,得到第二组读数:

幕拍数张力合计均值(‰)峰值峰值位置(1 起拍号)
第一夜510200034
第二夜6223666510
第三夜5204000513, 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 场景图:节点、边与入口条件 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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