6.1 三幕、五幕与起承转合

三幕、五幕与起承转合不是三种故事,而是对同一份 beats[].act 的三套分段规则:三幕按 act 直接分组得 3 段,五幕把首尾两幕各对半切得 5 段,起承转合把第二幕对半切得 4 段。本节用可跑的 S 层脚本把《雾港夜航》16 个节拍映射到三种模型,逐条对比分段边界。

6.1 三幕、五幕与起承转合

独立游戏的叙事设计与分支对话 用大量篇幅讲三幕、讲节拍、讲「第三幕要收得住」——那是散文式的结构经验,读起来有道理,却不可算:它没说清「第二幕到底从第几拍开始」,也没说清换成五幕之后第几拍归谁。本节把结构压回 beats[].act 这一个整数字段,于是「三幕」与「五幕」的差别不再是两种叙事学主张,而是两套分段规则:谁在哪一拍切、切出几段、每段几拍,全部可打印、可对比。那个链接讲的是结构经验,本节算的是结构比例。

三种模型读的是同一个字段

《雾港夜航》的 16 个节拍每拍带一个 act(1 / 2 / 3,对应三个夜)。三种结构模型对这个数组的读法不同,输入完全相同:

模型段数分段规则(全部只用 act)每段读作
三幕3按 act 直接分组第一夜 / 第二夜 / 第三夜
五幕5幕 1 与幕 3 各对半切,幕 2 整段保留 → 2 + 1 + 2呈现 / 上升 / 高潮 / 下降 / 结局
起承转合4幕 2 对半切:起 = 幕 1,承 / 转 = 幕 2 前后半,合 = 幕 3起 / 承 / 转 / 合

关键在最后一行:起承转合不是把 16 拍四等分(那是 4 拍一段,与幕边界无关),而是借第二幕的内部转折把四段读出来。三幕的边界落在「夜与夜之间」,起承转合的边界落在「第二夜中间」——两者的切点根本不在同一组位置上,这正是本节要打印出来的东西。

分段规则:对半切为什么前半多一拍

三种规则里唯一需要定义的算子是对半切:长度为奇数的段,前半取 ceil(n/2) 拍。这不是随手定的——它保证前半不短于后半,于是「呈现」不会比「上升」还短,「起」不会比它带出的东西还薄。

被切的幕拍数前半后半说明
第一幕(五幕用)532前半多一拍
第三幕(五幕用)532前半多一拍
第二幕(起承转合用)633偶数,对半

规则一旦写死,三种模型的分段就是确定性的:同一份 story_spec.json、同一套 act,任何语言、任何机器跑出来都是同一张分段表。这也是为什么它值得写进 S 层脚本,而不是留在策划文档的正文里。

S 层:把 act 映射成三种分段

下面这段是 S 层脚本 sim/na_06_1_structures.py。它读只读规格 config/story_spec.json,按三套规则各自切一遍,逐段输出起止 beat、拍数、张力峰值与张力均值(千分位整数),再把分段边界横向对比。全程不出现浮点:均值是 sum*1000//n,边界是拍数的前缀和。

#!/usr/bin/env python3
# file: sim/na_06_1_structures.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_06_1_structures.py
"""卷一 6.1 三种结构模型(S 层):把 beats[].act 映射成三幕 / 五幕 / 起承转合。

同一份 story_spec.json 的 16 个节拍,三种模型**只读同一个字段** act,
差别全在**分段规则**:

  三幕     :按 act 直接分组                       → 3 段
  五幕     :幕 1 与幕 3 各对半切(奇数前半多一拍) → 2 + 1 + 2 = 5 段
  起承转合 :幕 2 对半切,起=幕1 承/转=幕2 合=幕3   → 4 段

产出 out/narr06_models.csv(每段一行)与控制台的分段表 + 边界对比表。
整数口径:拍数与张力峰值一律 int,张力均值用千分位整数(sum*1000//n),禁浮点。
"""
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_models.csv")

TENSION_LO, TENSION_HI = 1, 5

NAMES = {
    "three_act": ["第一幕", "第二幕", "第三幕"],
    "five_act": ["一 呈现", "二 上升", "三 高潮", "四 下降", "五 结局"],
    "kishotenketsu": ["起", "承", "转", "合"],
}


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


def split_half(items: list) -> tuple[list, list]:
    """对半切:奇数长度时前半多一拍。纯整数。"""
    k = (len(items) + 1) // 2
    return items[:k], items[k:]


def acts_of(beats: list) -> list:
    """按 act 分组,返回按幕号升序的幕列表。"""
    acts: dict[int, list] = {}
    for b in beats:
        acts.setdefault(b["act"], []).append(b)
    return [acts[k] for k in sorted(acts)]


