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) 拍。这不是随手定的——它保证前半不短于后半,于是「呈现」不会比「上升」还短,「起」不会比它带出的东西还薄。
| 被切的幕 | 拍数 | 前半 | 后半 | 说明 |
|---|---|---|---|---|
| 第一幕(五幕用) | 5 | 3 | 2 | 前半多一拍 |
| 第三幕(五幕用) | 5 | 3 | 2 | 前半多一拍 |
| 第二幕(起承转合用) | 6 | 3 | 3 | 偶数,对半 |
规则一旦写死,三种模型的分段就是确定性的:同一份 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 序列 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。