def model_three_act(beats: list) -> list:
    return acts_of(beats)


def model_five_act(beats: list) -> list:
    a1, a2, a3 = acts_of(beats)
    a1f, a1b = split_half(a1)
    a3f, a3b = split_half(a3)
    return [a1f, a1b, a2, a3f, a3b]


def model_kishotenketsu(beats: list) -> list:
    a1, a2, a3 = acts_of(beats)
    a2f, a2b = split_half(a2)
    return [a1, a2f, a2b, a3]


MODELS = {
    "three_act": model_three_act,
    "five_act": model_five_act,
    "kishotenketsu": model_kishotenketsu,
}


def seg_row(name: str, i: int, seg: list) -> dict:
    """一段的整数摘要:起止 beat、拍数、张力峰值、张力均值(千分位)。"""
    return {
        "model": name,
        "index": i,
        "label": NAMES[name][i],
        "from": seg[0]["id"],
        "to": seg[-1]["id"],
        "beats": len(seg),
        "peak": max(b["tension"] for b in seg),
        "mean_milli": sum(b["tension"] for b in seg) * 1000 // len(seg),
    }


def boundaries(segments: list) -> list:
    """分段边界:返回「切在几拍之后」的整数列表。"""
    cuts, acc = [], 0
    for s in segments[:-1]:
        acc += len(s)
        cuts.append(acc)
    return cuts


def main() -> int:
    spec = load_spec()
    beats = spec["beats"]
    n = len(beats)

    for b in beats:
        if not (TENSION_LO <= b["tension"] <= TENSION_HI):
            print(f"FAIL 张力越界:{b['id']} = {b['tension']}")
            return 1

    rows: list[dict] = []
    cuts: dict[str, list] = {}
    for name, fn in MODELS.items():
        segs = fn(beats)
        total = sum(len(s) for s in segs)
        if total != n:
            print(f"FAIL {name} 覆盖 {total} 拍 != {n}")
            return 1
        flat = [b["id"] for s in segs for b in s]
        if flat != [b["id"] for b in beats]:
            print(f"FAIL {name} 分段后节拍顺序与原始不一致")
            return 1
        cuts[name] = boundaries(segs)
        for i, s in enumerate(segs):
            rows.append(seg_row(name, i, s))

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["model", "index", "label", "from", "to", "beats", "peak", "mean_milli"])
        w.writeheader()
        w.writerows(rows)

    print(f"spec={spec['meta']['title']}  beats={n}  tension_sum={sum(b['tension'] for b in beats)}")
    print("分段表(三种模型;拍数合计应恒为 %d)" % n)
    for name in MODELS:
        segs = MODELS[name](beats)
        print(f"  [{name}] {len(segs)} 段  拍数={[len(s) for s in segs]}")
        for r in [x for x in rows if x["model"] == name]:
            print(f"    {r['index'] + 1} {r['label']:<6} {r['from']}..{r['to']}  拍={r['beats']:>2}  峰值={r['peak']}  均值={r['mean_milli']:>4}‰")

    print("边界对比(切在第 k 拍之后;k 为 1 起)")
    print(f"  {'k':>2}  {'三幕':<4} {'五幕':<4} {'起承转合':<6}  说明")
    desc = {5: "第一夜 / 第二夜", 8: "承 / 转", 11: "第二夜 / 第三夜", 14: "四 下降 / 五 结局", 3: "一 呈现 / 二 上升"}
    allk = sorted({k for v in cuts.values() for k in v})
    for k in allk:
        marks = [("●" if k in cuts[m] else "·") for m in ("three_act", "five_act", "kishotenketsu")]
        print(f"  {k:>2}  {marks[0]:<4} {marks[1]:<4} {marks[2]:<6}  {desc.get(k, '')}")

    print(f"wrote out/narr06_models.csv rows={len(rows)}")
    print("OK na_06_1_structures:三模型均恰好覆盖 16 拍,张力不变量通过")
    return 0


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

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

spec=雾港夜航  beats=16  tension_sum=52
分段表(三种模型;拍数合计应恒为 16)
  [three_act] 3 段  拍数=[5, 6, 5]
    1 第一幕    b01..b05  拍= 5  峰值=3  均值=2000‰
    2 第二幕    b06..b11  拍= 6  峰值=5  均值=3666‰
    3 第三幕    b12..b16  拍= 5  峰值=5  均值=4000‰
  [five_act] 5 段  拍数=[3, 2, 6, 3, 2]
    1 一 呈现   b01..b03  拍= 3  峰值=2  均值=1666‰
    2 二 上升   b04..b05  拍= 2  峰值=3  均值=2500‰
    3 三 高潮   b06..b11  拍= 6  峰值=5  均值=3666‰
    4 四 下降   b12..b14  拍= 3  峰值=5  均值=4666‰
    5 五 结局   b15..b16  拍= 2  峰值=4  均值=3000‰
  [kishotenketsu] 4 段  拍数=[5, 3, 3, 5]
    1 起      b01..b05  拍= 5  峰值=3  均值=2000‰
    2 承      b06..b08  拍= 3  峰值=4  均值=3333‰
    3 转      b09..b11  拍= 3  峰值=5  均值=4000‰
    4 合      b12..b16  拍= 5  峰值=5  均值=4000‰
边界对比(切在第 k 拍之后;k 为 1 起)
   k  三幕   五幕   起承转合    说明
   3  ·    ●    ·       一 呈现 / 二 上升
   5  ●    ●    ●       第一夜 / 第二夜
   8  ·    ·    ●       承 / 转
  11  ●    ●    ●       第二夜 / 第三夜
  14  ·    ●    ·       四 下降 / 五 结局
wrote out/narr06_models.csv rows=12
OK na_06_1_structures:三模型均恰好覆盖 16 拍,张力不变量通过

同一趟写出的 out/narr06_models.csv(12 段,每段一行):

model,index,label,from,to,beats,peak,mean_milli
three_act,0,第一幕,b01,b05,5,3,2000
three_act,1,第二幕,b06,b11,6,5,3666
three_act,2,第三幕,b12,b16,5,5,4000
five_act,0,一 呈现,b01,b03,3,2,1666
five_act,1,二 上升,b04,b05,2,3,2500
five_act,2,三 高潮,b06,b11,6,5,3666
five_act,3,四 下降,b12,b14,3,5,4666
five_act,4,五 结局,b15,b16,2,4,3000
kishotenketsu,0,起,b01,b05,5,3,2000
kishotenketsu,1,承,b06,b08,3,4,3333
kishotenketsu,2,转,b09,b11,3,5,4000
kishotenketsu,3,合,b12,b16,5,5,4000

判读:五个切点,三种切法

把边界表横过来读,16 拍上共有 5 个候选切点,三种模型只用了其中一部分:

切点三幕五幕起承转合落在哪
第 3 拍后—✓—第一夜内部
第 5 拍后✓✓✓第一夜 / 第二夜
第 8 拍后——✓第二夜内部
第 11 拍后✓✓✓第二夜 / 第三夜
第 14 拍后—✓—第三夜内部

三行三列看下来,结论很干净:

  • 第 5 拍与第 11 拍是「公共边界」——三种模型都在这里切。它们是 act 变化的两个位置,任何以幕为单位的分段都绕不开。
  • 第 3、14 拍只有五幕用,因为它们来自「把首尾两幕各自对半切」这条规则;三幕与起承转合不切幕内,自然用不到。
  • 第 8 拍只有起承转合用,它来自「把第二幕对半切」;五幕把第二幕整段留作高潮,所以不切。

换句话说,三种模型的差异完全由两条规则生成:「切不切幕 1 / 幕 3」与「切不切幕 2」。三幕两条都不切(3 段),五幕切前者(5 段),起承转合切后者(4 段)。段数不是拍脑袋定的 3 / 5 / 4,而是这两条规则的枚举结果。

再看一个容易被忽略的读数:五幕的「三 高潮」是 b06..b11,起承转合的「转」是 b09..b11——同一批节拍被两个模型放进了不同的段。这不是矛盾,而是两种模型对「高潮从哪开始」的判定不同:五幕认为第二幕整段是高潮,起承转合认为第二幕的后半才是转折。同一份 act,两个都成立,差别被记录在分段规则里,而不是含糊在「节奏感」里。

自检表

检查项合格标准
单一输入三种模型只读 act,不读 summary、不读 tension
覆盖完整三种模型的分段并集都恰好是全部 16 拍,顺序不变
规则显式对半切规则写死为 ceil(n/2),奇数段前半不短于后半
边界可比切点用「切在第 k 拍之后」的整数表示,可横向对齐
说法诚实三幕 / 五幕 / 起承转合是同一份数据的三套读法,不是三种故事

分段表告诉我们「切在哪里」,但没告诉我们「这张表本身是否合法」——act 有没有回退、scene 有没有指向不存在的节点、有没有哪个场景一个节拍都没被覆盖。这些是节拍表自己的约束,下一节逐条校验。

阅读导航:上一节:5.3 弧光的可达性 · 下一节:6.2 节拍表:把结构写成 beat 序列 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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